Files
set50-system/backend/app/dashboard.py
Kunthawat Greethong 8db3d48ae2 [verified] P0-B registry-driven scoring + P3 PIT backtest + P4 factor-weight learning
P0-B (registry is the single source of truth for scoring):
- FACTORS now carries center/span normalization spec; unused hand-written
  per-theme surprise blocks in dashboard.py replaced by one registry-driven
  compute_theme_surprises() (themes.py).
- THEMES['banks'] adds bank_npl weight so NPL is genuinely blended.
- factor_value/normalize hardened against NaN/inf (finite guards).
- Board re-ranks (TRUE/GULF up, TOP->3) per registry weights; 3 new tests
  incl. 'changing a registry weight changes output'.

P3 (point-in-time backtest):
- run_backtest is now a real multi-rebalance engine (reallocates every window,
  reconciles holdings, marks to market) instead of allocate-once+break.
- Added leakage_guard (False unless a PIT score_fn is supplied), planned vs
  actual rebalances, and momentum_at() true 12-1 (skips last month, PIT).

P4 (factor-weight learning):
- weight_learning.py: cross-sectional Spearman IC, forward-return builder,
  IC aggregation + t-stat, and apply_weight_update (new = clip(old*(1+shrink*IC))).
- GET /api/v1/learning/momentum endpoint. Live result: momentum IC=0.012
  t=0.132 over 22 periods -> momentum has no reliable predictive power here.
  Macro/demographic factors blocked (no historical factor vintages yet).

Two independent review gates passed (deleg_fe6f45cd, deleg_718218f8): empty
security/logic arrays; their non-blocking suggestions applied (finite guards,
dedupe leakage_guard resolution). 226 tests pass; Vite build passes.
2026-08-27 07:12:18 +07:00

401 lines
22 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 _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 — registry-driven (THE single source of truth)
fetched = {
"macro_thai": macro_d, "auto_credit": auto_d,
"auto_npl": npl_d, "energy_thai": en_d, "bank_npl": bnpl_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),
]
# 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 _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) -> 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