[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.
This commit is contained in:
Kunthawat Greethong
2026-08-27 11:46:15 +07:00
parent 77978627e2
commit 068dff22d7
5 changed files with 376 additions and 8 deletions

View File

@@ -189,6 +189,43 @@ class RunBacktestTest(unittest.TestCase):
)
self.assertIs(res.leakage_guard, True)
@patch("app.backtest.load_price_snapshot", return_value=_flat_series())
def test_ledger_replaces_proxy_and_marks_dated_ledger(self, _load):
# A ledger with a dated real payment replaces the final-holdings proxy.
from app.dividend_ledger import DividendLedger
import tempfile
ledger = DividendLedger()
# A pays 2.0/share on 2026-01-31 (within the run window).
ledger.add("A", {
"ex_date": "2026-01-20", "pay_date": "2026-01-31",
"per_share": 2.0,
})
res = backtest.run_backtest(
"2026-01-01", "2026-02-01", capital=100_000,
score_fn=lambda s, a: {"A": {"combined": 1.0, "is_dividend": True,
"dividend_yield": 0.0}},
symbols=["A"], dividend_ledger=ledger,
)
self.assertEqual(res.dividend_method, "dated_ledger")
# A is the only dividend name: bucket1 (50%) buys 50,000/10.0 =
# 5,000 shares of A on 2026-01-01, at 2.0/share = 10,000 dividend.
self.assertEqual(res.dividend_income, 2.0 * 5_000.0)
@patch("app.backtest.load_price_snapshot", return_value=_flat_series())
def test_ledger_estimate_marks_dps_proxy(self, _load):
from app.dividend_ledger import DividendLedger
ledger = DividendLedger()
ledger.add("A", {"per_share": 1.0, "source": "dps_annual_proxy"})
res = backtest.run_backtest(
"2026-01-01", "2026-02-01", capital=100_000,
score_fn=lambda s, a: {"A": {"combined": 1.0, "is_dividend": True,
"dividend_yield": 0.0}},
symbols=["A"], dividend_ledger=ledger,
)
self.assertEqual(res.dividend_method, "dps_annual_proxy")
# bucket1 (50%) buys 5,000 shares of A -> 1.0 * 5,000 = 5,000
self.assertEqual(res.dividend_income, 1.0 * 5_000.0)
if __name__ == "__main__":
unittest.main()