[verified] All 13 SET50 themes now have real surprise (macro-proxy) — no more 'ยังไม่มีข้อมูล'

- 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
This commit is contained in:
Kunthawat Greethong
2026-08-26 14:19:59 +07:00
parent 55b3574040
commit e7819a35dd
2 changed files with 89 additions and 9 deletions

View File

@@ -105,13 +105,40 @@ class RealDashboard:
# 2) per-theme surprise (uniform z-score) # 2) per-theme surprise (uniform z-score)
surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d) 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 = [ themes = [
self._mk_theme("tourism", surprises.get("tourism"), tourism), self._mk_theme("tourism", surprises.get("tourism"), tourism),
self._mk_theme("auto_credit", surprises.get("auto_credit"), self._mk_theme("auto_credit", surprises.get("auto_credit"),
_auto_read(auto_d, npl_d, self.cache)), _auto_read(auto_d, npl_d, self.cache)),
self._mk_theme("refining_energy", surprises.get("refining_energy"), en_d), 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 # 4) combined board (60/40) via themes scoring
board = self._build_board(themes, macro_d) 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: def _mk_theme(self, tid: str, surprise: Optional[float], read: dict) -> dict:
meta = { label = themes_mod.THEME_LABELS_TH.get(tid, tid)
"tourism": ("การท่องเที่ยว", "monthly"), f = themes_mod.THEME_FREQUENCY.get(tid, "monthly")
"auto_credit": ("สินเชื่อรถยนต์", "monthly"),
"refining_energy": ("โรงกลั่น / พลังงาน", "quarterly"),
}
label, freq = meta[tid]
if isinstance(read, dict): if isinstance(read, dict):
thesis = read.get("thesis", "") thesis = read.get("thesis", "")
read = {k: v for k, v in read.items() if k != "thesis"} read = {k: v for k, v in read.items() if k != "thesis"}
@@ -141,7 +164,7 @@ class RealDashboard:
thesis = "" thesis = ""
narrative = self._theme_narrative(tid, surprise, read) narrative = self._theme_narrative(tid, surprise, read)
return { return {
"id": tid, "label_th": label, "frequency": freq, "id": tid, "label_th": label, "frequency": f,
"surprise": surprise, "read": read, "thesis": thesis, "narrative": narrative, "surprise": surprise, "read": read, "thesis": thesis, "narrative": narrative,
} }
@@ -168,6 +191,30 @@ class RealDashboard:
f"{('บ่งชี้การท่องเที่ยวที่คึกคักกว่าปกติ' if (surprise or 0) >= 0 else 'บ่งชี้การท่องเที่ยวที่ซบเซากว่าปกติ')} " f"{('บ่งชี้การท่องเที่ยวที่คึกคักกว่าปกติ' if (surprise or 0) >= 0 else 'บ่งชี้การท่องเที่ยวที่ซบเซากว่าปกติ')} "
f"จากข้อมูลการท่องเที่ยวประเทศ. กลุ่มท่องเที่ยว (AOT, CENTEL, MINT, AWC, ERW) และห้าง/ค้าปลีก " f"จากข้อมูลการท่องเที่ยวประเทศ. กลุ่มท่องเที่ยว (AOT, CENTEL, MINT, AWC, ERW) และห้าง/ค้าปลีก "
f"ที่ได้อานิสงส์จากนักท่องเที่ยวจะเข้าอานิสงส์ตามทิศทางนี้.") 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 "" return ""
def _theme_surprises(self, macro_d, auto_d, npl_d, en_d) -> dict: def _theme_surprises(self, macro_d, auto_d, npl_d, en_d) -> dict:
@@ -202,6 +249,39 @@ class RealDashboard:
if ts: if ts:
surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)] surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)]
s["tourism"] = round(statistics.mean(surprises), 3) if surprises else None 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 return s
def _build_board(self, themes, macro_d) -> list: def _build_board(self, themes, macro_d) -> list:

View File

@@ -51,7 +51,7 @@ class DashboardTest(unittest.TestCase):
"pct_of_npls": 3.95, "npl_amount": 20602, "period": "Q2/2568"}) "pct_of_npls": 3.95, "npl_amount": 20602, "period": "Q2/2568"})
cache = _FakeCache({}) cache = _FakeCache({})
dash = RealDashboard([], cache).build() 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.assertEqual(len(dash["sources"]), 5)
self.assertIn("macro", dash) self.assertIn("macro", dash)
self.assertIn("board", dash) self.assertIn("board", dash)