[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:
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
199
backend/app/weight_learning.py
Normal file
199
backend/app/weight_learning.py
Normal 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)
|
||||
87
backend/tests/test_backtest.py
Normal file
87
backend/tests/test_backtest.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
98
backend/tests/test_weight_learning.py
Normal file
98
backend/tests/test_weight_learning.py
Normal 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()
|
||||
Reference in New Issue
Block a user