Replace the single final-holdings yield proxy with a per-symbol dated dividend ledger for the backtest engine: - backend/app/dividend_ledger.py: DividendLedger store (ex_date, record_date, pay_date, per_share, source, estimate flag) with validation and persistence; credit_dividends credits per_share * qty once a payment is due (on/after ex-date and pay date); build_dps_ledger builds estimate rows from siamchart ratios.DPS (per-share, price-independent) as a step up from the yield-percentage proxy. - backend/app/backtest.py: run_backtest accepts dividend_ledger; when set, dividend_income comes from the ledger and dividend_method reports 'dated_ledger' (real rows) or 'dps_annual_proxy' (estimate). No ledger -> legacy final_holdings_yield_proxy preserved and labelled. - backend/app/__init__.py: /api/v1/backtest accepts use_ledger, wiring the DPS-built ledger. - tests: ledger store/credit (9) + backtest ledger integration (2 new) — full backend suite 266 passed. Live probe: use_ledger flips dividend_method to dps_annual_proxy with per-share income (4151.0) vs proxy (5041.96). Honest scope: DPS rows are estimates (no ex-date history in snapshot yet); real dated cash flows require collecting per-stock dividend history, which upgrades a symbol to dated_ledger when present.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""Real point-in-time multi-rebalance backtest engine.
|
|
|
|
True PIT honesty requires a `score_fn(score_by_symbol, as_of)` that returns the
|
|
combined scores *as they were known at `as_of`*. The default (current board) has
|
|
no historical factor vintages, so it is labeled non-PIT (`leakage_guard=False`).
|
|
When a PIT scorer is supplied, `leakage_guard=True`.
|
|
|
|
At each rebalance date the engine:
|
|
- resolves the combined score as-of that date (price data is genuinely
|
|
point-in-time w.r.t. price through `_latest_close`),
|
|
- marks the current portfolio to market,
|
|
- re-allocates the 50/20/30 dividend buckets over the current value,
|
|
- reconciles holdings (sells names that leave, buys/upsizes names that enter),
|
|
so `rebalances` reflects real re-trades, not a single allocate-once.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Optional
|
|
|
|
from .simulation import (
|
|
allocate_capital, load_price_snapshot,
|
|
)
|
|
|
|
# score_fn contract: (symbols: list[str], as_of: str|None) -> {sym: meta dict}
|
|
ScoreFn = Callable[[list[str], Optional[str]], dict]
|
|
|
|
|
|
def _bar_date(s: Optional[str]) -> dt.date:
|
|
if not s:
|
|
return dt.date.min
|
|
return dt.date.fromisoformat(str(s)[:10])
|
|
|
|
|
|
def _bars_up_to(series: dict, sym: str, date: dt.date) -> list:
|
|
bars = series.get(sym, {}).get("bars", [])
|
|
return [b for b in bars if _bar_date(b.get("date")) <= date]
|
|
|
|
|
|
def _latest_close(series: dict, sym: str, date: dt.date) -> Optional[float]:
|
|
bars = _bars_up_to(series, sym, date)
|
|
if bars:
|
|
return float(bars[-1]["adjusted_close"])
|
|
return None
|
|
|
|
|
|
def momentum_at(series: dict, sym: str, date: dt.date,
|
|
lookback_days: int = 252, skip_days: int = 21) -> Optional[float]:
|
|
"""True 12-1 momentum as of `date`: close at ~1 month ago / close ~12 months
|
|
before that, minus 1 — skipping the most recent month to avoid short-term
|
|
reversal. Only uses bars known up to `date` (no lookahead)."""
|
|
bars = _bars_up_to(series, sym, date)
|
|
if len(bars) < lookback_days + skip_days + 1:
|
|
return None
|
|
try:
|
|
ref = float(bars[-1 - skip_days]["adjusted_close"]) # ~1m ago
|
|
base = float(bars[-1 - skip_days - lookback_days]["adjusted_close"])
|
|
except (KeyError, TypeError, ValueError, IndexError):
|
|
return None
|
|
if ref <= 0 or base <= 0:
|
|
return None
|
|
return round((ref / base) - 1.0, 4)
|
|
|
|
|
|
@dataclass
|
|
class BacktestResult:
|
|
start: str
|
|
end: str
|
|
capital: float
|
|
final_value: float = 0.0
|
|
price_pnl: float = 0.0
|
|
dividend_income: float = 0.0
|
|
net_return: float = 0.0
|
|
trades: int = 0
|
|
rebalances: int = 0 # actual number of re-allocations executed
|
|
planned_rebalances: int = 0 # number of rebalance windows
|
|
holdings: dict = field(default_factory=dict) # final {sym: qty}
|
|
leakage_guard: bool = False # True only when a PIT score_fn was supplied
|
|
dividend_method: str = "final_holdings_yield_proxy" # which dividend model
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"start": self.start, "end": self.end, "capital": self.capital,
|
|
"final_value": round(self.final_value, 2),
|
|
"price_pnl": round(self.price_pnl, 2),
|
|
"dividend_income": round(self.dividend_income, 2),
|
|
"dividend_method": self.dividend_method,
|
|
"net_return": round(self.net_return, 4),
|
|
"trades": self.trades,
|
|
"rebalances": self.rebalances,
|
|
"planned_rebalances": self.planned_rebalances,
|
|
"holdings": self.holdings,
|
|
"leakage_guard": self.leakage_guard,
|
|
}
|
|
|
|
|
|
class BacktestError(Exception):
|
|
pass
|
|
|
|
|
|
def _rebalance_dates(start: str, end: str, freq: str = "monthly") -> list[str]:
|
|
s = dt.date.fromisoformat(start)
|
|
e = dt.date.fromisoformat(end)
|
|
if s >= e:
|
|
raise BacktestError("end must be after start")
|
|
dates = []
|
|
cur = s
|
|
if freq == "monthly":
|
|
while cur <= e:
|
|
dates.append(cur.isoformat())
|
|
y, m = (cur.year + 1, 1) if cur.month == 12 else (cur.year, cur.month + 1)
|
|
cur = dt.date(y, m, 1)
|
|
elif freq == "quarterly":
|
|
while cur <= e:
|
|
dates.append(cur.isoformat())
|
|
q = (cur.month - 1) // 3 + 1
|
|
if q == 4:
|
|
cur = dt.date(cur.year + 1, 1, 1)
|
|
else:
|
|
cur = dt.date(cur.year, q * 3 + 1, 1)
|
|
else:
|
|
raise BacktestError(f"unsupported freq {freq}")
|
|
return dates
|
|
|
|
|
|
def _resolve_scores(score_fn, syms: list[str], as_of: Optional[str]) -> tuple[dict, bool]:
|
|
"""Return (score_by_symbol, is_pit).
|
|
|
|
A supplied score_fn marks ``leakage_guard`` ONLY when the returned scores
|
|
carry a ``pit_meta`` proving they were built point-in-time:
|
|
- ``pit_meta = {"pit": true, ...}`` -> leakage_guard = True
|
|
- ``pit_meta`` present but ``pit=false`` (blocked/partial/fallback) -> False
|
|
- no ``pit_meta`` at all (an arbitrary caller-provided fn) -> False
|
|
|
|
This replaces the old behaviour that set leakage_guard=True for ANY supplied
|
|
callable, which could not distinguish a genuine PIT scorer from one that
|
|
silently reused the current board.
|
|
"""
|
|
if score_fn is None:
|
|
from .dashboard import default_scores
|
|
return default_scores(syms) or {}, False
|
|
out = score_fn(syms, as_of) or {}
|
|
if not out:
|
|
return out, False
|
|
# is_pit: the scores themselves assert PIT integrity via pit_meta.
|
|
any_pit = any(
|
|
isinstance(m, dict) and isinstance(m.get("pit_meta"), dict)
|
|
and bool(m.get("pit_meta", {}).get("pit"))
|
|
for m in out.values()
|
|
)
|
|
return out, any_pit
|
|
|
|
|
|
def _candidates_at(series: dict, syms: list[str], date: dt.date,
|
|
score_by_symbol: dict) -> list:
|
|
out = []
|
|
for sym in syms:
|
|
price = _latest_close(series, sym, date)
|
|
if not price or price <= 0:
|
|
continue
|
|
meta = score_by_symbol.get(sym, {})
|
|
from .simulation import Candidate
|
|
out.append(Candidate(
|
|
symbol=sym, price=price,
|
|
combined_score=float(meta.get("combined", 0.0)),
|
|
is_dividend=bool(meta.get("is_dividend", False)),
|
|
dividend_yield=float(meta.get("dividend_yield") or 0.0),
|
|
))
|
|
return out
|
|
|
|
|
|
def run_backtest(
|
|
start: str, end: str,
|
|
capital: float = 1_000_000,
|
|
rebalance_freq: str = "monthly",
|
|
score_fn: Optional[ScoreFn] = None,
|
|
symbols: Optional[list[str]] = None,
|
|
dividend_ledger=None,
|
|
) -> BacktestResult:
|
|
"""Run a multi-rebalance backtest over [start, end].
|
|
|
|
`score_fn(symbols, as_of)` returns {sym: {combined, is_dividend,
|
|
dividend_yield}} as of `as_of`. Default: current board (static, non-PIT ->
|
|
leakage_guard=False). A supplied score_fn sets leakage_guard=True only when
|
|
its scores assert pit_meta.
|
|
|
|
`dividend_ledger` (optional DividendLedger) replaces the final-holdings
|
|
yield proxy: dividends are credited as ``per_share * qty`` from the ledger's
|
|
dated per-symbol entries instead of ``final_qty * px * yield%``. When a
|
|
symbol has no ledger entry it earns no dividend (fail closed, no
|
|
fabrication). When `dividend_ledger` is None the legacy proxy is used and
|
|
labelled as such.
|
|
"""
|
|
from .dividend_ledger import DividendLedger, credit_dividends
|
|
from .dividend_ledger import DividendLedgerError
|
|
_ledger = dividend_ledger if dividend_ledger is not None else None
|
|
series = load_price_snapshot()
|
|
if not series:
|
|
raise BacktestError("no price snapshot")
|
|
syms = symbols or list(series.keys())
|
|
|
|
dates = _rebalance_dates(start, end, rebalance_freq)
|
|
result = BacktestResult(start=start, end=end, capital=capital)
|
|
result.planned_rebalances = len(dates)
|
|
|
|
cash = capital
|
|
holdings: dict[str, int] = {} # sym -> qty
|
|
total_dividend = 0.0
|
|
trades = 0
|
|
actual_rebalances = 0
|
|
leakage_guard = False
|
|
score_by_symbol: dict = {}
|
|
|
|
for d_iso in dates:
|
|
d = dt.date.fromisoformat(d_iso)
|
|
score_by_symbol, is_pit = _resolve_scores(score_fn, syms, d.isoformat())
|
|
leakage_guard = leakage_guard or is_pit
|
|
cands = _candidates_at(series, syms, d, score_by_symbol)
|
|
if not cands:
|
|
continue
|
|
# current portfolio value at d
|
|
port_val = cash + sum(
|
|
qty * (px or 0.0)
|
|
for sym, qty in holdings.items()
|
|
if (px := _latest_close(series, sym, d)) is not None
|
|
)
|
|
alloc = allocate_capital(port_val, cands)
|
|
target = {o.symbol: o.qty for o in alloc.orders}
|
|
|
|
# sell holdings not in the new target
|
|
for sym, qty in list(holdings.items()):
|
|
tgt = target.get(sym, 0)
|
|
if qty > tgt:
|
|
px = _latest_close(series, sym, d)
|
|
if px is None:
|
|
continue
|
|
cash += (qty - tgt) * px
|
|
holdings[sym] = tgt
|
|
trades += 1
|
|
# buy / upsize to target
|
|
for o in alloc.orders:
|
|
cur = holdings.get(o.symbol, 0)
|
|
if o.qty > cur:
|
|
cash -= (o.qty - cur) * o.price
|
|
holdings[o.symbol] = o.qty
|
|
trades += 1
|
|
actual_rebalances += 1
|
|
# drop zero-holding entries
|
|
holdings = {k: v for k, v in holdings.items() if v > 0}
|
|
|
|
_e = dt.date.fromisoformat(end)
|
|
ending_market_value = 0.0
|
|
dividend_method = "final_holdings_yield_proxy"
|
|
for sym, qty in holdings.items():
|
|
px = _latest_close(series, sym, _e)
|
|
if px:
|
|
ending_market_value += qty * px
|
|
if _ledger is not None:
|
|
# ledger-driven: per_share * qty from dated entries (fail closed
|
|
# if no entry -> no credit).
|
|
try:
|
|
credit = credit_dividends(_ledger, {sym: float(qty)}, _e)
|
|
except DividendLedgerError:
|
|
credit = 0.0
|
|
total_dividend += credit
|
|
dividend_method = "dated_ledger" if not _ledger_has_estimate(_ledger, sym) else "dps_annual_proxy"
|
|
else:
|
|
# legacy proxy: yield% * current market value (honest-flagged)
|
|
meta = score_by_symbol.get(sym, {})
|
|
yield_pct = float(meta.get("dividend_yield") or 0.0) / 100.0
|
|
total_dividend += qty * px * yield_pct
|
|
|
|
ending_equity_before_dividend = cash + ending_market_value
|
|
final_value = ending_equity_before_dividend + total_dividend
|
|
|
|
result.holdings = holdings
|
|
result.rebalances = actual_rebalances
|
|
result.final_value = final_value
|
|
result.dividend_income = total_dividend
|
|
result.price_pnl = ending_equity_before_dividend - capital
|
|
result.net_return = (final_value - capital) / capital if capital else 0.0
|
|
result.trades = trades
|
|
result.leakage_guard = leakage_guard
|
|
# record which dividend model produced `dividend_income`
|
|
result.dividend_method = dividend_method
|
|
return result
|
|
|
|
|
|
def _ledger_has_estimate(ledger, symbol: str) -> bool:
|
|
"""True if any ledger entry for `symbol` is a DPS annual proxy estimate."""
|
|
for e in ledger.entries(symbol):
|
|
if e.get("estimate"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def total_investable(cands: list) -> float:
|
|
return sum(c.price for c in cands if c.symbol)
|