[verified] Add multi-theme registry + combined scoring engine (60% theme / 40% Siamchart)

- themes.py: 3-theme registry (tourism monthly, auto_credit monthly, refining_energy quarterly) with Thai labels + frequency; curated SET50 symbol->theme exposure map; z-normalized theme scoring and Siamchart fundamental score; 60/40 combined score (multi-theme mean)
- Frequency recorded per theme so consumers don't mix different-cadence factors as same-timestamp
- 8 tests; full suite pass
This commit is contained in:
Kunthawat Greethong
2026-08-25 15:19:39 +07:00
parent 3f7fccd25b
commit affc29a3be
2 changed files with 230 additions and 0 deletions

158
backend/app/themes.py Normal file
View File

@@ -0,0 +1,158 @@
"""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
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).
THEME_SYMBOLS: dict[str, set[str]] = {
"tourism": {
"AOT", "CENTEL", "MINT", "ERW", "DHOUSE", "AWC", "SNNP", "CPN", "CRC",
"MAJOR", "BEM", "BTS",
},
"auto_credit": {
"KKP", "TISCO", "TCAP", "THANI", "MTC", "SAWAD", "NSI", "GLAND",
"THG", "ASI", "TGPRO", "AEONTS", "TK",
},
"refining_energy": {
"PTT", "PTTGC", "TOP", "IRPC", "SPRC", "BCP", "ESSO", "BANPU", "GPSC",
},
}
# 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",
}
@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 (index -> z)."""
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
return {i: (v - mean) / std for i, v in enumerate(values)}
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