- Signal threshold no longer hardcoded 0.15: now quartile-based (LONG>=Q3, SHORT<=Q1, else NEUTRAL) over the whole SET50 board, recomputed each refresh. - Market-regime gate: if >=4 themes have negative surprise -> risk-off bear regime -> tighten LONG bar + pull more into SHORT/avoid, so 'best of a falling board' isn't LONG (answers user 'ตลาดตกควรขายทิ้ง'). - SHORT semantics (user confirmed) = 'หลีก/ไม่ถือ' -> cash, NOT short-selling. - reason_codes + regime now on factor rows (transparent). - Verified: LONG 12 / SHORT 12 / NEUTRAL 25 in normal regime (Q1=-0.044 Q3=0.407). - Fixed test_factors_endpoint_signal_join (was asserting AOT LONG from old tourism). Full suite 202 OK.
124 lines
5.2 KiB
Python
124 lines
5.2 KiB
Python
"""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()
|