Files
set50-system/backend/app/themes.py
Kunthawat Greethong 6e78b6acb5 [verified] Apply R1-R5 (factor formula) + real bank-sector NPL collector
(a) R1-R5 (factor-refinement, grounded in methodology-research.md):
- R1 (PEAD): EPS-growth weight raised 1.0->1.5 in build_siamchart_score / symbol_breakdown (Bernard-Thomas 1990, Livnat-Mendenhall 2006)
- R2 (momentum): 12-1 momentum factor from Yahoo price snapshot (Jegadeesh-Titman 93; lite weight 0.5)
- R3 (regime): binary bear gate -> continuous stress = negative-themes fraction, smooth LONG/SHORT shift
- R5 (dividend screen): non-dividend / cut-yield names no longer go LONG (screen-off)
- R4 (earnings-revision) deferred: no free EPS-forecast source yet (documented)

(b) bank-sector NPL collector (BOT reportID 794, financial&insurance sector):
- refactored auto_npl to expose shared _parse_sector; new bank_npl.py reuses it
- registered bank_npl FACTOR -> auto-appears in sources table (6 rows) + blends into banks theme surprise (real NPL)
- +unit tests (test_bank_npl), test_dashboard updated (6 sources)

205 tests pass; verified live API (banks surprise incl. NPL 1.07, 6 sources).
2026-08-26 19:56:39 +07:00

498 lines
20 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": "สำรวจ/ผลิตพลังงาน",
}
# 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},
],
},
"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},
],
},
}
@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)}
_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/<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)
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},
}