4 Commits

Author SHA1 Message Date
Kunthawat Greethong
068dff22d7 [verified] Dated dividend cash-flow ledger replacing final-holdings proxy
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.
2026-08-27 11:46:15 +07:00
Kunthawat Greethong
1f630be2b5 [verified] PIT factor store + partial PIT score provider (PIT enabler)
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.
2026-08-27 09:26:12 +07:00
Kunthawat Greethong
1b971ac72d [verified] Fix backtest accounting identity + honest UI disclosure
Correct the multi-rebalance backtest accounting so ending wealth is
capital + price_pnl + dividend_income with no double counting:
- price_pnl now measures equity change excluding dividends (was reusing
  ending holdings value as 'price profit')
- dividend proxy is included in final_value and net_return, exposed as
  dividend_method=final_holdings_yield_proxy
- regression tests: flat price => zero price_pnl; flat + dividend =>
  dividend-only return; rising no-dividend => correct bucket P&L;
  multi-rebalance accounting identity
- UI (result card + saved-run history) labels dividends as ประมาณการปันผล
  (Proxy) and shows descriptive non-PIT badge when leakage_guard=false

Backend 239 tests passed; targeted backtest 11 passed; frontend build,
npm audit (0), static scan and diff check passed; fresh independent
review deleg_10918fed passed with empty blocker arrays.
Backtest remains descriptive non-PIT (leakage_guard=false) with the
default current-score scorer.
2026-08-27 09:00:39 +07:00
Kunthawat Greethong
8db3d48ae2 [verified] P0-B registry-driven scoring + P3 PIT backtest + P4 factor-weight learning
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.
2026-08-27 07:12:18 +07:00