Files
set50-system/backend/app/themes.py
Kunthawat Greethong 576d9e31ec feat(suggestion): 'ทำกำไร' = price-trend momentum (3/6/12m) gated on positive theme signal
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).
2026-08-31 10:05:00 +07:00

640 lines
26 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},
{"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},
}