Files
set50-system/backend/app/dashboard.py
Kunthawat Greethong e7819a35dd [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
2026-08-26 14:19:59 +07:00

337 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 _uniform_surprise(series: list[float], current: float) -> Optional[float]:
"""Uniform z-score surprise of `current` within a recent series."""
if len(series) < 2 or current is None:
return None
mean = statistics.mean(series)
stdev = statistics.pstdev(series)
return round(_zscore(current, mean, stdev), 3)
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, energy_thai, bot_tourism
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")
en_d = _fetch_with_cache(
self.cache, "energy_thai", lambda: energy_thai.fetch_energy_thai().to_dict(), "energy_thai")
macro_d = _fetch_with_cache(
self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai")
# 2) per-theme surprise (uniform z-score)
surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d)
# 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)
# 5) source provenance table
sources = self._build_sources(auto_d, npl_d, en_d, macro_d, tourism)
return {
"themes": themes,
"macro": macro_d,
"board": board,
"sources": sources,
"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 _theme_surprises(self, macro_d, auto_d, npl_d, en_d) -> dict:
# tourism: from the tourism arrivals YoY or use the bot tourism surprise
# auto: z-score of new_car_sales_yoy
# energy: z-score of TOP net profit trend (quarterly)
# Use macro consumption as a backdrop-related surprise proxy where series
# are unavailable; kept simple & deterministic.
import statistics
s = {}
auto_yoy = auto_d.get("new_car_sales_yoy")
npl = npl_d.get("pct_of_npls")
if auto_yoy is not None:
# single-value surprise: growth is bullish, rising NPL is bearish
base = min(max((float(auto_yoy) - 5.0) / 10.0, -1.0), 1.0)
if npl is not None:
base -= min(max((float(npl) - 3.0) / 5.0, 0.0), 1.0)
s["auto_credit"] = round(base, 3)
else:
s["auto_credit"] = None
# refine energies: use heads/tails of the quarterly net-profit read if present
en_q = en_d.get("quarterly") if isinstance(en_d, dict) else None
if isinstance(en_q, dict):
profits = [v.get("net_profit") for v in en_q.values() if isinstance(v, dict)]
profits = [p for p in profits if p is not None]
latest = profits[0] if profits else None
s["refining_energy"] = _uniform_surprise(profits[:4], latest) if profits else None
else:
s["refining_energy"] = None
s["tourism"] = None # set from tourism result below if available
ts = self.tourism_signals
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:
# combine theme scores + siamchart for the per-symbol board.
from . import siamchart_factors
fv = siamchart_factors.build_factor_view() or {"factors": []}
siamchart_score = themes_mod.build_siamchart_score(fv)
# per-theme symbol exposure: surprise applies to the theme's symbols
theme_scores: dict[str, dict[str, float]] = {}
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())
theme_scores[tid] = {s: float(surprise) for s in symbols if s in siamchart_score}
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, {})
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),
"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) -> list:
import datetime as _dt
now = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="minutes")
rows = [
{"จาก": "สินเชื่อรถยนต์ (ยอดขายรถ)", "แหล่ง": "TradingEconomics", "ข้อมูล": auto_d.get("as_of", "น่าล่าสุด")},
{"จาก": "NPL รถยนต์", "แหล่ง": "BOT FI_NP_003_S2", "ข้อมูล": npl_d.get("period", "รายไตรมาส")},
{"จาก": "โรงกลั่น (TOP)", "แหล่ง": "Thai Oil investor", "ข้อมูล": "รายไตรมาส"},
{"จาก": "ภาพรวมประเทศไทย", "แหล่ง": "BOT Thai Economy", "ข้อมูล": "รายเดือน"},
]
for r in rows:
r["dึงมาเมื่อ"] = now
# append tourism source if present
if tourism and isinstance(tourism, dict):
rows.append({"จาก": "ท่องเที่ยว", "แหล่ง": tourism.get("source", {}).get("source_id", "BOT"),
"ข้อมูล": tourism.get("period", ""), "dึงมาเมื่อ": now})
return rows