Add a point-in-time (PIT) factor/data store and a score provider so the backtest engine can rebuild per-symbol scores from data actually knowable at a given date, instead of silently reusing the live board: - backend/app/factor_vintages.py: append-only, provenance-complete store (observed_at/released_at/retrieved_at) with a SHA-256 canonical hash chain. value_at(as_of) only ever returns rows whose released_at <= as_of (real, testable anti-look-ahead); no value by as_of fails closed (returns None). - backend/app/pit_scorer.py: PitScoreProvider computes theme surprises from PIT factor values only, and a partial siamchart fundamental view (EPS growth from the 5-year series; current ratios marked partial). score_board attaches pit_meta so callers can tell PIT from fallback. - backend/app/backtest.py: _resolve_scores now sets leakage_guard ONLY when the supplied score_fn's meta asserts pit_meta.pit=true; an arbitrary callable with no PIT proof is no longer treated as PIT (closes the 'supplied fn => PIT' hole). - backend/app/__init__.py: /api/v1/backtest accepts use_pit, wiring the PIT provider; _load_siamchart_snapshot loads the SET50 fundamental snapshot. - tests: factor store (9), pit scorer (5), backtest leakage-guard gating (2 new + 1 corrected) — full backend suite 255 passed. Empty store fail-closes (leakage_guard=false) as proven by a live route probe. Honest scope: theme dimension is PIT from this store forward; siamchart fundamental remains partial (current ratios) and is flagged as such. No historical factor data before today exists, so pre-today backtests remain non-PIT by construction.
88 lines
3.4 KiB
Python
88 lines
3.4 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"], "partial")
|
|
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"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|