Files
set50-system/backend/app/factors.py
Kunthawat Greethong 12b34929d7 feat(factor): add energy_irpc (IRPC net margin) as 2nd Thai refiner signal
- new energy_irpc collector parsing IRPC performance-highlights table
  (net profit/EBITDA/ROE margins, latest period 3M26: +10.27%)
- factor energy_irpc_net_margin (sign +1) wired into refining_energy/
  exploration/utilities, extending the energy theme beyond TOP
- scheduler job + dashboard fetch + sources table row (now 9 sources)
- tests: parse (incl paren-negatives), value-key resolution, direction;
  suite 368 OK. Independent review passed: true
- Phase B feasibility: REIC/EPPO/NBTC/PTTEP are JS-rendered or anti-bot
  (recorded deferred in plan); IRPC was the clean server-rendered win
2026-08-29 11:13:17 +07:00

365 lines
13 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",
"energy_irpc": "energy_irpc",
"macro_thai": "macro_thai",
"thai_trade": "thai_trade",
"te_thailand": "te_thailand",
}
# 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)
},
# ---- second Thai refiner: IRPC net margin (extends energy beyond TOP) ----
"energy_irpc_net_margin": {
"name_th": "กำไรสุทธิ IRPC",
"source": "IRPC",
"frequency": "quarterly",
"fetch": "energy_irpc",
"value_key": "net_margin_pct",
"sign": 1,
"weight": 0.6,
"center": 3.0, "span": 8.0, # net margin %, ~0-3% neutral, refiners volatile
},
# ---- 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)
},
# ---- BOT Thai Economy fields that were already fetched but never used ----
# Wired into the registry so every scraped value actually feeds analysis
# (the user's rule: a fetched data point must be used, not just displayed).
"macro_core_inflation": {
"name_th": "เงินเฟ้อพื้นฐาน (Core)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "core_inflation_yoy",
"sign": -1,
"weight": 0.4,
"center": 1.0, "span": 6.0, # core ~1% target; higher is worse
},
"macro_unemployment": {
"name_th": "อัตราการว่างงาน",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "unemployment_pct",
"sign": -1,
"weight": 0.5,
"center": 1.0, "span": 3.0, # ~1% Thailand; higher unemployment is worse
},
# ---- Thailand external sector (TradingEconomics, current through 2026) ----
"external_current_account": {
"name_th": "ดุลบัญชีเดินสะพัด (USD ล้าน)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "thai_trade",
"value_key": "current_account_usdm",
"sign": 1,
"weight": 1.0,
"center": 0.0, "span": 3000.0, # USD mn; surplus positive, deficit negative
},
"external_exports": {
"name_th": "มูลค่าส่งออก (USD ล้าน)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "thai_trade",
"value_key": "exports_usdm",
"sign": 1,
"weight": 1.0,
"center": 30000.0, "span": 8000.0, # ~$34k USD mn/month
},
"external_imports": {
"name_th": "มูลค่านำเข้า (USD ล้าน)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "thai_trade",
"value_key": "imports_usdm",
"sign": 1,
"weight": 1.0,
"center": 34000.0, "span": 8000.0, # ~$38k USD mn/month (domestic demand proxy)
},
# ---- Thailand rates / credit / retail / confidence (TradingEconomics) ----
# Single-page snapshot factors deepening the macro-proxy themes. Sign +1 =
# higher value is bullish/helpful; sign -1 = the opposite. Theme weights are
# always positive magnitude (direction lives in `sign`, per the sign fix).
"te_interest_rate": {
"name_th": "อัตราดอกเบี้ย (rate)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "interest_rate_pct",
"sign": 1,
"weight": 0.5,
"center": 1.5, "span": 1.5, # ~1.0-2.0%; higher rate widens bank margin
},
"te_loan_growth": {
"name_th": "สินเชื่อภาคธุรกิจ",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "loans_to_fin_corp",
"sign": 1,
"weight": 0.5,
"center": 10000000.0, "span": 1500000.0, # THB mn (~10.5M); credit demand proxy
},
"te_consumer_credit": {
"name_th": "สินเชื่อผู้บริโภค",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "consumer_credit_thbmn",
"sign": 1,
"weight": 0.5,
"center": 5000000.0, "span": 800000.0, # THB mn consumer credit book
},
"te_household_debt_gdp": {
"name_th": "หนี้ครัวเรือนต่อ GDP",
"source": "TradingEconomics",
"frequency": "quarterly",
"fetch": "te_thailand",
"value_key": "household_debt_gdp_pct",
"sign": -1,
"weight": 0.5,
"center": 85.0, "span": 8.0, # ~87.5% of GDP; higher = leverage risk
},
"te_retail_sales_yoy": {
"name_th": "ยอดขายปลีก (YoY)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "retail_sales_yoy",
"sign": 1,
"weight": 0.8,
"center": 0.0, "span": 10.0, # % YoY, ~0 neutral
},
"te_consumer_confidence": {
"name_th": "ความเชื่อมั่นผู้บริโภค",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "consumer_confidence",
"sign": 1,
"weight": 0.5,
"center": 50.0, "span": 12.0, # points (~51.8); above 50 = optimistic
},
"te_property_prices": {
"name_th": "ราคาอสังหาริมทรัพย์ (YoY)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "property_prices_yoy",
"sign": 1,
"weight": 0.8,
"center": 0.0, "span": 6.0, # residential price % YoY, ~0-2% neutral
},
"te_business_confidence": {
"name_th": "ความเชื่อมั่นภาคธุรกิจ",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "te_thailand",
"value_key": "business_confidence",
"sign": 1,
"weight": 0.4,
"center": 50.0, "span": 10.0, # points (~46.7); above 50 = optimistic
},
}
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