182 lines
8.9 KiB
Python
182 lines
8.9 KiB
Python
"""Tests for the in-app data scheduler."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from app.scheduler import AppDataScheduler
|
|
|
|
|
|
class _FakeCache:
|
|
def __init__(self): self.calls = {}
|
|
def fetch_or_stale(self, key, fetcher):
|
|
self.calls[key] = self.calls.get(key, 0) + 1
|
|
return fetcher()
|
|
|
|
|
|
class SchedulerTest(unittest.TestCase):
|
|
def test_refresh_all_calls_collectors(self):
|
|
cache = _FakeCache()
|
|
snap_dir = Path(tempfile.mkdtemp())
|
|
sched = AppDataScheduler(cache, snap_dir, interval_seconds=99999)
|
|
# job fetchers import app.* modules; patch their fetch fns to return dicts
|
|
def fake(modname, fn):
|
|
return lambda: {"ok": True}
|
|
with patch("app.auto_credit.fetch_auto_credit", return_value=type("F", (), {"to_dict": lambda self: {"n": 1}})()), \
|
|
patch("app.auto_npl.fetch_auto_npl", return_value=type("F", (), {"to_dict": lambda self: {"n": 2}})()), \
|
|
patch("app.energy_thai.fetch_energy_thai", return_value=type("F", (), {"to_dict": lambda self: {"n": 3}})()), \
|
|
patch("app.macro_thai.fetch_macro_thai", return_value=type("F", (), {"to_dict": lambda self: {"n": 4}})()):
|
|
res = sched.refresh_all()
|
|
# tourism job is bot_tourism.BotTourismSource().fetch — not trivially patched; allow it to fail gracefully
|
|
self.assertGreaterEqual(len(res), 4)
|
|
self.assertTrue(any(r["ok"] for r in res))
|
|
|
|
def test_marker_written(self):
|
|
cache = _FakeCache()
|
|
snap_dir = Path(tempfile.mkdtemp())
|
|
sched = AppDataScheduler(cache, snap_dir, interval_seconds=99999)
|
|
with patch("app.macro_thai.fetch_macro_thai", return_value=type("F", (), {"to_dict": lambda self: {"n": 1}})()):
|
|
sched.refresh_all()
|
|
marker = snap_dir / "scheduler" / "last_refresh.json"
|
|
self.assertTrue(marker.exists())
|
|
import json
|
|
data = json.loads(marker.read_text())
|
|
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,
|
|
dividend_cooldown_seconds=cooldown)
|
|
|
|
def test_refreshes_once_then_respects_cooldown(self):
|
|
snap_dir = Path(tempfile.mkdtemp())
|
|
sched = self._sched(snap_dir, cooldown=3600) # 1h cooldown
|
|
calls = {"n": 0}
|
|
|
|
def fake_fetch(sym):
|
|
calls["n"] += 1
|
|
return [("2025-01-01", 1.0)]
|
|
|
|
fake_fv = {"factors": [{"symbol": "PTT"}]}
|
|
with patch("app.siamchart_factors.build_factor_view", return_value=fake_fv), \
|
|
patch("app.siamchart.fetch_dividend_history", side_effect=fake_fetch):
|
|
sched._maybe_refresh_dated_dividends() # first -> fetch
|
|
n_after_first = calls["n"]
|
|
sched._maybe_refresh_dated_dividends() # second, within cooldown -> skip
|
|
self.assertEqual(n_after_first, 1) # fetched once
|
|
self.assertEqual(calls["n"], 1) # second call skipped
|
|
self.assertTrue((snap_dir / "dividends" / "ledger.json").exists())
|
|
|
|
def test_refetches_after_cooldown_elapses(self):
|
|
snap_dir = Path(tempfile.mkdtemp())
|
|
sched = self._sched(snap_dir, cooldown=1) # 1s cooldown
|
|
calls = {"n": 0}
|
|
|
|
def fake_fetch(sym):
|
|
calls["n"] += 1
|
|
return [("2025-01-01", 1.0)]
|
|
|
|
fake_fv = {"factors": [{"symbol": "PTT"}]}
|
|
with patch("app.siamchart_factors.build_factor_view", return_value=fake_fv), \
|
|
patch("app.siamchart.fetch_dividend_history", side_effect=fake_fetch):
|
|
sched._maybe_refresh_dated_dividends()
|
|
self.assertEqual(calls["n"], 1)
|
|
# force cooldown to have elapsed by rewriting the marker back in time
|
|
import json
|
|
import datetime as _dt
|
|
sched._div_marker.write_text(
|
|
json.dumps({"at": (_dt.datetime.now().astimezone() - _dt.timedelta(hours=1)).isoformat()}),
|
|
encoding="utf-8",
|
|
)
|
|
sched._maybe_refresh_dated_dividends()
|
|
self.assertEqual(calls["n"], 2) # refetched
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|