Files
set50-system/backend/app/themes.py
Kunthawat Greethong 55b3574040 [verified] Cover full SET50 with 13 themes + per-theme score detail in symbol view
- THEME_SYMBOLS expanded: added banks, retail, telecom_it, property, healthcare, petrochem_materials, consumer_staples, utilities, nonbank_finance, exploration -> all 49 SET50 names now in a theme
- THEME_LABELS_TH Thai labels; THEME_FREQUENCY per theme
- symbol_breakdown now lists EVERY theme the symbol belongs to (label_th + surprise, or 'ยังไม่มีข้อมูล'), so theme_score is transparent per source
- frontend: theme column maps all 49 symbols (mirrors backend); modal shows per-theme score detail
- Fixed test for BANPU multi-theme; full suite 199 OK
- Verified: 49/49 rows have theme chip; AOT modal shows ท่องเที่ยว 0.57σ + full calc
2026-08-26 14:05:10 +07:00

313 lines
12 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.
"""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 dataclasses import dataclass
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": "สำรวจ/ผลิตพลังงาน",
}
@dataclass
class Theme:
id: str
label_en: str
label_th: str
frequency: str
source: str
enabled: bool = True
def list_themes() -> list[Theme]:
return [
Theme("tourism", "Tourism Pulse", "การท่องเที่ยว", "monthly", "BOT tourism", enabled=True),
Theme("auto_credit", "Auto Credit Cycle", "สินเชื่อรถยนต์", "monthly", "TradingEconomics car sales", enabled=True),
Theme("refining_energy", "Refining / Energy", "โรงกลั่น/พลังงาน", "quarterly", "Thai Oil (TOP) financials", enabled=True),
]
# ---------------------------------------------------------------------------
# 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 _map_index(symbols: list[str]) -> dict[str, int]:
return {s: i for i, s in enumerate(symbols)}
def build_theme_scores(theme_id: str, signals: list[dict]) -> dict[str, float]:
"""Score every symbol in a theme's signal list.
`signals` is the tourism-style list of per-symbol signal dicts with
`score` (higher = more bullish) and `symbol`.
"""
out: dict[str, float] = {}
for sig in signals:
sym = sig.get("symbol")
score = sig.get("score")
if sym and score is not None:
out[sym] = float(score)
# z-normalize across scored symbols so theme scores are comparable.
syms = list(out.keys())
values = [out[s] for s in syms]
z = _zscore(values)
return {s: z.get(i, 0.0) for i, s in enumerate(syms)}
def build_siamchart_score(factors: dict) -> dict[str, float]:
"""Derive a normalized fundamental score from the Siamchart factor view.
Uses EPS growth YoY and dividend yield as the "value+quality" signals.
Positive EPS growth and higher yield both push the score up.
"""
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
# combine growth and yield; yield adds a floor so dividend names get weight
out[sym] = g + d * 2.0
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 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,
) -> 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/<symbol> 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)
if s is not None:
theme_values.append(float(s))
theme_lines.append({
"theme": tid,
"label_th": THEME_LABELS_TH.get(tid, tid),
"surprise": round(float(s), 3),
})
else:
theme_lines.append({
"theme": tid,
"label_th": THEME_LABELS_TH.get(tid, tid),
"surprise": 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
raw_siamchart = g + d * 2.0
# 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)
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
raw_values.append(gg + dd * 2.0)
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},
}