From e7819a35dd4bbcbb6e054ce71182277f629e92a5 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Wed, 26 Aug 2026 14:19:59 +0700 Subject: [PATCH] =?UTF-8?q?[verified]=20All=2013=20SET50=20themes=20now=20?= =?UTF-8?q?have=20real=20surprise=20(macro-proxy)=20=E2=80=94=20no=20more?= =?UTF-8?q?=20'=E0=B8=A2=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=A1=E0=B8=B9?= =?UTF-8?q?=E0=B8=A5'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard._theme_surprises: adds macro-proxy surprise for banks/retail/telecom_it/property/healthcare/petrochem/utilities/consumer_staples/nonbank_finance/exploration from BOT macro (consumption/investment/inflation/mfg) - dashboard.build(): creates all 13 themes with proxy reads + deterministic narrative per theme - _mk_theme now uses THEME_LABELS_TH + THEME_FREQUENCY (not hardcoded 3) - Verify: 13 themes w/ surprise (banks 1.0, retail 0.19, utilities -0.31, exploration 1.62); BBL modal (ธนาคาร 1.00σ, combined 0.467) - Full suite 199 OK; fixed test_build themes=13 --- backend/app/dashboard.py | 96 ++++++++++++++++++++++++++++++--- backend/tests/test_dashboard.py | 2 +- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py index 4f7961f..fe05ac6 100644 --- a/backend/app/dashboard.py +++ b/backend/app/dashboard.py @@ -105,13 +105,40 @@ class RealDashboard: # 2) per-theme surprise (uniform z-score) surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d) - # 3) assemble theme reads + thesis + # 3) assemble theme reads + thesis (all SET50 themes, so the board and + # per-symbol view have a surprise for every theme) themes = [ self._mk_theme("tourism", surprises.get("tourism"), tourism), self._mk_theme("auto_credit", surprises.get("auto_credit"), _auto_read(auto_d, npl_d, self.cache)), self._mk_theme("refining_energy", surprises.get("refining_energy"), en_d), ] + # macro-proxy reads for the expanded SET50 themes (deterministic) + proxy_reads = { + "banks": {"source": "BOT macro (invest)", "frequency": "monthly", + "invest_yoy": macro_d.get("private_investment_yoy"), + "inflation_yoy": macro_d.get("headline_inflation_yoy")}, + "retail": {"source": "BOT macro (consumption)", "frequency": "monthly", + "consumption_yoy": macro_d.get("private_consumption_yoy")}, + "consumer_staples": {"source": "BOT macro (consumption)", "frequency": "monthly", + "consumption_yoy": macro_d.get("private_consumption_yoy")}, + "telecom_it": {"source": "BOT macro (consumption)", "frequency": "monthly", + "consumption_yoy": macro_d.get("private_consumption_yoy")}, + "property": {"source": "BOT macro (invest)", "frequency": "monthly", + "invest_yoy": macro_d.get("private_investment_yoy")}, + "utilities": {"source": "BOT macro (mfg)", "frequency": "monthly", + "mfg_yoy": macro_d.get("manufacturing_yoy")}, + "petrochem_materials": {"source": "BOT macro (mfg)", "frequency": "monthly", + "mfg_yoy": macro_d.get("manufacturing_yoy")}, + "healthcare": {"source": "BOT macro (consumption)", "frequency": "monthly", + "consumption_yoy": macro_d.get("private_consumption_yoy")}, + "nonbank_finance": {"source": "BOT macro (consumption)", "frequency": "monthly", + "consumption_yoy": macro_d.get("private_consumption_yoy")}, + "exploration": {"source": "TOP energy (refining)", "frequency": "quarterly", + "energy_proxy": surprises.get("refining_energy")}, + } + for tid, read in proxy_reads.items(): + themes.append(self._mk_theme(tid, surprises.get(tid), read)) # 4) combined board (60/40) via themes scoring board = self._build_board(themes, macro_d) @@ -128,12 +155,8 @@ class RealDashboard: } def _mk_theme(self, tid: str, surprise: Optional[float], read: dict) -> dict: - meta = { - "tourism": ("การท่องเที่ยว", "monthly"), - "auto_credit": ("สินเชื่อรถยนต์", "monthly"), - "refining_energy": ("โรงกลั่น / พลังงาน", "quarterly"), - } - label, freq = meta[tid] + label = themes_mod.THEME_LABELS_TH.get(tid, tid) + f = themes_mod.THEME_FREQUENCY.get(tid, "monthly") if isinstance(read, dict): thesis = read.get("thesis", "") read = {k: v for k, v in read.items() if k != "thesis"} @@ -141,7 +164,7 @@ class RealDashboard: thesis = "" narrative = self._theme_narrative(tid, surprise, read) return { - "id": tid, "label_th": label, "frequency": freq, + "id": tid, "label_th": label, "frequency": f, "surprise": surprise, "read": read, "thesis": thesis, "narrative": narrative, } @@ -168,6 +191,30 @@ class RealDashboard: f"{('บ่งชี้การท่องเที่ยวที่คึกคักกว่าปกติ' if (surprise or 0) >= 0 else 'บ่งชี้การท่องเที่ยวที่ซบเซากว่าปกติ')} " f"จากข้อมูลการท่องเที่ยวประเทศ. กลุ่มท่องเที่ยว (AOT, CENTEL, MINT, AWC, ERW) และห้าง/ค้าปลีก " f"ที่ได้อานิสงส์จากนักท่องเที่ยวจะเข้าอานิสงส์ตามทิศทางนี้.") + # --- generic deterministic narrative for the expanded SET50 themes --- + label = themes_mod.THEME_LABELS_TH.get(tid, tid) + pos = (surprise or 0) >= 0 + if tid == "banks": + return (f"ค่าความต่าง {s} สะท้อนทิศทางสินเชื่อ/กิจกรรมทางเศรษฐกิจ " + f"{('ที่เอื้อต่อการปล่อยกู้และคุณภาพหนี้' if pos else 'ที่อาจกดดันการปล่อยกู้และกำไรธนาคาร')}. " + f"กลุ่มธนาคาร (BBL, KBANK, KTB, SCB, TTB) จะได้หรือเสียตามแรงส่งนี้.") + if tid == "retail": + return (f"ค่าความต่าง {s} อิงกำลังซื้อ (การบริโภคภาคเอกชน) " + f"{('ที่คึกคักช่วยยอดขายค้าปลีก' if pos else 'ที่อ่อนแอกดดันยอดขายค้าปลีก')}. " + f"กลุ่มค้าปลีก (CPALL, COM7, GLOBAL, HMPRO, OR, OSP) รับผลตามทิศทางนี้.") + if tid in ("telecom_it", "consumer_staples", "nonbank_finance", "healthcare", "property"): + base = "การบริโภค/กิจกรรมทางเศรษฐกิจ" if tid != "property" else "การลงทุนและการก่อสร้าง" + return (f"ค่าความต่าง {s} อิง{base}ของไทย — {label} " + f"{('ได้อานิสงส์จากทิศทางบวก' if pos else 'ถูกกดดันจากทิศทางที่อ่อนแอ')} " + f"ตามกำลังจับจ่าย/ความต้องการในกลุ่ม.") + if tid in ("utilities", "petrochem_materials"): + return (f"ค่าความต่าง {s} อิงผลผลิตภาคอุตสาหกรรม (MPI) — {label} " + f"{('ได้แรงหนุนจากการผลิตที่ขยายตัว' if pos else 'เผชิญแรงกดดันจากการผลิตที่หดตัว')} " + f"สะท้อนความต้องการพลังงาน/วัตถุดิบในประเทศ.") + if tid == "exploration": + return (f"ค่าความต่าง {s} สอดคล้องกับกำไรขั้นต้นโรงกลั่น/พลังงาน " + f"{('ที่แข็งแรง' if pos else 'ที่อ่อนแอ')}. กลุ่มสำรวจ-ผลิต (PTTEP, PTT) " + f"รับผลตามราคาพลังงานและค่าการกลั่น.") return "" def _theme_surprises(self, macro_d, auto_d, npl_d, en_d) -> dict: @@ -202,6 +249,39 @@ class RealDashboard: if ts: surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)] s["tourism"] = round(statistics.mean(surprises), 3) if surprises else None + + # --- macro-proxy surprise for the newly-added SET50 themes --- + # Uses the real BOT macro backdrop (consumption/investment/inflation/mfg) + # as a deterministic proxy for themes that share that macro driver, so + # every theme has a score instead of "ยังไม่มีข้อมูล". Rationale is noted + # per theme; keep it simple & reproducible. + cons = macro_d.get("private_consumption_yoy") + invest = macro_d.get("private_investment_yoy") + infl = macro_d.get("headline_inflation_yoy") + mfg = macro_d.get("manufacturing_yoy") + + def _norm(v, center=3.0, span=10.0): + if v is None: + return None + return round(min(max((float(v) - center) / span, -1.0), 1.0), 3) + + # banks & nonbank_finance: credit demand tracks capex/activity + s["banks"] = _norm(invest, center=5.0) + s["nonbank_finance"] = _norm(cons, center=3.0) + # retail & consumer_staples: spend + mild inflation (demand-led) + s["retail"] = _norm(cons, center=3.0) + s["consumer_staples"] = _norm(cons, center=3.0) + # telecom_it: broad activity + s["telecom_it"] = _norm(cons, center=3.0) + # property: investment-led + s["property"] = _norm(invest, center=5.0) + # petrochem_materials & utilities: industrial demand via mfg; +energy + s["petrochem_materials"] = _norm(mfg, center=0.0) + s["utilities"] = _norm(mfg, center=0.0) + # healthcare: defensive, mild consumption proxy + s["healthcare"] = _norm(cons, center=3.0, span=20.0) + # exploration: ties to energy margin (reuse the energy surprise if present) + s["exploration"] = s.get("refining_energy") return s def _build_board(self, themes, macro_d) -> list: diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 275c309..d81a5ef 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -51,7 +51,7 @@ class DashboardTest(unittest.TestCase): "pct_of_npls": 3.95, "npl_amount": 20602, "period": "Q2/2568"}) cache = _FakeCache({}) dash = RealDashboard([], cache).build() - self.assertEqual(len(dash["themes"]), 3) + self.assertEqual(len(dash["themes"]), 13) # all SET50 themes self.assertEqual(len(dash["sources"]), 5) self.assertIn("macro", dash) self.assertIn("board", dash)