Files
set50-system/backend/app/dashboard.py
Kunthawat Greethong 325e164dd3 [verified] Fix P1-P2-P5 audit findings: simulation reuses board, source_summary clarity, dead-code removal + conftest
- P1: /api/v1/simulation now uses the canonical board score (default_scores)
  instead of a divergent 3-theme recompute -> 'จำลอง' can't disagree with board
  (live check: sim top pick PTT == top board combined 1.600). Removes binary
  auto/en signs, restores quality+momentum+dividend screen consistency.
- P2: dashboard emits source_summary{factor_keys, rows}; frontend shows
  'N ปัจจัย · M แหล่ง' so the 7-vs-5 count confusion is impossible.
- P5: removed dead themes.list_themes()/Theme/build_theme_scores/_map_index and
  the tests that locked them; added tests/conftest.py so pytest needs no PYTHONPATH.
- docs: audit-and-plan-2026-08-26.md (full P0-P5 plan) + engineering-log entry.
- 203 backend tests pass; Vite build passes. Independent reviewer: no security or
  logic blockers (minor error-leak suggestion applied: 503 message no longer leaks
  exception detail).
2026-08-27 03:21:22 +07:00

469 lines
26 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, bank_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")
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")
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, bnpl_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) + NPL ภาคการเงิน",
"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")},
"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, bnpl_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 _theme_surprises(self, macro_d, auto_d, npl_d, en_d, bnpl_d=None) -> 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.
# banks additionally blends real BOT financial-sector NPL (quarterly):
# rising NPL is a provisioning drag on bank earnings (bearish).
banks_s = _norm(invest, center=5.0)
if banks_s is not None and bnpl_d:
bnpl = bnpl_d.get("pct_of_npls")
if bnpl is not None:
# deduct up to ~0.5 from the surprise when NPL share is elevated
# (reference: financial-sector NPL % of total NPLs, roughly 1-5%).
banks_s = round(max(banks_s - min(max((float(bnpl) - 0.5) / 3.0, 0.0), 0.5), -1.0), 3)
s["banks"] = banks_s
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": []}
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) -> 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, "macro_thai": macro_d, "bank_npl": bnpl_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",
"macro_thai": "BOT Thai Economy",
"bot_tourism": "BOT Tourism",
}
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