"""Real multi-theme dashboard assembly. Replaces the tourism-only fixture dashboard with a REAL, multi-theme view of the Thai economy and the SET50 universe. For each of the 3 themes it reads the live Thai factor data (via the daily cache), computes a uniform z-score surprise, and assembles: - themes: list of {id, label_th, frequency, surprise, read: {...}, thesis} - macro: Thai macro backdrop (consumption/inflation/unemployment/tourism) - board: per-symbol combined score (60/40) that the simulation also uses - sources: provenance table (from -> source -> as_of -> fetched_at) Only real data is used; if a required collector fails the assembly fails loudly (no fixture fallback) per the user's explicit decision. """ from __future__ import annotations import statistics from typing import Any, Callable, Optional from . import macro_thai, themes as themes_mod class DashboardError(Exception): """Raised when real data cannot be assembled (no fixture fallback).""" def _fetch_with_cache( cache: Any, key: str, fetcher: Callable[[], dict], label: str, ) -> dict: try: val = cache.fetch_or_stale(key, fetcher) if isinstance(val, dict) and "data" in val: return val["data"] return val or {} except Exception as exc: raise DashboardError(f"no real data for {label}: {exc}") from exc def _zscore(value: float, mean: float, stdev: float) -> float: return (value - mean) / stdev if stdev else 0.0 def _auto_read(auto_d: dict, npl_d: dict, cache: Any) -> dict: # multi-source: volume (YoY) + credit quality (NPL) read = { "source": "TradingEconomics + BOT", "frequency": "monthly", "new_car_sales_yoy": auto_d.get("new_car_sales_yoy"), "total_vehicle_sales": auto_d.get("total_vehicle_sales"), "vehicle_production": auto_d.get("vehicle_production"), "auto_exports": auto_d.get("auto_exports"), "passenger_car_sales": auto_d.get("passenger_car_sales"), "auto_npl_pct": npl_d.get("pct_of_npls"), "auto_npl_amount": npl_d.get("npl_amount"), } read["thesis"] = ( "ยอดขายรถยนต์และสินเชื่อที่เกี่ยวข้อง (NPL) สะท้อนกำลังซื้อรถในประเทศ." ) return read class RealDashboard: """Assemble the real multi-theme dashboard from live collectors.""" def __init__(self, tourism_signals: list[dict], cache: Any, factor_view: Optional[dict] = None): self.tourism_signals = tourism_signals or [] self.cache = cache self.factor_view = factor_view or {"factors": []} def build(self) -> dict: # 1) live theme data (real, no fallback) from . import (auto_credit, auto_npl, bank_npl, energy_irpc, energy_thai, bot_tourism, macro_thai, te_thailand, thai_trade) tourism = None try: tourism = self.cache.fetch_or_stale("bot_tourism", lambda: bot_tourism.BotTourismSource().fetch()) except Exception: pass # handled below as no-data auto_d = _fetch_with_cache( self.cache, "auto_credit/tourism", lambda: auto_credit.fetch_auto_credit().to_dict(), "auto_credit") npl_d = _fetch_with_cache( self.cache, "auto_npl", lambda: auto_npl.fetch_auto_npl().to_dict(), "auto_npl") bnpl_d = _fetch_with_cache( self.cache, "bank_npl", lambda: bank_npl.fetch_bank_npl().to_dict(), "bank_npl") en_d = _fetch_with_cache( self.cache, "energy_thai", lambda: energy_thai.fetch_energy_thai().to_dict(), "energy_thai") irpc_d = _fetch_with_cache( self.cache, "energy_irpc", lambda: energy_irpc.fetch_energy_irpc().to_dict(), "energy_irpc") macro_d = _fetch_with_cache( self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai") trade_d = _fetch_with_cache( self.cache, "thai_trade", lambda: thai_trade.fetch_thai_trade().to_dict(), "thai_trade") te_d = _fetch_with_cache( self.cache, "te_thailand", lambda: te_thailand.fetch_te_thailand().to_dict(), "te_thailand") # 2) per-theme surprise — registry-driven (THE single source of truth) fetched = { "macro_thai": macro_d, "auto_credit": auto_d, "auto_npl": npl_d, "energy_thai": en_d, "energy_irpc": irpc_d, "bank_npl": bnpl_d, "thai_trade": trade_d, "te_thailand": te_d, } tourism_surprise = self._tourism_surprise() surprises = themes_mod.compute_theme_surprises( fetched, tourism_surprise=tourism_surprise) # 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, "irpc_net_margin_pct": (irpc_d or {}).get("net_margin_pct"), "source": "TOP + IRPC investor"}), ] # macro-proxy reads for the expanded SET50 themes (deterministic) proxy_reads = { "banks": {"source": "BOT macro + NPL + TE (rate/credit)", "frequency": "monthly", "invest_yoy": macro_d.get("private_investment_yoy"), "inflation_yoy": macro_d.get("headline_inflation_yoy"), "bank_npl_pct": (bnpl_d or {}).get("pct_of_npls"), "interest_rate_pct": (te_d or {}).get("interest_rate_pct"), "loan_growth": (te_d or {}).get("loans_to_fin_corp")}, "retail": {"source": "BOT macro + TE (retail/confidence)", "frequency": "monthly", "consumption_yoy": macro_d.get("private_consumption_yoy"), "retail_sales_yoy": (te_d or {}).get("retail_sales_yoy"), "consumer_confidence": (te_d or {}).get("consumer_confidence")}, "consumer_staples": {"source": "BOT macro + TE (retail/confidence)", "frequency": "monthly", "consumption_yoy": macro_d.get("private_consumption_yoy"), "retail_sales_yoy": (te_d or {}).get("retail_sales_yoy"), "consumer_confidence": (te_d or {}).get("consumer_confidence")}, "telecom_it": {"source": "BOT macro (consumption)", "frequency": "monthly", "consumption_yoy": macro_d.get("private_consumption_yoy")}, "property": {"source": "BOT macro + TE (property)", "frequency": "monthly", "invest_yoy": macro_d.get("private_investment_yoy"), "property_prices_yoy": (te_d or {}).get("property_prices_yoy"), "business_confidence": (te_d or {}).get("business_confidence")}, "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 + TE (credit/confidence)", "frequency": "monthly", "consumption_yoy": macro_d.get("private_consumption_yoy"), "consumer_credit": (te_d or {}).get("consumer_credit_thbmn"), "household_debt_gdp": (te_d or {}).get("household_debt_gdp_pct"), "consumer_confidence": (te_d or {}).get("consumer_confidence")}, "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) # 5) source provenance table sources = self._build_sources(auto_d, npl_d, en_d, macro_d, tourism, bnpl_d, trade_d, te_d, irpc_d) return { "themes": themes, "macro": macro_d, "board": board, "sources": sources, # unambiguous split so "7 vs 5" style confusion is impossible: # distinct provider rows vs raw FACTORS-registry factor keys. "source_summary": { "rows": len(sources), "factor_keys": _factor_key_count(), }, "as_of": macro_d.get("periods", {}).get("headline_inflation_yoy", ""), } def _mk_theme(self, tid: str, surprise: Optional[float], read: dict) -> dict: 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"} else: thesis = "" narrative = self._theme_narrative(tid, surprise, read) return { "id": tid, "label_th": label, "frequency": f, "surprise": surprise, "read": read, "thesis": thesis, "narrative": narrative, } def _theme_narrative(self, tid: str, surprise: Optional[float], read: dict) -> str: sign = "ดีขึ้น" if (surprise or 0) >= 0 else "แย่ลง" s = f"{abs(surprise):.2f}σ" if surprise is not None else "—" if tid == "auto_credit": yoy = read.get("new_car_sales_yoy") npl = read.get("auto_npl_pct") yoy_txt = f"ยอดขายรถยนต์โต {yoy:+.1f}% เมื่อเทียบรายปี" if yoy is not None else "ยอดขายรถยนต์ไม่ชัดเจน" npl_txt = f"สัดส่วนหนี้เสียรถยนต์ (NPL) อยู่ที่ {npl:.1f}%" if npl is not None else "ตัวเลขหนี้เสียรถยนต์ยังไม่ชัดเจน" direction = ("ส่งผลบวกต่อกำลังซื้อรถยนต์และธุรกิจที่เกี่ยวข้อง" if (surprise or 0) >= 0 else "อาจกดดันกำไรของกลุ่มลิสซิ่ง/สินเชื่อรถ เพราะความสามารถชำระหนี้แย่ลง") return (f"{yoy_txt} ขณะที่ {npl_txt}. ค่าความต่างรวม {s} บ่งชี้ทิศทาง{ ('ที่ดี' if (surprise or 0) >= 0 else 'ที่ต้องระวัง') } — " f"{direction}. หุ้นที่พึ่งพารายได้จากรถยนต์/สินเชื่อรถ เช่น ลิสซิ่ง ธนาคารในกลุ่ม " f"จะได้หรือเสียประโยชน์ตามทิศทางนี้.") if tid == "refining_energy": return (f"ค่าความต่าง {s} สำหรับธีมโรงกลั่น/พลังงาน " f"{('สะท้อนกำไรขั้นต้นโรงกลั่นที่แข็งแรง' if (surprise or 0) >= 0 else 'สะท้อนแรงกดดันต่อกำไรโรงกลั่น')} " f"จากข้อมูล TOP รายไตรมาส. กลุ่มพลังงาน (PTT, PTTGC, TOP, BCP, IRPC) จะได้รับผลตาม " f"ทิศทางราคาพลังงานและค่าการกลั่น.") if tid == "tourism": return (f"ค่าความต่าง {s} สำหรับธีมการท่องเที่ยว " 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 _tourism_surprise(self) -> Optional[float]: """Derive the tourism surprise from the bot-tourism observation set (cross-sectional mean of signal scores), when available. Returns None so `compute_theme_surprises` falls back to the registry factors.""" import statistics ts = self.tourism_signals if not ts: return None surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)] return round(statistics.mean(surprises), 3) if surprises else None def _build_board(self, themes, macro_d) -> list: # combine theme scores + siamchart for the per-symbol board. from . import siamchart_factors fv = siamchart_factors.build_factor_view() or {"factors": []} momentum = themes_mod._load_momentum() siamchart_score = themes_mod.build_siamchart_score(fv, momentum=momentum) # per-theme symbol exposure: surprise × firm_quality (real selection). # A strong name in a hot theme scores higher than a weak one. theme_scores: dict[str, dict[str, float]] = {} fv_all = fv for t in themes: tid = t["id"] surprise = t.get("surprise") if surprise is None: theme_scores[tid] = {} continue symbols = themes_mod.THEME_SYMBOLS.get(tid, set()) q = {} for s in symbols: if s not in siamchart_score: continue quality = themes_mod.quality_within_theme(s, tid, fv_all) q[s] = float(surprise) * quality theme_scores[tid] = q combined = themes_mod.combine_score( list(theme_scores.values()), siamchart_score, ) # factor metadata per symbol for the board columns fmap = {f.get("symbol"): f for f in fv.get("factors", [])} board = [] for sym, meta in combined.items(): f = fmap.get(sym, {}) # which themes this symbol belongs to (from THEME_SYMBOLS) — the # frontend derives the theme column from this, never a local map. sym_themes = [tid for tid, syms in themes_mod.THEME_SYMBOLS.items() if sym in syms] board.append({ "symbol": sym, "combined": round(meta.get("combined", 0.0), 3), "theme_score": round(meta.get("theme_score", 0.0), 3), "siamchart_score": round(meta.get("siamchart_score", 0.0), 3), "themes": sym_themes, "dividend_yield": f.get("dividend_yield"), "is_dividend": f.get("is_dividend"), }) board.sort(key=lambda r: r["combined"], reverse=True) return board def _build_sources(self, auto_d, npl_d, en_d, macro_d, tourism, bnpl_d=None, trade_d=None, te_d=None, irpc_d=None) -> list: """Sources derived from the FACTORS registry — adding a factor to factors.py auto-appends its source row here (no hardcoded list).""" import datetime as _dt from app import factors as factors_mod now = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="minutes") fetched = { "auto_credit": auto_d, "auto_npl": npl_d, "energy_thai": en_d, "energy_irpc": irpc_d, "macro_thai": macro_d, "bank_npl": bnpl_d, "thai_trade": trade_d, "te_thailand": te_d, } # group FACTORS by fetch module -> one row per distinct source source_by_module: dict = {} for fkey, f in factors_mod.FACTORS.items(): mod = f.get("fetch") if not mod: continue if mod not in source_by_module: source_by_module[mod] = { "mod": mod, "source": f.get("source", mod), "freq": f.get("frequency", "monthly"), "factors": [], } source_by_module[mod]["factors"].append(fkey) # next-update cadence per frequency (hours) _cadence = {"daily": 24, "monthly": 24 * 30, "quarterly": 24 * 91, "annual": 24 * 365, "weekly": 24 * 7} rows = [] for mod, meta in source_by_module.items(): data = fetched.get(mod) or {} _source_label = { "auto_credit": "TradingEconomics", "auto_npl": "BOT FI_NP_003_S2", "bank_npl": "BOT FI_NP_003_S2", "energy_thai": "Thai Oil investor", "energy_irpc": "IRPC investor", "macro_thai": "BOT Thai Economy", "bot_tourism": "BOT Tourism", "thai_trade": "TradingEconomics", "te_thailand": "TradingEconomics", } freq = meta["freq"] as_of = data.get("as_of") or data.get("period") or _period(data, meta["factors"]) next_in_h = _cadence.get(freq, 24 * 30) next_at = (now_plus(now, next_in_h)) rows.append({ "แหล่ง": _source_label.get(mod, meta["source"]), "ขอบเขต": " | ".join(factors_mod.FACTORS[k]["name_th"] for k in meta["factors"]), "ข้อมูล": as_of or "", "ความถี่": freq_th(freq), "อัปเดตครั้งต่อไป": next_at, "dึงมาเมื่อ": now, }) # tourism (fetched separately) if tourism and isinstance(tourism, dict): rows.insert(0, { "แหล่ง": "BOT Tourism", "ขอบเขต": "นักท่องเที่ยว", "ข้อมูล": tourism.get("period", "") or "", "ความถี่": "รายเดือน", "อัปเดตครั้งต่อไป": now_plus(now, 24 * 30), "dึงมาเมื่อ": now, }) return rows def now_plus(iso_now: str, hours: float) -> str: import datetime as _dt base = _dt.datetime.fromisoformat(iso_now) return (base + _dt.timedelta(hours=hours)).isoformat(timespec="minutes") def freq_th(freq: str) -> str: return {"daily": "รายวัน", "weekly": "รายสัปดาห์", "monthly": "รายเดือน", "quarterly": "รายไตรมาส", "annual": "รายปี"}.get(freq, freq) def _period(data: dict, factor_keys: list) -> str: from app import factors as factors_mod for k in factor_keys: f = factors_mod.FACTORS.get(k, {}) vk = f.get("value_key") if vk and data.get(vk) is not None: return f.get("name_th", k) return "--" def _factor_key_count() -> int: """Number of FACTORS-registry entries (each a distinct factor key).""" from app import factors as factors_mod return len(factors_mod.FACTORS) def default_scores(syms: Optional[list] = None) -> dict: """Per-symbol {combined, is_dividend, dividend_yield} from the live board. Used as the default baseline for the backtest & simulation engines (honest: current combined scores; a PIT score_fn can be supplied to avoid lookahead). `syms=None` returns every symbol on the board. """ from app import daily_cache from app import siamchart_factors fv = siamchart_factors.build_factor_view() cache = daily_cache.DailyCache() dash = RealDashboard([], cache, factor_view=fv).build() out = {} for row in dash.get("board", []): out[row["symbol"]] = { "combined": row.get("combined", 0.0), "is_dividend": row.get("is_dividend", False), "dividend_yield": row.get("dividend_yield") or 0.0, } if syms: out = {s: out.get(s, {}) for s in syms if s in out} return out