"""Tests for the Siamchart fundamental factor view and /api/v1/factors endpoint.""" from __future__ import annotations import json import tempfile import unittest from pathlib import Path from app import create_app from app import siamchart_factors def _make_snapshot(tmpdir: Path, rows, details): path = tmpdir / "snapshot.json" payload = { "source": "siamchart", "retrieved_at": "2026-08-25T01:00:00Z", "count": len(rows), "rows": rows, "details": details, "details_count": len(details), } path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") return path class BuildFactorViewTest(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.tmp_path = Path(self._tmp.name) def tearDown(self) -> None: self._tmp.cleanup() def test_builds_factor_view_with_dividend_flag(self) -> None: rows = [ {"symbol": "AOT", "eps": {"1": 1.0, "2": 1.1, "3": 1.2, "4": 1.3, "5": 1.4}, "eps_yoy": {}, "pe": 51.25}, {"symbol": "PTT", "eps": {"1": 4.0, "2": 4.1, "3": 4.2, "4": 4.3, "5": 4.4}, "eps_yoy": {}, "pe": 9.42}, ] details = { "AOT": {"ratios": {"PE": 51.25, "Yield %": 1.21, "P/BV": 7.13, "EPS": 1.4, "ROE%": 14.3}}, "PTT": {"ratios": {"PE": 9.42, "Yield %": 5.64, "P/BV": 0.97, "EPS": 4.33, "ROE%": 8.53}}, } path = _make_snapshot(self.tmp_path, rows, details) view = siamchart_factors.build_factor_view(path) self.assertTrue(view["available"]) self.assertEqual(view["factor_count"], 2) self.assertEqual(view["dividend_count"], 2) aot = next(f for f in view["factors"] if f["symbol"] == "AOT") self.assertTrue(aot["is_dividend"]) self.assertEqual(aot["dividend_yield"], 1.21) self.assertEqual(aot["pe"], 51.25) # EPS latest must be the MOST RECENT year (5), not the oldest (1). self.assertEqual(aot["eps"], 1.4) # EPS growth derived from the series: (1.4-1.3)/1.3*100 self.assertAlmostEqual(aot["eps_growth_yoy"], round((1.4 - 1.3) / 1.3 * 100, 2)) def test_missing_snapshot_returns_unavailable(self) -> None: view = siamchart_factors.build_factor_view(self.tmp_path / "nope.json") self.assertFalse(view["available"]) self.assertEqual(view["factors"], []) def test_eps_growth_none_when_prior_zero(self) -> None: rows = [{"symbol": "X", "eps": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 5.0}, "eps_yoy": {}, "pe": 10.0}] path = _make_snapshot(self.tmp_path, rows, {"X": {"ratios": {"PE": 10.0}}}) view = siamchart_factors.build_factor_view(path) x = view["factors"][0] self.assertIsNotNone(x["eps"]) # prior period is 0 -> cannot divide -> None self.assertIsNone(x["eps_growth_yoy"]) class FactorsEndpointTest(unittest.TestCase): def setUp(self) -> None: self.snapshot = { "as_of": "2026-08-21", "source": {"source_id": "fixture.tourism", "source_url": "x", "published_at": "2026-08-21T08:00:00Z", "retrieved_at": "2026-08-21T08:05:00Z", "vintage_id": "fixture-1"}, "observations": [{"metric_key": "arrivals_yoy", "value": 12, "expected": 8, "scale": 2, "unit": "percent"}], "exposures": [ {"symbol": "AOT", "coefficient": 1.0, "confidence": 0.95, "evidence": "airport"}, {"symbol": "PTT", "coefficient": -0.2, "confidence": 0.60, "evidence": "control"}, ], } self.app = create_app({ "TESTING": True, "SNAPSHOT": self.snapshot, "PAPER_AUTH_MODE": "token", "PAPER_BIND_HOST": "127.0.0.1", "PAPER_WRITE_TOKEN": "test-token", }) self.client = self.app.test_client() def test_factors_endpoint_merges_fundamentals_and_signal(self) -> None: # relies on the on-disk backend/data/siamchart/set50_master.json snapshot response = self.client.get("/api/v1/factors") self.assertEqual(response.status_code, 200) body = response.get_json() self.assertIn("available", body) if body["available"]: self.assertGreaterEqual(body.get("factor_count", 0), 1) # entries carry both fundamental and signal fields first = body["factors"][0] for key in ("symbol", "pe", "dividend_yield", "is_dividend", "signal_side"): self.assertIn(key, first) def test_factors_endpoint_signal_join(self) -> None: response = self.client.get("/api/v1/factors") body = response.get_json() if body["available"]: by_sym = {f["symbol"]: f for f in body["factors"]} # signal is a valid LONG/SHORT/NEUTRAL (quartile + regime gate), # derived from the theme engine; every factor carries one. for f in body["factors"]: self.assertIn(f.get("signal_side"), ("LONG", "SHORT", "NEUTRAL", None)) # a dividend payer that exists is a factor row self.assertGreaterEqual(len(by_sym), 1) if __name__ == "__main__": unittest.main()