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