Owner rule: a stock that should be bought for profit is one whose PRICE is likely to rise in the next 3-6 months — not one with high EPS growth (BTS had EPS +137% yet flat/falling price). The old selection ranked buckets 1/2 by (60/40 theme+siamchart where siamchart was EPS-growth dominated). - themes.price_trend_score(): blend of ~3/6/12-month price momentum, z-scored across the universe (heavier 3/6m weight per the 3-6 month tenure). - allocate_capital: buckets 1/2 rank by momentum, gated on theme_signal > 0 (mean surprise across the symbol's themes). theme_signal=None (backtest path) is not gated so PIT backtest still allocates. Bucket 3 unchanged (yield top). - suggestion endpoint passes real momentum + theme_signal from the live board. - Verified: bucket 1 now picks CRC/BEM (dividend + rising price); falling-price PTT/MINT go to bucket 3 by yield, not bucket 1. Full suite 372 green (3 new momentum/theme-gate tests).
640 lines
26 KiB
Python
640 lines
26 KiB
Python
"""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},
|
||
{"key": "external_current_account", "weight": 0.3},
|
||
],
|
||
},
|
||
"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": "energy_irpc_net_margin", "weight": 0.6},
|
||
{"key": "macro_mfg", "weight": 0.3},
|
||
],
|
||
},
|
||
"banks": {
|
||
"label_th": "ธนาคาร",
|
||
"factors": [
|
||
{"key": "macro_investment", "weight": 1.0},
|
||
{"key": "macro_inflation", "weight": 0.4},
|
||
{"key": "macro_core_inflation", "weight": 0.3},
|
||
{"key": "bank_npl", "weight": 0.6},
|
||
{"key": "te_interest_rate", "weight": 0.4},
|
||
{"key": "te_loan_growth", "weight": 0.4},
|
||
],
|
||
},
|
||
"retail": {
|
||
"label_th": "ค้าปลีก",
|
||
"factors": [
|
||
{"key": "macro_consumption", "weight": 1.0},
|
||
{"key": "macro_inflation", "weight": 0.3},
|
||
{"key": "macro_core_inflation", "weight": 0.2},
|
||
{"key": "macro_unemployment", "weight": 0.3},
|
||
{"key": "external_imports", "weight": 0.2},
|
||
{"key": "external_current_account", "weight": 0.2},
|
||
{"key": "te_retail_sales_yoy", "weight": 0.7},
|
||
{"key": "te_consumer_confidence", "weight": 0.4},
|
||
],
|
||
},
|
||
"consumer_staples": {
|
||
"label_th": "อาหาร/อุปโภค",
|
||
"factors": [
|
||
{"key": "macro_consumption", "weight": 1.0},
|
||
{"key": "macro_inflation", "weight": 0.2},
|
||
{"key": "macro_core_inflation", "weight": 0.15},
|
||
{"key": "macro_unemployment", "weight": 0.3},
|
||
{"key": "external_imports", "weight": 0.15},
|
||
{"key": "te_retail_sales_yoy", "weight": 0.6},
|
||
{"key": "te_consumer_confidence", "weight": 0.3},
|
||
],
|
||
},
|
||
"telecom_it": {
|
||
"label_th": "สื่อสาร/ไอที",
|
||
"factors": [
|
||
{"key": "macro_consumption", "weight": 0.8},
|
||
{"key": "macro_investment", "weight": 0.4},
|
||
{"key": "external_exports", "weight": 0.2},
|
||
{"key": "te_business_confidence", "weight": 0.3},
|
||
],
|
||
},
|
||
"property": {
|
||
"label_th": "อสังหาริมทรัพย์",
|
||
"factors": [
|
||
{"key": "macro_investment", "weight": 1.0},
|
||
{"key": "macro_consumption", "weight": 0.5},
|
||
{"key": "macro_inflation", "weight": 0.3},
|
||
{"key": "external_imports", "weight": 0.2},
|
||
{"key": "te_property_prices", "weight": 0.9},
|
||
{"key": "te_business_confidence", "weight": 0.3},
|
||
],
|
||
},
|
||
"healthcare": {
|
||
"label_th": "โรงพยาบาล",
|
||
"factors": [
|
||
{"key": "macro_consumption", "weight": 0.5},
|
||
{"key": "macro_unemployment", "weight": 0.4},
|
||
{"key": "te_business_confidence", "weight": 0.2},
|
||
],
|
||
},
|
||
"petrochem_materials": {
|
||
"label_th": "ปิโตรเคมี/วัสดุ",
|
||
"factors": [
|
||
{"key": "macro_mfg", "weight": 1.0},
|
||
{"key": "macro_inflation", "weight": 0.3},
|
||
{"key": "external_exports", "weight": 0.4},
|
||
{"key": "external_current_account", "weight": 0.3},
|
||
],
|
||
},
|
||
"utilities": {
|
||
"label_th": "สาธารณูปโภค",
|
||
"factors": [
|
||
{"key": "macro_mfg", "weight": 1.0},
|
||
{"key": "energy_net_margin", "weight": 0.3},
|
||
{"key": "energy_irpc_net_margin", "weight": 0.2},
|
||
],
|
||
},
|
||
"nonbank_finance": {
|
||
"label_th": "การเงินนอกธนาคาร",
|
||
"factors": [
|
||
{"key": "macro_consumption", "weight": 1.0},
|
||
{"key": "auto_npl", "weight": 0.3},
|
||
{"key": "macro_unemployment", "weight": 0.3},
|
||
{"key": "te_consumer_credit", "weight": 0.5},
|
||
{"key": "te_household_debt_gdp", "weight": 0.4},
|
||
{"key": "te_consumer_confidence", "weight": 0.3},
|
||
],
|
||
},
|
||
"exploration": {
|
||
"label_th": "สำรวจ/ผลิตพลังงาน",
|
||
"factors": [
|
||
{"key": "energy_net_margin", "weight": 1.0},
|
||
{"key": "energy_irpc_net_margin", "weight": 0.4},
|
||
{"key": "macro_inflation", "weight": 0.2},
|
||
{"key": "external_exports", "weight": 0.3},
|
||
{"key": "external_current_account", "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
|
||
|
||
|
||
def factor_source_breakdown(fetched: dict, theme_id: str) -> list:
|
||
"""Per-factor contribution detail for one theme (what the user asked for).
|
||
|
||
For each FACTOR a theme references, show exactly how it contributed to the
|
||
theme surprise:
|
||
- source: the fetch-module name (e.g. 'macro_thai', 'te_thailand')
|
||
- name_th: the factor's Thai label
|
||
- raw: the raw collected value
|
||
- normalized: the sign/center/span-normalized score in [-1, 1]
|
||
- weight: the per-theme weight (positive magnitude; direction is in sign)
|
||
- contribution: weight * normalized
|
||
- missing: True when the source had no value so the factor was dropped
|
||
|
||
This is the audit trail that lets the owner see "which source scored what,
|
||
and how the weight was applied" and tune weights/thesis more easily.
|
||
"""
|
||
from . import factors as factors_mod
|
||
|
||
tdef = THEMES.get(theme_id, {})
|
||
rows = []
|
||
for ref in tdef.get("factors", []):
|
||
fkey = ref.get("key")
|
||
fact = factors_mod.FACTORS.get(fkey)
|
||
if not fact:
|
||
continue
|
||
fetch_mod = fact.get("fetch")
|
||
val = factors_mod.factor_value(fact, fetched.get(fetch_mod))
|
||
w = float(ref.get("weight", 1.0))
|
||
if val is None:
|
||
rows.append({
|
||
"factor": fkey, "source": fetch_mod,
|
||
"name_th": fact.get("name_th", fkey),
|
||
"frequency": fact.get("frequency", "monthly"),
|
||
"sign": fact.get("sign", 1),
|
||
"raw": None, "normalized": None, "weight": w,
|
||
"contribution": None, "missing": True,
|
||
})
|
||
continue
|
||
norm = factors_mod.normalize(val, sign=fact.get("sign", 1),
|
||
center=fact.get("center", 0.0),
|
||
span=fact.get("span", 10.0))
|
||
rows.append({
|
||
"factor": fkey, "source": fetch_mod,
|
||
"name_th": fact.get("name_th", fkey),
|
||
"frequency": fact.get("frequency", "monthly"),
|
||
"sign": fact.get("sign", 1),
|
||
"raw": round(val, 4) if val is not None else None,
|
||
"normalized": norm,
|
||
"weight": w,
|
||
"contribution": round(w * (norm or 0.0), 4) if norm is not None else None,
|
||
"missing": False,
|
||
})
|
||
return rows
|
||
|
||
|
||
_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 price_trend_score(series: dict, lookbacks=(63, 126, 252), weights=(0.4, 0.35, 0.25)) -> dict[str, float]:
|
||
"""Mid-term price trend per symbol — the owner's definition of "ทำกำไร".
|
||
|
||
The owner wants buckets 1/2 to mean "a stock whose price is likely to rise in
|
||
the next 3-6 months", which is a *price-trend* signal, not EPS growth. This
|
||
blends multi-horizon momentum over ~3 / 6 / 12 months (trading days), then
|
||
z-scores across the universe so the score is comparable. Heavier weight on
|
||
the shorter horizons (3/6m) matches the 3-6 month tenure the owner named.
|
||
|
||
Returns {symbol: z(trend)}. Symbols without enough price history are omitted
|
||
(callers treat them as ineligible/momentum-neutral).
|
||
"""
|
||
import statistics
|
||
mom = {sym: [] for sym in series}
|
||
for sym, s in series.items():
|
||
bars = s.get("bars", [])
|
||
if not bars:
|
||
continue
|
||
todays = float(bars[-1]["adjusted_close"])
|
||
if todays <= 0:
|
||
continue
|
||
for lb in lookbacks:
|
||
if len(bars) > lb:
|
||
base = float(bars[-1 - lb]["adjusted_close"])
|
||
if base > 0:
|
||
mom[sym].append((todays / base) - 1.0)
|
||
else:
|
||
mom[sym].append(0.0)
|
||
else:
|
||
mom[sym].append(None)
|
||
raw: dict[str, float] = {}
|
||
for sym, vals in mom.items():
|
||
contrib = [w * (v or 0.0) for v, w in zip(vals, weights) if v is not None]
|
||
if contrib:
|
||
raw[sym] = sum(contrib)
|
||
if not raw:
|
||
return {}
|
||
vals = list(raw.values())
|
||
mean = statistics.mean(vals)
|
||
sd = statistics.pstdev(vals) or 1.0
|
||
return {sym: round((v - mean) / sd, 4) for sym, v in raw.items()}
|
||
|
||
|
||
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},
|
||
}
|