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.
Add a point-in-time (PIT) factor/data store and a score provider so the
backtest engine can rebuild per-symbol scores from data actually knowable
at a given date, instead of silently reusing the live board:
- backend/app/factor_vintages.py: append-only, provenance-complete store
(observed_at/released_at/retrieved_at) with a SHA-256 canonical hash chain.
value_at(as_of) only ever returns rows whose released_at <= as_of (real,
testable anti-look-ahead); no value by as_of fails closed (returns None).
- backend/app/pit_scorer.py: PitScoreProvider computes theme surprises from
PIT factor values only, and a partial siamchart fundamental view (EPS
growth from the 5-year series; current ratios marked partial). score_board
attaches pit_meta so callers can tell PIT from fallback.
- backend/app/backtest.py: _resolve_scores now sets leakage_guard ONLY when
the supplied score_fn's meta asserts pit_meta.pit=true; an arbitrary
callable with no PIT proof is no longer treated as PIT (closes the
'supplied fn => PIT' hole).
- backend/app/__init__.py: /api/v1/backtest accepts use_pit, wiring the PIT
provider; _load_siamchart_snapshot loads the SET50 fundamental snapshot.
- tests: factor store (9), pit scorer (5), backtest leakage-guard gating (2
new + 1 corrected) — full backend suite 255 passed. Empty store fail-closes
(leakage_guard=false) as proven by a live route probe.
Honest scope: theme dimension is PIT from this store forward; siamchart
fundamental remains partial (current ratios) and is flagged as such. No
historical factor data before today exists, so pre-today backtests remain
non-PIT by construction.
P0-B (registry is the single source of truth for scoring):
- FACTORS now carries center/span normalization spec; unused hand-written
per-theme surprise blocks in dashboard.py replaced by one registry-driven
compute_theme_surprises() (themes.py).
- THEMES['banks'] adds bank_npl weight so NPL is genuinely blended.
- factor_value/normalize hardened against NaN/inf (finite guards).
- Board re-ranks (TRUE/GULF up, TOP->3) per registry weights; 3 new tests
incl. 'changing a registry weight changes output'.
P3 (point-in-time backtest):
- run_backtest is now a real multi-rebalance engine (reallocates every window,
reconciles holdings, marks to market) instead of allocate-once+break.
- Added leakage_guard (False unless a PIT score_fn is supplied), planned vs
actual rebalances, and momentum_at() true 12-1 (skips last month, PIT).
P4 (factor-weight learning):
- weight_learning.py: cross-sectional Spearman IC, forward-return builder,
IC aggregation + t-stat, and apply_weight_update (new = clip(old*(1+shrink*IC))).
- GET /api/v1/learning/momentum endpoint. Live result: momentum IC=0.012
t=0.132 over 22 periods -> momentum has no reliable predictive power here.
Macro/demographic factors blocked (no historical factor vintages yet).
Two independent review gates passed (deleg_fe6f45cd, deleg_718218f8): empty
security/logic arrays; their non-blocking suggestions applied (finite guards,
dedupe leakage_guard resolution). 226 tests pass; Vite build passes.
- backtest.py: buy-and-hold backtest over [start,end] — allocates 50/20/30 at first available rebalance date, marks to market to end, accrues dividend, reports {final_value, price_pnl, dividend_income, net_return, trades, holdings}
- Fixed double-spend bug (was allocating full capital every rebalance -> negative cash)
- dashboard.default_scores(): per-symbol combined/dividend/yield baseline for backtest
- POST /api/v1/backtest + GET /api/v1/backtest/runs (results persisted in app state -> survive refresh)
- Frontend: backtest section w/ start/end/capital/freq inputs + P&L KPIs + run history table
- Honest note: uses current combined scores as static baseline (non-PIT); PIT score_fn pluggable
- Verified: 1M -> 1.088M (+8.80%) over 2024-06..2026-06; history persists across refresh