Files
set50-system/backend/tests/test_pit_scorer.py
Kunthawat Greethong 887e9c9208 [verified] PIT siamchart vintage store un-partials the fundamental dimension
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.
2026-08-27 11:55:36 +07:00

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()