Files
set50-system/backend/app/themes.py
Kunthawat Greethong d87a1ada39 [verified] Cross-theme surprise normalization + historical factor store (P4 enabler)
A. Cross-theme comparability:
- compute_theme_surprises now weight-normalizes by total |weight| (weighted
  average), so every theme surprise on same [-1,1] scale regardless of factor
  count/weight (retail 0.189->0.145; auto_credit 1.0->0.64).

B. Historical factor store (enables learning macro/demographic factors):
- New factor_history.py: append-only per-factor JSONL, dedupes unchanged
  values, rejects non-finite, records every FACTORS value each scheduler run.
- scheduler.py: jobs carry fetch_module; refresh_all records factor history
  (non-fatal); added bank_npl job.
- GET /api/v1/learning/factors?min_points= reports n_points/learnable per
  factor so users see when P4 learning unlocks (validated query parsing).
- weight_learning: generic learn_factor_series() aggregator (momentum reuses).

Independent review deleg_5dd358e3 passed=true (empty security/logic arrays);
its two robustness suggestions applied (finite guard in record(), clean 400 on
bad min_points). 234 tests pass; Vite build passes.
2026-08-27 07:32:16 +07:00

509 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 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/<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},
}