Add an append-only, hash-chained store of every collected Siamchart
fundamental snapshot so the 40% fundamental dimension can be reconstructed
at a historical date instead of always reading the latest snapshot:
- backend/app/siamchart_vintages.py: SiamchartVintageStore persists each
snapshot under its retrieved_at with a SHA-256 canonical hash chain
(tamper/reorder detectable); snapshot_at(as_of) returns the newest
snapshot whose retrieved_at <= as_of (anti-look-ahead), and fails closed
(returns {}) when none is knowable yet. Deduplicates identical
retrieved_at+body persists.
- backend/app/pit_scorer.py: PitScoreProvider accepts siamchart_store; when
wired, siamchart_factor_view reads the snapshot knowable at as_of
(pit_grade='pit') instead of the current snapshot (pit_grade='current').
score_board no longer forces partial_pit when a store is present — the
fundamental dimension is PIT; the theme dimension still fails closed
(pit=false) unless every theme factor has a released PIT value by as_of.
- backend/app/__init__.py: /api/v1/backtest use_pit seeds the first vintage
from the current snapshot (idempotent) and wires the store.
- tests: store (6) + scorer-with-store anti-look-ahead (1) — full backend
suite 273 passed.
Honest scope: snapshots are stored whole and reconstructible forward;
EPS year-keys inside a snapshot are not tied to calendar years, so EPS
growth stays latest-vs-prior (not fiscal-year-pinned). No history before the
first collected snapshot exists.
112 lines
4.9 KiB
Python
112 lines
4.9 KiB
Python
"""Tests for the partial point-in-time score provider (PIT enabler)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from app.factor_vintages import FactorVintageStore
|
|
from app.pit_scorer import PitScoreProvider, _eps_growth_from_series
|
|
|
|
|
|
def _make_store():
|
|
tmp = tempfile.TemporaryDirectory()
|
|
return FactorVintageStore(Path(tmp.name)), tmp
|
|
|
|
|
|
class EpsGrowthTest(unittest.TestCase):
|
|
def test_growth_from_five_year_series(self):
|
|
# 1.0 -> 1.1 -> 1.2 -> 1.3 -> 1.4 : latest growth = (1.4-1.3)/1.3
|
|
self.assertAlmostEqual(
|
|
_eps_growth_from_series({"1": 1.0, "2": 1.1, "3": 1.2, "4": 1.3, "5": 1.4}),
|
|
round((1.4 - 1.3) / 1.3 * 100, 2),
|
|
)
|
|
|
|
def test_growth_none_when_fewer_than_two(self):
|
|
self.assertIsNone(_eps_growth_from_series({"1": 1.0}))
|
|
self.assertIsNone(_eps_growth_from_series({}))
|
|
|
|
|
|
class PitScoreProviderTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.store, self._tmp = _make_store()
|
|
self.addCleanup(self._tmp.cleanup)
|
|
# tourism theme uses tourism_arrivals_ytd (BOT, monthly) in the real
|
|
# registry; build a tiny explicit map so the test is self-contained.
|
|
self.provider = PitScoreProvider(
|
|
self.store,
|
|
siamchart_snapshot=None,
|
|
theme_factor_map={"tourism": [{"key": "tourism_arrivals_ytd", "weight": 1.0}]},
|
|
)
|
|
|
|
def test_theme_surprise_uses_only_released_before_as_of(self):
|
|
# real value released Feb
|
|
self.store.record(
|
|
"tourism_arrivals_ytd", 16.2,
|
|
observed_at="2026-01-31T00:00:00+07:00",
|
|
released_at="2026-02-15T09:00:00+07:00",
|
|
)
|
|
# decoy released June — must be invisible at 2026-03-01
|
|
self.store.record(
|
|
"tourism_arrivals_ytd", 999.0,
|
|
observed_at="2026-05-31T00:00:00+07:00",
|
|
released_at="2026-06-15T09:00:00+07:00",
|
|
)
|
|
at_march = self.provider.theme_surprise_report("tourism", "2026-03-01T00:00:00+07:00")
|
|
self.assertFalse(at_march["blocked"])
|
|
# normalize(16.2, center=20, span=15) = (16.2-20)/15 = -0.2533 (sign +1)
|
|
self.assertAlmostEqual(at_march["surprise"], round((16.2 - 20.0) / 15.0, 4), places=3)
|
|
|
|
def test_theme_blocked_when_no_value_released_by_as_of(self):
|
|
self.store.record(
|
|
"tourism_arrivals_ytd", 16.2,
|
|
observed_at="2026-01-31T00:00:00+07:00",
|
|
released_at="2026-02-15T09:00:00+07:00",
|
|
)
|
|
report = self.provider.theme_surprise_report("tourism", "2026-01-15T00:00:00+07:00")
|
|
self.assertTrue(report["blocked"])
|
|
self.assertIsNone(report["surprise"])
|
|
|
|
def test_siamchart_partial_grade_and_eps_growth(self):
|
|
snap = {
|
|
"retrieved_at": "2026-08-25T01:41:43Z",
|
|
"rows": [{"symbol": "AOT", "eps": {"1": 1.0, "2": 1.1, "3": 1.2, "4": 1.3, "5": 1.4}}],
|
|
"details": {"AOT": {"ratios": {"Yield %": 1.21, "PE": 51.25}}},
|
|
}
|
|
provider = PitScoreProvider(self.store, siamchart_snapshot=snap)
|
|
view = provider.siamchart_factor_view("2026-08-25T00:00:00+07:00")
|
|
aot = view["AOT"]
|
|
self.assertEqual(aot["pit_grade"], "current")
|
|
self.assertAlmostEqual(aot["eps_growth_yoy"], round((1.4 - 1.3) / 1.3 * 100, 2))
|
|
self.assertEqual(aot["dividend_yield"], 1.21)
|
|
self.assertTrue(aot["is_dividend"])
|
|
|
|
def test_siamchart_store_reads_snapshot_at_as_of(self):
|
|
# With a vintage store wired, the provider reads the snapshot knowable
|
|
# at as_of (anti-look-ahead), not the current snapshot.
|
|
from app.siamchart_vintages import SiamchartVintageStore
|
|
import tempfile
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
sstore = SiamchartVintageStore(tmp.name)
|
|
sstore.persist({"retrieved_at": "2026-01-10T00:00:00+07:00",
|
|
"rows": [{"symbol": "AOT", "eps": {"1": 1.0, "2": 1.1, "3": 1.2, "4": 1.3, "5": 1.4}}],
|
|
"details": {"AOT": {"ratios": {"Yield %": 1.0}}}})
|
|
sstore.persist({"retrieved_at": "2026-06-10T00:00:00+07:00",
|
|
"rows": [{"symbol": "AOT", "eps": {"1": 1.0, "2": 1.1, "3": 1.2, "4": 1.3, "5": 1.4}}],
|
|
"details": {"AOT": {"ratios": {"Yield %": 9.0}}}})
|
|
provider = PitScoreProvider(self.store, siamchart_snapshot=None,
|
|
siamchart_store=sstore)
|
|
# at March only the January snapshot is visible -> yield 1.0, pit grade
|
|
march = provider.siamchart_factor_view("2026-03-01T00:00:00+07:00")
|
|
self.assertEqual(march["AOT"]["dividend_yield"], 1.0)
|
|
self.assertEqual(march["AOT"]["pit_grade"], "pit")
|
|
# at July the June snapshot is visible -> yield 9.0
|
|
july = provider.siamchart_factor_view("2026-07-01T00:00:00+07:00")
|
|
self.assertEqual(july["AOT"]["dividend_yield"], 9.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|