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

python
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

FieldDerived from
Starting valueFirst equity sample in the period
Ending valueLast equity sample in the period
Net cashflowSum of Cashflow amounts in the period
Realized P&LSum across LotBook.closed_lots in the period
  Long-termLots held > 365 days
  Short-termLots held ≤ 365 days
Unrealized P&LSum of LedgerPosition.unrealized_pnl
Fees paidSum of TradeRecord.fee in the period
TransactionsCount of fills in the period

Holdings table

One row per open LedgerPosition:

text
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:

text
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.

text
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).

python
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

python
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_samples and the performance section is None. Omit lot_book and 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.

python
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_fees when 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.