[verified] P0-B registry-driven scoring + P3 PIT backtest + P4 factor-weight learning

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.
This commit is contained in:
Kunthawat Greethong
2026-08-27 07:12:18 +07:00
parent 325e164dd3
commit 8db3d48ae2
10 changed files with 697 additions and 161 deletions

View File

@@ -755,6 +755,31 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
return jsonify({"error": str(exc), "available": False}), 503
return jsonify({"available": True, **dash})
@app.get("/api/v1/learning/momentum")
def learning_momentum():
"""P4 factor-weight learning report for the 12-1 momentum factor.
Runs a strictly point-in-time IC analysis over the price history:
does 12-1 momentum at month t predict 3m forward returns across the
cross-section? Reports mean IC, t-stat, n periods, and a suggested
weight delta. Honest: returns 503 when no price snapshot exists.
Macro/demographic factors are not yet attributable (no vintages).
"""
from app import weight_learning as wl
from app import simulation as sim
start = request.args.get("start") or "2024-06-01"
end = request.args.get("end") or "2026-06-01"
try:
series = sim.load_price_snapshot()
except (sim.SimulationError, OSError) as exc:
return jsonify({"error": f"price snapshot: {exc}"}), 503
symbols = sorted(series.keys())
try:
learning = wl.learn_momentum(series, symbols, start, end)
except (wl.WeightLearningError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"factor": learning.to_dict(), "window": {"start": start, "end": end}})
@app.route("/api/v1/paper/ledger", methods=["GET", "POST"])
def paper_ledger():
current_ledger = app.extensions["paper_ledger"]

View File

@@ -1,28 +1,67 @@
"""Real backtest engine — allocate across a date range, track P&L.
"""Real point-in-time multi-rebalance backtest engine.
This replaces the single-snapshot "forward allocation" as the primary backtest:
it runs the combined-score 50/20/30 allocation at each rebalance date over a
user-chosen [start, end] window, marks to market daily, accrues dividends, and
reports total P&L (price + dividend + net).
True PIT honesty requires a `score_fn(score_by_symbol, as_of)` that returns the
combined scores *as they were known at `as_of`*. The default (current board) has
no historical factor vintages, so it is labeled non-PIT (`leakage_guard=False`).
When a PIT scorer is supplied, `leakage_guard=True`.
Honesty: runs on revised vendor history (non-PIT public data). At each rebalance
date we only use prices/fundamentals known up to that date (no future leak), but
this is exploratory paper research, never validated PIT evidence.
At each rebalance date the engine:
- resolves the combined score as-of that date (price data is genuinely
point-in-time w.r.t. price through `_latest_close`),
- marks the current portfolio to market,
- re-allocates the 50/20/30 dividend buckets over the current value,
- reconciles holdings (sells names that leave, buys/upsizes names that enter),
so `rebalances` reflects real re-trades, not a single allocate-once.
"""
from __future__ import annotations
import datetime as dt
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from typing import Callable, Optional
from .simulation import (
Candidate, Order, allocate_capital, load_price_snapshot, MIN_SHARES,
allocate_capital, load_price_snapshot,
)
_PROC_DIR = Path(__file__).resolve().parent.parent / "data" / "prices"
# score_fn contract: (symbols: list[str], as_of: str|None) -> {sym: meta dict}
ScoreFn = Callable[[list[str], Optional[str]], dict]
def _bar_date(s: Optional[str]) -> dt.date:
if not s:
return dt.date.min
return dt.date.fromisoformat(str(s)[:10])
def _bars_up_to(series: dict, sym: str, date: dt.date) -> list:
bars = series.get(sym, {}).get("bars", [])
return [b for b in bars if _bar_date(b.get("date")) <= date]
def _latest_close(series: dict, sym: str, date: dt.date) -> Optional[float]:
bars = _bars_up_to(series, sym, date)
if bars:
return float(bars[-1]["adjusted_close"])
return None
def momentum_at(series: dict, sym: str, date: dt.date,
lookback_days: int = 252, skip_days: int = 21) -> Optional[float]:
"""True 12-1 momentum as of `date`: close at ~1 month ago / close ~12 months
before that, minus 1 — skipping the most recent month to avoid short-term
reversal. Only uses bars known up to `date` (no lookahead)."""
bars = _bars_up_to(series, sym, date)
if len(bars) < lookback_days + skip_days + 1:
return None
try:
ref = float(bars[-1 - skip_days]["adjusted_close"]) # ~1m ago
base = float(bars[-1 - skip_days - lookback_days]["adjusted_close"])
except (KeyError, TypeError, ValueError, IndexError):
return None
if ref <= 0 or base <= 0:
return None
return round((ref / base) - 1.0, 4)
@dataclass
@@ -35,8 +74,10 @@ class BacktestResult:
dividend_income: float = 0.0
net_return: float = 0.0
trades: int = 0
rebalances: int = 0
holdings: dict = field(default_factory=dict) # final
rebalances: int = 0 # actual number of re-allocations executed
planned_rebalances: int = 0 # number of rebalance windows
holdings: dict = field(default_factory=dict) # final {sym: qty}
leakage_guard: bool = False # True only when a PIT score_fn was supplied
def to_dict(self) -> dict:
return {
@@ -45,8 +86,11 @@ class BacktestResult:
"price_pnl": round(self.price_pnl, 2),
"dividend_income": round(self.dividend_income, 2),
"net_return": round(self.net_return, 4),
"trades": self.trades, "rebalances": self.rebalances,
"trades": self.trades,
"rebalances": self.rebalances,
"planned_rebalances": self.planned_rebalances,
"holdings": self.holdings,
"leakage_guard": self.leakage_guard,
}
@@ -54,24 +98,6 @@ class BacktestError(Exception):
pass
def _bars_up_to(series: dict, sym: str, date: dt.date) -> list:
bars = series.get(sym, {}).get("bars", [])
return [b for b in bars if _bar_date(b.get("date")) <= date]
def _bar_date(s: Optional[str]) -> dt.date:
if not s:
return dt.date.min
return dt.date.fromisoformat(str(s)[:10])
def _latest_close(series: dict, sym: str, date: dt.date) -> Optional[float]:
bars = _bars_up_to(series, sym, date)
if bars:
return float(bars[-1]["adjusted_close"])
return None
def _rebalance_dates(start: str, end: str, freq: str = "monthly") -> list[str]:
s = dt.date.fromisoformat(start)
e = dt.date.fromisoformat(end)
@@ -97,15 +123,25 @@ def _rebalance_dates(start: str, end: str, freq: str = "monthly") -> list[str]:
return dates
def _resolve_scores(score_fn, syms: list[str], as_of: Optional[str]) -> tuple[dict, bool]:
"""Return (score_by_symbol, is_pit). A supplied score_fn marks leakage_guard;
the default (None) uses the current board -> non-PIT."""
if score_fn is None:
from .dashboard import default_scores
return default_scores(syms) or {}, False
out = score_fn(syms, as_of)
return out or {}, True
def _candidates_at(series: dict, syms: list[str], date: dt.date,
score_by_symbol: dict) -> list:
"""Point-in-time candidates: price up to date, combined score for that date."""
out = []
for sym in syms:
price = _latest_close(series, sym, date)
if not price or price <= 0:
continue
meta = score_by_symbol.get(sym, {})
from .simulation import Candidate
out.append(Candidate(
symbol=sym, price=price,
combined_score=float(meta.get("combined", 0.0)),
@@ -119,70 +155,88 @@ def run_backtest(
start: str, end: str,
capital: float = 1_000_000,
rebalance_freq: str = "monthly",
score_fn=None,
score_fn: Optional[ScoreFn] = None,
symbols: Optional[list[str]] = None,
) -> BacktestResult:
"""Run the backtest. `score_fn(symbols) -> {sym: {combined, is_dividend,
dividend_yield}}` returns point-in-time combined scores (default: from the
current dashboard board, which is honest as a static baseline)."""
"""Run a multi-rebalance backtest over [start, end].
`score_fn(symbols, as_of)` returns {sym: {combined, is_dividend,
dividend_yield}} as of `as_of`. Default: current board (static, non-PIT ->
leakage_guard=False). A supplied score_fn sets leakage_guard=True.
"""
series = load_price_snapshot()
if not series:
raise BacktestError("no price snapshot")
syms = symbols or list(series.keys())
if score_fn is None:
# default: current combined scores (static baseline — honest non-PIT).
from .dashboard import default_scores
score_by_symbol = default_scores(syms) or {}
else:
score_by_symbol = score_fn(syms)
dates = _rebalance_dates(start, end, rebalance_freq)
result = BacktestResult(start=start, end=end, capital=capital)
result.rebalances = len(dates)
result.planned_rebalances = len(dates)
# holdings: {sym: {qty, cost}}
holdings: dict = {}
total_dividend = 0.0
cash = capital
holdings: dict[str, int] = {} # sym -> qty
total_dividend = 0.0
trades = 0
actual_rebalances = 0
leakage_guard = False
score_by_symbol: dict = {}
_e = dt.date.fromisoformat(end)
# Buy-and-hold backtest: allocate once at the first rebalance date that has
# price data, then hold to the end. (A full multi-rebalance engine with
# position selling is a follow-up; this answers 'what would I have earned by
# buying per this system on <start> and holding until <end>?' without the
# double-spend bug.)
for d_iso in dates:
d = dt.date.fromisoformat(d_iso)
score_by_symbol, is_pit = _resolve_scores(score_fn, syms, d.isoformat())
leakage_guard = leakage_guard or is_pit
cands = _candidates_at(series, syms, d, score_by_symbol)
if not cands:
continue
alloc = allocate_capital(capital, cands)
for o in alloc.orders:
holdings[o.symbol] = {"qty": o.qty, "cost": o.notional}
cash -= o.notional
trades += 1
break # single allocation at first available rebalance, then hold
# current portfolio value at d
port_val = cash + sum(
qty * (px or 0.0)
for sym, qty in holdings.items()
if (px := _latest_close(series, sym, d)) is not None
)
alloc = allocate_capital(port_val, cands)
target = {o.symbol: o.qty for o in alloc.orders}
# mark-to-market to end + dividend
# sell holdings not in the new target
for sym, qty in list(holdings.items()):
tgt = target.get(sym, 0)
if qty > tgt:
px = _latest_close(series, sym, d)
if px is None:
continue
cash += (qty - tgt) * px
holdings[sym] = tgt
trades += 1
# buy / upsize to target
for o in alloc.orders:
cur = holdings.get(o.symbol, 0)
if o.qty > cur:
cash -= (o.qty - cur) * o.price
holdings[o.symbol] = o.qty
trades += 1
actual_rebalances += 1
# drop zero-holding entries
holdings = {k: v for k, v in holdings.items() if v > 0}
_e = dt.date.fromisoformat(end)
final_value = cash
for sym, h in holdings.items():
for sym, qty in holdings.items():
px = _latest_close(series, sym, _e)
if px:
final_value += h["qty"] * px
# crude dividend: yield% * cost (proxy, honest-flagged)
final_value += qty * px
# dividend proxy: yield% * current market value (honest-flagged)
meta = score_by_symbol.get(sym, {})
yield_pct = float(meta.get("dividend_yield") or 0.0) / 100.0
total_dividend += h["cost"] * yield_pct
total_dividend += qty * px * yield_pct
result.holdings = {s: h["qty"] for s, h in holdings.items()}
result.holdings = holdings
result.rebalances = actual_rebalances
result.final_value = final_value
result.price_pnl = final_value - cash - total_dividend
result.dividend_income = total_dividend
result.price_pnl = final_value - cash - total_dividend
result.net_return = (final_value - capital) / capital if capital else 0.0
result.trades = trades
result.leakage_guard = leakage_guard
return result

View File

@@ -45,15 +45,6 @@ def _zscore(value: float, mean: float, stdev: float) -> float:
return (value - mean) / stdev if stdev else 0.0
def _uniform_surprise(series: list[float], current: float) -> Optional[float]:
"""Uniform z-score surprise of `current` within a recent series."""
if len(series) < 2 or current is None:
return None
mean = statistics.mean(series)
stdev = statistics.pstdev(series)
return round(_zscore(current, mean, stdev), 3)
def _auto_read(auto_d: dict, npl_d: dict, cache: Any) -> dict:
# multi-source: volume (YoY) + credit quality (NPL)
read = {
@@ -104,8 +95,14 @@ class RealDashboard:
macro_d = _fetch_with_cache(
self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai")
# 2) per-theme surprise (uniform z-score)
surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d, bnpl_d)
# 2) per-theme surprise — registry-driven (THE single source of truth)
fetched = {
"macro_thai": macro_d, "auto_credit": auto_d,
"auto_npl": npl_d, "energy_thai": en_d, "bank_npl": bnpl_d,
}
tourism_surprise = self._tourism_surprise()
surprises = themes_mod.compute_theme_surprises(
fetched, tourism_surprise=tourism_surprise)
# 3) assemble theme reads + thesis (all SET50 themes, so the board and
# per-symbol view have a surprise for every theme)
@@ -227,81 +224,16 @@ class RealDashboard:
f"รับผลตามราคาพลังงานและค่าการกลั่น.")
return ""
def _theme_surprises(self, macro_d, auto_d, npl_d, en_d, bnpl_d=None) -> dict:
# tourism: from the tourism arrivals YoY or use the bot tourism surprise
# auto: z-score of new_car_sales_yoy
# energy: z-score of TOP net profit trend (quarterly)
# Use macro consumption as a backdrop-related surprise proxy where series
# are unavailable; kept simple & deterministic.
def _tourism_surprise(self) -> Optional[float]:
"""Derive the tourism surprise from the bot-tourism observation set
(cross-sectional mean of signal scores), when available. Returns None
so `compute_theme_surprises` falls back to the registry factors."""
import statistics
s = {}
auto_yoy = auto_d.get("new_car_sales_yoy")
npl = npl_d.get("pct_of_npls")
if auto_yoy is not None:
# single-value surprise: growth is bullish, rising NPL is bearish
base = min(max((float(auto_yoy) - 5.0) / 10.0, -1.0), 1.0)
if npl is not None:
base -= min(max((float(npl) - 3.0) / 5.0, 0.0), 1.0)
s["auto_credit"] = round(base, 3)
else:
s["auto_credit"] = None
# refine energies: use heads/tails of the quarterly net-profit read if present
en_q = en_d.get("quarterly") if isinstance(en_d, dict) else None
if isinstance(en_q, dict):
profits = [v.get("net_profit") for v in en_q.values() if isinstance(v, dict)]
profits = [p for p in profits if p is not None]
latest = profits[0] if profits else None
s["refining_energy"] = _uniform_surprise(profits[:4], latest) if profits else None
else:
s["refining_energy"] = None
s["tourism"] = None # set from tourism result below if available
ts = self.tourism_signals
if ts:
surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)]
s["tourism"] = round(statistics.mean(surprises), 3) if surprises else None
# --- macro-proxy surprise for the newly-added SET50 themes ---
# Uses the real BOT macro backdrop (consumption/investment/inflation/mfg)
# as a deterministic proxy for themes that share that macro driver, so
# every theme has a score instead of "ยังไม่มีข้อมูล". Rationale is noted
# per theme; keep it simple & reproducible.
cons = macro_d.get("private_consumption_yoy")
invest = macro_d.get("private_investment_yoy")
infl = macro_d.get("headline_inflation_yoy")
mfg = macro_d.get("manufacturing_yoy")
def _norm(v, center=3.0, span=10.0):
if v is None:
return None
return round(min(max((float(v) - center) / span, -1.0), 1.0), 3)
# banks & nonbank_finance: credit demand tracks capex/activity.
# banks additionally blends real BOT financial-sector NPL (quarterly):
# rising NPL is a provisioning drag on bank earnings (bearish).
banks_s = _norm(invest, center=5.0)
if banks_s is not None and bnpl_d:
bnpl = bnpl_d.get("pct_of_npls")
if bnpl is not None:
# deduct up to ~0.5 from the surprise when NPL share is elevated
# (reference: financial-sector NPL % of total NPLs, roughly 1-5%).
banks_s = round(max(banks_s - min(max((float(bnpl) - 0.5) / 3.0, 0.0), 0.5), -1.0), 3)
s["banks"] = banks_s
s["nonbank_finance"] = _norm(cons, center=3.0)
# retail & consumer_staples: spend + mild inflation (demand-led)
s["retail"] = _norm(cons, center=3.0)
s["consumer_staples"] = _norm(cons, center=3.0)
# telecom_it: broad activity
s["telecom_it"] = _norm(cons, center=3.0)
# property: investment-led
s["property"] = _norm(invest, center=5.0)
# petrochem_materials & utilities: industrial demand via mfg; +energy
s["petrochem_materials"] = _norm(mfg, center=0.0)
s["utilities"] = _norm(mfg, center=0.0)
# healthcare: defensive, mild consumption proxy
s["healthcare"] = _norm(cons, center=3.0, span=20.0)
# exploration: ties to energy margin (reuse the energy surprise if present)
s["exploration"] = s.get("refining_energy")
return s
if not ts:
return None
surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)]
return round(statistics.mean(surprises), 3) if surprises else None
def _build_board(self, themes, macro_d) -> list:
# combine theme scores + siamchart for the per-symbol board.

View File

@@ -13,6 +13,7 @@ analysis engine data-driven and auditable.
from __future__ import annotations
import math
import statistics
from typing import Any, Callable, Optional
@@ -38,6 +39,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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)",
@@ -47,6 +49,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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": "การผลิตรถยนต์",
@@ -56,6 +59,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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": "ส่งออกรถยนต์",
@@ -65,6 +69,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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 รถยนต์",
@@ -74,6 +79,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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 ภาคการเงิน",
@@ -83,6 +89,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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": "กำไรสุทธิโรงกลั่น",
@@ -92,6 +99,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"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": {
@@ -102,6 +110,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "private_consumption_yoy",
"sign": 1,
"weight": 1.0,
"center": 3.0, "span": 10.0, # YoY %, ~3% trend
},
"macro_investment": {
"name_th": "การลงทุนภาคเอกชน (YoY)",
@@ -111,6 +120,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "private_investment_yoy",
"sign": 1,
"weight": 1.0,
"center": 5.0, "span": 10.0, # YoY %, ~5% trend
},
"macro_mfg": {
"name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)",
@@ -120,6 +130,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "manufacturing_yoy",
"sign": 1,
"weight": 1.0,
"center": 0.0, "span": 10.0, # YoY %, ~0 neutral
},
"macro_inflation": {
"name_th": "เงินเฟ้อ",
@@ -129,6 +140,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "headline_inflation_yoy",
"sign": -1,
"weight": 0.5,
"center": 2.0, "span": 10.0, # ~2% target; higher is worse (sign -1)
},
}
@@ -137,7 +149,7 @@ class FactorError(ValueError):
pass
def factor_value(fact: dict, fetched: dict) -> Optional[float]:
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:
@@ -151,17 +163,21 @@ def factor_value(fact: dict, fetched: dict) -> Optional[float]:
rev = row.get("sales")
if np_ is not None and rev:
try:
return float(np_) / float(rev) * 100.0 # net margin %
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:
return float(val) if val is not None else None
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,
@@ -169,12 +185,19 @@ def normalize(value: Optional[float], sign: int = 1,
"""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 = (float(value) - center) / span * float(sign)
num = (value - center) / span * float(sign)
return round(min(max(num, -1.0), 1.0), 4)

View File

@@ -124,6 +124,7 @@ THEMES: dict[str, dict] = {
"factors": [
{"key": "macro_investment", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.4},
{"key": "bank_npl", "weight": -0.6},
],
},
"retail": {
@@ -210,6 +211,54 @@ def _zscore(values: list) -> dict:
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 blend of its
declared FACTORS (each normalized by its own center/span/sign and extracted
via `factor_value`). This replaces the old hand-written per-theme blocks:
editing `THEMES`/`FACTORS` weights now actually changes the score.
`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():
blended = 0.0
n = 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
blended += float(ref.get("weight", 1.0)) * norm
n += 1
if n == 0:
out[tid] = None
else:
s = max(-1.0, min(1.0, blended))
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
_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

View File

@@ -0,0 +1,199 @@
"""Factor-weight learning loop (P4).
Learns whether a factor predicts forward returns — and, if so, nudges its weight
up (or down) — from historical PIT data. The deliverable per factor is an
attribution report + a before/after weight:
new_weight = clip(old_weight * (1 + shrink * ic_mean), min_w, max_w)
Honesty guards:
- only uses data available at time `t` (point-in-time by construction),
- cross-sectional IC (Spearman rank correlation) is computed per period and
aggregated, not fit on the full window (avoids the big look-ahead),
- a holdout tail is never used to tune weights (caller keeps it out),
- factors with too few overlapping periods contribute no weight change.
Historical *factor vintages* for macro/demographic factors are not collected yet,
so their learning results are reported as BLOCKED (ic_mean=None) rather than
fabricated. Momentum is the first factor with a genuine PIT historical series
derivable from the existing price archive and is exercised end-to-end.
"""
from __future__ import annotations
import datetime as dt
import math
import statistics
from dataclasses import dataclass, field
from typing import Optional
from .backtest import momentum_at
DEFAULT_SHRINK = 0.5 # how aggressively IC moves the weight
DEFAULT_MIN_W = 0.1
DEFAULT_MAX_W = 3.0
FORWARD_MONTHS = 3 # reward horizon (t -> t+3m forward return)
@dataclass
class FactorLearning:
factor_key: str
n_periods: int = 0
ic_mean: Optional[float] = None
ic_std: Optional[float] = None
ic_tstat: Optional[float] = None
old_weight: Optional[float] = None
new_weight: Optional[float] = None
blocked: bool = False # True when no PIT historical series is available
def to_dict(self) -> dict:
return {
"factor_key": self.factor_key,
"n_periods": self.n_periods,
"ic_mean": self.ic_mean,
"ic_std": self.ic_std,
"ic_tstat": self.ic_tstat,
"old_weight": self.old_weight,
"new_weight": self.new_weight,
"blocked": self.blocked,
}
class WeightLearningError(ValueError):
pass
# ---------------------------------------------------------------------------
# IC helpers
# ---------------------------------------------------------------------------
def _rank(vals: list[float]) -> list[float]:
"""Rank-normalize a list (average ties)."""
idx = sorted(range(len(vals)), key=lambda i: vals[i])
ranks = [0.0] * len(vals)
i = 0
while i < len(vals):
j = i
while j + 1 < len(vals) and vals[idx[j + 1]] == vals[idx[i]]:
j += 1
avg = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[idx[k]] = avg
i = j + 1
return ranks
def _corr(a: list[float], b: list[float]) -> float:
n = len(a)
if n < 3:
return 0.0
ma = statistics.fmean(a)
mb = statistics.fmean(b)
cov = sum((a[i] - ma) * (b[i] - mb) for i in range(n))
va = sum((x - ma) ** 2 for x in a)
vb = sum((y - mb) ** 2 for y in b)
if va <= 0 or vb <= 0:
return 0.0
return cov / math.sqrt(va * vb)
def spearman_ic(factor_values: dict[str, float],
forward_returns: dict[str, float]) -> Optional[float]:
"""Cross-sectional Spearman (rank) IC between a factor and forward returns."""
syms = [s for s in factor_values
if s in forward_returns
and math.isfinite(factor_values[s])
and math.isfinite(forward_returns[s])]
if len(syms) < 3:
return None
fv = [factor_values[s] for s in syms]
fr = [forward_returns[s] for s in syms]
return _corr(_rank(fv), _rank(fr))
# ---------------------------------------------------------------------------
# Forward-return construction (price-derived)
# ---------------------------------------------------------------------------
def _forward_return(series: dict, sym: str, t: dt.date, months: int) -> Optional[float]:
bars = [b for b in series.get(sym, {}).get("bars", [])
if _bar_date(b.get("date")) <= t]
if len(bars) < 2:
return None
start_px = float(bars[-1]["adjusted_close"])
horizon = _add_months(t, months)
future = [b for b in series.get(sym, {}).get("bars", [])
if _bar_date(b.get("date")) <= horizon]
if len(future) < 2:
return None
end_px = float(future[-1]["adjusted_close"])
if start_px <= 0:
return None
return (end_px / start_px) - 1.0
def _add_months(d: dt.date, months: int) -> dt.date:
m = d.month - 1 + months
y = d.year + m // 12
m = m % 12 + 1
# clamp day to the last valid day of the target month (e.g. Jan 31 -> Feb 28)
import calendar
day = min(d.day, calendar.monthrange(y, m)[1])
return dt.date(y, m, day)
def _bar_date(s: Optional[str]) -> dt.date:
if not s:
return dt.date.min
return dt.date.fromisoformat(str(s)[:10])
# ---------------------------------------------------------------------------
# Momentum factor learning (real PIT demo)
# ---------------------------------------------------------------------------
def learn_momentum(series: dict, symbols: list[str], start: str, end: str,
step_days: int = 21, forward_months: int = FORWARD_MONTHS) -> FactorLearning:
"""Learn whether 12-1 momentum predicts 3m forward returns, entirely PIT."""
s = dt.date.fromisoformat(start)
e = dt.date.fromisoformat(end)
ic_series: list[float] = []
cur = s
while cur <= e:
fv: dict[str, float] = {}
fr: dict[str, float] = {}
for sym in symbols:
m = momentum_at(series, sym, cur)
f = _forward_return(series, sym, cur, forward_months)
if m is not None and f is not None:
fv[sym] = m
fr[sym] = f
ic = spearman_ic(fv, fr)
if ic is not None:
ic_series.append(ic)
cur += dt.timedelta(days=step_days)
res = FactorLearning(factor_key="momentum_12_1")
res.n_periods = len(ic_series)
if ic_series:
res.ic_mean = round(statistics.fmean(ic_series), 4)
res.ic_std = round(statistics.pstdev(ic_series), 4)
res.ic_tstat = round(res.ic_mean / (res.ic_std / math.sqrt(len(ic_series))), 3) \
if res.ic_std else None
return res
# ---------------------------------------------------------------------------
# Weight update
# ---------------------------------------------------------------------------
def apply_weight_update(learning: FactorLearning, shrink: float = DEFAULT_SHRINK,
min_w: float = DEFAULT_MIN_W, max_w: float = DEFAULT_MAX_W) -> None:
"""Fold a learned IC into the factor's weight (in place).
new = clip(old * (1 + shrink * ic_mean), min_w, max_w).
Blocked / no-data factors keep their weight (new == old).
"""
if learning.old_weight is None:
return
if learning.blocked or learning.ic_mean is None or learning.n_periods < 3:
learning.new_weight = learning.old_weight
return
nw = learning.old_weight * (1.0 + shrink * learning.ic_mean)
learning.new_weight = round(min(max(nw, min_w), max_w), 4)

View File

@@ -0,0 +1,87 @@
"""Tests for the point-in-time multi-rebalance backtest engine."""
from __future__ import annotations
import datetime as dt
import unittest
from unittest.mock import patch
from app import backtest
def _fake_series() -> dict:
"""Synthetic daily price series for A (up) and B (flat), 300+ days."""
def bars(base, drift):
out = []
for i in range(400):
d = (dt.date(2025, 1, 1) + dt.timedelta(days=i)).isoformat()
out.append({"date": d, "adjusted_close": base + drift * i})
return out
return {"A": {"bars": bars(10.0, 0.1)}, "B": {"bars": bars(20.0, 0.0)}}
class RebalanceDatesTest(unittest.TestCase):
def test_monthly(self):
d = backtest._rebalance_dates("2026-01-01", "2026-04-01")
self.assertEqual(d, ["2026-01-01", "2026-02-01", "2026-03-01", "2026-04-01"])
def test_quarterly_boundaries(self):
d = backtest._rebalance_dates("2026-01-01", "2027-04-01", "quarterly")
self.assertEqual(d, ["2026-01-01", "2026-04-01", "2026-07-01",
"2026-10-01", "2027-01-01", "2027-04-01"])
def test_end_after_start_required(self):
with self.assertRaises(backtest.BacktestError):
backtest._rebalance_dates("2026-06-01", "2026-06-01")
def test_bad_freq(self):
with self.assertRaises(backtest.BacktestError):
backtest._rebalance_dates("2026-01-01", "2026-06-01", "weekly")
class MomentumAtTest(unittest.TestCase):
def test_true_12_1_skips_last_month(self):
series = _fake_series()
d = dt.date(2026, 5, 1)
m = backtest.momentum_at(series, "A", d)
# A rises 0.1/day; momentum over 12m should be clearly positive.
self.assertIsNotNone(m)
self.assertTrue(m is not None and m > 0.0)
class RunBacktestTest(unittest.TestCase):
def _score_fn(self, syms, as_of):
return {s: {"combined": 1.0 if s == "A" else 0.5,
"is_dividend": True, "dividend_yield": 2.0} for s in syms}
@patch("app.backtest.load_price_snapshot", return_value=_fake_series())
def test_multi_rebalance_reuses_portfolio(self, _load):
res = backtest.run_backtest(
"2026-01-01", "2026-06-01", capital=1_000_000,
rebalance_freq="monthly", score_fn=self._score_fn,
symbols=["A", "B"],
)
self.assertEqual(res.planned_rebalances, 6)
# Real re-allocations happened (price data exists for every window).
self.assertEqual(res.rebalances, 6)
self.assertGreater(res.trades, 0)
# Supplying a score_fn -> leakage_guard True (PIT contract).
self.assertTrue(res.leakage_guard)
@patch("app.backtest.load_price_snapshot", return_value={})
def test_no_price_snapshot_raises(self, _load):
with self.assertRaises(backtest.BacktestError):
backtest.run_backtest("2026-01-01", "2026-06-01")
@patch("app.backtest.load_price_snapshot", return_value=_fake_series())
def test_default_no_score_fn_non_pit(self, _load):
# Without a score_fn, the engine uses the current board -> non-PIT.
res = backtest.run_backtest(
"2026-01-01", "2026-03-01", capital=100_000,
rebalance_freq="monthly", symbols=["A", "B"],
)
self.assertFalse(res.leakage_guard)
if __name__ == "__main__":
unittest.main()

View File

@@ -113,3 +113,72 @@ class QualitySelectionTest(unittest.TestCase):
self.assertIn("quality", contrib)
self.assertIn("theme_score", contrib)
self.assertAlmostEqual(contrib["theme_score"], contrib["surprise"] * contrib["quality"], places=3)
class RegistryDrivenSurpriseTest(unittest.TestCase):
"""P0-B: the declarative FACTORS/THEMES registry is now the single source
of truth for theme surprises — editing a weight genuinely changes output."""
@staticmethod
def _fetched(**macro):
# Build a minimal `fetched` dict (fetch-module -> collector dict).
return {
"macro_thai": {
"private_consumption_yoy": macro.get("cons", 4.9),
"private_investment_yoy": macro.get("invest", 18.1),
"headline_inflation_yoy": macro.get("infl", 1.95),
"manufacturing_yoy": macro.get("mfg", -3.1),
"tourists_ytd_mn": macro.get("tour", 16.2),
},
"auto_credit": {
"new_car_sales_yoy": 20.07, "vehicle_production": 117383.0,
"auto_exports": 81526.0,
},
"auto_npl": {"pct_of_npls": 3.95},
"energy_thai": {
"quarterly": {"Q1/2026": {"net_profit": 19481.0, "sales": 114809.0}},
},
}
def test_retail_driven_by_registry_weights(self):
from app import themes
s = themes.compute_theme_surprises(self._fetched())
# registry retail = consumption*1.0 + inflation*(-0.3):
# cons=4.9 -> (4.9-3)/10=0.19 ; infl=1.95, sign -1 -> -(1.95-2)/10=0.005*0.3? no:
# inflation normalized = -(1.95-2)/10 = +0.005, weight -0.3 -> -0.0015
# retail ≈ 0.19 - 0.0015 ≈ 0.189
self.assertIsNotNone(s["retail"])
self.assertAlmostEqual(s["retail"], 0.189, places=3)
def test_changing_factor_weight_changes_output(self):
"""The defining property of P0-B: the registry is not decorative."""
from unittest.mock import patch
from app import themes
base = themes.compute_theme_surprises(self._fetched())["retail"]
# Double consumption's weight in the retail theme -> surprise must rise.
original = themes.THEMES["retail"]["factors"]
try:
themes.THEMES["retail"]["factors"] = [
{"key": "macro_consumption", "weight": 2.0},
{"key": "macro_inflation", "weight": -0.3},
]
changed = themes.compute_theme_surprises(self._fetched())["retail"]
finally:
themes.THEMES["retail"]["factors"] = original
self.assertGreater(changed, base)
def test_tourism_override_wins(self):
from app import themes
s = themes.compute_theme_surprises(self._fetched(), tourism_surprise=0.123)
self.assertEqual(s["tourism"], 0.123)
# Without override, tourism falls back to registry factors (not None).
s2 = themes.compute_theme_surprises(self._fetched())
self.assertIsNotNone(s2["tourism"])
def test_normalize_rejects_non_finite(self):
from app import factors
self.assertIsNone(factors.normalize(float("nan")))
self.assertIsNone(factors.normalize(float("inf")))
self.assertIsNone(factors.normalize(None))
# finite value still normalizes
self.assertEqual(factors.normalize(15.0, sign=1, center=5.0, span=10.0), 1.0)

View File

@@ -0,0 +1,98 @@
"""Tests for the factor-weight learning loop (P4)."""
from __future__ import annotations
import datetime as dt
import unittest
from app import weight_learning as wl
class SpearmanICTest(unittest.TestCase):
def test_perfect_positive(self):
# Factor and forward returns perfectly rank-aligned -> IC = 1.
fv = {"A": 1.0, "B": 2.0, "C": 3.0, "D": 4.0}
fr = {"A": 0.1, "B": 0.2, "C": 0.3, "D": 0.4}
ic = wl.spearman_ic(fv, fr)
self.assertIsNotNone(ic)
self.assertTrue(ic is not None and abs(ic - 1.0) < 1e-5)
def test_inverse_is_negative_one(self):
fv = {"A": 1.0, "B": 2.0, "C": 3.0, "D": 4.0}
fr = {"A": 0.4, "B": 0.3, "C": 0.2, "D": 0.1}
ic = wl.spearman_ic(fv, fr)
self.assertIsNotNone(ic)
self.assertTrue(ic is not None and abs(ic + 1.0) < 1e-5)
def test_too_few_symbols_returns_none(self):
self.assertIsNone(wl.spearman_ic({"A": 1.0}, {"A": 0.1}))
def test_invalid_symbols_excluded(self):
fv = {"A": 1.0, "B": 2.0, "C": 3.0, "D": 4.0, "E": float("nan")}
fr = {"A": 0.1, "B": 0.2, "C": 0.3, "D": 0.4, "E": 0.5}
ic = wl.spearman_ic(fv, fr)
self.assertIsNotNone(ic)
self.assertTrue(ic is not None and abs(ic - 1.0) < 1e-5)
class WeightUpdateTest(unittest.TestCase):
def test_positive_ic_raises_weight(self):
l = wl.FactorLearning("f", n_periods=12, ic_mean=0.3,
old_weight=1.0)
wl.apply_weight_update(l, shrink=0.5)
self.assertIsNotNone(l.new_weight)
self.assertTrue(l.new_weight is not None and l.new_weight > 1.0)
def test_negative_ic_lowers_weight(self):
l = wl.FactorLearning("f", n_periods=12, ic_mean=-0.4,
old_weight=1.0)
wl.apply_weight_update(l, shrink=0.5)
self.assertIsNotNone(l.new_weight)
self.assertTrue(l.new_weight is not None and l.new_weight < 1.0)
def test_clamped_to_bounds(self):
l = wl.FactorLearning("f", n_periods=12, ic_mean=10.0, old_weight=1.0)
wl.apply_weight_update(l, shrink=1.0, max_w=3.0)
self.assertEqual(l.new_weight, 3.0)
def test_blocked_keeps_weight(self):
l = wl.FactorLearning("f", n_periods=0, ic_mean=None,
old_weight=1.0, blocked=True)
wl.apply_weight_update(l)
self.assertEqual(l.new_weight, 1.0)
def test_no_old_weight_returns(self):
l = wl.FactorLearning("f", n_periods=12, ic_mean=0.2, old_weight=None)
wl.apply_weight_update(l)
self.assertIsNone(l.new_weight)
class MomentumLearningTest(unittest.TestCase):
@staticmethod
def _series():
# A trends up strongly (positive momentum), B flat.
def bars(base, drift):
out = []
for i in range(500):
d = (dt.date(2024, 1, 1) + dt.timedelta(days=i)).isoformat()
out.append({"date": d, "adjusted_close": base + drift * i})
return out
return {"A": {"bars": bars(10.0, 0.05)}, "B": {"bars": bars(20.0, 0.0)}}
def test_learn_runs_without_error(self):
res = wl.learn_momentum(self._series(), ["A", "B"],
"2025-06-01", "2026-06-01")
self.assertIsInstance(res, wl.FactorLearning)
self.assertGreaterEqual(res.n_periods, 0)
class AddMonthsTest(unittest.TestCase):
def test_clamps_day_to_end_of_month(self):
# Jan 31 + 1 month must clamp to Feb 28/29, not raise.
self.assertEqual(wl._add_months(dt.date(2026, 1, 31), 1), dt.date(2026, 2, 28))
self.assertEqual(wl._add_months(dt.date(2026, 1, 31), 2), dt.date(2026, 3, 31))
self.assertEqual(wl._add_months(dt.date(2024, 1, 31), 1), dt.date(2024, 2, 29)) # leap
if __name__ == "__main__":
unittest.main()