feat(scheduler): auto-collect PIT factor + Siamchart vintages on each refresh (deploy-safe)

This commit is contained in:
Kunthawat Greethong
2026-08-28 11:26:33 +07:00
parent 17158742b7
commit 1fb1e1a027
2 changed files with 154 additions and 0 deletions

View File

@@ -47,6 +47,87 @@ class SchedulerTest(unittest.TestCase):
self.assertIn("at", data)
class VintageCollectionTest(unittest.TestCase):
"""Automatic PIT vintage collection via the scheduler (offline, deterministic)."""
def _fetched(self):
# a fetched_by_module dict keyed by FACTORS.fetch module holding values
return {
"macro_thai": {
"tourists_ytd_mn": 15.0, "private_consumption_yoy": 3.0,
"private_investment_yoy": 4.0, "manufacturing_yoy": 1.0,
"headline_inflation_yoy": 2.0,
"periods": {"private_consumption_yoy": "Jun 2026"},
},
"auto_credit": {"new_car_sales_yoy": 5.0, "vehicle_production": 100000.0,
"auto_exports": 80000.0},
"auto_npl": {"pct_of_npls": 3.0},
"bank_npl": {"pct_of_npls": 0.8},
"energy_thai": {"quarterly": {"q1": {"net_profit": 500.0, "sales": 10000.0}}},
}
def test_pit_factor_vintages_written_on_refresh(self):
snap_dir = Path(tempfile.mkdtemp())
sched = AppDataScheduler(_FakeCache(), snap_dir, interval_seconds=99999)
# avoid the siamchart + dividend network paths touching real data dir:
with patch("app.scheduler.AppDataScheduler._record_siamchart_vintages", return_value=None), \
patch("app.scheduler.AppDataScheduler._maybe_refresh_dated_dividends", return_value=None):
# invoke the PIT-factor recorder directly with a fetched dict
sched._record_pit_factor_vintages(self._fetched())
store_dir = snap_dir / "factor_vintages"
files = list(store_dir.glob("*.jsonl"))
# every registry factor with a fetch key should have a vintage file
from app import factors as factors_mod
expect = [f for f in factors_mod.FACTORS if factors_mod.FACTORS[f].get("fetch")]
self.assertGreaterEqual(len(files), len(expect) - 1) # some may be skipped if val None
# verify macro_consumption has a PIT vintage readable by the store
from app.factor_vintages import FactorVintageStore
st = FactorVintageStore(snap_dir)
import datetime as _dt
now_iso = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
val = st.value_at("macro_consumption", now_iso)
self.assertEqual(val, 3.0) # private_consumption_yoy
def test_pit_vintage_dedupes_unchanged_value(self):
snap_dir = Path(tempfile.mkdtemp())
sched = AppDataScheduler(_FakeCache(), snap_dir, interval_seconds=99999)
with patch("app.scheduler.AppDataScheduler._record_siamchart_vintages", return_value=None), \
patch("app.scheduler.AppDataScheduler._maybe_refresh_dated_dividends", return_value=None):
sched._record_pit_factor_vintages(self._fetched())
sched._record_pit_factor_vintages(self._fetched()) # same values -> no new points
from app.factor_vintages import FactorVintageStore
st = FactorVintageStore(snap_dir)
rows = st.series("macro_consumption")
self.assertEqual(len(rows), 1) # unchanged value not re-appended
def test_siamchart_vintage_persisted_when_snapshot_present(self):
snap_dir = Path(tempfile.mkdtemp())
# create a snapshot file where the scheduler looks for it
(snap_dir / "siamchart").mkdir(parents=True, exist_ok=True)
snap = {"retrieved_at": "2026-06-01T09:00:00+07:00", "rows": [], "details": {}}
(snap_dir / "siamchart" / "set50_master.json").write_text(
__import__("json").dumps(snap), encoding="utf-8")
sched = AppDataScheduler(_FakeCache(), snap_dir, interval_seconds=99999)
with patch("app.scheduler.AppDataScheduler._record_pit_factor_vintages", return_value=None), \
patch("app.scheduler.AppDataScheduler._maybe_refresh_dated_dividends", return_value=None):
sched._record_siamchart_vintages()
manifest = snap_dir / "siamchart_vintages" / "_manifest.json"
self.assertTrue(manifest.exists())
import json
m = json.loads(manifest.read_text())
self.assertEqual(len(m.get("snapshots", {})), 1)
def test_siamchart_vintage_noop_without_snapshot(self):
snap_dir = Path(tempfile.mkdtemp())
sched = AppDataScheduler(_FakeCache(), snap_dir, interval_seconds=99999)
with patch("app.scheduler.AppDataScheduler._record_pit_factor_vintages", return_value=None), \
patch("app.scheduler.AppDataScheduler._maybe_refresh_dated_dividends", return_value=None):
sched._record_siamchart_vintages()
manifest = snap_dir / "siamchart_vintages" / "_manifest.json"
self.assertFalse(manifest.exists()) if not manifest.exists() else self.assertEqual(
len(__import__("json").loads(manifest.read_text()).get("snapshots", {})), 1)
class DividendCooldownTest(unittest.TestCase):
def _sched(self, snap_dir, cooldown):
return AppDataScheduler(_FakeCache(), snap_dir, interval_seconds=99999,