"""Tests for the multi-theme registry + scoring.""" from __future__ import annotations import unittest from app import themes from app import auto_credit, auto_npl, bank_npl, energy_irpc, macro_thai, te_thailand, thai_trade class ThemesTest(unittest.TestCase): def test_exposure_mapping_contains_expected(self) -> None: self.assertIn("AOT", themes.THEME_SYMBOLS["tourism"]) self.assertIn("PTT", themes.THEME_SYMBOLS["refining_energy"]) self.assertIn("TISCO", themes.THEME_SYMBOLS["auto_credit"]) def test_frequency_recorded_per_theme(self) -> None: self.assertEqual(themes.THEME_FREQUENCY["refining_energy"], "quarterly") self.assertEqual(themes.THEME_FREQUENCY["tourism"], "monthly") def test_siamchart_score_uses_growth_and_yield(self) -> None: factors = { "factors": [ {"symbol": "X", "eps_growth_yoy": 10.0, "dividend_yield": 2.0}, {"symbol": "Y", "eps_growth_yoy": -5.0, "dividend_yield": 0.0}, ] } sc = themes.build_siamchart_score(factors) # X = 10 + 2*2 = 14 ; Y = -5 -> X higher self.assertGreater(sc["X"], sc["Y"]) def test_siamchart_raw_score_has_momentum_inside_formula(self) -> None: # Momentum is a first-class factor WITHIN the formula (owner rule): # raw = eps_growth*1.5 + dividend_yield*2.0 + momentum*0.5. self.assertEqual( round(themes.siamchart_raw_score(4.0, 1.0, 2.0), 6), round(4.0 * 1.5 + 1.0 * 2.0 + 2.0 * 0.5, 6), # 6 + 2 + 1 = 9 ) self.assertEqual(themes._SIAMCHART_WEIGHTS["momentum"], 0.5) def test_siamchart_momentum_raises_score(self) -> None: factors = { "factors": [ {"symbol": "UP", "eps_growth_yoy": 0.0, "dividend_yield": 0.0}, {"symbol": "DN", "eps_growth_yoy": 0.0, "dividend_yield": 0.0}, ] } momentum = {"UP": 1.0, "DN": -1.0} sc = themes.build_siamchart_score(factors, momentum=momentum) # Same fundamentals, only momentum differs -> UP must win. self.assertGreater(sc["UP"], sc["DN"]) def test_combine_60_40(self) -> None: theme_scores = [{"A": 1.0, "B": -1.0}] siamchart = {"A": 2.0, "B": 0.0} merged = themes.combine_score(theme_scores, siamchart) # A: 0.6*1.0 + 0.4*2.0 = 1.4 ; B: 0.6*(-1) + 0.4*0 = -0.6 self.assertAlmostEqual(merged["A"]["combined"], 1.4, places=5) self.assertAlmostEqual(merged["B"]["combined"], -0.6, places=5) self.assertAlmostEqual(merged["A"]["theme_score"], 1.0, places=5) def test_multiple_themes_average(self) -> None: # Symbol in two themes -> theme_score is the mean. t1 = {"A": 1.0} t2 = {"A": 3.0} merged = themes.combine_score([t1, t2], {}) self.assertAlmostEqual(merged["A"]["theme_score"], 2.0, places=5) def test_missing_sym_siamchart_gets_theme_only(self) -> None: merged = themes.combine_score([{"A": 2.0}], {}) self.assertAlmostEqual(merged["A"]["combined"], 0.6 * 2.0, places=5) if __name__ == "__main__": unittest.main() class SymbolBreakdownTest(unittest.TestCase): def test_breakdown_shows_components(self): factor_view = { "factors": [ {"symbol": "AOT", "eps_growth_yoy": 10.0, "dividend_yield": 2.0, "pe": 20.0, "eps": 5.0, "pbv": 2.0, "roe": 15.0, "is_dividend": True, "company_name": "Airports"}, ] } theme_surprises = {"tourism": 0.57, "auto_credit": 0.81, "refining_energy": 1.62} from app import themes d = themes.symbol_breakdown("AOT", factor_view=factor_view, theme_surprises=theme_surprises, latest_price=67.0, price_date="2026-08-21") self.assertEqual(d["symbol"], "AOT") self.assertEqual(d["themes"], ["tourism"]) # AOT in tourism map self.assertEqual(d["theme_contributions"][0]["surprise"], 0.57) self.assertIn("siamchart_components", d) self.assertEqual(d["weights"], {"theme": 0.6, "siamchart": 0.4}) self.assertIsInstance(d["combined_score"], float) self.assertEqual(d["price"]["latest"], 67.0) self.assertIn("fundamentals", d) def test_breakdown_symbol_without_theme(self): factor_view = {"factors": [{"symbol": "BANPU", "eps_growth_yoy": -2.0, "dividend_yield": 0.0, "is_dividend": False, "company_name": "BANPU"}]} from app import themes d = themes.symbol_breakdown("BANPU", factor_view=factor_view, theme_surprises={}) self.assertEqual(d["theme_score"], 0.0) # BANPU is in energy/petrochem/utilities maps (full SET50 coverage) self.assertTrue(set(d["themes"]) >= {"refining_energy", "petrochem_materials", "utilities"}) # no surprises set -> every contribution has surprise=None and theme_score 0 self.assertTrue(all(c["surprise"] is None for c in d["theme_contributions"])) class QualitySelectionTest(unittest.TestCase): def test_quality_differentiates_strong_vs_weak_in_theme(self): from app import themes fv = {"factors": [ {"symbol": "BBL", "roe": 12.0, "eps_growth_yoy": 8.0}, {"symbol": "KBANK", "roe": 10.0, "eps_growth_yoy": 5.0}, {"symbol": "KTB", "roe": 9.0, "eps_growth_yoy": 3.0}, {"symbol": "SCB", "roe": 8.0, "eps_growth_yoy": 2.0}, {"symbol": "TTB", "roe": 5.0, "eps_growth_yoy": -2.0}, ]} q_bbl = themes.quality_within_theme("BBL", "banks", fv) q_ttb = themes.quality_within_theme("TTB", "banks", fv) self.assertGreater(q_bbl, q_ttb) # strong bank outscores weak one def test_breakdown_has_quality_and_theme_score_per_theme(self): from app import themes fv = {"factors": [ {"symbol": "BBL", "roe": 12.0, "eps_growth_yoy": 8.0}, {"symbol": "KTB", "roe": 9.0, "eps_growth_yoy": 3.0}, ]} d = themes.symbol_breakdown("BBL", factor_view=fv, theme_surprises={"banks": 1.0}) contrib = next(c for c in d["theme_contributions"] if c["theme"] == "banks") self.assertIn("quality", contrib) self.assertIn("theme_score", contrib) self.assertAlmostEqual(contrib["theme_score"], contrib["surprise"] * contrib["quality"], places=3) class RegistryDrivenSurpriseTest(unittest.TestCase): """P0-B: the declarative FACTORS/THEMES registry is now the single source of truth for theme surprises — editing a weight genuinely changes output.""" @staticmethod def _fetched(**macro): # Build a minimal `fetched` dict (fetch-module -> collector dict). return { "macro_thai": { "private_consumption_yoy": macro.get("cons", 4.9), "private_investment_yoy": macro.get("invest", 18.1), "headline_inflation_yoy": macro.get("infl", 1.95), "manufacturing_yoy": macro.get("mfg", -3.1), "tourists_ytd_mn": macro.get("tour", 16.2), }, "auto_credit": { "new_car_sales_yoy": 20.07, "vehicle_production": 117383.0, "auto_exports": 81526.0, }, "auto_npl": {"pct_of_npls": 3.95}, "energy_thai": { "quarterly": {"Q1/2026": {"net_profit": 19481.0, "sales": 114809.0}}, }, } def test_retail_driven_by_registry_weights(self): from app import themes s = themes.compute_theme_surprises(self._fetched()) # registry retail = weighted average over |weights| of # consumption*1.0 + inflation*0.3 (sign -1 intrinsic in the factor): # cons=4.9 -> (4.9-3)/10=0.19 (w=1.0) ; inflation sign -1 -> # -(1.95-2)/10=+0.005 (w=+0.3) -> weighted = 0.19 + 0.0015 = 0.1915 # surprise = 0.1915 / (1.0 + 0.3) = 0.147 self.assertIsNotNone(s["retail"]) self.assertAlmostEqual(s["retail"], 0.147, places=3) def test_changing_factor_weight_changes_output(self): """The defining property of P0-B: the registry is not decorative.""" from unittest.mock import patch from app import themes base = themes.compute_theme_surprises(self._fetched())["retail"] # Double consumption's weight in the retail theme -> surprise must rise. original = themes.THEMES["retail"]["factors"] try: themes.THEMES["retail"]["factors"] = [ {"key": "macro_consumption", "weight": 2.0}, {"key": "macro_inflation", "weight": -0.3}, ] changed = themes.compute_theme_surprises(self._fetched())["retail"] finally: themes.THEMES["retail"]["factors"] = original self.assertGreater(changed, base) def test_tourism_override_wins(self): from app import themes s = themes.compute_theme_surprises(self._fetched(), tourism_surprise=0.123) self.assertEqual(s["tourism"], 0.123) # Without override, tourism falls back to registry factors (not None). s2 = themes.compute_theme_surprises(self._fetched()) self.assertIsNotNone(s2["tourism"]) def test_normalize_rejects_non_finite(self): from app import factors self.assertIsNone(factors.normalize(float("nan"))) self.assertIsNone(factors.normalize(float("inf"))) self.assertIsNone(factors.normalize(None)) # finite value still normalizes self.assertEqual(factors.normalize(15.0, sign=1, center=5.0, span=10.0), 1.0) def test_new_macro_factors_actually_move_scores(self): """Wire-in proof (user rule: a fetched field must feed analysis). core_inflation + unemployment were fetched by BOT macro_thai but not used. Once present with a non-neutral value they must change the retail/banks surprise vs a neutral value — i.e. they are not dead data. """ from app import themes neutral = { "core_inflation_yoy": 1.0, # == center(1.0) -> 0 normalised "unemployment_pct": 1.0, # == center(1.0) -> 0 normalised } base_fetched = self._fetched() base_fetched["macro_thai"].update(neutral) s_neutral = themes.compute_theme_surprises( {k: dict(v) for k, v in base_fetched.items()}) hot = dict(base_fetched) hot["macro_thai"]["unemployment_pct"] = 3.0 # above center -> bearish s_hot = themes.compute_theme_surprises({k: dict(v) for k, v in hot.items()}) # higher unemployment is bearish (sign -1, +ve weight) -> retail/healthcare lower self.assertLess(s_hot["retail"], s_neutral["retail"]) self.assertLess(s_hot["healthcare"], s_neutral["healthcare"]) hot_infl = dict(base_fetched) hot_infl["macro_thai"]["core_inflation_yoy"] = 3.5 # above center -> bearish s_infl = themes.compute_theme_surprises({k: dict(v) for k, v in hot_infl.items()}) self.assertLess(s_infl["banks"], s_neutral["banks"]) def test_bearish_factors_move_score_the_right_way(self): """Regression for the sign-inversion bug. Factor `sign` is applied once inside normalize() so a *positive* theme weight means "more of this factor matters". Bearish factors (NPL, inflation, unemployment) all carry sign -1; a negative theme weight made the double product turn positive — i.e. higher NPL/inflation RAISED the theme score. Lock the correct direction: higher NPL must LOWER auto_credit/banks; lower NPL must RAISE them. """ from app import themes base = self._fetched() # base has auto_npl pct 3.95, bank_npl NOT present (only via macro) -> use # a full fetched incl. bank_npl so the factor is exercised. base["bank_npl"] = {"pct_of_npls": 1.0} low = themes.compute_theme_surprises({k: dict(v) for k, v in base.items()}) hi = dict(base) hi["auto_npl"] = {"pct_of_npls": 8.0} # much worse credit quality hi["bank_npl"] = {"pct_of_npls": 5.0} s_hi = themes.compute_theme_surprises({k: dict(v) for k, v in hi.items()}) # higher NPL -> lower surprise in the credit-heavy themes (bearish) self.assertLess(s_hi["auto_credit"], low["auto_credit"]) self.assertLess(s_hi["banks"], low["banks"]) def test_every_factor_value_key_resolves_to_a_fetched_field(self): """Contract (user rule): every FACTORS.value_key must be a real field the registered fetch module emits. A factor that reads a key the collector never produces is dead weight — catches 'fetched but not used' the other way (a value_key that can never be populated).""" from app import factors for fkey, fact in factors.FACTORS.items(): fetch_mod = fact.get("fetch") value_key = fact.get("value_key") if value_key is None: continue # energy_thai derives its value from a nested quarterly dict, not a # top-level key — covered separately by factor_value(). if fetch_mod == "energy_thai": continue if fetch_mod == "energy_irpc": keys = set(energy_irpc.EnergyIrpcSnapshot().to_dict().keys()) # resolve the module's snapshot .to_dict() keys if fetch_mod == "macro_thai": keys = set(macro_thai.MacroThaiSnapshot().to_dict().keys()) elif fetch_mod == "auto_credit": keys = set(auto_credit.AutoCreditSnapshot().to_dict().keys()) elif fetch_mod == "auto_npl": keys = set(auto_npl.AutoNplSnapshot().to_dict().keys()) elif fetch_mod == "bank_npl": keys = set(bank_npl.BankNplSnapshot().to_dict().keys()) elif fetch_mod == "thai_trade": keys = set(thai_trade.ThaiTradeSnapshot().to_dict().keys()) elif fetch_mod == "te_thailand": keys = set(te_thailand.ThaiFactorsSnapshot().to_dict().keys()) else: continue self.assertIn( value_key, keys, f"factor {fkey!r} targets value_key {value_key!r} that fetch " f"module {fetch_mod!r} never emits -> dead factor", ) def test_factor_source_breakdown_shows_per_source_contribution(self): """Audit trail: each factor shows source/raw/normalized/weight/contribution so the owner can see exactly how every source scored and weight applied.""" from app import themes fetched = { "macro_thai": { "private_consumption_yoy": 4.9, "headline_inflation_yoy": 1.95, "manufacturing_yoy": -3.1, "private_investment_yoy": 18.1, "core_inflation_yoy": 1.0, "unemployment_pct": 1.0, "tourists_ytd_mn": 16.2, }, "te_thailand": { "retail_sales_yoy": -5.0, "consumer_confidence": 50.0, "interest_rate_pct": 1.5, "loans_to_fin_corp": 10000000.0, }, "thai_trade": {"imports_usdm": 38000.0, "current_account_usdm": 500.0}, } rows = themes.factor_source_breakdown(fetched, "retail") # retail includes te_thailand retail_sales_yoy (drives the negative read) te_retail = next(r for r in rows if r["factor"] == "te_retail_sales_yoy") self.assertEqual(te_retail["source"], "te_thailand") self.assertEqual(te_retail["raw"], -5.0) self.assertEqual(te_retail["normalized"], -0.5) # (-5-0)/10 self.assertEqual(te_retail["weight"], 0.7) self.assertAlmostEqual(te_retail["contribution"], -0.35, places=4) self.assertFalse(te_retail["missing"]) # every row carries the audit fields for r in rows: self.assertIn("source", r) self.assertIn("weight", r) self.assertIn("contribution", r)