P0-B (registry is the single source of truth for scoring): - FACTORS now carries center/span normalization spec; unused hand-written per-theme surprise blocks in dashboard.py replaced by one registry-driven compute_theme_surprises() (themes.py). - THEMES['banks'] adds bank_npl weight so NPL is genuinely blended. - factor_value/normalize hardened against NaN/inf (finite guards). - Board re-ranks (TRUE/GULF up, TOP->3) per registry weights; 3 new tests incl. 'changing a registry weight changes output'. P3 (point-in-time backtest): - run_backtest is now a real multi-rebalance engine (reallocates every window, reconciles holdings, marks to market) instead of allocate-once+break. - Added leakage_guard (False unless a PIT score_fn is supplied), planned vs actual rebalances, and momentum_at() true 12-1 (skips last month, PIT). P4 (factor-weight learning): - weight_learning.py: cross-sectional Spearman IC, forward-return builder, IC aggregation + t-stat, and apply_weight_update (new = clip(old*(1+shrink*IC))). - GET /api/v1/learning/momentum endpoint. Live result: momentum IC=0.012 t=0.132 over 22 periods -> momentum has no reliable predictive power here. Macro/demographic factors blocked (no historical factor vintages yet). Two independent review gates passed (deleg_fe6f45cd, deleg_718218f8): empty security/logic arrays; their non-blocking suggestions applied (finite guards, dedupe leakage_guard resolution). 226 tests pass; Vite build passes.
213 lines
7.2 KiB
Python
213 lines
7.2 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 math
|
|
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",
|
|
"bank_npl": "bank_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,
|
|
"center": 20.0, "span": 15.0, # cumulative arrivals in millions, ~20mn neutral
|
|
},
|
|
"auto_sales_yoy": {
|
|
"name_th": "ยอดขายรถยนต์ (YoY)",
|
|
"source": "TradingEconomics",
|
|
"frequency": "monthly",
|
|
"fetch": "auto_credit",
|
|
"value_key": "new_car_sales_yoy",
|
|
"sign": 1,
|
|
"weight": 1.0,
|
|
"center": 5.0, "span": 10.0, # YoY %, ~5% long-run growth
|
|
},
|
|
"auto_production": {
|
|
"name_th": "การผลิตรถยนต์",
|
|
"source": "TradingEconomics",
|
|
"frequency": "monthly",
|
|
"fetch": "auto_credit",
|
|
"value_key": "vehicle_production",
|
|
"sign": 1,
|
|
"weight": 0.4,
|
|
"center": 0.0, "span": 200000.0, # units/month (~117K), scale captured as level
|
|
},
|
|
"auto_exports": {
|
|
"name_th": "ส่งออกรถยนต์",
|
|
"source": "TradingEconomics",
|
|
"frequency": "monthly",
|
|
"fetch": "auto_credit",
|
|
"value_key": "auto_exports",
|
|
"sign": 1,
|
|
"weight": 0.3,
|
|
"center": 0.0, "span": 200000.0, # units (~82K), scale captured as level
|
|
},
|
|
"auto_npl": {
|
|
"name_th": "NPL รถยนต์",
|
|
"source": "BOT",
|
|
"frequency": "quarterly",
|
|
"fetch": "auto_npl",
|
|
"value_key": "pct_of_npls",
|
|
"sign": -1,
|
|
"weight": 1.0,
|
|
"center": 3.0, "span": 5.0, # NPL as % of loans, ~3% neutral
|
|
},
|
|
"bank_npl": {
|
|
"name_th": "NPL ภาคการเงิน",
|
|
"source": "BOT",
|
|
"frequency": "quarterly",
|
|
"fetch": "bank_npl",
|
|
"value_key": "pct_of_npls",
|
|
"sign": -1,
|
|
"weight": 1.0,
|
|
"center": 0.5, "span": 3.0, # financial-sector NPL share (~0.5-5%)
|
|
},
|
|
"energy_net_margin": {
|
|
"name_th": "กำไรสุทธิโรงกลั่น",
|
|
"source": "TOP",
|
|
"frequency": "quarterly",
|
|
"fetch": "energy_thai",
|
|
"value_key": "net_margin_quarter",
|
|
"sign": 1,
|
|
"weight": 1.0,
|
|
"center": 5.0, "span": 10.0, # net margin % (derived from quarterly)
|
|
},
|
|
# ---- 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,
|
|
"center": 3.0, "span": 10.0, # YoY %, ~3% trend
|
|
},
|
|
"macro_investment": {
|
|
"name_th": "การลงทุนภาคเอกชน (YoY)",
|
|
"source": "BOT",
|
|
"frequency": "monthly",
|
|
"fetch": "macro_thai",
|
|
"value_key": "private_investment_yoy",
|
|
"sign": 1,
|
|
"weight": 1.0,
|
|
"center": 5.0, "span": 10.0, # YoY %, ~5% trend
|
|
},
|
|
"macro_mfg": {
|
|
"name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)",
|
|
"source": "BOT",
|
|
"frequency": "monthly",
|
|
"fetch": "macro_thai",
|
|
"value_key": "manufacturing_yoy",
|
|
"sign": 1,
|
|
"weight": 1.0,
|
|
"center": 0.0, "span": 10.0, # YoY %, ~0 neutral
|
|
},
|
|
"macro_inflation": {
|
|
"name_th": "เงินเฟ้อ",
|
|
"source": "BOT",
|
|
"frequency": "monthly",
|
|
"fetch": "macro_thai",
|
|
"value_key": "headline_inflation_yoy",
|
|
"sign": -1,
|
|
"weight": 0.5,
|
|
"center": 2.0, "span": 10.0, # ~2% target; higher is worse (sign -1)
|
|
},
|
|
}
|
|
|
|
|
|
class FactorError(ValueError):
|
|
pass
|
|
|
|
|
|
def factor_value(fact: dict, fetched: Optional[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:
|
|
margin = float(np_) / float(rev) * 100.0 # net margin %
|
|
except (TypeError, ValueError, ZeroDivisionError):
|
|
return None
|
|
return margin if math.isfinite(margin) else None
|
|
return None
|
|
if key is None:
|
|
return None
|
|
val = fetched.get(key)
|
|
try:
|
|
out = float(val) if val is not None else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if out is None or not math.isfinite(out):
|
|
return None
|
|
return out
|
|
|
|
|
|
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.
|
|
Non-finite values (NaN/inf) are rejected rather than propagated.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
value = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not math.isfinite(value):
|
|
return None
|
|
if span <= 0:
|
|
span = 1.0
|
|
num = (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
|