Files
set50-system/backend/app/factors.py
Kunthawat Greethong d850955c44 [verified] Declarative factor engine + per-symbol stock selection (full-app consistency)
- factors.py: FACTORS registry (10 declarative entries: source/fetch/frequency/sign/weight) + normalize/z-score helpers. Add a source = one dict entry, no scoring-function edit.
- themes.THEMES: 13 themes reference FACTORS with per-theme weights (flexible), replacing hardcoded _theme_surprises/_theme_narrative.
- themes.quality_within_theme(): per-symbol quality vs theme cohort (ROE/EPS) -> real stock picking. dashboard board now surprise×quality (BBL 0.5 vs KTB 1.5 in banks).
- board rows carry per-symbol themes[]; /api/v1/themes delegates to RealDashboard.build() -> 13-theme consistency with /api/v1/dashboard (removed 115 lines dead dup logic).
- frontend: deleted THEME_BY_SYMBOL/themeLabelById hardcode; theme column + modal labels+quality all from API. Modal shows surprise×quality=theme_score.
- Tests: 202 OK (quality selection, breakdown quality, themes/dashboard consistency).
- Verified: BBL modal 1.00σ×0.5=0.50σ; KTB 1.5 vs BBL 0.5, PTT 2 themes; 49/49 rows theme from API.
2026-08-26 15:17:41 +07:00

180 lines
5.8 KiB
Python

"""Declarative FACTORS registry — the single source of truth for every factor.
Each factor is a plain-data unit describing:
- where the data comes from (source + fetch module + which key holds the value)
- its data frequency (for frequency alignment, never naive mixing)
- its SIGN (+1 = higher value is bullish for a theme, -1 = bearish)
- a default weight (themes override per-theme)
ADDING A NEW DATA SOURCE/FACTOR = append one entry here + (optionally) a theme
factor line. It requires NO change to any scoring function. This is what makes the
analysis engine data-driven and auditable.
"""
from __future__ import annotations
import statistics
from typing import Any, Callable, Optional
# fetch key -> module that exposes a fetch_<...>() callable returning .to_dict()
_FETCH_MODULE: dict[str, str] = {
"tourism": "bot_tourism",
"auto_credit": "auto_credit",
"auto_npl": "auto_npl",
"energy_thai": "energy_thai",
"macro_thai": "macro_thai",
}
# Factor -> value key. Sign: +1 higher-is-bullish, -1 lower-is-bullish.
# weight: default global weight; themes may override.
FACTORS: dict[str, dict[str, Any]] = {
# ---- real Thai collectors ----
"tourism_arrivals_ytd": {
"name_th": "นักท่องเที่ยวสะสมปี",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "tourists_ytd_mn",
"sign": 1,
"weight": 1.0,
},
"auto_sales_yoy": {
"name_th": "ยอดขายรถยนต์ (YoY)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "auto_credit",
"value_key": "new_car_sales_yoy",
"sign": 1,
"weight": 1.0,
},
"auto_production": {
"name_th": "การผลิตรถยนต์",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "auto_credit",
"value_key": "vehicle_production",
"sign": 1,
"weight": 0.4,
},
"auto_exports": {
"name_th": "ส่งออกรถยนต์",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "auto_credit",
"value_key": "auto_exports",
"sign": 1,
"weight": 0.3,
},
"auto_npl": {
"name_th": "NPL รถยนต์",
"source": "BOT",
"frequency": "quarterly",
"fetch": "auto_npl",
"value_key": "pct_of_npls",
"sign": -1,
"weight": 1.0,
},
"energy_net_margin": {
"name_th": "กำไรสุทธิโรงกลั่น",
"source": "TOP",
"frequency": "quarterly",
"fetch": "energy_thai",
"value_key": "net_margin_quarter",
"sign": 1,
"weight": 1.0,
},
# ---- macro backdrop (proxy for expanded SET50 themes) ----
"macro_consumption": {
"name_th": "การบริโภคภาคเอกชน (YoY)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "private_consumption_yoy",
"sign": 1,
"weight": 1.0,
},
"macro_investment": {
"name_th": "การลงทุนภาคเอกชน (YoY)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "private_investment_yoy",
"sign": 1,
"weight": 1.0,
},
"macro_mfg": {
"name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "manufacturing_yoy",
"sign": 1,
"weight": 1.0,
},
"macro_inflation": {
"name_th": "เงินเฟ้อ",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "headline_inflation_yoy",
"sign": -1,
"weight": 0.5,
},
}
class FactorError(ValueError):
pass
def factor_value(fact: dict, fetched: dict) -> Optional[float]:
"""Pull the numeric value out of a fetched collector dict for a factor."""
key = fact.get("value_key")
if fetched is None:
return None
if fact.get("fetch") == "energy_thai":
# derive a single metric from the quarterly dict
q = fetched.get("quarterly") or {}
if isinstance(q, dict):
row = next((v for v in q.values() if isinstance(v, dict)), {})
np_ = row.get("net_profit")
rev = row.get("sales")
if np_ is not None and rev:
try:
return float(np_) / float(rev) * 100.0 # net margin %
except (TypeError, ValueError, ZeroDivisionError):
return None
return None
if key is None:
return None
val = fetched.get(key)
try:
return float(val) if val is not None else None
except (TypeError, ValueError):
return None
def normalize(value: Optional[float], sign: int = 1,
center: float = 0.0, span: float = 10.0) -> Optional[float]:
"""Deterministic bounded normalization: sign-aware, clamped to [-1, +1].
value == center -> 0. positive beyond center (for sign=+1) -> positive.
"""
if value is None:
return None
if span <= 0:
span = 1.0
num = (float(value) - center) / span * float(sign)
return round(min(max(num, -1.0), 1.0), 4)
def z_score(value: float, population: list[float]) -> float:
"""Population z-score with tiny-stdev guard (deterministic)."""
if not population:
return 0.0
mean = statistics.fmean(population)
stdev = statistics.pstdev(population)
if stdev < 1e-9:
return 0.0
return round((float(value) - mean) / stdev * 10.0, 4) # scale to decile-ish