"""Multi-theme registry, symbol->theme exposure mapping, and combined scoring. Aggregates the three Thai alternative-factor themes (tourism, auto_credit, refining_energy) plus the Siamchart fundamental provider into a per-symbol combined score, per the user's confirmed weighting: combined = 0.6 * theme_score + 0.4 * siamchart_score where `theme_score` is the mean of the enabled theme scores that cover a symbol (so a symbol in several themes averages its theme scores), and `siamchart_score` is a normalized fundamental score. Frequency handling: theme scores carry an explicit `frequency` (daily/monthly/ quarterly/annual). Consumers must not mix different-frequency factors as if they were the same timestamp — see `frequency` on each scored theme. """ from __future__ import annotations import statistics from typing import Optional # --------------------------------------------------------------------------- # Theme registry + symbol->theme exposure mapping # --------------------------------------------------------------------------- # Symbols known to belong to each theme. This is a *curated* partial mapping of # the SET50 universe; symbols not listed here get no direct theme exposure for # that theme (theme_score contribution = those themes' factor value applied via # a generic market exposure fallback, see scoring). # # Expanded to cover the FULL SET50 universe (2026, 49 names): every symbol is # assigned to at least one industry/theme so the board + per-symbol detail can # always report a theme. Deterministic, curated by industry sector. THEME_SYMBOLS: dict[str, set[str]] = { "tourism": { "AOT", "CENTEL", "MINT", "AWC", "CPN", "CRC", "BEM", "BTS", "ERW", "DHOUSE", "MAJOR", "SNNP", }, "auto_credit": { "KKP", "TISCO", "TCAP", "THANI", "MTC", "SAWAD", "NSI", "AEONTS", "TK", "THG", "GLAND", "ASI", "TGPRO", }, "refining_energy": { "PTT", "PTTGC", "TOP", "IRPC", "SPRC", "BCP", "ESSO", "BANPU", "GPSC", }, "banks": {"BBL", "KBANK", "KTB", "SCB", "TTB"}, "retail": {"CPALL", "COM7", "GLOBAL", "HMPRO", "OSP", "OR", "CPN", "CRC"}, "telecom_it": {"ADVANC", "TRUE", "DELTA", "COM7"}, "property": {"LH", "AWC", "CPN", "CRC", "GLOBAL", "HMPRO"}, "healthcare": {"BDMS", "BH"}, "petrochem_materials": {"IVL", "SCC", "SCGP", "PTTGC", "BANPU"}, "consumer_staples": {"CPF", "TU", "OSP", "CBG"}, "utilities": {"BGRIM", "EGCO", "RATCH", "GPSC", "GULF", "BANPU", "EA"}, "nonbank_finance": {"JMT", "JMART", "KTC", "TIDLOR", "MTC", "SAWAD", "KTC", "AEONTS"}, "exploration": {"PTTEP", "PTT"}, } # Theme frequency (data cadence of the underlying factor). Used to keep # multi-frequency factors from being mixed as same-timestamp. THEME_FREQUENCY: dict[str, str] = { "tourism": "monthly", "auto_credit": "monthly", "refining_energy": "quarterly", "banks": "quarterly", "retail": "monthly", "telecom_it": "quarterly", "property": "quarterly", "healthcare": "quarterly", "petrochem_materials": "quarterly", "consumer_staples": "quarterly", "utilities": "quarterly", "nonbank_finance": "quarterly", "exploration": "quarterly", } # Thai labels for every theme (used in the board theme column + per-symbol view) THEME_LABELS_TH: dict[str, str] = { "tourism": "ท่องเที่ยว", "auto_credit": "รถยนต์/สินเชื่อ", "refining_energy": "พลังงาน/โรงกลั่น", "banks": "ธนาคาร", "retail": "ค้าปลีก", "telecom_it": "สื่อสาร/ไอที", "property": "อสังหาริมทรัพย์", "healthcare": "โรงพยาบาล", "petrochem_materials": "ปิโตรเคมี/วัสดุ", "consumer_staples": "อาหาร/อุปโภค", "utilities": "สาธารณูปโภค", "nonbank_finance": "การเงินนอกธนาคาร", "exploration": "สำรวจ/ผลิตพลังงาน", } # Declarative THEMES definition — which FACTORS drive each theme, with per-theme # weight (flexible: how much that factor plausibly impacts stock valuation in # this theme). A theme's surprise = weighted blend of its factors. ADDING a # factor to a theme = edit this dict; no scoring-function change. THEMES: dict[str, dict] = { "tourism": { "label_th": "ท่องเที่ยว", "factors": [ {"key": "tourism_arrivals_ytd", "weight": 1.0}, {"key": "macro_consumption", "weight": 0.4}, ], }, "auto_credit": { "label_th": "รถยนต์/สินเชื่อ", "factors": [ {"key": "auto_sales_yoy", "weight": 1.0}, {"key": "auto_production", "weight": 0.4}, {"key": "auto_exports", "weight": 0.3}, {"key": "auto_npl", "weight": -0.6}, ], }, "refining_energy": { "label_th": "พลังงาน/โรงกลั่น", "factors": [ {"key": "energy_net_margin", "weight": 1.0}, {"key": "macro_mfg", "weight": 0.3}, ], }, "banks": { "label_th": "ธนาคาร", "factors": [ {"key": "macro_investment", "weight": 1.0}, {"key": "macro_inflation", "weight": -0.4}, {"key": "bank_npl", "weight": -0.6}, ], }, "retail": { "label_th": "ค้าปลีก", "factors": [ {"key": "macro_consumption", "weight": 1.0}, {"key": "macro_inflation", "weight": -0.3}, ], }, "consumer_staples": { "label_th": "อาหาร/อุปโภค", "factors": [ {"key": "macro_consumption", "weight": 1.0}, {"key": "macro_inflation", "weight": -0.2}, ], }, "telecom_it": { "label_th": "สื่อสาร/ไอที", "factors": [ {"key": "macro_consumption", "weight": 0.8}, {"key": "macro_investment", "weight": 0.4}, ], }, "property": { "label_th": "อสังหาริมทรัพย์", "factors": [ {"key": "macro_investment", "weight": 1.0}, {"key": "macro_consumption", "weight": 0.5}, {"key": "macro_inflation", "weight": -0.3}, ], }, "healthcare": { "label_th": "โรงพยาบาล", "factors": [ {"key": "macro_consumption", "weight": 0.5}, ], }, "petrochem_materials": { "label_th": "ปิโตรเคมี/วัสดุ", "factors": [ {"key": "macro_mfg", "weight": 1.0}, {"key": "macro_inflation", "weight": -0.3}, ], }, "utilities": { "label_th": "สาธารณูปโภค", "factors": [ {"key": "macro_mfg", "weight": 1.0}, {"key": "energy_net_margin", "weight": 0.3}, ], }, "nonbank_finance": { "label_th": "การเงินนอกธนาคาร", "factors": [ {"key": "macro_consumption", "weight": 1.0}, {"key": "auto_npl", "weight": -0.3}, ], }, "exploration": { "label_th": "สำรวจ/ผลิตพลังงาน", "factors": [ {"key": "energy_net_margin", "weight": 1.0}, {"key": "macro_inflation", "weight": -0.2}, ], }, } # --------------------------------------------------------------------------- # Scoring helpers # --------------------------------------------------------------------------- def _zscore(values: list) -> dict: """Normalize a list of floats to z-scores, clamped to [-3, 3].""" n = len(values) if n == 0: return {} mean = sum(values) / n var = sum((v - mean) ** 2 for v in values) / n std = var ** 0.5 or 1.0 out = {} for i, v in enumerate(values): z = (v - mean) / std out[i] = max(-3.0, min(3.0, z)) return out def compute_theme_surprises(fetched: dict, tourism_surprise: Optional[float] = None) -> dict: """Registry-driven per-theme surprise — THE single source of truth. `fetched` maps fetch-module name -> collector dict (e.g. {"macro_thai": {...}, "auto_credit": {...}, "energy_thai": {...}}). For each theme in `THEMES`, the surprise is the **weighted average** of its declared FACTORS (each normalized by its own center/span/sign and extracted via `factor_value`), normalised by the total absolute weight of the factors that actually contributed. Normalising by |weight| keeps every theme's surprise on the same [-1, 1] scale regardless of how many (or how heavy) its factors are, so a surprise of +0.2 means the same thing for retail and for banks (cross-theme comparable — important for P4 weight learning). `tourism_surprise` (optional) overrides the tourism theme so the richer bot-tourism observation z-score can win when available; otherwise tourism falls back to its registry factors. """ from . import factors as factors_mod out: dict[str, Optional[float]] = {} for tid, tdef in THEMES.items(): weighted = 0.0 w_sum = 0.0 for ref in tdef.get("factors", []): fkey = ref.get("key") fact = factors_mod.FACTORS.get(fkey) if not fact: continue val = factors_mod.factor_value(fact, fetched.get(fact.get("fetch"))) if val is None: continue norm = factors_mod.normalize(val, sign=fact.get("sign", 1), center=fact.get("center", 0.0), span=fact.get("span", 10.0)) if norm is None: continue w = float(ref.get("weight", 1.0)) weighted += w * norm w_sum += abs(w) if w_sum == 0: out[tid] = None else: s = max(-1.0, min(1.0, weighted / w_sum)) out[tid] = round(s, 3) # tourism override: prefer the observation-derived surprise when provided. if tourism_surprise is not None and "tourism" in out: out["tourism"] = round(max(-1.0, min(1.0, float(tourism_surprise))), 3) return out _SIAMCHART_GROWTH_W = 1.5 # R1 (PEAD): EPS-growth dominates value; literature (Bernard-Thomas 1990, # Livnat-Mendenhall 2006) shows drift follows earnings, not just yield. _SIAMCHART_YIELD_W = 2.0 # dividend floor for value names _SIAMCHART_MOMENTUM_W = 0.5 # R2: EM momentum exists but is noisy -> keep it a small trend boost. def _load_momentum(lookback_days: int = 252) -> dict[str, float]: """12-1 momentum per symbol from the latest price snapshot (deterministic). R2 (Jegadeesh-Titman 1993; EM evidence: weaker but positive). Returns {symbol: (close_today / close_{-12m}) - 1}. Lookback uses trading days so it aligns to ~12 calendar months. """ try: from . import simulation series = simulation.load_price_snapshot() except Exception: return {} out: dict[str, float] = {} for sym, s in series.items(): bars = s.get("bars", []) if len(bars) < lookback_days + 1: continue try: today = float(bars[-1]["adjusted_close"]) base = float(bars[-1 - lookback_days]["adjusted_close"]) except (KeyError, TypeError, ValueError, IndexError): continue if today <= 0 or base <= 0: continue out[sym] = round((today / base) - 1.0, 4) return out def build_siamchart_score(factors: dict, momentum: Optional[dict[str, float]] = None) -> dict[str, float]: """Derive a normalized fundamental score from the Siamchart factor view. Uses EPS growth YoY (weighted above yield per PEAD literature) and dividend yield. Optional momentum (12-1, from price snapshot) adds a low-weight trend component; EM momentum is noisier, so it stays small. """ out: dict[str, float] = {} for f in factors.get("factors", []): sym = f.get("symbol") if not sym: continue g = f.get("eps_growth_yoy") d = f.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.0 m = (momentum or {}).get(sym, 0.0) # R1+R2: growth dominates (PEAD), yield floors, momentum adds trend. out[sym] = g * _SIAMCHART_GROWTH_W + d * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * m syms = list(out.keys()) z = _zscore([out[s] for s in syms]) return {s: z.get(i, 0.0) for i, s in enumerate(syms)} def combine_score(theme_scores: list[dict[str, float]], siamchart_score: dict[str, float], weight_theme: float = 0.6, weight_siamchart: float = 0.4) -> dict[str, dict]: """Combine per-symbol theme (averaged) and siamchart scores into final. Returns {symbol: {'theme_score', 'siamchart_score', 'combined', 'themes':[...]}}. """ all_syms: dict[str, list[float]] = {} theme_membership: dict[str, list[str]] = {} for ts in theme_scores: for sym, val in ts.items(): all_syms.setdefault(sym, []).append(val) theme_membership.setdefault(sym, []).append(sym) merged: dict[str, dict] = {} # every symbol that has a siamchart score OR a theme score universe = set(all_syms) | set(siamchart_score) for sym in universe: tl = all_syms.get(sym, []) tavg = (sum(tl) / len(tl)) if tl else 0.0 sc = siamchart_score.get(sym, 0.0) combined = weight_theme * tavg + weight_siamchart * sc merged[sym] = { "theme_score": tavg, "siamchart_score": sc, "combined": combined, "themes": theme_membership.get(sym, []), } return merged def quality_within_theme(symbol: str, theme_id: str, factor_view: dict) -> float: """Relative firm quality of a symbol within its theme cohort (stock picking). Deterministic, bounded to [0.5, 1.5] around 1.0: - compute the theme cohort = all THEME_SYMBOLS[theme_id] that have a factor row - quality = 1.0 + 0.5 * z(ROE, cohort) (above cohort = >1, below = <1) - damped by 0.3 * z(EPS growth, cohort) Stronger names in a hot theme rank higher -> the theme actually "picks" stocks instead of giving every member the same flat surprise. """ cohort = [s for s in THEME_SYMBOLS.get(theme_id, set()) if s != symbol] fmap = {f.get("symbol"): f for f in factor_view.get("factors", [])} fac = fmap.get(symbol) if not fac: return 1.0 roe = fac.get("roe") epsg = fac.get("eps_growth_yoy") def _cohort_z(val, getter): vals = [] for c in cohort: cf = fmap.get(c) v = getter(cf) if v is not None: vals.append(float(v)) if not vals or val is None: return 0.0 mean = statistics.fmean(vals) stdev = statistics.pstdev(vals) if stdev < 1e-9: return 0.0 return (float(val) - mean) / stdev z_roe = _cohort_z(roe, lambda f: f.get("roe") if f else None) z_epsg = _cohort_z(epsg, lambda f: f.get("eps_growth_yoy") if f else None) quality = 1.0 + 0.5 * max(min(z_roe, 2.0), -2.0) + 0.3 * max(min(z_epsg, 2.0), -2.0) return round(max(min(quality, 1.5), 0.5), 3) def symbol_breakdown( symbol: str, *, factor_view: dict, theme_surprises: dict[str, float], latest_price: Optional[float] = None, price_date: str = "", weight_theme: float = 0.6, weight_siamchart: float = 0.4, momentum: Optional[dict[str, float]] = None, ) -> dict: """Transparent per-symbol scoring breakdown. Shows exactly how `combined` was derived: theme_score = mean of the theme surprise scores covering this symbol siamchart_score = z-scored (EPS growth + dividend_yield*2) combined = weight_theme*theme_score + weight_siamchart*siamchart_score Returns a dict suitable for the /api/v1/symbols/ view. Deterministic and reuses the same formula as `combine_score` so the board and the detail always agree. """ # the themes this symbol belongs to (from the curated exposure map) member_themes = [tid for tid, syms in THEME_SYMBOLS.items() if symbol in syms] theme_lines = [] theme_values = [] for tid in member_themes: s = theme_surprises.get(tid) q = quality_within_theme(symbol, tid, factor_view) if s is not None: ts = round(float(s) * q, 3) theme_values.append(ts) theme_lines.append({ "theme": tid, "label_th": THEME_LABELS_TH.get(tid, tid), "surprise": round(float(s), 3), "quality": q, "theme_score": ts, }) else: theme_lines.append({ "theme": tid, "label_th": THEME_LABELS_TH.get(tid, tid), "surprise": None, "quality": q, "theme_score": None, }) theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0 # find the factor row for this symbol fac = next((f for f in factor_view.get("factors", []) if f.get("symbol") == symbol), {}) g = fac.get("eps_growth_yoy") d = fac.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.0 m = (momentum or {}).get(symbol, 0.0) raw_siamchart = g * _SIAMCHART_GROWTH_W + d * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * m # z-score against the full universe (same as build_siamchart_score); capture # the population stats so the view can show HOW -2.8 became -0.588. siamchart_map = build_siamchart_score(factor_view, momentum=momentum) siamchart_score = siamchart_map.get(symbol, 0.0) # recompute the population of raw scores to expose mean / stdev raw_values = [] for f in factor_view.get("factors", []): if not f.get("symbol"): continue gg = f.get("eps_growth_yoy") dd = f.get("dividend_yield") or 0.0 gg = float(gg) if gg is not None else 0.0 mm = (momentum or {}).get(f.get("symbol"), 0.0) raw_values.append(gg * _SIAMCHART_GROWTH_W + dd * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * mm) pop_mean = statistics.mean(raw_values) if raw_values else 0.0 pop_stdev = statistics.pstdev(raw_values) if raw_values else 0.0 combined = round(weight_theme * theme_score + weight_siamchart * siamchart_score, 3) return { "symbol": symbol, "company_name": fac.get("company_name", ""), "themes": member_themes, "theme_contributions": theme_lines, "theme_score": round(theme_score, 3), "siamchart_score": round(siamchart_score, 3), "siamchart_components": { "eps_growth_yoy": fac.get("eps_growth_yoy"), "dividend_yield": fac.get("dividend_yield"), "raw_growth_plus_yield_2x": round(raw_siamchart, 3), }, "siamchart_z_note": { "formula": "z = (raw_i - mean) / stdev", "raw_i": round(raw_siamchart, 3), "population_mean": round(pop_mean, 3), "population_stdev": round(pop_stdev, 3), "universe_size": len(raw_values), }, "combined_calc": [ {"label": "คะแนนธีม", "value": round(theme_score, 3), "weight": weight_theme, "note": "ค่าเฉลี่ยของ theme surprise ที่หุ้นนี้อยู่ใน", "term": f"{theme_score:.3f} × {weight_theme}"}, {"label": "คะแนนพื้นฐาน", "value": round(siamchart_score, 3), "weight": weight_siamchart, "note": f"z-score ของ ((EPS growth {g:+.1f}%) + ปันผล {d:.1f}%×2) เมื่อเทียบทั้ง universe", "term": f"{siamchart_score:.3f} × {weight_siamchart}"}, ], "combined_score": combined, "combined_formula": f"({theme_score:.3f} × {weight_theme}) + ({siamchart_score:.3f} × {weight_siamchart}) = {combined}", "weights": {"theme": weight_theme, "siamchart": weight_siamchart}, "fundamentals": { "pe": fac.get("pe"), "eps": fac.get("eps"), "pbv": fac.get("pbv"), "roe": fac.get("roe"), "dps": fac.get("dps"), "is_dividend": fac.get("is_dividend"), }, "price": {"latest": latest_price, "date": price_date}, }