Files
quant-trading/research/lab/engine.py
Gahow Wang 2c19e5efe5 feat: US 3-sleeve portfolio and lowrisk_top20; US portfolio research harness
Register two live strategies from the 2026-09-29 US research:
- lowrisk_top20: idiosyncratic vol + low beta, top 20, monthly, buffer 2
  (new `low_risk` signal in strategies/robust_momentum.py)
- us_three_sleeve: 1/3 each of overnight_recovery_top10, lowrisk_top20 and
  trend_rider_v7_vt24 in one account (strategies/sleeve_portfolio.py, a
  mixed stock+ETF strategy). Sleeve shares drift with their returns and reset
  at a month start once any is >25% off its share.

Research (research/lab, S&P 500 PIT, next-open fills):
- rotation.py / persistence.py: walk-forward factor weighting (factor
  momentum, rolling IC, market state) and last-year's-winner selection do not
  beat a fixed composite
- us_sweep.py: holdings count, z/vol weights, dynamic N, tranching and market
  overlays (Faber SMA, de-risk vol target, statistical jump model,
  momentum state); engine.py gains zvol weighting and dyn_n
- fetch_us_fundamentals.py / fundamentals.py / fund_eval.py: SEC EDGAR
  point-in-time fundamentals (first-filed values) and factor tests
- trend_rider_long.py / us_final.py: 2010-2026 TrendRider curves and the
  3-sleeve check (2011-2026 Sharpe 1.21, CAGR 21.9%, MaxDD -23%)

Web-UI engine, 2017-09..2026-09, $2/order: us_three_sleeve CAGR 28.9%,
Sharpe 1.49, MaxDD -24.8%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:03:50 +08:00

286 lines
12 KiB
Python

"""
Event-driven long-only portfolio simulator for factor research.
Differences from ``main.backtest`` (the engine behind the web UI numbers):
* Execution lag. A score computed from the close of day s is traded at the
OPEN of day s+1 (``exec_at="open"``) or the CLOSE of day s+1
(``exec_at="close"``). ``main.backtest`` fills at the same close the signal
saw, which the live trader cannot do.
* Holdings drift between rebalances; costs are charged on actual trades.
* Tradability. A stock that is suspended, or opens at limit-up (buy) or
limit-down (sell) on the execution day keeps its current position. Stocks
outside the point-in-time index on the signal date are never bought.
* Costs are asymmetric (CN stamp tax on sells) and include an optional fixed
per-order fee expressed against a reference capital.
Conventions: every DataFrame shares one date index and one column set
(``Panel.close.columns``). ``score`` is "higher is better"; NaN means
"not eligible".
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
@dataclass
class Costs:
buy: float = 0.0010 # fraction of traded value
sell: float = 0.0010
sell_tax: pd.Series | None = None # extra sell-side tax by date (CN stamp duty)
fixed_fee: float = 0.0 # currency per order
capital: float = 10_000.0 # reference capital for the fixed fee
@dataclass
class Panel:
market: str
open: pd.DataFrame
high: pd.DataFrame
low: pd.DataFrame
close: pd.DataFrame
volume: pd.DataFrame
member: pd.DataFrame # bool: in PIT index on that date
can_buy: pd.DataFrame # bool: buyable at that day's open
can_sell: pd.DataFrame # bool: sellable at that day's open
cash_ret: pd.Series # daily return of the idle-cash asset
benchmark: pd.Series # index level (cap-weighted)
costs: Costs = field(default_factory=Costs)
extra: dict = field(default_factory=dict) # market-specific fields (turn, pe, ...)
@property
def dates(self) -> pd.DatetimeIndex:
return self.close.index
def slice(self, start=None, end=None) -> "Panel":
idx = self.dates
m = np.ones(len(idx), bool)
if start is not None:
m &= idx >= pd.Timestamp(start)
if end is not None:
m &= idx <= pd.Timestamp(end)
def sub(x):
if isinstance(x, (pd.Series, pd.DataFrame)) and x.index.equals(idx):
return x.loc[m]
return x
return Panel(self.market, sub(self.open), sub(self.high), sub(self.low), sub(self.close),
sub(self.volume), sub(self.member), sub(self.can_buy), sub(self.can_sell),
sub(self.cash_ret), sub(self.benchmark), self.costs,
{k: sub(v) for k, v in self.extra.items() if not k.startswith("_")})
@dataclass
class Result:
equity: pd.Series
returns: pd.Series
turnover: pd.Series # one-way traded value / equity, per day
holdings: pd.DataFrame # weights after each rebalance (rows = exec dates)
exposure: pd.Series # invested fraction at each close
name: str = ""
def rebalance_dates(dates: pd.DatetimeIndex, every: int | str, offset: int = 0,
start: int = 0) -> np.ndarray:
"""Boolean mask of signal dates.
``every`` is a trading-day interval (5 = weekly) or "W"/"M" for the last
trading day of each calendar week/month.
"""
mask = np.zeros(len(dates), bool)
if isinstance(every, str):
key = dates.to_period(every[0])
last = pd.Series(np.arange(len(dates)), index=dates).groupby(key).max().values
mask[last] = True
mask[:start] = False
return mask
mask[start + offset::every] = True
return mask
def select_top(score_row: np.ndarray, top_n: int, held: np.ndarray | None = None,
buffer: float = 0.0) -> np.ndarray:
"""Indices of the chosen names. With a buffer, current holdings stay while
their rank is within ``top_n * (1 + buffer)``."""
valid = np.where(~np.isnan(score_row))[0]
if len(valid) == 0:
return valid
order = valid[np.argsort(-score_row[valid], kind="stable")]
if buffer <= 0 or held is None or not held.any():
return order[:top_n]
rank = np.empty(len(score_row)); rank[:] = np.inf
rank[order] = np.arange(len(order))
keep = [i for i in np.where(held)[0] if rank[i] < top_n * (1 + buffer)]
keep = sorted(keep, key=lambda i: rank[i])[:top_n]
chosen = list(keep)
for i in order:
if len(chosen) >= top_n:
break
if i not in keep:
chosen.append(i)
return np.array(chosen, int)
def zvol_weights(score_row: np.ndarray, idx: np.ndarray, vol: np.ndarray,
lo: float = 0.5, hi: float = 2.0) -> np.ndarray:
"""Grinold (1994) alpha = vol x IC x z, so mean-variance weights with a
diagonal risk model are proportional to z / vol. z is the normal score of
the name's cross-sectional percentile; weights are clipped to
[lo/N, hi/N] and renormalized."""
from scipy.stats import norm
valid = ~np.isnan(score_row)
pct = np.full(len(score_row), np.nan)
order = np.argsort(score_row[valid], kind="stable")
ranks = np.empty(valid.sum()); ranks[order] = (np.arange(valid.sum()) + 0.5) / valid.sum()
pct[valid] = ranks
z = norm.ppf(pct[idx])
v = np.where(vol > 0, vol, np.nan)
v = np.nan_to_num(v, nan=np.nanmedian(v) if np.isfinite(v).any() else 1.0)
raw = np.clip(z, 0.01, None) / v
n = len(idx)
w = raw / raw.sum()
for _ in range(5):
w = np.clip(w, lo / n, hi / n)
w = w / w.sum()
return w
def simulate(panel: Panel, score: pd.DataFrame | None = None, top_n: int = 10,
every: int | str = 5, offset: int = 0, warmup: int = 0,
weighting: str = "equal", buffer: float = 0.0,
exposure: pd.Series | None = None, exec_at: str = "open",
target_weights: pd.DataFrame | None = None, min_trade: float = 0.002,
dyn_n: tuple[int, float] | None = None, name: str = "") -> Result:
"""Run a long-only top-N strategy.
score : higher = better, evaluated at each signal date's close.
target_weights : alternative to score — explicit weights per signal date
(rows need only exist on signal dates; NaN rows skip).
weighting : "equal" | "invvol" (60-day vol) | "score" (score-proportional, clipped >0)
| "zvol" (normal score / 60-day vol, see ``zvol_weights``)
dyn_n : (min_n, threshold): hold every member whose score is at least
``threshold`` (e.g. a mean factor percentile of 0.8), at least
``min_n`` and at most ``top_n`` names.
exposure : fraction of equity to invest, read on the signal date
(e.g. a market-trend or vol-target overlay); rest earns cash_ret.
exec_at : "open" → trade at next day's open; "close" → next day's close.
min_trade : resizing trades smaller than this fraction of equity are
skipped (the live trader skips tiny orders too); full
entries and exits always trade.
"""
dates = panel.dates
n_days, n_assets = panel.close.shape
C = panel.close.ffill().values
O = panel.open.values
O = np.where(np.isnan(O), C, O) # missing open → fall back to close
prevC = np.vstack([C[:1], C[:-1]])
member = panel.member.values
can_buy = panel.can_buy.values
can_sell = panel.can_sell.values
cash_ret = panel.cash_ret.reindex(dates).fillna(0.0).values
sell_tax = (panel.costs.sell_tax.reindex(dates).fillna(0.0).values
if panel.costs.sell_tax is not None else np.zeros(n_days))
S = score.reindex(index=dates, columns=panel.close.columns).values if score is not None else None
TW = (target_weights.reindex(index=dates, columns=panel.close.columns).values
if target_weights is not None else None)
expo = exposure.reindex(dates).ffill().fillna(1.0).values if exposure is not None else np.ones(n_days)
if weighting in ("invvol", "zvol"):
rets = panel.close.pct_change(fill_method=None)
vol = rets.rolling(60, min_periods=40).std().values
sig_mask = rebalance_dates(dates, every, offset, start=warmup)
h = np.zeros(n_assets) # value held per asset (at last mark)
cash = 1.0
eq = np.empty(n_days); turn = np.zeros(n_days); inv = np.zeros(n_days)
hold_rows, hold_idx = [], []
pending = None # target weight vector awaiting execution
for d in range(n_days):
# mark overnight / full-day moves
with np.errstate(invalid="ignore", divide="ignore"):
gap = np.where(prevC[d] > 0, O[d] / prevC[d], 1.0)
intraday = np.where(O[d] > 0, C[d] / O[d], 1.0)
gap = np.nan_to_num(gap, nan=1.0, posinf=1.0)
intraday = np.nan_to_num(intraday, nan=1.0, posinf=1.0)
cash *= 1 + cash_ret[d]
if pending is not None:
if exec_at == "open":
h *= gap
else:
h *= gap * intraday
V = h.sum() + cash
tgt = pending * V
# untradable names keep their current value
frozen_buy = (tgt > h) & ~can_buy[d]
frozen_sell = (tgt < h) & ~can_sell[d]
frozen = frozen_buy | frozen_sell
if frozen.any():
tgt[frozen] = h[frozen]
free = ~frozen & (pending > 0)
room = V - tgt[frozen].sum()
if free.any() and pending[free].sum() > 0:
budget = min(room, pending[free].sum() * V)
tgt[free] = pending[free] / pending[free].sum() * max(budget, 0.0)
if min_trade > 0:
tiny = (np.abs(tgt - h) < min_trade * V) & (tgt > 0) & (h > 0)
tgt[tiny] = h[tiny]
trade = tgt - h
buys = trade[trade > 0].sum()
sells = -trade[trade < 0].sum()
n_orders = int((np.abs(trade) > 1e-4 * V).sum())
cost = buys * panel.costs.buy + sells * (panel.costs.sell + sell_tax[d])
# Equity is normalized to 1.0 == costs.capital, so a fixed fee per
# order is fixed_fee / capital in these units at any book size.
if panel.costs.fixed_fee > 0:
cost += n_orders * panel.costs.fixed_fee / panel.costs.capital
cash += h.sum() - tgt.sum() - cost
h = tgt
turn[d] = (buys + sells) / 2 / V if V > 0 else 0.0
hold_rows.append(h / max(h.sum() + cash, 1e-12)); hold_idx.append(dates[d])
if exec_at == "open":
h *= intraday
pending = None
else:
h *= gap * intraday
V = h.sum() + cash
eq[d] = V
inv[d] = h.sum() / V if V > 0 else 0.0
if sig_mask[d] and d + 1 < n_days:
if TW is not None:
w = np.nan_to_num(TW[d], nan=0.0)
if np.isnan(TW[d]).all():
continue
else:
row = np.where(member[d], S[d], np.nan)
held = h > 1e-12
n_pick = top_n
if dyn_n is not None:
n_pick = int(np.clip((row >= dyn_n[1]).sum(), dyn_n[0], top_n))
idx = select_top(row, n_pick, held, buffer)
w = np.zeros(n_assets)
if len(idx) >= min(max(1, n_pick // 2), 20):
if weighting == "zvol":
w[idx] = zvol_weights(row, idx, vol[d, idx])
elif weighting == "invvol":
iv = 1.0 / np.where(vol[d, idx] > 0, vol[d, idx], np.nan)
iv = np.nan_to_num(iv, nan=np.nanmean(iv) if np.isfinite(iv).any() else 1.0)
w[idx] = iv / iv.sum()
elif weighting == "score":
s = np.clip(row[idx] - np.nanmin(row[idx]) + 1e-6, 0, None)
w[idx] = s / s.sum()
else:
w[idx] = 1.0 / len(idx)
pending = w * float(np.clip(expo[d], 0.0, 1.0))
equity = pd.Series(eq, index=dates)
rets = equity.pct_change().fillna(0.0)
holdings = pd.DataFrame(hold_rows, index=hold_idx, columns=panel.close.columns)
return Result(equity, rets, pd.Series(turn, index=dates), holdings,
pd.Series(inv, index=dates), name)