[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({"error": str(exc), "available": False}), 503
return jsonify({"available": True, **dash}) 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"]) @app.route("/api/v1/paper/ledger", methods=["GET", "POST"])
def paper_ledger(): def paper_ledger():
current_ledger = app.extensions["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: True PIT honesty requires a `score_fn(score_by_symbol, as_of)` that returns the
it runs the combined-score 50/20/30 allocation at each rebalance date over a combined scores *as they were known at `as_of`*. The default (current board) has
user-chosen [start, end] window, marks to market daily, accrues dividends, and no historical factor vintages, so it is labeled non-PIT (`leakage_guard=False`).
reports total P&L (price + dividend + net). When a PIT scorer is supplied, `leakage_guard=True`.
Honesty: runs on revised vendor history (non-PIT public data). At each rebalance At each rebalance date the engine:
date we only use prices/fundamentals known up to that date (no future leak), but - resolves the combined score as-of that date (price data is genuinely
this is exploratory paper research, never validated PIT evidence. 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 from __future__ import annotations
import datetime as dt import datetime as dt
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from typing import Callable, Optional
from typing import Optional
from .simulation import ( 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 @dataclass
@@ -35,8 +74,10 @@ class BacktestResult:
dividend_income: float = 0.0 dividend_income: float = 0.0
net_return: float = 0.0 net_return: float = 0.0
trades: int = 0 trades: int = 0
rebalances: int = 0 rebalances: int = 0 # actual number of re-allocations executed
holdings: dict = field(default_factory=dict) # final 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: def to_dict(self) -> dict:
return { return {
@@ -45,8 +86,11 @@ class BacktestResult:
"price_pnl": round(self.price_pnl, 2), "price_pnl": round(self.price_pnl, 2),
"dividend_income": round(self.dividend_income, 2), "dividend_income": round(self.dividend_income, 2),
"net_return": round(self.net_return, 4), "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, "holdings": self.holdings,
"leakage_guard": self.leakage_guard,
} }
@@ -54,24 +98,6 @@ class BacktestError(Exception):
pass 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]: def _rebalance_dates(start: str, end: str, freq: str = "monthly") -> list[str]:
s = dt.date.fromisoformat(start) s = dt.date.fromisoformat(start)
e = dt.date.fromisoformat(end) e = dt.date.fromisoformat(end)
@@ -97,15 +123,25 @@ def _rebalance_dates(start: str, end: str, freq: str = "monthly") -> list[str]:
return dates 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, def _candidates_at(series: dict, syms: list[str], date: dt.date,
score_by_symbol: dict) -> list: score_by_symbol: dict) -> list:
"""Point-in-time candidates: price up to date, combined score for that date."""
out = [] out = []
for sym in syms: for sym in syms:
price = _latest_close(series, sym, date) price = _latest_close(series, sym, date)
if not price or price <= 0: if not price or price <= 0:
continue continue
meta = score_by_symbol.get(sym, {}) meta = score_by_symbol.get(sym, {})
from .simulation import Candidate
out.append(Candidate( out.append(Candidate(
symbol=sym, price=price, symbol=sym, price=price,
combined_score=float(meta.get("combined", 0.0)), combined_score=float(meta.get("combined", 0.0)),
@@ -119,70 +155,88 @@ def run_backtest(
start: str, end: str, start: str, end: str,
capital: float = 1_000_000, capital: float = 1_000_000,
rebalance_freq: str = "monthly", rebalance_freq: str = "monthly",
score_fn=None, score_fn: Optional[ScoreFn] = None,
symbols: Optional[list[str]] = None, symbols: Optional[list[str]] = None,
) -> BacktestResult: ) -> BacktestResult:
"""Run the backtest. `score_fn(symbols) -> {sym: {combined, is_dividend, """Run a multi-rebalance backtest over [start, end].
dividend_yield}}` returns point-in-time combined scores (default: from the
current dashboard board, which is honest as a static baseline).""" `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() series = load_price_snapshot()
if not series: if not series:
raise BacktestError("no price snapshot") raise BacktestError("no price snapshot")
syms = symbols or list(series.keys()) 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) dates = _rebalance_dates(start, end, rebalance_freq)
result = BacktestResult(start=start, end=end, capital=capital) 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 cash = capital
holdings: dict[str, int] = {} # sym -> qty
total_dividend = 0.0
trades = 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: for d_iso in dates:
d = dt.date.fromisoformat(d_iso) 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) cands = _candidates_at(series, syms, d, score_by_symbol)
if not cands: if not cands:
continue continue
alloc = allocate_capital(capital, cands) # current portfolio value at d
for o in alloc.orders: port_val = cash + sum(
holdings[o.symbol] = {"qty": o.qty, "cost": o.notional} qty * (px or 0.0)
cash -= o.notional for sym, qty in holdings.items()
trades += 1 if (px := _latest_close(series, sym, d)) is not None
break # single allocation at first available rebalance, then hold )
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 final_value = cash
for sym, h in holdings.items(): for sym, qty in holdings.items():
px = _latest_close(series, sym, _e) px = _latest_close(series, sym, _e)
if px: if px:
final_value += h["qty"] * px final_value += qty * px
# crude dividend: yield% * cost (proxy, honest-flagged) # dividend proxy: yield% * current market value (honest-flagged)
meta = score_by_symbol.get(sym, {}) meta = score_by_symbol.get(sym, {})
yield_pct = float(meta.get("dividend_yield") or 0.0) / 100.0 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.final_value = final_value
result.price_pnl = final_value - cash - total_dividend
result.dividend_income = 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.net_return = (final_value - capital) / capital if capital else 0.0
result.trades = trades result.trades = trades
result.leakage_guard = leakage_guard
return result 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 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: def _auto_read(auto_d: dict, npl_d: dict, cache: Any) -> dict:
# multi-source: volume (YoY) + credit quality (NPL) # multi-source: volume (YoY) + credit quality (NPL)
read = { read = {
@@ -104,8 +95,14 @@ class RealDashboard:
macro_d = _fetch_with_cache( macro_d = _fetch_with_cache(
self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai") self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai")
# 2) per-theme surprise (uniform z-score) # 2) per-theme surprise — registry-driven (THE single source of truth)
surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d, bnpl_d) 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 # 3) assemble theme reads + thesis (all SET50 themes, so the board and
# per-symbol view have a surprise for every theme) # per-symbol view have a surprise for every theme)
@@ -227,81 +224,16 @@ class RealDashboard:
f"รับผลตามราคาพลังงานและค่าการกลั่น.") f"รับผลตามราคาพลังงานและค่าการกลั่น.")
return "" return ""
def _theme_surprises(self, macro_d, auto_d, npl_d, en_d, bnpl_d=None) -> dict: def _tourism_surprise(self) -> Optional[float]:
# tourism: from the tourism arrivals YoY or use the bot tourism surprise """Derive the tourism surprise from the bot-tourism observation set
# auto: z-score of new_car_sales_yoy (cross-sectional mean of signal scores), when available. Returns None
# energy: z-score of TOP net profit trend (quarterly) so `compute_theme_surprises` falls back to the registry factors."""
# Use macro consumption as a backdrop-related surprise proxy where series
# are unavailable; kept simple & deterministic.
import statistics 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 ts = self.tourism_signals
if ts: if not ts:
surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)] return None
s["tourism"] = round(statistics.mean(surprises), 3) if surprises else None surprises = [x.get("score", 0) for x in ts if isinstance(x, dict)]
return 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
def _build_board(self, themes, macro_d) -> list: def _build_board(self, themes, macro_d) -> list:
# combine theme scores + siamchart for the per-symbol board. # 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 from __future__ import annotations
import math
import statistics import statistics
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -38,6 +39,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "tourists_ytd_mn", "value_key": "tourists_ytd_mn",
"sign": 1, "sign": 1,
"weight": 1.0, "weight": 1.0,
"center": 20.0, "span": 15.0, # cumulative arrivals in millions, ~20mn neutral
}, },
"auto_sales_yoy": { "auto_sales_yoy": {
"name_th": "ยอดขายรถยนต์ (YoY)", "name_th": "ยอดขายรถยนต์ (YoY)",
@@ -47,6 +49,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "new_car_sales_yoy", "value_key": "new_car_sales_yoy",
"sign": 1, "sign": 1,
"weight": 1.0, "weight": 1.0,
"center": 5.0, "span": 10.0, # YoY %, ~5% long-run growth
}, },
"auto_production": { "auto_production": {
"name_th": "การผลิตรถยนต์", "name_th": "การผลิตรถยนต์",
@@ -56,6 +59,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "vehicle_production", "value_key": "vehicle_production",
"sign": 1, "sign": 1,
"weight": 0.4, "weight": 0.4,
"center": 0.0, "span": 200000.0, # units/month (~117K), scale captured as level
}, },
"auto_exports": { "auto_exports": {
"name_th": "ส่งออกรถยนต์", "name_th": "ส่งออกรถยนต์",
@@ -65,6 +69,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "auto_exports", "value_key": "auto_exports",
"sign": 1, "sign": 1,
"weight": 0.3, "weight": 0.3,
"center": 0.0, "span": 200000.0, # units (~82K), scale captured as level
}, },
"auto_npl": { "auto_npl": {
"name_th": "NPL รถยนต์", "name_th": "NPL รถยนต์",
@@ -74,6 +79,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "pct_of_npls", "value_key": "pct_of_npls",
"sign": -1, "sign": -1,
"weight": 1.0, "weight": 1.0,
"center": 3.0, "span": 5.0, # NPL as % of loans, ~3% neutral
}, },
"bank_npl": { "bank_npl": {
"name_th": "NPL ภาคการเงิน", "name_th": "NPL ภาคการเงิน",
@@ -83,6 +89,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "pct_of_npls", "value_key": "pct_of_npls",
"sign": -1, "sign": -1,
"weight": 1.0, "weight": 1.0,
"center": 0.5, "span": 3.0, # financial-sector NPL share (~0.5-5%)
}, },
"energy_net_margin": { "energy_net_margin": {
"name_th": "กำไรสุทธิโรงกลั่น", "name_th": "กำไรสุทธิโรงกลั่น",
@@ -92,6 +99,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "net_margin_quarter", "value_key": "net_margin_quarter",
"sign": 1, "sign": 1,
"weight": 1.0, "weight": 1.0,
"center": 5.0, "span": 10.0, # net margin % (derived from quarterly)
}, },
# ---- macro backdrop (proxy for expanded SET50 themes) ---- # ---- macro backdrop (proxy for expanded SET50 themes) ----
"macro_consumption": { "macro_consumption": {
@@ -102,6 +110,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "private_consumption_yoy", "value_key": "private_consumption_yoy",
"sign": 1, "sign": 1,
"weight": 1.0, "weight": 1.0,
"center": 3.0, "span": 10.0, # YoY %, ~3% trend
}, },
"macro_investment": { "macro_investment": {
"name_th": "การลงทุนภาคเอกชน (YoY)", "name_th": "การลงทุนภาคเอกชน (YoY)",
@@ -111,6 +120,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "private_investment_yoy", "value_key": "private_investment_yoy",
"sign": 1, "sign": 1,
"weight": 1.0, "weight": 1.0,
"center": 5.0, "span": 10.0, # YoY %, ~5% trend
}, },
"macro_mfg": { "macro_mfg": {
"name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)", "name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)",
@@ -120,6 +130,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "manufacturing_yoy", "value_key": "manufacturing_yoy",
"sign": 1, "sign": 1,
"weight": 1.0, "weight": 1.0,
"center": 0.0, "span": 10.0, # YoY %, ~0 neutral
}, },
"macro_inflation": { "macro_inflation": {
"name_th": "เงินเฟ้อ", "name_th": "เงินเฟ้อ",
@@ -129,6 +140,7 @@ FACTORS: dict[str, dict[str, Any]] = {
"value_key": "headline_inflation_yoy", "value_key": "headline_inflation_yoy",
"sign": -1, "sign": -1,
"weight": 0.5, "weight": 0.5,
"center": 2.0, "span": 10.0, # ~2% target; higher is worse (sign -1)
}, },
} }
@@ -137,7 +149,7 @@ class FactorError(ValueError):
pass 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.""" """Pull the numeric value out of a fetched collector dict for a factor."""
key = fact.get("value_key") key = fact.get("value_key")
if fetched is None: if fetched is None:
@@ -151,17 +163,21 @@ def factor_value(fact: dict, fetched: dict) -> Optional[float]:
rev = row.get("sales") rev = row.get("sales")
if np_ is not None and rev: if np_ is not None and rev:
try: try:
return float(np_) / float(rev) * 100.0 # net margin % margin = float(np_) / float(rev) * 100.0 # net margin %
except (TypeError, ValueError, ZeroDivisionError): except (TypeError, ValueError, ZeroDivisionError):
return None return None
return margin if math.isfinite(margin) else None
return None return None
if key is None: if key is None:
return None return None
val = fetched.get(key) val = fetched.get(key)
try: try:
return float(val) if val is not None else None out = float(val) if val is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if out is None or not math.isfinite(out):
return None
return out
def normalize(value: Optional[float], sign: int = 1, 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]. """Deterministic bounded normalization: sign-aware, clamped to [-1, +1].
value == center -> 0. positive beyond center (for sign=+1) -> positive. value == center -> 0. positive beyond center (for sign=+1) -> positive.
Non-finite values (NaN/inf) are rejected rather than propagated.
""" """
if value is None: if value is None:
return None return None
try:
value = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(value):
return None
if span <= 0: if span <= 0:
span = 1.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) return round(min(max(num, -1.0), 1.0), 4)

View File

@@ -124,6 +124,7 @@ THEMES: dict[str, dict] = {
"factors": [ "factors": [
{"key": "macro_investment", "weight": 1.0}, {"key": "macro_investment", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.4}, {"key": "macro_inflation", "weight": -0.4},
{"key": "bank_npl", "weight": -0.6},
], ],
}, },
"retail": { "retail": {
@@ -210,6 +211,54 @@ def _zscore(values: list) -> dict:
return out 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, _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. # Livnat-Mendenhall 2006) shows drift follows earnings, not just yield.
_SIAMCHART_YIELD_W = 2.0 # dividend floor for value names _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("quality", contrib)
self.assertIn("theme_score", contrib) self.assertIn("theme_score", contrib)
self.assertAlmostEqual(contrib["theme_score"], contrib["surprise"] * contrib["quality"], places=3) 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()

View File

@@ -166,11 +166,11 @@ Design (phased, honest about PIT):
| # | Change | Risk | Acceptance | Status | | # | Change | Risk | Acceptance | Status |
|---|--------|------|-----------|--------| |---|--------|------|-----------|--------|
| P0 | **Unify scoring** — registry-driven `compute_theme_surprises()` from `FACTORS`+`THEMES`; board calls it; delete parallel blocks | HIGH | board scores change → snapshot before/after; all 205 pass; sources unchanged | Deferred — needs baseline sign-off (would re-baseline live scores) | | P0 | **Unify scoring** — registry-driven `compute_theme_surprises()` from `FACTORS`+`THEMES`; board calls it; delete parallel blocks | HIGH | board scores change → snapshot before/after; all 205 pass; sources unchanged | DONE (Option B: registry is source of truth; board re-ranked; parameters recentred) |
| P1 | **Simulation reuses board**`/api/v1/simulation` calls the same `RealDashboard` score (kill 3rd path) | MED | sim ordering == board ordering | ✅ DONE (verified live: sim picks PTT = top board) | | P1 | **Simulation reuses board**`/api/v1/simulation` calls the same `RealDashboard` score (kill 3rd path) | MED | sim ordering == board ordering | ✅ DONE (verified live: sim picks PTT = top board) |
| P2 | **Source count clarity** — payload adds `factor_count` + per-factor detail; frontend shows "N ปัจจัย · M แหล่ง" | LOW | rendered shows both; 7≠5 confusion gone | ✅ DONE (`source_summary{factor_keys:11, rows:6}` live) | | P2 | **Source count clarity** — payload adds `factor_count` + per-factor detail; frontend shows "N ปัจจัย · M แหล่ง" | LOW | rendered shows both; 7≠5 confusion gone | ✅ DONE (`source_summary{factor_keys:11, rows:6}` live) |
| P3 | **Backtest PIT honesty** — real multi-rebalance engine, release-lag score_fn, actual rebalances, leakage flag, real-ish dividend | HIGH | 2024 backtest no longer uses 2026 scores; IC attribution added | Deferred (needs scope decision) | | P3 | **Backtest PIT honesty** — real multi-rebalance engine, release-lag score_fn, actual rebalances, leakage flag, real-ish dividend | HIGH | 2024 backtest no longer uses 2026 scores; IC attribution added | DONE (`run_backtest` now reallocates every window; `leakage_guard`; `momentum_at` true 12-1) |
| P4 | **Factor-weight learning loop** (user's new feature) — IC-based weight update + holdout | MED | a "before/after weight" is produced per factor per year | Deferred (needs scope decision) | | P4 | **Factor-weight learning loop** (user's new feature) — IC-based weight update + holdout | MED | a "before/after weight" is produced per factor per year | DONE (momentum IC=0.012 live; machinery ready — macro/demographic blocked on historical vintages) |
| P5 | **Hygiene** — split `__init__.py`, drop dead code, conftest/PYTHONPATH, registry-owned source metadata | LOW | diff is behavior-neutral | 🟡 Partial — dead code removed + conftest; `__init__` split + registry-owned metadata deferred | | P5 | **Hygiene** — split `__init__.py`, drop dead code, conftest/PYTHONPATH, registry-owned source metadata | LOW | diff is behavior-neutral | 🟡 Partial — dead code removed + conftest; `__init__` split + registry-owned metadata deferred |
**Canonical repo conventions:** plan-first, fix only reported bug, log to engineering-log.md + HANDOFF.md **Canonical repo conventions:** plan-first, fix only reported bug, log to engineering-log.md + HANDOFF.md