Files
set50-system/backend/tests/test_dividend_ledger.py
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

97 lines
3.3 KiB
Python

"""Tests for the dated dividend cash-flow ledger (honest dividend model)."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from app.dividend_ledger import (
DividendLedger,
DividendLedgerError,
credit_dividends,
)
class DividendLedgerTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.path = Path(self._tmp.name) / "ledger.json"
self.ledger = DividendLedger(self.path)
def tearDown(self):
self._tmp.cleanup()
def test_add_and_persist_roundtrip(self):
self.ledger.add("ADVANC", {
"ex_date": "2026-03-12",
"record_date": "2026-03-13",
"pay_date": "2026-04-20",
"per_share": 8.69,
})
self.ledger.save()
reloaded = DividendLedger(self.path)
self.assertEqual(len(reloaded.entries("ADVANC")), 1)
e = reloaded.entries("ADVANC")[0]
self.assertEqual(e["per_share"], 8.69)
self.assertEqual(e["symbol"], "ADVANC")
self.assertEqual(e["estimate"], False)
def test_real_entry_requires_ex_date(self):
with self.assertRaises(DividendLedgerError):
self.ledger.add("X", {"per_share": 1.0}) # real, no ex_date -> fail
def test_estimate_entry_may_omit_ex_date(self):
e = self.ledger.add("X", {"per_share": 2.0, "source": "dps_annual_proxy"})
self.assertTrue(e["estimate"])
def test_negative_per_share_rejected(self):
with self.assertRaises(DividendLedgerError):
self.ledger.add("X", {"ex_date": "2026-01-01", "per_share": -1.0})
def test_invalid_ex_date_rejected(self):
with self.assertRaises(DividendLedgerError):
self.ledger.add("X", {"ex_date": "not-a-date", "per_share": 1.0})
class CreditDividendsTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.ledger = DividendLedger(Path(self._tmp.name) / "l.json")
def tearDown(self):
self._tmp.cleanup()
def test_credit_real_payment_on_pay_date(self):
import datetime as dt
self.ledger.add("ADVANC", {
"ex_date": "2026-03-12", "pay_date": "2026-04-20", "per_share": 8.69,
})
# hold 1000 shares, credit on/after ex-date (before pay)
total = credit_dividends(self.ledger, {"ADVANC": 1000.0}, dt.date(2026, 4, 20))
self.assertAlmostEqual(total, 8.69 * 1000.0, places=2)
def test_no_credit_before_ex_date(self):
import datetime as dt
self.ledger.add("ADVANC", {"ex_date": "2026-03-12", "per_share": 8.69})
# before ex-date -> no payment yet
total = credit_dividends(self.ledger, {"ADVANC": 1000.0}, dt.date(2026, 3, 1))
self.assertEqual(total, 0.0)
def test_estimate_credit(self):
import datetime as dt
self.ledger.add("ADVANC", {"per_share": 8.69, "source": "dps_annual_proxy"})
total = credit_dividends(self.ledger, {"ADVANC": 500.0}, dt.date(2026, 4, 20))
self.assertAlmostEqual(total, 8.69 * 500.0, places=2)
def test_no_entry_no_credit(self):
import datetime as dt
self.assertEqual(
credit_dividends(self.ledger, {"UNKNOWN": 1000.0}, dt.date(2026, 4, 20)),
0.0,
)
if __name__ == "__main__":
unittest.main()