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.
97 lines
3.3 KiB
Python
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()
|