Client statements
Per-account statements: holdings, transactions, realized gains (LT/ST split), performance summary.
Every advisor delivers a periodic statement to every client, typically quarterly. StatementBuilder produces that statement as a structured dataclass. Render it to text (shipped), JSON (shipped), or HTML/PDF (L2 with templates). The builder returns; the caller decides how to persist.
It is a read layer over data the Professional tier already captures: PositionLedger, per-account LotBook, equity curve, and optional Cashflow events.
Example
from datetime import datetime, timezone
from horizon.reporting import StatementBuilder, Cashflow
builder = StatementBuilder()
stmt = builder.build(
account=account, # from AccountRegistry
client=client, # optional display name
custodian_name="Schwab Advisor Services",
ledger=ledger, # trades + positions
lot_book=result.lot_books["acc_jane"], # realized gains with LT/ST
equity_samples=result.equity_curve, # performance section
cashflows=[
Cashflow(funded_at, +100_000, "deposit"),
],
period_start=datetime(2026, 1, 1, tzinfo=timezone.utc),
period_end=datetime(2026, 4, 1, tzinfo=timezone.utc),
)
print(stmt.to_text())
Contents
Header
Account id, display name, client display name, custodian, base currency, period start and end, generation timestamp.
Summary
| Field | Derived from |
|---|---|
| Starting value | First equity sample in the period |
| Ending value | Last equity sample in the period |
| Net cashflow | Sum of Cashflow amounts in the period |
| Realized P&L | Sum across LotBook.closed_lots in the period |
| Long-term | Lots held > 365 days |
| Short-term | Lots held ≤ 365 days |
| Unrealized P&L | Sum of LedgerPosition.unrealized_pnl |
| Fees paid | Sum of TradeRecord.fee in the period |
| Transactions | Count of fills in the period |
Holdings table
One row per open LedgerPosition:
Market Qty Avg Cost Last Mkt Value Unrealized
AAPL 100.00 180.00 185.00 18,500.00 +500.00
MSFT 50.00 400.00 410.00 20,500.00 +500.00
Transactions table
Every fill in the window, chronological:
Date Market Side Qty Price Fee Realized
2026-01-15 AAPL buy 100.00 180.00 1.00 0.00
2026-02-03 AAPL sell 50.00 185.00 0.50 250.00
Text render caps at 100 rows. Use .to_dict() for the full list.
Realized gains by lot
One row per lot closed in the period, with holding period and LT/ST flag. See Tax lots.
Market Qty Acquired Closed Days LT/ST Realized
AAPL 100.00 2023-06-15 2026-01-10 940 LT 2,000.00
AAPL 50.00 2025-12-01 2026-01-12 42 ST 250.00
Lots adjusted by a wash sale are flagged (was_wash_sale=True) in the structured output.
Performance summary
When equity_samples is passed, the builder runs PerformanceBuilder on the period and attaches the full PerformanceReport (TWR, MWR, drawdown, Sharpe, period breakdowns).
Renderers
Plain text
stmt.to_text() produces a fixed-width 78-column layout for log files, email, or internal review. Same output as print(stmt).
JSON
stmt.to_dict() is JSON-safe (timestamps become ISO strings).
import json
with open(f"{stmt.account_id}_{period_label}.json", "w") as f:
json.dump(stmt.to_dict(), f, indent=2, default=str)
HTML / PDF
L2. The dataclass layout is already shaped to fit Jinja / WeasyPrint templates; bind stmt.to_dict() to a template.
Multi-account batch
for account in registry.accounts():
stmt = StatementBuilder().build(
account=account,
client=registry.get_client(account.client_id),
ledger=ledger,
lot_book=result.lot_books.get(account.account_id),
equity_samples=per_account_equity.get(account.account_id),
cashflows=cashflows_by_account.get(account.account_id, []),
period_start=q1_start,
period_end=q1_end,
)
out_path = f"statements/2026-Q1/{account.account_id}.json"
with open(out_path, "w") as f:
json.dump(stmt.to_dict(), f, indent=2, default=str)
Robustness
- Missing sources are tolerated. Omit
equity_samplesand the performance section isNone. Omitlot_bookand realized gains are empty. - Windowing is inclusive on both ends.
- Empty periods render cleanly with zero rows.
PDF rendering
horizon.reporting.pdf.render_statement_pdf(statement, out_path) produces a PDF from any ClientStatement. The renderer wraps WeasyPrint and lazy-imports it; install with pip install weasyprint. A sensible default template is bundled; pass template_path= to override.
from horizon.reporting.pdf import render_statement_pdf
render_statement_pdf(stmt, out_path="statements/2026-Q1/acc_1.pdf")
Status
- Management and performance fee accrual: see Fees. Flows into
summary.total_feeswhen wired. - 1099-B: see Tax packets.
- HTML and PDF renderers shipped; the dataclass shape is frozen.
The dataclass is the canonical representation. Every render (text, JSON, HTML, PDF, REST response) is a different renderer on top.