[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:
158
backend/app/themes.py
Normal file
158
backend/app/themes.py
Normal 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
|
||||
Reference in New Issue
Block a user