[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({"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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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("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)
|
||||||
|
|||||||
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()
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user