fix: unify backtest execution and add forward validation candidates

Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band.

Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
2026-10-01 02:24:01 -07:00
parent 761725d2c8
commit 722cade974
45 changed files with 1561 additions and 1021 deletions

View File

@@ -102,13 +102,15 @@ uv run python -m unittest discover tests
**Data manager** (`data_manager.py`): Persistent price storage in `data/{market}.csv` (close) and `data/{market}_{open,high,low,volume}.csv`. First run downloads 10 years of history; every run re-downloads the last 30 days. New tickers from index rebalances are backfilled automatically; tickers missing from a partial batch are retried alone before being marked unavailable (retried weekly). `update(market, tickers, with_open=True)` returns (close, open); `update(..., fields=[...])` returns `{field: panel}`.
**Backtest engine** (`main.py`): Orchestrates data loading, strategy execution, and visualization. `backtest()` has two modes. Close mode (no `open_data`, used by `main.py` and old research scripts) is vectorized and trades at the close the signal saw. Live mode (`open_data` given, used by the web UI through `backtest_runner.py`) keeps the book like `trader.py`: same signal timing (`strategy_positions`), holdings that drift, the $50/0.1% order floor, `trader.RESIZE_BAND`, fees on the orders placed (`simulate_live_book`).
**Backtest engine** (`main.py`, `backtest_runner.py`): all replay paths use `signals.session_targets` and the cash-funded order ledger in `execution.py`. Price strategies may use the session open and fill at its close; close-only inputs and BarStrategy inputs use prior completed bars. Historical membership restricts ranks and selection separately from price history. Missing quotes and zero volume cannot fill; the book checks available cash after fees, handles whole shares/lots, and applies conservative CN closing-limit restrictions and sell-side stamp tax. Registry replays retain at least 315 prior sessions and score all compared strategies on a common actual window. `--offline` on main/precompute reads a frozen local snapshot. Data coverage and adjusted-share/corporate-action limitations are exposed in API metadata.
`precompute_backtest.py` reruns the ledger for cash-injection scenarios; metrics use unit NAV after actual fees. The API and `web/src/shared/strategyTypes.ts` share this cash-flow contract. `ENGINE_VERSION` in `execution.py` identifies the execution protocol; API cache keys also include its version.
**Daily trader** (`trader.py`): Live/forward-testing system with persistent portfolio state in `data/trader_{market}_{strategy}.json`. The `auto` subcommand runs both signal generation and execution in a single invocation — designed for cron. The `simulate` subcommand replays a date range day-by-day with realistic portfolio tracking (fractional shares, cash, commissions). Orders: whole A-share lots are allocated nearest-lot within cash (`_allocate_lots`); a held name is resized only when ≥ `RESIZE_BAND` (10%) off target. Available strategies: `recovery_mom_top10`, `recovery_mom_top20`, `momentum`, `momentum_quality`, `dual_momentum`, `inverse_vol`, `trend_following`, `buy_and_hold`, 32 factor-combo strategies (`fc_{signal}_{freq}` — see `strategies/factor_combo.py`), the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`, `lowrisk_top20`, the US sleeve portfolios `us_three_sleeve` and `us_momentum_qqq_trend`, and the agent-reviewed `overnight_recovery_agent_top10` (US only, forward only).
**Live books** (`trader.LIVE_BOOKS`): a book is a cohort of strategies, each with its own account, started on one date with one capital and a flat fee per order. The original book (every live strategy, `MONITOR_CAPITAL`, `MARKET_FEES`) keeps state in `data/trader_*.json`; a named book keeps it in `data/books/<book>/`. The monitor runs every book; `auto`/`status`/`log`/`compare`/`replay` and `bridge.py live` take `--book`, and `bridge.py books` lists them for the UI's book selector. `selected_2026_09` (30 strategies, US $10,000 / CN ¥100,000, $2 / ¥10 per order) starts on 2026-09-25 (US) and 2026-09-28 (CN); `overnight_recovery_agent_top10` joined it on 2026-09-30 and starts with the book.
**Strategy protocol** (`strategies/base.py`): All strategies inherit from `Strategy` ABC and implement `generate_signals(data) → DataFrame` where the returned DataFrame contains portfolio weights (rows = dates, columns = assets, values sum to ~1.0 per row). Each strategy is responsible for applying its own 1-day lag via `.shift(1)` to avoid lookahead bias — the backtest engine does not shift. A `BarStrategy` declares `fields` (close/open/high/low/volume) and receives `{field: DataFrame}`; live it uses completed bars only (row d from bars through d-1, traded at the close of d), while close-price strategies read the session's open as their latest price.
**Strategy protocol** (`strategies/base.py`, `signals.py`): All strategies inherit from `Strategy` ABC and implement `generate_signals(data) → DataFrame` where the returned DataFrame contains portfolio weights (rows = dates, columns = assets, values sum to ~1.0 per row). Each strategy returns lagged weights (row t uses bars through t-1). `signals.session_targets` converts this to a feasible execution-day target; the ledger trades at that day’s close. `StockStrategy` accepts a separate `eligible` mask so pre-admission observations remain usable but nonmembers cannot be selected. A `BarStrategy` declares `fields` (close/open/high/low/volume) and receives `{field: DataFrame}`; live it uses completed bars only (row d from bars through d-1, traded at the close of d), while close-price strategies read the session's open as their latest price.
**Strategies**:
- `buy_and_hold.py` — Equal-weight static allocation
@@ -137,6 +139,16 @@ uv run python -m unittest discover tests
- Python 3.12+, managed with `uv`.
- Do not show matplotlib figures when running backtests; use `--no-plot`.
The `*_rb20` strategies are execution-only candidates with a 20% resize band; baseline strategies keep 10%. They run alongside their baselines only in `validation_2026_10`, starting with the first market session on/after 2026-10-02, with matching capital and fees. Existing forward histories are preserved and future daily snapshots record the engine version. These candidates were selected from previously examined historical data and have no independent forward performance yet.
Registered-strategy comparisons use the same ledger:
```bash
uv run python -m research.lab.evaluate --market us --registered overnight_recovery_top10 us_three_sleeve --start 2018-01-01 --end 2026-09-28 --output data/research/results/new_comparison
```
The comparison writes its fixed budget and acceptance rule before running and refuses to overwrite an existing study. Trial producers append versioned JSONL; legacy CSV trial counts remain a lower bound. `stepwise --objective maximin` selects only inside the design period. Previously inspected validation periods must not be described as independent OOS data.
## Server Deployment
Three `systemd --user` services, defined in `deploy/systemd/`, run from `/home/gahow/projects/quant-trading`:

View File

@@ -1,197 +1,229 @@
"""
Market-level backtests behind the web UI: ``bridge.py backtest`` (on demand)
and ``precompute_backtest.py`` (the strategy page) share this one data path.
* Universe: point-in-time index members in both markets (S&P 500 history from
Wikipedia, CSI 300 history in data/csi300_history.json); prices outside a
stock's membership are masked, so strategies cannot pick it then.
* Book: kept like the live trader keeps it — its signal timing
(``main.strategy_positions`` live mode), drifting holdings, the same order
size floor and resize band (``main.simulate_live_book``).
* Bars: panels a BarStrategy needs (open/high/low/volume) are loaded too.
"""
"""Shared, feasible replay for the API, precompute and strategy comparisons."""
from __future__ import annotations
import sys
import numpy as np
import pandas as pd
import data_manager
import universe_history as uh
from main import simulate_live_book, strategy_positions
from strategies.base import BarStrategy
from trader import (
ETF_STRATEGY_UNIVERSES,
LIVE_EXCLUDED_STRATEGIES,
RESIZE_BAND,
STRATEGY_REGISTRY,
convert_quote_currency,
filter_tradable_tickers,
price_request,
strategy_extra_tickers,
strategy_fields,
)
from execution import ENGINE_VERSION, simulate_book
from trader import (ETF_STRATEGY_UNIVERSES, STRATEGY_REGISTRY, RESIZE_BAND, AGENT_REVIEW_STRATEGIES,
convert_quote_currency, price_request, strategy_extra_tickers,
strategy_fields, strategy_signal_tickers, make_strategy,
session_signals, strategy_supports_market, strategy_resize_band)
from universe import UNIVERSES
# Rebalances below this size are not charged the per-trade fee in backtests,
# matching the live trader's minimum order size.
MIN_TRADE_VALUE = 50.0
def _log(msg: str) -> None:
def _log(msg):
print(msg, file=sys.stderr)
def trade_events(diff: pd.DataFrame, threshold: float = 0.01) -> list[dict]:
"""Trade events from per-ticker traded weights (rows = days)."""
trades = []
for date in diff.index:
row = diff.loc[date]
significant = row[row.abs() > threshold]
if significant.empty:
continue
buys = significant[significant > 0]
sells = significant[significant < 0]
if len(buys) > 0 or len(sells) > 0:
trades.append({
"date": date.strftime("%Y-%m-%d"),
"buys": len(buys),
"sells": len(sells),
"buyTickers": list(buys.nlargest(3).index),
"sellTickers": list(sells.nsmallest(3).index),
})
return trades
rows = []
for day, row in diff.iterrows():
buy, sell = row[row > threshold], row[row < -threshold]
if len(buy) + len(sell):
rows.append({"date": str(day.date()), "buys": len(buy), "sells": len(sell),
"buyTickers": list(buy.nlargest(3).index),
"sellTickers": list(sell.nsmallest(3).index)})
return rows
def order_events(trades: list[dict]) -> list[dict]:
days = {}
for t in trades:
day = days.setdefault(t["date"], {"date": t["date"], "buys": 0, "sells": 0,
"buyTickers": [], "sellTickers": []})
side = "buy" if t["action"] == "BUY" else "sell"
day[side + "s"] += 1
day[side + "Tickers"].append(t["ticker"])
for day in days.values():
for key in ("buyTickers", "sellTickers"):
day[key] = day[key][:3]
return list(days.values())
def benchmark_curve(prices: pd.Series, dates: pd.DatetimeIndex, capital: float) -> list[dict]:
"""Benchmark value on ``dates``, rebased to ``capital`` on the first date it has a price."""
series = prices.reindex(dates).ffill().dropna()
if series.empty:
return []
values = series / series.iloc[0] * capital
return [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)} for d, v in values.items()]
return [{"date": str(d.date()), "value": round(float(v), 2)}
for d, v in (series / series.iloc[0] * capital).items()]
def _slice_years(panels: dict[str, pd.DataFrame], years: int | None) -> dict[str, pd.DataFrame]:
if not years:
return panels
def _panels(data_market, tickers, fields, refresh):
filled = (data_manager.update(data_market, tickers, fields=fields) if refresh
else data_manager.load_fields(data_market, fields))
raw = data_manager.load_raw(data_market)
if raw is None:
raise FileNotFoundError(f"No local prices for {data_market}")
# Only valuation forward-fills. Indicators and tradability see actual bars.
filled["close"] = raw.reindex_like(filled["close"])
return filled
def load_stock_panels(market: str, strategy_names: list[str], years: int | None,
*, refresh: bool = True) -> dict:
u = UNIVERSES[market]
# Refresh membership explicitly only in the data-loading stage.
if refresh:
if market == "us":
uh.load_sp500_history()
else:
uh.load_csi300_history()
sample = pd.DatetimeIndex([pd.Timestamp.today().normalize()])
ever = list(uh.pit_membership(market, sample).columns)
extras = {t for n in strategy_names for t in strategy_extra_tickers(n) + strategy_signal_tickers(n, market)}
fields = sorted({"close", "open", "volume"} | {f for n in strategy_names for f in strategy_fields(n)})
panels = _panels(market, sorted(set(ever) | extras | {u['benchmark']}), fields, refresh)
close = panels["close"]
cutoff = close.index[-1] - pd.DateOffset(years=years)
return {f: p[p.index >= cutoff] for f, p in panels.items()}
member = uh.pit_membership(market, close.index)
stocks = sorted(set(member.columns) & set(close.columns) - extras - {u['benchmark']})
observed = close.reindex(columns=member.columns).notna() & member
coverage = observed.sum(axis=1).div(member.sum(axis=1).replace(0, np.nan))
return {"panels": panels, "member": member, "stocks": stocks,
"benchmark": u["benchmark"], "benchmark_label": u["benchmark_label"],
"coverage": coverage}
def load_stock_panels(market: str, strategy_names: list[str], years: int | None) -> dict:
"""PIT-masked panels for the market's stock strategies."""
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
since = None
if years:
since = (pd.Timestamp.today() - pd.DateOffset(years=years + 1)).strftime("%Y-%m-%d")
ever = uh.pit_tickers_ever(market, since=since) or []
extras = {t for n in strategy_names for t in strategy_extra_tickers(n)}
request = sorted(set(tickers) | set(ever) | extras | {benchmark})
fields = sorted({"close", "open"} | {f for n in strategy_names for f in strategy_fields(n)})
_log(f"PIT universe: {len(request)} tickers ({len(ever)} ever index members); fields {fields}")
panels = data_manager.update(market, request, fields=fields)
panels = _slice_years(panels, years)
member = None
if ever:
member = uh.mask_prices_pit(panels["close"], market).notna() | panels["close"].isna()
# Only tickers that were index members: the shared panel also holds
# benchmarks and the ETFs of mixed strategies (TQQQ, SOXL, ...), which
# the membership mask passes through unmasked.
members_ever = set(ever)
stocks = [t for t in panels["close"].columns
if t in members_ever and t not in extras
and panels["close"][t].where(member[t]).notna().any()]
else:
stocks = filter_tradable_tickers(panels["close"], tickers)
return {"panels": panels, "member": member, "stocks": stocks, "benchmark": benchmark,
"benchmark_label": universe["benchmark_label"]}
def prepare_strategy(name: str, market: str, panels: dict, cols: list[str], *,
member: pd.DataFrame | None = None, years: int | None = None,
start=None, end=None, strategy=None) -> dict:
close = panels["close"]
if end is not None:
close = close.loc[:end]
benchmark = "SPY" if name in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
if benchmark in close:
close = close.loc[close[benchmark].notna()]
extra = strategy_extra_tickers(name) + strategy_signal_tickers(name, market)
cols = sorted(set(cols + extra) & set(close.columns))
bars = {f: panels[f].reindex(index=close.index, columns=cols)
for f in strategy_fields(name)}
targets = session_signals(market, name, cols, close.index, close,
panels["open"].reindex(index=close.index),
bars=bars, member=member, strategy=strategy)
cutoff = pd.Timestamp(start) if start is not None else (
close.index[-1] - pd.DateOffset(years=years) if years else close.index[0])
days = close.index[close.index >= cutoff]
if len(days) < 2:
raise ValueError("Need at least two replay sessions")
return {"name": name, "market": market, "targets": targets.loc[days],
"prices": close.reindex(columns=targets.columns).loc[days],
"warmup_rows": int((close.index < days[0]).sum()),
"resize_band": strategy_resize_band(name),
"previous_prices": close.reindex(columns=targets.columns).ffill().shift(1).loc[days],
"volume": panels["volume"].reindex(index=days, columns=targets.columns) if "volume" in panels else None}
def _run(strategy, panels: dict[str, pd.DataFrame], cols: list[str], capital: float,
tx_cost: float, fixed_fee: float, fee_base: float, fee_per_share: float,
member: pd.DataFrame | None = None, min_trade_value: float = MIN_TRADE_VALUE):
"""Close-price strategies see prices masked to index membership (they
cannot pick a non-member); a BarStrategy sees full histories plus the
membership as ``bars["eligible"]``. The book is valued on unmasked closes."""
close, opens = panels["close"][cols], panels["open"][cols]
if isinstance(strategy, BarStrategy):
bars = {f: panels[f][cols] for f in strategy.fields}
if member is not None:
bars["eligible"] = member.reindex(columns=cols).fillna(True)
held = strategy_positions(strategy, close, open_data=opens, bars=bars)
else:
m = member.reindex(columns=cols).fillna(True) if member is not None else None
held = strategy_positions(strategy, close.where(m) if m is not None else close,
open_data=opens.where(m) if m is not None else opens)
equity, traded = simulate_live_book(held, close, capital, tx_cost, fixed_fee, fee_base,
fee_per_share, min_trade_value, RESIZE_BAND)
return equity, trade_events(traded)
def replay_prepared(plan: dict, capital: float, tx_cost: float, fixed_fee: float,
fee_base: float = 0.0, fee_per_share: float = 0.0,
*, contributions=None, integer_shares: bool = True,
resize_band: float | None = None, cash_returns=None) -> dict:
result = simulate_book(plan["targets"], plan["prices"], capital, market=plan["market"],
previous_prices=plan.get("previous_prices"), volume=plan.get("volume"),
transaction_cost=tx_cost, fixed_fee=fixed_fee,
fee_base=fee_base, fee_per_share=fee_per_share,
integer_shares=integer_shares,
lot_size=100 if plan["market"] == "cn" else 1,
min_trade_value=MIN_TRADE_VALUE,
resize_band=plan.get("resize_band", RESIZE_BAND) if resize_band is None else resize_band,
contributions=contributions, cash_returns=cash_returns)
# Include the initial capital so day-one commissions count in all metrics.
anchor = plan["prices"].index[0] - pd.Timedelta(days=1)
for key in ("equity", "unit_equity"):
result[key] = pd.concat([pd.Series([capital], index=[anchor]), result[key]])
result.update(name=plan["name"], trades=order_events(result["trade_log"]),
warmup_rows=plan.get("warmup_rows", 0))
return result
def _run(strategy, panels, cols, capital, tx_cost, fixed_fee, fee_base, fee_per_share,
member=None, min_trade_value=MIN_TRADE_VALUE):
# Compatibility for callers that already constructed a strategy.
from signals import session_targets
from strategies.base import BarStrategy
close = panels["close"][cols]
bars = {f: panels[f][cols] for f in strategy.fields} if isinstance(strategy, BarStrategy) else None
targets = session_targets(strategy, close, panels["open"][cols], bars, member)
result = simulate_book(targets, close, capital, transaction_cost=tx_cost, fixed_fee=fixed_fee,
fee_base=fee_base, fee_per_share=fee_per_share,
min_trade_value=min_trade_value, resize_band=RESIZE_BAND)
return result["equity"], order_events(result["trade_log"])
def run_market(market: str, strategy_names: list[str], years: int | None, capital: float,
tx_cost: float, fixed_fee: float, fee_base: float = 0.0,
fee_per_share: float = 0.0) -> dict | None:
"""Backtest every strategy once; returns raw equity series and metadata."""
stock = [n for n in strategy_names if n.removeprefix("sim_") not in ETF_STRATEGY_UNIVERSES]
etf = [n for n in strategy_names if n.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES]
fee_per_share: float = 0.0, *, refresh: bool = True,
start=None, end=None) -> dict | None:
if any(n in AGENT_REVIEW_STRATEGIES for n in strategy_names):
raise ValueError("Agent-reviewed strategies are forward-only; use their dated live record")
invalid = [n for n in strategy_names if not strategy_supports_market(n, market)]
if invalid:
raise ValueError(f"Unsupported strategies for {market}: {invalid}")
fees = dict(tx_cost=tx_cost, fixed_fee=fixed_fee, fee_base=fee_base, fee_per_share=fee_per_share)
runs: list[dict] = []
out = {"market": market, "benchmark_label": "SPY", "benchmark_curve": [],
"start_date": None, "end_date": None, "tx_cost": tx_cost, "fixed_fee": fixed_fee}
if stock:
data = load_stock_panels(market, stock, years)
panels, stocks = data["panels"], data["stocks"]
close = panels["close"]
_log(f"Universe: {len(stocks)} stocks, {close.index[0].date()} to {close.index[-1].date()}")
for name in stock:
_log(f" Running: {name}")
try:
strategy = STRATEGY_REGISTRY[name]()
cols = stocks + [t for t in strategy_extra_tickers(name)
if t in close.columns and t not in stocks]
# Strategies the live book cannot hold (e.g. equal weight over
# ~500 names) are shown without the live order-size floor.
floor = 0.0 if name in LIVE_EXCLUDED_STRATEGIES else MIN_TRADE_VALUE
equity, trades = _run(strategy, panels, cols, capital, **fees,
member=data["member"], min_trade_value=floor)
runs.append({"name": name, "equity": equity, "trades": trades})
_log(f" OK: {name} (final: {equity.iloc[-1]:,.0f})")
except Exception as e: # noqa: BLE001 — one broken strategy must not sink the page
_log(f" FAILED: {name}: {e}")
out.update(benchmark_label=data["benchmark_label"],
benchmark_curve=benchmark_curve(close[data["benchmark"]], close.index, capital),
start_date=close.index[0].strftime("%Y-%m-%d"),
end_date=close.index[-1].strftime("%Y-%m-%d"))
for name in etf:
_log(f" Running ETF strategy: {name}")
stocks = [n for n in strategy_names if n not in ETF_STRATEGY_UNIVERSES]
stock = load_stock_panels(market, stocks, years, refresh=refresh) if stocks else None
etf_names = [n for n in strategy_names if n in ETF_STRATEGY_UNIVERSES]
etfs = None
if etf_names:
request = sorted(set(t for n in etf_names for t in ETF_STRATEGY_UNIVERSES[n]) | {"SPY"})
etfs = _panels("etfs", price_request(market, request), ["close", "open"], refresh)
etfs = {f: convert_quote_currency(p) for f, p in etfs.items()}
histories = []
for panel, benchmark in ((stock["panels"], stock["benchmark"]) if stock else (None, None),
(etfs, "SPY")):
if panel is not None:
sessions = panel["close"].index[panel["close"][benchmark].notna()]
if end is not None:
sessions = sessions[sessions <= pd.Timestamp(end)]
if len(sessions) <= 315:
raise ValueError("Registered replay needs 315 pre-window sessions")
histories.append(sessions)
common_end = min(h[-1] for h in histories)
requested_start = (pd.Timestamp(start) if start is not None else
common_end - pd.DateOffset(years=years) if years else min(h[0] for h in histories))
common_start = max(requested_start, max(h[315] for h in histories))
runs, errors = [], []
for name in strategy_names:
try:
etf_tickers = ETF_STRATEGY_UNIVERSES[name.removeprefix("sim_")]
close, opens = data_manager.update(
"etfs", price_request(market, sorted(set(etf_tickers + ["SPY"]))), with_open=True)
panels = _slice_years({"close": convert_quote_currency(close),
"open": convert_quote_currency(opens)}, years)
strategy = STRATEGY_REGISTRY[name]()
equity, trades = _run(strategy, panels, filter_tradable_tickers(panels["close"], etf_tickers),
capital, **fees)
runs.append({"name": name, "equity": equity, "trades": trades})
_log(f" OK: {name} (final: {equity.iloc[-1]:,.0f})")
if not stock and not out["benchmark_curve"] and "SPY" in panels["close"].columns:
spy = panels["close"]["SPY"]
out.update(benchmark_label="SPY (Benchmark)",
benchmark_curve=benchmark_curve(spy, spy.index, capital),
start_date=spy.index[0].strftime("%Y-%m-%d"),
end_date=spy.index[-1].strftime("%Y-%m-%d"))
except Exception as e: # noqa: BLE001
_log(f" FAILED: {name}: {e}")
panels = etfs if name in ETF_STRATEGY_UNIVERSES else stock["panels"]
cols = ETF_STRATEGY_UNIVERSES[name] if name in ETF_STRATEGY_UNIVERSES else stock["stocks"]
member = None if name in ETF_STRATEGY_UNIVERSES else stock["member"]
plan = prepare_strategy(name, market, panels, cols, member=member, start=common_start, end=common_end)
result = replay_prepared(plan, capital, **fees)
result["_plan"] = plan
result["_fees"] = fees
runs.append(result)
_log(f" {name}: {result['equity'].iloc[-1]:,.2f}")
except (ValueError, KeyError, FileNotFoundError) as exc:
errors.append({"strategy": name, "error": str(exc)})
_log(f" FAILED {name}: {exc}")
if not runs:
if errors:
raise ValueError(str(errors))
return None
out["strategy_runs"] = runs
return out
first, last = runs[0]["equity"].index[[0, -1]]
bench = stock["benchmark"] if stock else "SPY"
panels = stock["panels"] if stock else etfs
dates = runs[0]["equity"].index
curve = benchmark_curve(panels["close"][bench], dates[1:], capital)
curve.insert(0, {"date": str(first.date()), "value": capital})
coverage = stock["coverage"].loc[first:last].dropna() if stock else pd.Series(dtype=float)
warnings = ["使用复权价格:历史整股、整手及公司行动的模拟仍有近似。",
"缺少完整退市结算和市场冲击数据,收益为研究估计。"]
if market == "cn":
warnings.append("沪深300历史成分股按月记录;缺少完整历史ST状态及集合竞价数据,涨跌停成交采用保守限制。")
if common_start > requested_start:
warnings.append(f"为保留315个交易日的预热历史,统一统计窗口从 {common_start.date()} 开始;更早区间需补齐行情。")
if len(coverage) and coverage.min() < .99:
warnings.append(f"历史成分股报价覆盖率最低为 {coverage.min():.1%},仍存在样本缺失偏差。")
return {"market": market, "benchmark_label": stock["benchmark_label"] if stock else "SPY",
"benchmark_curve": curve, "start_date": str(first.date()), "end_date": str(last.date()),
**fees, "strategy_runs": runs, "engine_version": ENGINE_VERSION,
"actual_years": round((last - first).days / 365.25, 1),
"warnings": warnings, "errors": errors,
"coverage": {"minimum": float(coverage.min()), "median": float(coverage.median())}
if len(coverage) else None}

View File

@@ -27,7 +27,7 @@ from trader import (
LIVE_EXCLUDED_STRATEGIES,
MARKET_FEE_TIERED,
MONITOR_CAPITAL,
STRATEGY_REGISTRY,
STRATEGY_REGISTRY, EXECUTION_VARIANTS,
book_dir,
book_strategies,
convert_quote_currency,
@@ -482,6 +482,11 @@ STRATEGY_META = {
}
for _name, (_base, _market) in EXECUTION_VARIANTS.items():
STRATEGY_META[_name] = {**STRATEGY_META[_base], "markets": [_market],
"label": STRATEGY_META[_base]["label"] + " · 20%再平衡带(前瞻候选)"}
def _log(msg: str) -> None:
print(msg, file=sys.stderr)
@@ -740,7 +745,8 @@ def cmd_backtest(args: argparse.Namespace) -> None:
raw = run_market(market, known, years, capital, tx_cost, fixed_fee,
**MARKET_FEE_TIERED.get(market, {}))
results = [
_build_strategy_result(run["name"], run["equity"], run["trades"], injection_schedule, capital)
_build_strategy_result(run["name"], run["equity"], run["trades"], injection_schedule, capital,
execution_plan=run.get("_plan"), fee_params=run.get("_fees"))
for run in (raw["strategy_runs"] if raw else [])
]
benchmark_label = raw["benchmark_label"] if raw else "SPY"
@@ -749,13 +755,18 @@ def cmd_backtest(args: argparse.Namespace) -> None:
output = {
"meta": {
"market": market,
"years": years,
"years": raw.get("actual_years", years) if raw else years,
"requestedYears": years,
"initialCapital": capital,
"startDate": results[0]["equityCurve"][0]["date"] if results else None,
"endDate": results[0]["equityCurve"][-1]["date"] if results else None,
"txCost": tx_cost,
"fixedFee": fixed_fee,
"injectionSchedule": injection_schedule,
"engineVersion": raw.get("engine_version") if raw else None,
"warnings": raw.get("warnings", []) if raw else [],
"coverage": raw.get("coverage") if raw else None,
"errors": raw.get("errors", []) if raw else [],
},
"strategies": results,
"benchmark": {
@@ -773,21 +784,29 @@ def _build_strategy_result(
trade_events: list[dict],
injection_schedule: dict | None,
initial_capital: float,
*, execution_plan: dict | None = None, fee_params: dict | None = None,
) -> dict:
"""Build a strategy result dict with optional injection applied.
Return and risk metrics, drawdowns and periodic returns are time-weighted
(computed on the curve without injections), so injected cash never counts
as performance. ``capitalFlows`` reports the money view: total invested,
(computed from cash-flow-adjusted NAV), so injected cash never counts
as performance. The orders and fees are replayed at the actual capital. ``capitalFlows`` reports the money view: total invested,
final value and profit of the injected curve.
"""
injection_events = []
equity_with_inj = None
metric_equity = equity
if injection_schedule:
returns = equity.pct_change().fillna(0.0)
contributions, injection_events = build_injection_schedule(equity, injection_schedule)
equity_with_inj = equity_with_cashflows(returns, contributions, initial_capital)
if execution_plan is None or fee_params is None:
raise ValueError("Cash injections require an order replay; scaling a return curve is not supported")
from backtest_runner import replay_prepared
days = execution_plan["prices"].index
contributions, injection_events = build_injection_schedule(equity.reindex(days), injection_schedule)
replay = replay_prepared(execution_plan, initial_capital, contributions=contributions, **fee_params)
equity_with_inj = replay["equity"]
metric_equity = replay["unit_equity"]
trade_events = replay["trades"]
# Curve shown to the user: the money curve when cash is injected.
final_equity = equity_with_inj if equity_with_inj is not None else equity
@@ -804,14 +823,10 @@ def _build_strategy_result(
base_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
for d, v in equity.items()]
# Time-weighted metrics on the curve without injections
strat_metrics = metrics.raw_summary(equity)
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(
equity, trade_events,
)
drawdowns = compute_drawdown_periods(equity)
monthly_ret, yearly_ret = compute_periodic_returns(equity)
strat_metrics = metrics.raw_summary(metric_equity)
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(metric_equity, trade_events)
drawdowns = compute_drawdown_periods(metric_equity)
monthly_ret, yearly_ret = compute_periodic_returns(metric_equity)
meta = STRATEGY_META.get(name, {"label": name, "category": "other"})
@@ -1060,7 +1075,9 @@ def cmd_books() -> None:
"strategies": {m: book_strategies(None, m) for m in ("us", "cn")}}]
for book_id, cfg in LIVE_BOOKS.items():
books.append({"id": book_id, "label": cfg["label"], "startDate": cfg["start"],
"capital": cfg["capital"], "feePerOrder": cfg["fee_per_order"],
"capital": cfg["capital"],
"feePerOrder": None if "fee_schedule" in cfg else cfg["fee_per_order"],
"feeSchedule": cfg.get("fee_schedule"),
"strategies": {m: book_strategies(book_id, m) for m in ("us", "cn")}})
json.dump(books, sys.stdout)

View File

@@ -258,11 +258,10 @@ def _merge_refresh(existing: pd.DataFrame | None, new: pd.DataFrame,
def _fill_open(close: pd.DataFrame, open_raw: pd.DataFrame | None) -> pd.DataFrame:
"""Align open prices to the close panel; fall back to close where open is missing."""
if open_raw is None or open_raw.empty:
return close.copy()
aligned = open_raw.reindex(index=close.index, columns=close.columns).ffill()
return aligned.fillna(close)
"""Align quotes without manufacturing a print from a stale or later price."""
if open_raw is None:
return pd.DataFrame(np.nan, index=close.index, columns=close.columns)
return open_raw.reindex(index=close.index, columns=close.columns)
YAHOO_FIELDS = {"close": "Close", "open": "Open", "high": "High", "low": "Low", "volume": "Volume"}

325
execution.py Normal file
View File

@@ -0,0 +1,325 @@
"""Shared long-only order sizing and cash-funded execution for replay and trading."""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
ENGINE_VERSION = "cash-ledger-pit-v2"
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
return cash + sum(shares * prices.get(t, 0.0) for t, shares in holdings.items())
def lot_rule(ticker: str, lot_size: int) -> tuple[int, int]:
# STAR orders start at 200 shares, then increase by one (SSE rule 6.7).
return (1, 200) if lot_size == 100 and ticker.startswith("688") and ticker.endswith(".SS") else (lot_size, lot_size)
def market_permissions(market, day, raw: pd.Series, previous: pd.Series, volume: pd.Series | None = None):
fresh = raw.notna() & np.isfinite(raw) & (raw > 0)
if volume is not None:
fresh &= volume.reindex(raw.index).gt(0)
buy, sell = fresh.copy(), fresh.copy()
tax = 0.0
if market == "cn":
limits = pd.Series(.10, index=raw.index)
for t in raw.index:
if str(t).startswith("688") or (str(t).startswith("30") and day >= pd.Timestamp("2020-08-24")):
limits[t] = .20
change = raw / previous.reindex(raw.index) - 1
# A closing print at the limit does not establish an available fill.
buy &= ~(change >= limits - .002)
sell &= ~(change <= -limits + .002)
# MOF announcement 2023-39: half-rate from 2023-08-28, sells only.
tax = .0005 if day >= pd.Timestamp("2023-08-28") else .001
return set(raw.index[buy]), set(raw.index[sell]), tax
def _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
lot_size: int, reserve: float) -> dict:
"""Whole-lot share targets: round each move toward zero, then spend the
remaining cash one lot at a time on the names furthest below target,
as long as the lot brings them nearer (nearest-lot rounding)."""
final, spend = {}, 0.0
for t, want in desired.items():
cur = holdings.get(t, 0.0)
step, minimum = lot_rule(t, lot_size)
if want <= 0:
final[t] = 0.0 # full exit, odd lot included
else:
delta = int((want - cur) / step) * step
final[t] = cur + (delta if abs(delta) >= minimum else 0)
spend += (final[t] - cur) * prices[t]
budget = cash - spend - reserve
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
for gap, t in reversed(short):
step, minimum = lot_rule(t, lot_size)
extra = minimum if final[t] == holdings.get(t, 0) else step
lot_value = extra * prices[t]
if gap > 0.5 * lot_value and lot_value <= budget:
final[t] += extra
budget -= lot_value
return final
def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0,
integer_shares: bool = False, lot_size: int = 1,
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
"""
Compute trades needed to move from current holdings to target weights.
If integer_shares=True, positions move in whole multiples of ``lot_size``
(e.g. 100-share A-share board lots): each move is rounded toward zero and
leftover cash then buys one more lot for the names furthest below target
when that lot brings them nearer; a position whose target is zero is
always sold in full, odd lot included.
A held position with a nonzero target is not resized while it is within
``resize_band`` (fraction of its target value) of the target.
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
Returns list of {ticker, shares_delta, direction, est_value}.
"""
weights = np.array(list(target_weights.values()), dtype=float)
if not np.isfinite(weights).all() or (weights < 0).any() or weights.sum() > 1 + 1e-8:
raise ValueError("Targets must be finite, long-only and cash-funded (sum <= 1)")
total = portfolio_value(holdings, prices, cash)
frozen = frozen or set()
desired = {}
for ticker in sorted(set(target_weights) | set(holdings)):
if ticker in frozen:
continue
price = prices.get(ticker, 0.0)
if not math.isfinite(price) or price <= 0:
continue
target_weight = target_weights.get(ticker, 0.0)
target_value = total * target_weight
current_shares = holdings.get(ticker, 0.0)
if (current_shares > 0 and target_weight > 0
and abs(target_value - current_shares * price) < resize_band * target_value):
continue
desired[ticker] = target_value / price
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
reserve=0.002 * total + 10.0 * len(desired))
if integer_shares else desired)
raw = []
for ticker, target_shares in targets.items():
price = prices[ticker]
current_shares = holdings.get(ticker, 0.0)
delta = target_shares - current_shares
trade_value = abs(delta * price)
if trade_value < min_trade_value and target_shares > 0:
continue
if abs(delta) < 1e-10:
continue
if integer_shares and abs(delta) < 1:
continue
raw.append({
"ticker": ticker,
"shares_delta": int(round(delta)) if integer_shares else delta,
"direction": "BUY" if delta > 0 else "SELL",
"est_value": round(trade_value, 2),
"price": round(price, 6),
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
})
return raw
def _per_trade_commission(
shares: float,
price: float,
tx_cost: float,
fixed_fee: float,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
) -> float:
"""Commission for one trade.
Matches the IBKR-style tiered formula used by the backtest engine:
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
With fee_base=0 and fee_per_share=0 this degenerates to the flat
fixed-fee model (legacy behavior).
"""
bps_cost = abs(shares) * price * tx_cost
per_trade = fee_base + fee_per_share * abs(shares)
floor = max(fixed_fee, per_trade)
return bps_cost + floor
def execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
trade_date: str = "", integer_shares: bool = False,
lot_size: int = 1, sell_tax: float = 0.0) -> None:
"""Execute trades: update holdings and cash in state, append to trade_log.
Sells release cash before buys. Every buy must fit cash after all fees,
including fractional-share orders.
Per-trade commission supports both the legacy flat ``fixed_fee`` and
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
schedule used by the backtest engine.
"""
holdings = state["holdings"]
cash = state["cash"]
# Sell first so buys cannot implicitly borrow against unsettled target changes.
sells = [t for t in trades if t["shares_delta"] < 0]
buys = [t for t in trades if t["shares_delta"] > 0]
ordered = sells + buys
us_fee_per_share = fee_per_share
for trade in ordered:
ticker = trade["ticker"]
delta = trade["shares_delta"]
price = prices.get(ticker)
if price is None or not math.isfinite(price) or price <= 0:
continue
if delta < 0:
delta = -min(-delta, holdings.get(ticker, 0.0))
if abs(delta) < 1e-10:
continue
cost = abs(delta * price)
# The per-share schedule applies to US-listed shares only; HK lines
# (often < $1/share) pay the proportional cost and the ticket floor.
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
commission = _per_trade_commission(
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
if delta > 0:
# BUY — resize to the cash available after fees.
if (cost + commission) > cash:
# Try buying fewer shares that we can afford, accounting for
# the per-share variable component of the commission.
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
if affordable_price_unit <= 0:
continue
# Both branches of max(floor, base + per-share fee) must fit.
affordable = min((cash - fixed_fee) / (price * (1 + tx_cost)),
(cash - fee_base) / affordable_price_unit)
if integer_shares:
step, minimum = lot_rule(ticker, lot_size)
affordable = max(0, math.floor(affordable / step)) * step
if affordable < minimum:
continue
else:
affordable = max(0.0, affordable * (1 - 1e-12))
if affordable <= 1e-10:
continue
delta = affordable
cost = abs(delta * price)
commission = _per_trade_commission(
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
cash -= (cost + commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta
else:
# SELL
commission += cost * sell_tax
if commission > cash + cost:
continue
cash += (cost - commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
# Remove zero holdings
if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 1e-10):
del holdings[ticker]
state["trade_log"].append({
"date": trade_date,
"action": "BUY" if delta > 0 else "SELL",
"ticker": ticker,
"shares": abs(delta),
"price": round(price, 6),
"value": cost,
"commission": commission,
})
if cash < -1e-7:
raise ValueError("Execution would create an unfunded cash balance")
state["cash"] = max(cash, 0.0)
if integer_shares:
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
else:
state["holdings"] = {k: v for k, v in holdings.items() if abs(v) >= 1e-10}
def rebalance(state: dict, targets: dict, prices: dict, fresh: set, *,
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
min_trade_value: float = 50.0, resize_band: float = 0.10,
integer_shares: bool = False, lot_size: int = 1,
trade_date: str = "", can_buy: set | None = None,
can_sell: set | None = None, sell_tax: float = 0.0) -> list[dict]:
value = portfolio_value(state["holdings"], prices, state["cash"])
floor = max(min_trade_value, .001 * value) if min_trade_value else 0.0
frozen = (set(targets) | set(state["holdings"])) - fresh
orders = compute_trades(state["holdings"], state["cash"], targets, prices,
floor, integer_shares, lot_size, frozen, resize_band)
orders = [o for o in orders
if ((o["shares_delta"] > 0 and (can_buy is None or o["ticker"] in can_buy))
or (o["shares_delta"] < 0 and (can_sell is None or o["ticker"] in can_sell)))]
first = len(state["trade_log"])
execute_trades(state, orders, prices, transaction_cost, fixed_fee, fee_base,
fee_per_share, trade_date, integer_shares, lot_size, sell_tax)
return state["trade_log"][first:]
def simulate_book(targets: pd.DataFrame, prices: pd.DataFrame, capital: float,
*, fresh_prices: pd.DataFrame | None = None,
contributions: pd.Series | None = None,
cash_returns: pd.Series | None = None,
can_buy: pd.DataFrame | None = None, can_sell: pd.DataFrame | None = None,
sell_tax: pd.Series | None = None, market: str | None = None,
previous_prices: pd.DataFrame | None = None,
volume: pd.DataFrame | None = None, **options):
"""Targets are executed at their row's close; stale marks never authorize fills."""
raw = prices if fresh_prices is None else fresh_prices.reindex_like(prices)
marks = prices.ffill()
previous = marks.shift(1) if previous_prices is None else previous_prices.reindex_like(prices)
targets = targets.reindex_like(prices).fillna(0.0)
flows = (contributions.reindex(prices.index).fillna(0.0) if contributions is not None
else pd.Series(0.0, index=prices.index))
rates = (cash_returns.reindex(prices.index).fillna(0.0) if cash_returns is not None
else pd.Series(0.0, index=prices.index))
state = {"holdings": {}, "cash": float(capital), "trade_log": []}
equity, units, values, cash = [], [], [], []
unit_count = float(capital)
for i, day in enumerate(prices.index):
quote = marks.iloc[i].dropna().to_dict()
state["cash"] *= 1 + float(rates.iloc[i])
before = portfolio_value(state["holdings"], quote, state["cash"])
flow = float(flows.iloc[i])
if flow < 0:
raise ValueError("Only nonnegative contributions are supported")
unit_count += flow / (before / unit_count) if before > 0 else 0.0
state["cash"] += flow
fresh = set(raw.columns[(raw.iloc[i].notna() & (raw.iloc[i] > 0)).to_numpy()])
allowed_buy, allowed_sell, tax = market_permissions(market, day, raw.iloc[i], previous.iloc[i],
volume.loc[day] if volume is not None else None)
row = targets.iloc[i]
rebalance(state, row[row != 0].to_dict(), quote, fresh,
can_buy=set(can_buy.columns[can_buy.loc[day]]) if can_buy is not None else allowed_buy,
can_sell=set(can_sell.columns[can_sell.loc[day]]) if can_sell is not None else allowed_sell,
sell_tax=float(sell_tax.loc[day]) if sell_tax is not None else tax,
trade_date=str(day.date()), **options)
value = portfolio_value(state["holdings"], quote, state["cash"])
equity.append(value)
units.append(value / unit_count * capital)
cash.append(state["cash"])
values.append(before)
return {"equity": pd.Series(equity, index=prices.index),
"unit_equity": pd.Series(units, index=prices.index),
"cash": pd.Series(cash, index=prices.index),
"pretrade_equity": pd.Series(values, index=prices.index),
"trade_log": state["trade_log"], "holdings": state["holdings"]}

253
main.py
View File

@@ -10,6 +10,8 @@ import metrics
import universe_history as uh
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
from strategies.base import BarStrategy
from signals import session_targets
from execution import simulate_book
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.inverse_vol import InverseVolatilityStrategy
@@ -27,96 +29,40 @@ from universe import UNIVERSES
# ---------------------------------------------------------------------------
def strategy_positions(strategy, data: pd.DataFrame, open_data: pd.DataFrame | None = None,
bars: dict[str, pd.DataFrame] | None = None) -> pd.DataFrame:
"""Weights actually held over (close t-1, close t] for each row t.
Close mode (``open_data`` is None): row t's weights use closes through t-1
and trade at the close of t-1 — the same bar the signal saw.
Live mode (``open_data`` given), matching trader.py:
* close-price strategies read the session's open as their latest price
and trade at that session's close;
* a BarStrategy reads completed bars through t-1 and trades at the close
of t.
"""
if isinstance(strategy, BarStrategy):
weights = strategy.generate_signals(bars if bars is not None else {"close": data})
weights = weights.reindex(data.index).fillna(0.0)
return weights.shift(1).fillna(0.0) if open_data is not None else weights
if open_data is not None:
# The strategy's shift(1) on opens gives weights[t] = f(open_{t-1});
# undo it so the row-t decision uses open_t, then hold it from close t.
weights = strategy.generate_signals(open_data).shift(-1).fillna(0.0)
return weights.reindex(data.index).fillna(0.0).shift(1).fillna(0.0)
return strategy.generate_signals(data).reindex(data.index).fillna(0.0)
bars: dict[str, pd.DataFrame] | None = None,
eligible: pd.DataFrame | None = None) -> pd.DataFrame:
"""Weights held over (close t-1, close t], after a feasible prior close fill."""
if eligible is None and bars is not None:
eligible = bars.get("eligible")
return session_targets(strategy, data, open_data, bars, eligible).shift(1).fillna(0.0)
def simulate_live_book(held: pd.DataFrame, data: pd.DataFrame, initial_capital: float,
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
min_trade_value: float = 0.0,
resize_band: float = 0.0) -> tuple[pd.Series, pd.DataFrame]:
"""Day-by-day book kept the way trader.py keeps it.
``held`` row t holds the target weights for (close t-1, close t]. Holdings
drift with prices; at each close the book trades toward the next row's
targets: entries and exits always, a held name only once it is at least
``resize_band`` (relative to its target) away. Orders smaller than
``max(min_trade_value, 0.1% of equity)`` are skipped unless they close a
position. Costs are charged on the orders placed.
Returns (equity, traded weight per ticker on the row whose holdings changed).
"""
targets = held.reindex(index=data.index, columns=data.columns).fillna(0.0).to_numpy()
prices = data.ffill().to_numpy(dtype=float)
rets = np.nan_to_num(data.ffill().pct_change(fill_method=None).to_numpy(dtype=float), nan=0.0)
per_share = np.array([0.0 if str(c).endswith(".HK") else fee_per_share for c in data.columns])
n, m = targets.shape
w = np.zeros(m)
value = float(initial_capital)
equity = np.empty(n)
traded = np.zeros((n, m))
for t in range(n):
if value <= 0:
equity[t:] = 0.0
break
gross = w * (1.0 + rets[t])
growth = gross.sum() + (1.0 - w.sum()) # idle cash earns nothing
value *= growth
w = gross / growth if growth > 0 else np.zeros(m)
if t + 1 < n:
tgt = targets[t + 1]
new = w.copy()
entry_exit = (tgt <= 0) | (w <= 0)
resize = ~entry_exit & (np.abs(tgt - w) >= resize_band * tgt)
change = (entry_exit | resize) & (np.abs(tgt - w) > 0)
new[change] = tgt[change]
if new.sum() > 1.0: # kept winners leave less room
buys = change & (new > w)
room = max(1.0 - new[~buys].sum(), 0.0)
new[buys] *= room / new[buys].sum() if new[buys].sum() > 0 else 0.0
delta = new - w
floor = (max(min_trade_value, 0.001 * value) / value) if min_trade_value > 0 else 1e-12
skip = (np.abs(delta) < floor) & (new > 0)
delta[skip] = 0.0
new[skip] = w[skip]
orders = np.abs(delta) > 1e-12
if orders.any():
dollars = np.abs(delta) * value
cost = dollars.sum() * transaction_cost
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
with np.errstate(divide="ignore", invalid="ignore"):
shares = np.where(prices[t] > 0, dollars / prices[t], 0.0)
fee = np.maximum(fixed_fee, fee_base + per_share * shares)
cost += fee[orders].sum()
if cost >= value: # fees ate the book
equity[t:] = 0.0
break
w = new * value / (value - cost)
value -= cost
traded[t + 1] = delta
equity[t] = value
return (pd.Series(equity, index=data.index),
pd.DataFrame(traded, index=data.index, columns=data.columns))
min_trade_value: float = 0.0, resize_band: float = 0.0,
*, fresh_prices: pd.DataFrame | None = None,
integer_shares: bool = False, lot_size: int = 1):
targets = held.reindex_like(data).shift(-1).fillna(0.0)
# No decision after the last row is represented by `held`; preserve the book.
result = simulate_book(targets.iloc[:-1], data.iloc[:-1], initial_capital,
fresh_prices=fresh_prices.iloc[:-1] if fresh_prices is not None else None,
transaction_cost=transaction_cost, fixed_fee=fixed_fee,
fee_base=fee_base, fee_per_share=fee_per_share,
min_trade_value=min_trade_value, resize_band=resize_band,
integer_shares=integer_shares, lot_size=lot_size)
traded = pd.DataFrame(0.0, index=data.index, columns=data.columns)
for trade in result["trade_log"]:
i = data.index.get_loc(pd.Timestamp(trade["date"]))
value = result["pretrade_equity"].iloc[i]
traded.iloc[i + 1, data.columns.get_loc(trade["ticker"])] += (
trade["value"] / value * (1 if trade["action"] == "BUY" else -1)) if value else 0.0
marks = data.ffill().iloc[-1].dropna().to_dict()
from execution import portfolio_value
last = portfolio_value(result["holdings"], marks,
float(result["cash"].iloc[-1]) if len(result["cash"]) else initial_capital)
equity = pd.concat([result["equity"], pd.Series([last], index=data.index[-1:])])
return equity, traded
def backtest(
@@ -133,7 +79,7 @@ def backtest(
resize_band: float = 0.0,
) -> pd.Series:
"""
Vectorized backtest.
Cash-funded backtest with next-session execution and drifting holdings.
Parameters
----------
@@ -173,62 +119,8 @@ def backtest(
Daily equity curve.
"""
positions = strategy_positions(strategy, data, open_data=open_data, bars=bars)
if open_data is not None:
equity, _ = simulate_live_book(
positions, data, initial_capital, transaction_cost, fixed_fee, fee_base,
fee_per_share, min_trade_value, resize_band,
)
return equity
# A missing close carries the last price (zero return), as before.
daily_returns = data.ffill().pct_change(fill_method=None).fillna(0.0)
portfolio_returns = (daily_returns * positions).sum(axis=1)
# Turnover cost: sum of absolute weight changes each day
turnover = positions.diff().abs().sum(axis=1).fillna(0.0)
portfolio_returns -= turnover * transaction_cost
# Per-trade fee. Supports both flat ($2/trade) and tiered (IBKR-style)
# schedules: fee = max(fixed_fee, fee_base + fee_per_share * shares).
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
weight_changes = positions.diff().fillna(0.0)
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
eq_prev = equity_running.shift(1).fillna(initial_capital)
if min_trade_value > 0:
threshold = np.maximum(min_trade_value, 0.001 * eq_prev.abs())
charged = weight_changes.abs().mul(eq_prev.abs(), axis=0).ge(threshold, axis=0)
else:
charged = weight_changes.abs() > 1e-8
if fee_per_share > 0:
# Convert per-ticker weight change into share count traded.
# dollar_traded[i, t] = |w[i,t] - w[i,t-1]| * equity[t-1]
# shares_traded[i, t] = dollar_traded / price[i, t]
dollar_traded = weight_changes.abs().mul(eq_prev, axis=0)
shares_traded = dollar_traded.div(data).replace(
[np.inf, -np.inf], 0.0,
).fillna(0.0)
# The per-share schedule applies to US-listed shares only.
per_share = pd.Series(fee_per_share, index=data.columns)
per_share[[c for c in data.columns if str(c).endswith(".HK")]] = 0.0
per_trade_fee = (fee_base + shares_traded.mul(per_share, axis=1)).clip(
lower=fixed_fee,
)
per_trade_fee = per_trade_fee.where(charged, 0.0)
daily_fee = per_trade_fee.sum(axis=1)
else:
n_trades = charged.sum(axis=1)
daily_fee = n_trades * fixed_fee
fee_impact = daily_fee / eq_prev
portfolio_returns -= fee_impact
equity = (1 + portfolio_returns).cumprod() * initial_capital
# A portfolio that loses everything stays at zero; compounding negative
# equity would otherwise produce meaningless values and metrics.
ruined = (equity <= 0).cummax()
if ruined.any():
equity = equity.where(~ruined, 0.0)
equity, _ = simulate_live_book(positions, data, initial_capital, transaction_cost,
fixed_fee, fee_base, fee_per_share, min_trade_value, resize_band)
return equity
@@ -309,52 +201,34 @@ def main() -> None:
"--attribution-export", default=None,
help="Directory to export factor attribution CSVs",
)
parser.add_argument("--offline", action="store_true", help="Use local prices and membership without refreshing")
args = parser.parse_args()
initial_capital = args.capital if args.capital is not None else 10_000
use_open = args.execution == "open-close"
universe = UNIVERSES[args.market]
tickers = universe["fetch"]()
tickers = [] if args.offline else universe["fetch"]()
benchmark = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
# PIT universe: include all historical index members for US market
pit_intervals = None
if args.market == "us":
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark]))
print(f"--- PIT universe: {len(all_tickers)} tickers (current + historical members) ---")
else:
all_tickers = sorted(set(tickers + [benchmark]))
result = data_manager.update(args.market, all_tickers, with_open=use_open)
if use_open:
data, open_data = result
else:
data = result
open_data = None
if args.years:
cutoff = data.index[-1] - pd.DateOffset(years=args.years)
data = data[data.index >= cutoff]
if open_data is not None:
open_data = open_data[open_data.index >= cutoff]
print(f"--- Sliced to last {args.years} years: {data.index[0].date()} to {data.index[-1].date()} ---")
# Apply PIT mask: NaN out prices for non-member dates
if pit_intervals is not None:
print("--- Applying PIT membership mask (survivorship-bias fix) ---")
data = uh.mask_prices(data, pit_intervals)
if open_data is not None:
open_data = uh.mask_prices(open_data, pit_intervals)
# Filter tickers to only those with any valid data
if pit_intervals is not None:
tickers = [t for t in data.columns if t != benchmark and data[t].notna().any()]
else:
tickers = [t for t in tickers if t in data.columns]
print(f"--- Universe: {len(tickers)} stocks + {benchmark} benchmark ---")
membership = uh.pit_membership(args.market, pd.DatetimeIndex([pd.Timestamp.today()]), refresh=not args.offline)
all_tickers = sorted(set(tickers) | set(membership.columns) | {benchmark})
fields = ["close", "open", "volume"]
panels = (data_manager.load_fields(args.market, fields) if args.offline else
data_manager.update(args.market, all_tickers, fields=fields))
data = data_manager.load_raw(args.market).reindex_like(panels["close"])
sessions = data.index[data[benchmark].notna()]
data = data.loc[sessions]
open_data = panels["open"].reindex_like(data) if use_open else None
member = uh.pit_membership(args.market, data.index).reindex(columns=data.columns, fill_value=False)
member[benchmark] = True
tickers = sorted(t for t in member.columns if t != benchmark and member[t].any())
if len(data) <= 315:
raise ValueError("Need 315 pre-window trading sessions")
requested = data.index[-1] - pd.DateOffset(years=args.years) if args.years else data.index[0]
start = max(requested, data.index[315])
days = data.index[data.index >= start]
print(f"--- Scoring {days[0].date()} to {days[-1].date()}; prior history is retained ---")
top_n = args.top_n if args.top_n else max(5, len(tickers) // 10)
print(f"--- Selective strategies will pick top {top_n} stocks ---")
@@ -369,7 +243,7 @@ def main() -> None:
strategies = {
# --- Original strategies ---
"Buy & Hold (EW)": (BuyAndHoldStrategy(), data[tickers], open_tickers),
"Buy & Hold": (BuyAndHoldStrategy(asset=benchmark), data[[benchmark]], open_data[[benchmark]] if open_data is not None else None),
"Momentum": (MomentumStrategy(lookback=252, skip=21, top_n=top_n), data[tickers], open_tickers),
"Inverse Volatility": (InverseVolatilityStrategy(vol_window=20), data[tickers], open_tickers),
"Multi-Factor": (MultiFactorStrategy(tickers=tickers, benchmark=benchmark, top_n=top_n), data, open_all),
@@ -386,12 +260,19 @@ def main() -> None:
results: dict[str, pd.Series] = {}
for name, (strategy, strat_data, strat_open) in strategies.items():
print(f"\nRunning: {name}")
results[name] = backtest(strategy, strat_data, initial_capital=initial_capital,
fixed_fee=args.fixed_fee, open_data=strat_open)
targets = session_targets(strategy, strat_data, strat_open,
eligible=member.reindex_like(strat_data))
run = simulate_book(targets.loc[days], strat_data.loc[days], initial_capital,
previous_prices=strat_data.ffill().shift(1).loc[days],
volume=panels["volume"].reindex(index=days, columns=strat_data.columns),
market=args.market, integer_shares=True,
lot_size=100 if args.market == "cn" else 1,
fixed_fee=args.fixed_fee, min_trade_value=50, resize_band=.10)
anchor = pd.Series([initial_capital], index=[days[0] - pd.Timedelta(days=1)])
results[name] = pd.concat([anchor, run["equity"]])
# Add benchmark (normalized to same initial capital)
bench = data[benchmark].dropna()
results[benchmark_label] = (bench / bench.iloc[0]) * initial_capital
bench = data[benchmark].reindex(days).ffill().dropna()
results[benchmark_label] = pd.concat([anchor, bench / bench.iloc[0] * initial_capital])
results_df = pd.DataFrame(results)

View File

@@ -27,7 +27,7 @@ from datetime import datetime
import pandas as pd
from backtest_runner import run_market
from backtest_runner import run_market, ENGINE_VERSION
from bridge import (
STRATEGY_META,
_build_strategy_result,
@@ -102,6 +102,7 @@ def build_scenario_for_market(
try:
result = _build_strategy_result(
run["name"], equity, run["trades"], injection_schedule, capital,
execution_plan=run.get("_plan"), fee_params=run.get("_fees"),
)
strategy_results.append(result)
except Exception as e:
@@ -124,13 +125,18 @@ def build_scenario_for_market(
return {
"meta": {
"market": raw["market"],
"years": years,
"years": raw.get("actual_years", years),
"requestedYears": years,
"initialCapital": capital,
"startDate": raw["start_date"],
"endDate": raw["end_date"],
"txCost": raw["tx_cost"],
"fixedFee": raw["fixed_fee"],
"injectionSchedule": injection_schedule,
"engineVersion": raw.get("engine_version"),
"warnings": raw.get("warnings", []),
"coverage": raw.get("coverage"),
"errors": raw.get("errors", []),
},
"strategies": strategy_results,
"benchmark": {
@@ -150,6 +156,7 @@ def main():
"--years", type=int, default=10,
help="Backtest period in years (default: 10)",
)
parser.add_argument("--offline", action="store_true", help="Use the frozen local data snapshot")
args = parser.parse_args()
output_dir = args.output or os.path.join(
@@ -182,7 +189,7 @@ def main():
"us", us_strategies,
years=args.years, capital=us_capital,
tx_cost=0.001, fixed_fee=2.0,
fee_base=1.88, fee_per_share=0.009,
fee_base=1.88, fee_per_share=0.009, refresh=not args.offline,
)
log("=" * 60)
@@ -191,7 +198,7 @@ def main():
cn_raw = run_market(
"cn", cn_strategies,
years=args.years, capital=cn_capital,
tx_cost=0.001, fixed_fee=5.0,
tx_cost=0.001, fixed_fee=5.0, refresh=not args.offline,
)
# Materialize every scenario × market by applying its injection schedule.
@@ -218,13 +225,14 @@ def main():
output = {
"computedAt": datetime.now().isoformat(),
"config": {"years": args.years, "initialCapital": MARKET_CAPITAL},
"config": {"years": args.years, "initialCapital": MARKET_CAPITAL, "engineVersion": ENGINE_VERSION},
"scenarios": scenarios_out,
}
output_path = os.path.join(output_dir, "backtest_results.json")
with open(output_path, "w") as f:
with open(output_path + ".tmp", "w") as f:
json.dump(_sanitize_for_json(output), f, default=str)
os.replace(output_path + ".tmp", output_path)
file_size = os.path.getsize(output_path) / (1024 * 1024)
log("=" * 60)

View File

@@ -20,6 +20,7 @@ from research.lab.engine import Costs, simulate
from research.lab.factor_scan import RESULTS, load
from research.lab.report import deflated_sharpe, stats, yearly
from research.lab.stepwise import SUBPERIODS
from research.lab.trials import count_trials
warnings.filterwarnings("ignore")
@@ -81,7 +82,15 @@ def main() -> None:
ap.add_argument("--market", default="us")
ap.add_argument("--add", nargs="*", default=[], help="extra 'name=combo|top|every|buffer'")
ap.add_argument("--n-trials", type=int, default=0, help="override trial count for DSR")
ap.add_argument("--registered", nargs="+", help="Validate registered strategies using the production ledger")
ap.add_argument("--start")
ap.add_argument("--end")
ap.add_argument("--output")
args = ap.parse_args()
if args.registered:
from research.lab.registered import evaluate_registered
evaluate_registered(args)
return
mk = "us" if args.market == "us" else "cn"
p, _ = load(args.market)
cands = dict(CANDIDATES[mk])
@@ -89,8 +98,9 @@ def main() -> None:
name, rest = spec.split("=", 1)
combo, top, every, buf = rest.split("|")
cands[name] = (combo, dict(top_n=int(top), every=int(every), buffer=float(buf)))
trials_path = os.path.join(RESULTS, f"trials_{args.market}.csv")
n_trials = args.n_trials or (len(pd.read_csv(trials_path)) if os.path.exists(trials_path) else 100)
n_trials = args.n_trials or count_trials(RESULTS, args.market)
if n_trials < 1:
raise ValueError("DSR needs a trial ledger or an explicit --n-trials count")
full = (SUBPERIODS[mk][0][0], WINDOW[1])
out = {}
fee, cap = LIVE_FEES[mk]
@@ -115,7 +125,8 @@ def main() -> None:
expo = (0.5 + 0.25 * slow + 0.25 * fast).where(slow + fast < 2, 1.0)
res["window_trend_overlay"] = summarize(phase_runs(p, s, *WINDOW, exposure=expo, **kw), *WINDOW)
dsr = deflated_sharpe(res["full"]["_rets"], n_trials)
res["dsr_full"] = dsr
res["dsr_full"] = {**dsr, "observed_trials_lower_bound": n_trials,
"evidence_role": "development, not independent OOS"}
out[name] = res
w, f = res["window"], res["full"]
print(f"{name:42s} 2016-26: CAGR {w['cagr']*100:5.1f}% [{w['cagr_phase_min']*100:4.1f},{w['cagr_phase_max']*100:4.1f}] "

111
research/lab/registered.py Normal file
View File

@@ -0,0 +1,111 @@
"""Fixed-budget validation of registered strategies on the shared execution engine."""
from __future__ import annotations
import copy
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
import metrics
import data_manager
from backtest_runner import (ENGINE_VERSION, load_stock_panels, prepare_strategy, replay_prepared)
from trader import make_strategy
from strategies.robust_momentum import RobustMomentumStrategy
from research.lab.trials import append_trials
def _stats(equity):
return {k: v for k, v in metrics.raw_summary(equity).items()
if k in ('annualizedReturn', 'maxDrawdown', 'sharpeRatio', 'annualizedVolatility')}
def evaluate_registered(args):
market = 'cn' if args.market.startswith('cn') else 'us'
destination = Path(args.output or f'data/research/results/registered_{market}')
destination.mkdir(parents=True, exist_ok=True)
panels = load_stock_panels(market, args.registered, None, refresh=False)
end = args.end or str(panels['panels']['close'].index[-1].date())
start = args.start or '2018-01-01'
profiles = ['baseline', 'resize20', 'buffer3'] + (['cash_shy'] if market == 'us' else [])
protocol = {
'created_at': datetime.now(timezone.utc).isoformat(), 'engine': ENGINE_VERSION,
'market': market, 'strategies': args.registered, 'start': start, 'end': end,
'profiles': profiles, 'one_way_costs': [.001, .0025], 'capital': 10000 if market == 'us' else 70000,
'selection_rule': 'CAGR improves at both costs; drawdown deterioration <= 2pp; improves in >=2/3 time blocks',
'role': 'historical development comparison; not independent OOS; do not auto-promote',
'source_hashes': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
for p in [Path('execution.py'), Path('signals.py'), Path('backtest_runner.py')]
+ sorted(Path('strategies').glob('*.py'))},
'data_hashes': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
for p in [Path('data') / f'{market}.csv', Path('data') / f'{market}_open.csv',
Path('data/sp500_history.json' if market == 'us' else 'data/csi300_history.json')]},
}
protocol_path = destination / 'protocol.json'
if protocol_path.exists():
raise FileExistsError(f'Choose a new --output to preserve the completed study: {destination}')
protocol_path.write_text(json.dumps(protocol, indent=2))
rows, verdicts = [], []
fees = {'tx_cost': .001, 'fixed_fee': 2 if market == 'us' else 5,
'fee_base': 1.88 if market == 'us' else 0, 'fee_per_share': .009 if market == 'us' else 0}
for name in args.registered:
base_strategy = make_strategy(name, market)
base_plan = prepare_strategy(name, market, panels['panels'], panels['stocks'], member=panels['member'],
start=start, end=end, strategy=base_strategy)
if base_plan['warmup_rows'] < 315:
raise ValueError('Registered comparison requires >=315 pre-window sessions; move --start or add history')
dates = base_plan['prices'].index
cuts = [0, len(dates)//3, 2*len(dates)//3, len(dates)]
for profile in profiles:
plan, band = base_plan, .1
if profile == 'resize20':
band = .2
elif profile == 'buffer3':
if not isinstance(base_strategy, RobustMomentumStrategy):
continue
changed = copy.deepcopy(base_strategy); changed.buffer = 3.0
plan = prepare_strategy(name, market, panels['panels'], panels['stocks'], member=panels['member'],
start=start, end=end, strategy=changed)
elif profile == 'cash_shy':
if 'SHY' not in panels['panels']['close']:
raise ValueError('SHY bars are required for a traded cash sleeve')
plan = dict(base_plan)
plan['prices'] = base_plan['prices'].copy()
plan['prices']['SHY'] = panels['panels']['close']['SHY'].reindex(dates)
if base_plan.get('volume') is not None:
plan['volume'] = base_plan['volume'].copy()
plan['volume']['SHY'] = panels['panels']['volume']['SHY'].reindex(dates)
target = base_plan['targets'].reindex(columns=plan['prices'].columns, fill_value=0).copy()
target['SHY'] += (1-target.sum(axis=1)).clip(lower=0)
plan['targets'] = target
for cost in protocol['one_way_costs']:
run = replay_prepared(plan, protocol['capital'], **{**fees, 'tx_cost': cost}, resize_band=band)
row = {'strategy': name, 'profile': profile, 'cost': cost, **_stats(run['equity']),
'orders': len(run['trade_log']), 'fees': sum(t['commission'] for t in run['trade_log']),
'minimum_cash': float(run['cash'].min()), 'blocks': []}
for a, b in zip(cuts[:-1], cuts[1:]):
row['blocks'].append(_stats(run['equity'].iloc[a:b+1]))
rows.append(row)
append_trials(destination/'trials.jsonl', [row], protocol)
print(name, profile, cost, f"CAGR {row['annualizedReturn']:.2%}",
f"DD {row['maxDrawdown']:.2%}", flush=True)
baseline = {r['cost']: r for r in rows if r['strategy'] == name and r['profile'] == 'baseline'}
for profile in profiles[1:]:
candidates = [r for r in rows if r['strategy'] == name and r['profile'] == profile]
if not candidates:
continue
passed = all(r['annualizedReturn'] > baseline[r['cost']]['annualizedReturn']
and r['maxDrawdown'] >= baseline[r['cost']]['maxDrawdown']-.02
and sum(a['annualizedReturn'] > b['annualizedReturn']
for a,b in zip(r['blocks'],baseline[r['cost']]['blocks'])) >= 2
for r in candidates)
verdicts.append({'strategy': name, 'profile': profile,
'verdict': 'candidate_for_forward_only' if passed else 'retain_baseline'})
summary = {'protocol': protocol, 'comparisons': rows, 'decisions': verdicts,
'boundary': 'Missing historical constituents and adjusted-share execution remain limitations.'}
(destination/'results.json').write_text(json.dumps(summary, indent=2, allow_nan=False))
pd.DataFrame([{k:v for k,v in r.items() if k!='blocks'} for r in rows]).to_csv(destination/'results.csv',index=False)
print('Saved',destination/'results.json',flush=True)
return summary

View File

@@ -1,11 +1,12 @@
"""
Forward stepwise selection of equal-weight rank composites, scored on the
DESIGN period only; the holdout is evaluated once for the final picks.
DESIGN period only; historical validation is reported separately. Previously inspected history
is not a fresh out-of-sample test.
PYTHONPATH=. uv run python -m research.lab.stepwise --market us --max-k 4
Every evaluated composite is appended to the trial ledger
(data/research/results/trials_{market}.csv) so deflated Sharpe can count them.
(data/research/results/trials_{market}.jsonl) so deflated Sharpe can count them.
"""
import argparse
@@ -18,6 +19,7 @@ import pandas as pd
from research.lab.factor_scan import RESULTS, load, phase_avg
from research.lab.factors import FACTORS, zrank
from research.lab.trials import append_trials
warnings.filterwarnings("ignore")
@@ -55,7 +57,8 @@ def main() -> None:
print(f"{len(names)} factor ranks in {time.time() - t:.0f}s", flush=True)
a, b = periods["design"]
ledger = []
subs = SUBPERIODS[mk]
subs = [(max(sa, a), min(sb, b)) for sa, sb in SUBPERIODS[mk]
if max(sa, a) <= min(sb, b)]
def evaluate(combo: tuple[str, ...]) -> dict:
s = sum(ranks[n] for n in combo) / len(combo)
@@ -93,23 +96,25 @@ def main() -> None:
best_overall += ranked[:args.beam]
if args.objective == "maximin":
_save(ledger, args.market)
_save(ledger, args.market, {"selection_start": a, "selection_end": b,
"role": "development", "config": vars(args)})
return
# holdout, once, for the beam winners of each k
print("=== holdout of design winners ===")
print("=== historical validation of design winners (previously inspected data) ===")
ha, hb = periods["holdout"]
for c, st in best_overall:
s = sum(ranks[n] for n in c) / len(c)
hs = phase_avg(p, s, ha, hb, args.top_n, args.every)
print(f" {'+'.join(c):55s} D sh {st['sharpe']:.2f} cagr {st['cagr']*100:5.1f}% | "
f"H sh {hs['sharpe']:.2f} cagr {hs['cagr']*100:5.1f}% mdd {hs['mdd']*100:5.1f}%", flush=True)
_save(ledger, args.market)
_save(ledger, args.market, {"selection_start": a, "selection_end": b,
"role": "development", "config": vars(args)})
def _save(ledger: list[dict], market: str) -> None:
def _save(ledger: list[dict], market: str, protocol: dict) -> None:
os.makedirs(RESULTS, exist_ok=True)
path = os.path.join(RESULTS, f"trials_{market}.csv")
pd.DataFrame(ledger).to_csv(path, mode="a", header=not os.path.exists(path), index=False)
path = os.path.join(RESULTS, f"trials_{market}.jsonl")
append_trials(path, ledger, protocol)
if __name__ == "__main__":

45
research/lab/trials.py Normal file
View File

@@ -0,0 +1,45 @@
"""Append-only trial records; legacy CSVs are read without discarding wide rows."""
import csv
import json
from datetime import datetime, timezone
from pathlib import Path
import fcntl
def append_trials(path, rows, protocol):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
created = datetime.now(timezone.utc).isoformat()
with path.open('a') as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
for row in rows:
record = {'schema': 1, 'created_at': created, 'protocol': protocol, 'result': row}
fh.write(json.dumps(record, allow_nan=False, default=float) + '\n')
def count_trials(directory, market):
"""Observed trials are a lower bound; unrecorded searches remain unknown."""
total = 0
paths = [Path(directory) / f'trials_{market}.csv', Path(directory) / f'trials_{market}.jsonl']
if market == 'us':
paths += [Path(directory) / 'trials_us_sweep.csv', Path(directory) / 'trials_us_sweep.jsonl']
for path in paths:
if not path.exists():
continue
with path.open() as fh:
if path.suffix == '.jsonl':
for line in fh:
if line.strip():
record = json.loads(line)
if record.get('schema') != 1:
raise ValueError(f'Unknown trial schema: {path}')
total += 1
else:
rows = csv.reader(fh)
next(rows, None)
for row in rows:
if row:
if len(row) < 2:
raise ValueError(f'Incomplete trial row: {path}')
total += 1
return total

View File

@@ -130,16 +130,13 @@ def main() -> None:
print(line(f" {c} 2006-", describe(rr[c].dropna())))
# deflated Sharpe: every configuration this and the previous round tried
n = 0
for f in ("trials_us.csv", "trials_us_sweep.csv"):
path = os.path.join(RESULTS, f)
if os.path.exists(path):
with open(path) as fh:
n += sum(1 for line in fh if not line.startswith(("combo,", "score,")))
n += 12 + 60 + 20 # rotation schemes, fund_eval variants, defensive variants
from research.lab.trials import count_trials
n = count_trials(RESULTS, "us")
if n < 1:
raise ValueError("A trial ledger is required for DSR")
r = monthly_blend(R.loc[SPAN[0]:SPAN[1]].fillna(0.0), three)
dsr = deflated_sharpe(r, n)
out["dsr"] = {"n_trials": n, **{k: float(v) for k, v in dsr.items()}}
out["dsr"] = {"observed_trials_lower_bound": n, "role": "development", **{k: float(v) for k, v in dsr.items()}}
print(f"\nDSR of the 3-sleeve book: {dsr['dsr']:.3f} (Sharpe {dsr['sr_annual']:.2f} vs "
f"expected max of {n} unskilled trials {dsr['sr0_annual']:.2f})")
print("\nyearly returns (3 sleeves vs SPY):")

View File

@@ -5,7 +5,7 @@ rules (PIT S&P 500 members, next-open fills, 10 bp costs, phase averaging).
PYTHONPATH=. uv run --with numba python -m research.lab.us_sweep --part construction
PYTHONPATH=. uv run --with numba python -m research.lab.us_sweep --part overlays
Every configuration is appended to data/research/results/trials_us_sweep.csv
Every configuration is appended to data/research/results/trials_us_sweep.jsonl
so the deflated Sharpe of the final pick can count them.
"""
@@ -100,11 +100,10 @@ def fmt(name, st) -> str:
def log_trials(rows, part):
os.makedirs(RESULTS, exist_ok=True)
path = os.path.join(RESULTS, "trials_us_sweep.csv")
df = pd.DataFrame([{k: v for k, v in r.items() if not k.startswith("_") and k != "blocks"} for r in rows])
df["part"] = part
df.to_csv(path, mode="a", header=not os.path.exists(path), index=False)
from research.lab.trials import append_trials
records = [{k: v for k, v in r.items() if not k.startswith("_") and k != "blocks"} for r in rows]
append_trials(os.path.join(RESULTS, "trials_us_sweep.jsonl"), records,
{"part": part, "role": "development; historical data already examined"})
def construction(p, S) -> list[dict]:

44
signals.py Normal file
View File

@@ -0,0 +1,44 @@
"""One timing and point-in-time eligibility contract for every execution path."""
import numpy as np
import pandas as pd
from strategies.base import BarStrategy
def session_targets(strategy, close: pd.DataFrame, open_data: pd.DataFrame | None = None,
bars: dict[str, pd.DataFrame] | None = None,
eligible: pd.DataFrame | None = None) -> pd.DataFrame:
if close.empty:
return pd.DataFrame(0.0, index=close.index, columns=close.columns)
member = (eligible.reindex_like(close).fillna(False).astype(bool) if eligible is not None
else pd.DataFrame(True, index=close.index, columns=close.columns))
if isinstance(strategy, BarStrategy):
inputs = dict(bars if bars is not None else {"close": close})
inputs["eligible"] = member & inputs["close"].notna()
weights = strategy.generate_signals(inputs)
known = member.shift(1, fill_value=False)
else:
# Price history remains observable before index admission. Membership
# restricts cross-sectional selection and the eventual execution targets.
inputs = close if open_data is None else open_data.reindex_like(close)
native = getattr(strategy, "accepts_eligibility", False)
kwargs = {"eligible": member} if native else {}
inputs = inputs if native else inputs.where(member)
if open_data is not None:
nxt = inputs.index[-1] + pd.Timedelta(days=1)
padded = pd.concat([inputs, inputs.iloc[[-1]].set_axis([nxt], axis=0)])
if native:
kwargs["eligible"] = pd.concat([member, member.iloc[[-1]].set_axis([nxt], axis=0)])
weights = strategy.generate_signals(padded, **kwargs).shift(-1).reindex(close.index)
known = member
else:
weights = strategy.generate_signals(inputs, **kwargs)
known = member.shift(1, fill_value=False)
weights = weights.reindex(index=close.index, columns=close.columns).fillna(0.0)
weights = weights.where(known & member, 0.0)
arr = weights.to_numpy()
if (not np.isfinite(arr).all() or (arr < -1e-10).any()
or (weights.sum(axis=1) > 1 + 1e-8).any()):
raise ValueError("Strategy produced non-finite, short or unfunded targets")
return weights.clip(lower=0.0)

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class AdaptiveMomentumStrategy(Strategy):
class AdaptiveMomentumStrategy(StockStrategy):
"""
Momentum with dynamic position sizing via inverse-volatility weighting.
@@ -25,18 +25,19 @@ class AdaptiveMomentumStrategy(Strategy):
self.vol_window = vol_window
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Momentum ranking
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip)
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip, fill_method=None)
momentum = mask_scores(momentum, eligible)
n_valid = momentum.notna().sum(axis=1)
enough = n_valid >= self.top_n
score_rank = momentum.rank(axis=1, ascending=False, na_option="bottom")
score_rank = mask_scores(momentum, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
# Inverse-vol weighting among selected stocks
returns = data.pct_change()
returns = data.pct_change(fill_method=None)
vol = returns.rolling(self.vol_window).std().replace(0, np.nan)
inv_vol = (1.0 / vol).where(top_mask, 0.0)
@@ -44,7 +45,7 @@ class AdaptiveMomentumStrategy(Strategy):
signals = inv_vol.div(row_sums, axis=0).fillna(0.0)
# Regime scaling: compare current market vol to its 1-year median
market_vol = vol.mean(axis=1)
market_vol = mask_scores(vol, eligible).mean(axis=1)
vol_median = market_vol.rolling(252).median()
vol_scale = (vol_median / market_vol).clip(0.3, 1.5)
signals = signals.mul(vol_scale, axis=0)

View File

@@ -121,6 +121,7 @@ class AgentReviewStrategy(BarStrategy):
eligible = close.notna()
if "eligible" in bars:
eligible &= bars["eligible"].reindex_like(close).fillna(False).astype(bool)
panel["eligible"] = eligible
return self._score(panel).where(eligible)
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:

View File

@@ -20,6 +20,14 @@ class Strategy(ABC):
pass
def mask_scores(scores: pd.DataFrame, eligible: pd.DataFrame | None) -> pd.DataFrame:
return scores if eligible is None else scores.where(eligible.reindex_like(scores).fillna(False))
class StockStrategy(Strategy):
accepts_eligibility = True
class BarStrategy(Strategy):
"""Strategy on completed daily bars with several fields.

View File

@@ -1,15 +1,20 @@
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class BuyAndHoldStrategy(Strategy):
"""
A simple buy and hold strategy.
"""
def generate_signals(self, data):
"""
Generates equal weights for all assets.
"""
tickers = data.columns
weights = pd.DataFrame(1 / len(tickers), index=data.index, columns=tickers)
class BuyAndHoldStrategy(StockStrategy):
def __init__(self, asset: str | None = None):
self.asset = asset
def generate_signals(self, data, eligible=None):
if self.asset is not None:
if self.asset not in data:
raise ValueError(f"Missing buy-and-hold benchmark: {self.asset}")
weights = pd.DataFrame(0.0, index=data.index, columns=data.columns)
weights[self.asset] = data[self.asset].notna().astype(float)
else:
# A fixed initial basket; new listings never enter retrospectively.
first = data.iloc[0]
values = data.ffill().div(first).where(first.notna(), 0.0).fillna(0.0)
weights = values.div(values.sum(axis=1).replace(0, float('nan')), axis=0).fillna(0.0)
return weights.shift(1).fillna(0.0)

View File

@@ -15,10 +15,10 @@ With:
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class CompositeAlphaStrategy(Strategy):
class CompositeAlphaStrategy(StockStrategy):
"""
Multi-factor alpha composite with regime gating.
"""
@@ -49,7 +49,9 @@ class CompositeAlphaStrategy(Strategy):
self.regime_gate = regime_gate
self.regime_ma = regime_ma
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
if self.regime_gate and self.benchmark not in data.columns:
raise ValueError(f"Missing regime benchmark: {self.benchmark}")
# Separate benchmark from stocks if needed
if self.tickers is not None:
stock_data = data[[t for t in self.tickers if t in data.columns]]
@@ -63,17 +65,17 @@ class CompositeAlphaStrategy(Strategy):
).min() - 1
# --- Factor 2: Intermediate momentum (7-month, skip 1 month) ---
int_mom = stock_data.shift(self.skip).pct_change(self.intermediate_period - self.skip)
int_mom = stock_data.shift(self.skip).pct_change(self.intermediate_period - self.skip, fill_method=None)
# --- Factor 3: Quality composite ---
# Consistency: fraction of positive 21-day returns
monthly_ret = stock_data.pct_change(21)
monthly_ret = stock_data.pct_change(21, fill_method=None)
consistency = (monthly_ret > 0).astype(float).rolling(
self.quality_window, min_periods=self.quality_window // 2
).mean()
# Up-volume proxy: sum of positive daily returns over 20 days
daily_ret = stock_data.pct_change()
daily_ret = stock_data.pct_change(fill_method=None)
up_vol_proxy = daily_ret.where(daily_ret > 0, 0).rolling(20, min_periods=15).sum()
# --- Factor 4: Above MA200 (per-stock trend filter) ---
@@ -81,10 +83,10 @@ class CompositeAlphaStrategy(Strategy):
above_ma = (stock_data > ma200)
# --- Cross-sectional ranks ---
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
mom_rank = int_mom.rank(axis=1, pct=True, na_option="keep")
con_rank = consistency.rank(axis=1, pct=True, na_option="keep")
upv_rank = up_vol_proxy.rank(axis=1, pct=True, na_option="keep")
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(int_mom, eligible).rank(axis=1, pct=True, na_option="keep")
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
upv_rank = mask_scores(up_vol_proxy, eligible).rank(axis=1, pct=True, na_option="keep")
# Composite: recovery 30%, int_momentum 30%, consistency 20%, up_volume 20%
composite = (0.30 * rec_rank + 0.30 * mom_rank +
@@ -94,7 +96,8 @@ class CompositeAlphaStrategy(Strategy):
composite = composite.where(above_ma, np.nan)
# --- Select top_n ---
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
composite = mask_scores(composite, eligible)
n_valid = composite.notna().sum(axis=1)
enough = n_valid >= min(self.top_n, 5)
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class DualMomentumStrategy(Strategy):
class DualMomentumStrategy(StockStrategy):
"""
Dual momentum with multi-timeframe confirmation.
@@ -19,29 +19,31 @@ class DualMomentumStrategy(Strategy):
def __init__(self, top_n: int = 20):
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Three timeframe returns (each skipping most recent 5 days)
skip = 5
short_mom = data.shift(skip).pct_change(63 - skip) # ~3 month
med_mom = data.shift(skip).pct_change(126 - skip) # ~6 month
long_mom = data.shift(skip).pct_change(252 - skip) # ~12 month
short_mom = data.shift(skip).pct_change(63 - skip, fill_method=None) # ~3 month
med_mom = data.shift(skip).pct_change(126 - skip, fill_method=None) # ~6 month
long_mom = data.shift(skip).pct_change(252 - skip, fill_method=None) # ~12 month
# All three must be positive (absolute momentum across timeframes)
all_positive = (short_mom > 0) & (med_mom > 0) & (long_mom > 0)
# Composite score: average percentile rank across timeframes
short_rank = short_mom.rank(axis=1, pct=True, na_option="keep")
med_rank = med_mom.rank(axis=1, pct=True, na_option="keep")
long_rank = long_mom.rank(axis=1, pct=True, na_option="keep")
short_rank = mask_scores(short_mom, eligible).rank(axis=1, pct=True, na_option="keep")
med_rank = mask_scores(med_mom, eligible).rank(axis=1, pct=True, na_option="keep")
long_rank = mask_scores(long_mom, eligible).rank(axis=1, pct=True, na_option="keep")
composite = (short_rank + med_rank + long_rank) / 3
# Only consider stocks passing absolute filter
composite_filtered = composite.where(all_positive, np.nan)
composite_filtered = composite_filtered.where(data.notna())
composite_filtered = mask_scores(composite_filtered, eligible)
n_valid = composite_filtered.notna().sum(axis=1)
enough = n_valid >= 1
rank = composite_filtered.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(composite_filtered, eligible).rank(axis=1, ascending=False, na_option="bottom")
effective_n = n_valid.clip(upper=self.top_n)
top_mask = (rank <= effective_n.values.reshape(-1, 1)) & enough.values.reshape(-1, 1)
top_mask = top_mask & all_positive

View File

@@ -11,10 +11,10 @@ Improvements over base RecoveryMomentumStrategy:
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class EnhancedRecoveryMomentumStrategy(Strategy):
class EnhancedRecoveryMomentumStrategy(StockStrategy):
"""
Recovery + momentum with inverse-vol weighting and regime awareness.
"""
@@ -39,34 +39,35 @@ class EnhancedRecoveryMomentumStrategy(Strategy):
self.top_n = top_n
self.regime_scale = regime_scale
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Factor 1: Recovery — price / rolling min
recovery = data / data.rolling(self.recovery_window, min_periods=self.recovery_window).min() - 1
# Factor 2: 12-1 month momentum
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip)
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip, fill_method=None)
# Factor 3: Intermediate momentum (6m) as a filter gate
# Only consider stocks with positive intermediate trend
intermediate = data.shift(self.mom_skip).pct_change(self.intermediate_mom - self.mom_skip)
intermediate = data.shift(self.mom_skip).pct_change(self.intermediate_mom - self.mom_skip, fill_method=None)
trend_gate = intermediate > 0
# Cross-sectional percentile ranks
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
# Composite score (50/50), gated by intermediate trend
composite = 0.5 * rec_rank + 0.5 * mom_rank
composite = composite.where(trend_gate, np.nan)
# Select top_n
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
composite = mask_scores(composite, eligible)
n_valid = composite.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
# Inverse-vol weighting among selected stocks
returns = data.pct_change()
returns = data.pct_change(fill_method=None)
vol = returns.rolling(self.vol_window, min_periods=30).std().replace(0, np.nan)
inv_vol = (1.0 / vol).where(top_mask, 0.0)
@@ -75,7 +76,7 @@ class EnhancedRecoveryMomentumStrategy(Strategy):
# Regime scaling: reduce exposure when market vol is high
if self.regime_scale:
market_vol = vol.mean(axis=1)
market_vol = mask_scores(vol, eligible).mean(axis=1)
vol_median = market_vol.rolling(252, min_periods=126).median()
vol_ratio = (vol_median / market_vol).clip(0.5, 1.2)
signals = signals.mul(vol_ratio, axis=0)

View File

@@ -19,14 +19,14 @@ New approach:
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
def _rank(df):
return df.rank(axis=1, pct=True, na_option="keep")
def _rank(df, eligible=None):
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
class EnsembleAlphaStrategy(Strategy):
class EnsembleAlphaStrategy(StockStrategy):
"""
Ensemble of the two strongest signals with tail-risk protection.
"""
@@ -45,28 +45,28 @@ class EnsembleAlphaStrategy(Strategy):
self.tail_threshold = tail_threshold
self.tail_scale = tail_scale
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
p = data
# === Signal A: rec_mfilt + deep_upvol (from FactorCombo) ===
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
mom_filter = p.shift(21).pct_change(105)
mom_filter = p.shift(21).pct_change(105, fill_method=None)
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
rec_mfilt_r = _rank(rec_mfilt, eligible)
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
deep_upvol_r = _rank(deep_upvol, eligible)
signal_a = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# === Signal B: Recovery 63d + 12-1 momentum (from RecoveryMom) ===
rec_63 = p / p.rolling(63, min_periods=63).min() - 1
mom_12_1 = p.shift(21).pct_change(231)
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
rec_63_r = _rank(rec_63)
mom_r = _rank(mom_12_1)
rec_63_r = _rank(rec_63, eligible)
mom_r = _rank(mom_12_1, eligible)
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
@@ -74,7 +74,9 @@ class EnsembleAlphaStrategy(Strategy):
ensemble = 0.5 * signal_a + 0.5 * signal_b
# === Select top_n ===
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
ensemble = ensemble.where(data.notna())
rank = mask_scores(ensemble, eligible).rank(axis=1, ascending=False, na_option="bottom")
ensemble = mask_scores(ensemble, eligible)
n_valid = ensemble.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
@@ -99,7 +101,7 @@ class EnsembleAlphaStrategy(Strategy):
# otherwise daily flips force half-out/half-in trades that burn
# the account through fixed per-trade fees.
if self.tail_protection:
mkt_ret = ret.mean(axis=1, skipna=True)
mkt_ret = mask_scores(ret, eligible).mean(axis=1, skipna=True)
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < self.tail_threshold
@@ -111,7 +113,7 @@ class EnsembleAlphaStrategy(Strategy):
return signals.shift(1).fillna(0.0)
class EnhancedFactorComboStrategy(Strategy):
class EnhancedFactorComboStrategy(StockStrategy):
"""
FactorCombo signal enhanced with:
1. Additional momentum confirmation (12-1 momentum rank as tiebreaker)
@@ -131,31 +133,33 @@ class EnhancedFactorComboStrategy(Strategy):
self.mom_boost = mom_boost
self.tail_protection = tail_protection
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
p = data
# Core signal: rec_mfilt + deep_upvol
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
mom_filter = p.shift(21).pct_change(105)
mom_filter = p.shift(21).pct_change(105, fill_method=None)
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
rec_mfilt_r = _rank(rec_mfilt, eligible)
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
deep_upvol_r = _rank(deep_upvol, eligible)
base_signal = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# Momentum boost: 12-1 month return rank
mom_12_1 = p.shift(21).pct_change(231)
mom_r = _rank(mom_12_1)
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
mom_r = _rank(mom_12_1, eligible)
# Combined: base + momentum tiebreaker
signal = (1 - self.mom_boost) * base_signal + self.mom_boost * mom_r
# Select top_n
rank = signal.rank(axis=1, ascending=False, na_option="bottom")
signal = signal.where(data.notna())
rank = mask_scores(signal, eligible).rank(axis=1, ascending=False, na_option="bottom")
signal = mask_scores(signal, eligible)
n_valid = signal.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
@@ -177,7 +181,7 @@ class EnhancedFactorComboStrategy(Strategy):
# Tail protection (rebal-gated to avoid daily-flip turnover)
if self.tail_protection:
mkt_ret = ret.mean(axis=1, skipna=True)
mkt_ret = mask_scores(ret, eligible).mean(axis=1, skipna=True)
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < -0.15
@@ -189,7 +193,7 @@ class EnhancedFactorComboStrategy(Strategy):
return signals.shift(1).fillna(0.0)
class RiskManagedEnsembleStrategy(Strategy):
class RiskManagedEnsembleStrategy(StockStrategy):
"""
EnsembleAlpha with market-aware drawdown risk management.
@@ -230,15 +234,15 @@ class RiskManagedEnsembleStrategy(Strategy):
self.vol_spike_lookback = vol_spike_lookback
self.vol_spike_floor = vol_spike_floor
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Step 1: Get raw signals from the ensemble (already shifted by 1)
raw = self.ensemble.generate_signals(data)
raw = self.ensemble.generate_signals(data, eligible=eligible)
# Step 2: Compute MARKET returns over valid (non-masked) columns.
# `daily_rets` keeps NaN for PIT-masked tickers so they don't dilute
# the cross-sectional mean to ~0.
daily_rets = data.pct_change()
mkt_rets = daily_rets.mean(axis=1, skipna=True)
daily_rets = data.pct_change(fill_method=None)
mkt_rets = mask_scores(daily_rets, eligible).mean(axis=1, skipna=True)
# Step 3: Market drawdown dampener
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
@@ -272,7 +276,7 @@ class RiskManagedEnsembleStrategy(Strategy):
return raw.mul(final_scale, axis=0)
class SharpeBoostedEnsembleStrategy(Strategy):
class SharpeBoostedEnsembleStrategy(StockStrategy):
"""
Optimized ensemble targeting Sharpe >1.5 while maintaining high CAGR.
@@ -310,33 +314,35 @@ class SharpeBoostedEnsembleStrategy(Strategy):
self.dd_floor = dd_floor
self.dd_denom = dd_denom
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
p = data
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
# === Signal A: rec_mfilt + deep_upvol ===
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
mom_filter = p.shift(21).pct_change(105)
mom_filter = p.shift(21).pct_change(105, fill_method=None)
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
rec_mfilt_r = _rank(rec_mfilt, eligible)
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
deep_upvol_r = _rank(deep_upvol, eligible)
signal_a = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# === Signal B: Recovery 63d + 12-1 momentum ===
rec_63 = p / p.rolling(63, min_periods=63).min() - 1
mom_12_1 = p.shift(21).pct_change(231)
rec_63_r = _rank(rec_63)
mom_r = _rank(mom_12_1)
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
rec_63_r = _rank(rec_63, eligible)
mom_r = _rank(mom_12_1, eligible)
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
# === Ensemble: equal-weight average of both signals ===
ensemble = 0.5 * signal_a + 0.5 * signal_b
# === Select top_n ===
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
ensemble = ensemble.where(data.notna())
rank = mask_scores(ensemble, eligible).rank(axis=1, ascending=False, na_option="bottom")
ensemble = mask_scores(ensemble, eligible)
n_valid = ensemble.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
@@ -361,7 +367,7 @@ class SharpeBoostedEnsembleStrategy(Strategy):
# burning the account through fixed per-trade fees ($2 US / $5 CN).
# Use cross-sectional mean of non-masked returns so PIT-masked
# NaN→0 fills don't dilute the market signal.
daily_rets = data.pct_change()
daily_rets = data.pct_change(fill_method=None)
port_rets = (signals * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
short_vol = port_rets.rolling(20, min_periods=10).std() * np.sqrt(252)
vol_median = short_vol.rolling(252, min_periods=126).median()
@@ -371,7 +377,7 @@ class SharpeBoostedEnsembleStrategy(Strategy):
asym_scale_raw[high_vol_neg] = self.asym_vol_floor
# === Market-DD dampener (rebal-gated, NaN-aware market mean) ===
mkt_rets = daily_rets.mean(axis=1, skipna=True)
mkt_rets = mask_scores(daily_rets, eligible).mean(axis=1, skipna=True)
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(

View File

@@ -18,7 +18,7 @@ Each can run at daily/weekly/biweekly/monthly rebalancing frequency.
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
# ---------------------------------------------------------------------------
@@ -26,11 +26,11 @@ from strategies.base import Strategy
# ---------------------------------------------------------------------------
def _mom_12_1(p):
return p.shift(21).pct_change(231)
return p.shift(21).pct_change(231, fill_method=None)
def _mom_intermediate(p):
return p.shift(21).pct_change(147)
return p.shift(21).pct_change(147, fill_method=None)
def _rec_63(p):
@@ -46,29 +46,29 @@ def _above_ma200(p):
def _up_volume_proxy(p):
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
return ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
def _gap_up_freq(p):
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
return (ret > 0.01).astype(float).rolling(60, min_periods=40).mean()
def _consistent_returns(p):
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
return (ret > 0).astype(float).rolling(252, min_periods=126).mean()
def _rec_mom_filtered(p):
rec = p / p.rolling(126, min_periods=126).min() - 1
mom = p.shift(21).pct_change(105)
mom = p.shift(21).pct_change(105, fill_method=None)
return rec.where(mom > 0, np.nan)
def _up_capture(p):
ret = p.pct_change()
mkt = ret.mean(axis=1)
def _up_capture(p, eligible=None):
ret = p.pct_change(fill_method=None)
mkt = mask_scores(ret, eligible).mean(axis=1)
up_mkt = mkt > 0
arr = ret.values.copy()
arr[~up_mkt.values, :] = np.nan
@@ -79,9 +79,9 @@ def _up_capture(p):
return stock_avg.div(mkt_avg, axis=0)
def _down_resilience(p):
ret = p.pct_change()
mkt = ret.mean(axis=1)
def _down_resilience(p, eligible=None):
ret = p.pct_change(fill_method=None)
mkt = mask_scores(ret, eligible).mean(axis=1)
down_mkt = mkt < 0
arr = ret.values.copy()
arr[~down_mkt.values, :] = np.nan
@@ -89,16 +89,16 @@ def _down_resilience(p):
return -down_ret.rolling(120, min_periods=30).mean()
def _quality_mom(p):
mom_r = _mom_12_1(p).rank(axis=1, pct=True, na_option="keep")
con_r = _consistent_returns(p).rank(axis=1, pct=True, na_option="keep")
up_r = _up_volume_proxy(p).rank(axis=1, pct=True, na_option="keep")
def _quality_mom(p, eligible=None):
mom_r = _rank(_mom_12_1(p), eligible)
con_r = _rank(_consistent_returns(p), eligible)
up_r = _rank(_up_volume_proxy(p), eligible)
return 0.4 * mom_r + 0.3 * con_r + 0.3 * up_r
def _mom_x_gap(p):
mom_r = _mom_12_1(p).rank(axis=1, pct=True, na_option="keep")
gap_r = _gap_up_freq(p).rank(axis=1, pct=True, na_option="keep")
def _mom_x_gap(p, eligible=None):
mom_r = _rank(_mom_12_1(p), eligible)
gap_r = _rank(_gap_up_freq(p), eligible)
return mom_r * gap_r
@@ -106,47 +106,47 @@ def _mom_x_gap(p):
# Combo signal constructors (weighted rank sums)
# ---------------------------------------------------------------------------
def _rank(df):
return df.rank(axis=1, pct=True, na_option="keep")
def _rank(df, eligible=None):
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
# US combos
def signal_rec_mfilt_deep_upvol(p):
rec_mfilt_r = _rank(_rec_mom_filtered(p))
deep_upvol_r = _rank(_rec_126(p)) * _rank(_up_volume_proxy(p))
deep_upvol_rr = _rank(deep_upvol_r)
def signal_rec_mfilt_deep_upvol(p, eligible=None):
rec_mfilt_r = _rank(_rec_mom_filtered(p), eligible)
deep_upvol_r = _rank(_rec_126(p), eligible) * _rank(_up_volume_proxy(p), eligible)
deep_upvol_rr = _rank(deep_upvol_r, eligible)
return 0.5 * rec_mfilt_r + 0.5 * deep_upvol_rr
def signal_ma200_mom7m_rec126(p):
return (0.33 * _rank(_above_ma200(p))
+ 0.33 * _rank(_mom_intermediate(p))
+ 0.34 * _rank(_rec_126(p)))
def signal_ma200_mom7m_rec126(p, eligible=None):
return (0.33 * _rank(_above_ma200(p), eligible)
+ 0.33 * _rank(_mom_intermediate(p), eligible)
+ 0.34 * _rank(_rec_126(p), eligible))
def signal_rec_mfilt_ma200(p):
return 0.5 * _rank(_rec_mom_filtered(p)) + 0.5 * _rank(_above_ma200(p))
def signal_rec_mfilt_ma200(p, eligible=None):
return 0.5 * _rank(_rec_mom_filtered(p), eligible) + 0.5 * _rank(_above_ma200(p), eligible)
def signal_mom7m_rec126(p):
return 0.5 * _rank(_mom_intermediate(p)) + 0.5 * _rank(_rec_126(p))
def signal_mom7m_rec126(p, eligible=None):
return 0.5 * _rank(_mom_intermediate(p), eligible) + 0.5 * _rank(_rec_126(p), eligible)
# CN combos
def signal_up_cap_quality_mom(p):
return 0.5 * _rank(_up_capture(p)) + 0.5 * _rank(_quality_mom(p))
def signal_up_cap_quality_mom(p, eligible=None):
return 0.5 * _rank(_up_capture(p, eligible), eligible) + 0.5 * _rank(_quality_mom(p, eligible), eligible)
def signal_down_resil_qual_mom(p):
return 0.5 * _rank(_down_resilience(p)) + 0.5 * _rank(_quality_mom(p))
def signal_down_resil_qual_mom(p, eligible=None):
return 0.5 * _rank(_down_resilience(p, eligible), eligible) + 0.5 * _rank(_quality_mom(p, eligible), eligible)
def signal_rec63_mom_gap(p):
return 0.5 * _rank(_rec_63(p)) + 0.5 * _rank(_mom_x_gap(p))
def signal_rec63_mom_gap(p, eligible=None):
return 0.5 * _rank(_rec_63(p), eligible) + 0.5 * _rank(_mom_x_gap(p, eligible), eligible)
def signal_up_cap_mom_gap(p):
return 0.5 * _rank(_up_capture(p)) + 0.5 * _rank(_mom_x_gap(p))
def signal_up_cap_mom_gap(p, eligible=None):
return 0.5 * _rank(_up_capture(p, eligible), eligible) + 0.5 * _rank(_mom_x_gap(p, eligible), eligible)
# ---------------------------------------------------------------------------
@@ -171,7 +171,7 @@ SIGNAL_REGISTRY = {
# Strategy class
# ---------------------------------------------------------------------------
class FactorComboStrategy(Strategy):
class FactorComboStrategy(StockStrategy):
"""
Generic factor-combination strategy with configurable rebalancing frequency.
@@ -192,11 +192,12 @@ class FactorComboStrategy(Strategy):
self.rebal_freq = rebal_freq
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
sig = self.signal_func(data)
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
sig = self.signal_func(data, eligible=eligible).where(data.notna())
# Select top_n by signal rank
rank = sig.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(sig, eligible).rank(axis=1, ascending=False, na_option="bottom")
sig = mask_scores(sig, eligible)
n_valid = sig.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)

View File

@@ -14,14 +14,14 @@ Also tests:
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
def _rank(df):
return df.rank(axis=1, pct=True, na_option="keep")
def _rank(df, eligible=None):
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
class HybridAlphaStrategy(Strategy):
class HybridAlphaStrategy(StockStrategy):
"""
Combines FactorCombo's best signal with risk-parity weighting.
"""
@@ -40,28 +40,29 @@ class HybridAlphaStrategy(Strategy):
self.use_invvol = use_invvol
self.regime_dampen = regime_dampen
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
p = data
# --- Signal: rec_mfilt + deep x upvol (from FactorCombo) ---
# Recovery momentum-filtered
rec = p / p.rolling(126, min_periods=126).min() - 1
mom = p.shift(21).pct_change(105)
mom = p.shift(21).pct_change(105, fill_method=None)
rec_mfilt = rec.where(mom > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
rec_mfilt_r = _rank(rec_mfilt, eligible)
# Deep recovery x up-volume
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
deep_upvol_r = _rank(deep_upvol, eligible)
# Combined signal
signal = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# --- Select top_n ---
rank = signal.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(signal, eligible).rank(axis=1, ascending=False, na_option="bottom")
signal = mask_scores(signal, eligible)
n_valid = signal.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
@@ -81,7 +82,7 @@ class HybridAlphaStrategy(Strategy):
# --- Light regime dampening ---
if self.regime_dampen < 1.0:
# Use market-wide vol regime instead of MA200 (more responsive)
market_vol = ret.mean(axis=1).rolling(20).std() * np.sqrt(252)
market_vol = mask_scores(ret, eligible).mean(axis=1).rolling(20).std() * np.sqrt(252)
vol_90th = market_vol.rolling(252, min_periods=126).quantile(0.90)
high_vol = market_vol > vol_90th
regime_scale = pd.Series(1.0, index=data.index)
@@ -101,7 +102,7 @@ class HybridAlphaStrategy(Strategy):
return signals.shift(1).fillna(0.0)
class RecoveryQualityBlendStrategy(Strategy):
class RecoveryQualityBlendStrategy(StockStrategy):
"""
Blends recovery, momentum, and quality without strict MA200 filter.
Uses intermediate momentum as a soft signal (not hard gate).
@@ -125,34 +126,34 @@ class RecoveryQualityBlendStrategy(Strategy):
self.rebal_freq = rebal_freq
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
p = data
# Recovery
recovery = p / p.rolling(self.recovery_window, min_periods=self.recovery_window).min() - 1
# 12-1 month momentum
momentum = p.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip)
momentum = p.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip, fill_method=None)
# Intermediate momentum (7m)
int_mom = p.shift(self.mom_skip).pct_change(147 - self.mom_skip)
int_mom = p.shift(self.mom_skip).pct_change(147 - self.mom_skip, fill_method=None)
# Quality: consistency
monthly_ret = p.pct_change(21)
monthly_ret = p.pct_change(21, fill_method=None)
consistency = (monthly_ret > 0).astype(float).rolling(
self.quality_window, min_periods=self.quality_window // 2
).mean()
# Up-volume proxy
ret = p.pct_change()
ret = p.pct_change(fill_method=None)
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
# Cross-sectional ranks
rec_rank = _rank(recovery)
mom_rank = _rank(momentum)
int_mom_rank = _rank(int_mom)
con_rank = _rank(consistency)
upv_rank = _rank(up_vol)
rec_rank = _rank(recovery, eligible)
mom_rank = _rank(momentum, eligible)
int_mom_rank = _rank(int_mom, eligible)
con_rank = _rank(consistency, eligible)
upv_rank = _rank(up_vol, eligible)
# Composite: weighted blend of all factors
# Recovery 25%, momentum 25%, intermediate momentum 20%, quality 15%, up_vol 15%
@@ -160,7 +161,8 @@ class RecoveryQualityBlendStrategy(Strategy):
0.15 * con_rank + 0.15 * upv_rank)
# Select top_n
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
composite = mask_scores(composite, eligible)
n_valid = composite.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)

View File

@@ -11,10 +11,11 @@ Improvements over base MomentumQualityStrategy:
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
from strategies.momentum_quality import rolling_max_drawdown
class ImprovedMomentumQualityStrategy(Strategy):
class ImprovedMomentumQualityStrategy(StockStrategy):
"""
Momentum + quality + recovery with monthly rebal and inv-vol weighting.
"""
@@ -37,45 +38,44 @@ class ImprovedMomentumQualityStrategy(Strategy):
self.rebal_freq = rebal_freq
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# --- Momentum factor ---
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip)
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip, fill_method=None)
# --- Quality: return consistency (vectorized) ---
# Fraction of positive 21-day returns over rolling window
monthly_ret = data.pct_change(21)
monthly_ret = data.pct_change(21, fill_method=None)
positive_indicator = (monthly_ret > 0).astype(float)
consistency = positive_indicator.rolling(
self.quality_window, min_periods=self.quality_window // 2
).mean()
# --- Quality: inverse max drawdown ---
rolling_max = data.rolling(self.quality_window, min_periods=self.quality_window // 2).max()
drawdown = data / rolling_max - 1
worst_dd = drawdown.rolling(self.quality_window, min_periods=self.quality_window // 2).min()
inv_dd = -worst_dd # higher = smaller drawdown = better
quality_dd = rolling_max_drawdown(data, self.quality_window)
# --- Recovery factor ---
recovery = data / data.rolling(self.recovery_window, min_periods=self.recovery_window).min() - 1
# --- Cross-sectional ranking ---
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
con_rank = consistency.rank(axis=1, pct=True, na_option="keep")
dd_rank = inv_dd.rank(axis=1, pct=True, na_option="keep")
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
dd_rank = mask_scores(quality_dd, eligible).rank(axis=1, pct=True, na_option="keep")
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
# Composite: momentum 35%, recovery 25%, consistency 20%, drawdown 20%
scores = (0.35 * mom_rank + 0.25 * rec_rank +
0.20 * con_rank + 0.20 * dd_rank)
# --- Select top_n ---
scores = scores.where(data.notna())
scores = mask_scores(scores, eligible)
n_valid = scores.notna().sum(axis=1)
enough = n_valid >= self.top_n
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
# --- Inverse-vol weighting ---
returns = data.pct_change()
returns = data.pct_change(fill_method=None)
vol = returns.rolling(self.vol_window, min_periods=30).std().replace(0, np.nan)
inv_vol = (1.0 / vol).where(top_mask, 0.0)
row_sums = inv_vol.sum(axis=1).replace(0, np.nan)

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class InverseVolatilityStrategy(Strategy):
class InverseVolatilityStrategy(StockStrategy):
"""
Risk parity via inverse-volatility weighting.
@@ -17,13 +17,13 @@ class InverseVolatilityStrategy(Strategy):
def __init__(self, vol_window: int = 20):
self.vol_window = vol_window
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
returns = data.pct_change()
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
returns = data.pct_change(fill_method=None)
vol = returns.rolling(self.vol_window).std()
# Replace zero vol with NaN to avoid division by zero
vol = vol.replace(0, np.nan)
inv_vol = 1.0 / vol
inv_vol = mask_scores((1.0 / vol).where(data.notna()), eligible)
row_sums = inv_vol.sum(axis=1).replace(0, np.nan)
signals = inv_vol.div(row_sums, axis=0).fillna(0.0)

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class MeanReversionStrategy(Strategy):
class MeanReversionStrategy(StockStrategy):
"""
Monthly "buy the dip" with momentum confirmation.
@@ -20,17 +20,18 @@ class MeanReversionStrategy(Strategy):
self.rebal_freq = rebal_freq
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
long_mom = data.pct_change(self.mom_lookback)
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
long_mom = data.pct_change(self.mom_lookback, fill_method=None)
has_momentum = long_mom > 0
short_ret = data.pct_change(self.dip_window)
short_ret = data.pct_change(self.dip_window, fill_method=None)
# Among positive-momentum stocks, rank by biggest dip
scores = short_ret.where(has_momentum, np.nan)
# Rank: most negative = rank 1 (biggest dip)
rank = scores.rank(axis=1, ascending=True, na_option="bottom")
rank = mask_scores(scores, eligible).rank(axis=1, ascending=True, na_option="bottom")
scores = mask_scores(scores, eligible)
n_valid = scores.notna().sum(axis=1)
effective_n = n_valid.clip(upper=self.top_n)

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class MomentumStrategy(Strategy):
class MomentumStrategy(StockStrategy):
"""
Classic cross-sectional momentum strategy (Jegadeesh & Titman, 1993).
@@ -19,14 +19,16 @@ class MomentumStrategy(Strategy):
self.skip = skip # ~1 month — skip to avoid reversal
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Momentum: return from T-(lookback) to T-skip
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip)
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip, fill_method=None)
momentum = momentum.where(data.notna())
momentum = mask_scores(momentum, eligible)
n_valid = momentum.notna().sum(axis=1)
enough_data = n_valid >= self.top_n
score_rank = momentum.rank(axis=1, ascending=False, na_option="bottom")
score_rank = mask_scores(momentum, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough_data.values.reshape(-1, 1)
raw = top_mask.astype(float)

View File

@@ -1,9 +1,14 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class MomentumQualityStrategy(Strategy):
def rolling_max_drawdown(prices: pd.DataFrame, window: int) -> pd.DataFrame:
return prices.rolling(window).apply(
lambda x: np.min(x / np.maximum.accumulate(x) - 1), raw=True)
class MomentumQualityStrategy(StockStrategy):
"""
Momentum + quality factor strategy using price-derived signals only.
@@ -23,44 +28,39 @@ class MomentumQualityStrategy(Strategy):
self.quality_window = quality_window
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# --- Momentum factor ---
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip)
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip, fill_method=None)
# --- Quality factor 1: return consistency ---
# Rolling 21-day (monthly) returns, then count fraction positive over past year
monthly_ret = data.pct_change(21)
monthly_ret = data.pct_change(21, fill_method=None)
consistency = monthly_ret.rolling(self.quality_window).apply(
lambda x: (x > 0).sum() / len(x), raw=True
)
# --- Quality factor 2: inverse max drawdown ---
# Smaller drawdown = higher quality
def rolling_max_dd(prices: pd.DataFrame, window: int) -> pd.DataFrame:
rolling_max = prices.rolling(window).max()
drawdown = prices / rolling_max - 1
# Rolling worst drawdown (most negative)
worst_dd = drawdown.rolling(window).min()
# Invert: less negative = better, so negate
return -worst_dd # higher = smaller drawdown = better
inv_dd = rolling_max_dd(data, self.quality_window)
quality_dd = rolling_max_drawdown(data, self.quality_window)
# --- Cross-sectional ranking ---
# na_option="keep" so NaN stocks stay NaN in the composite. With
# "bottom" + default ascending=True, NaN entries receive pct=1.0 and
# the additive composite ends up maximal for NaN rows, silently
# selecting pre-IPO / delisted names as "top".
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
con_rank = consistency.rank(axis=1, pct=True, na_option="keep")
dd_rank = inv_dd.rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
dd_rank = mask_scores(quality_dd, eligible).rank(axis=1, pct=True, na_option="keep")
# Composite: momentum 50%, consistency 25%, drawdown 25%
scores = 0.50 * mom_rank + 0.25 * con_rank + 0.25 * dd_rank
# --- Select top_n ---
scores = scores.where(data.notna())
scores = mask_scores(scores, eligible)
n_valid = scores.notna().sum(axis=1)
enough = n_valid >= self.top_n
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
raw = top_mask.astype(float)

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class MultiFactorStrategy(Strategy):
class MultiFactorStrategy(StockStrategy):
"""
Multi-factor strategy combining momentum and value signals with a market-timing filter.
@@ -33,7 +33,7 @@ class MultiFactorStrategy(Strategy):
self.value_period = value_period
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
stock = data[self.tickers]
# --- Market timing filter ---
@@ -42,7 +42,7 @@ class MultiFactorStrategy(Strategy):
# --- Momentum factor ---
# Return from T-(momentum_period+skip) to T-skip, avoiding the last month
momentum = stock.shift(self.momentum_skip).pct_change(self.momentum_period)
momentum = stock.shift(self.momentum_skip).pct_change(self.momentum_period, fill_method=None)
# --- Value factor ---
# min_price_over_period / current_price (higher = more "undervalued" vs recent range)
@@ -51,16 +51,17 @@ class MultiFactorStrategy(Strategy):
# --- Cross-sectional ranking (each row ranked across assets) ---
# na_option="keep" so NaN stocks (pre-IPO / delisted / masked) stay
# NaN in the composite score instead of being assigned pct=1.0.
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
val_rank = value.rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
val_rank = mask_scores(value, eligible).rank(axis=1, pct=True, na_option="keep")
scores = mom_rank + val_rank # combined score, higher = better
# --- Select top_n assets per row ---
# Only allocate rows that have enough valid scores
scores = mask_scores(scores, eligible)
n_valid = scores.notna().sum(axis=1)
enough_data = n_valid >= self.top_n
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough_data.values.reshape(-1, 1)
# Equal-weight allocation among selected assets

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class RecoveryMomentumStrategy(Strategy):
class RecoveryMomentumStrategy(StockStrategy):
"""
Recovery + momentum composite factor strategy.
@@ -29,26 +29,27 @@ class RecoveryMomentumStrategy(Strategy):
self.rebal_freq = rebal_freq
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Factor 1: Recovery — price / rolling min
recovery = data / data.rolling(self.recovery_window).min() - 1
# Factor 2: 12-1 month momentum
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip)
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip, fill_method=None)
# Cross-sectional percentile ranks. na_option="keep" is critical:
# with "bottom" + ascending=True default, NaN stocks get pct=1.0, so
# non-member / delisted / pre-IPO names leak into the composite as
# "top" candidates. "keep" propagates NaN and the final ascending=False
# + "bottom" rank pushes them to the end where they are not selected.
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
# Composite score (50/50)
composite = 0.5 * rec_rank + 0.5 * mom_rank
# Select top_n
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
composite = mask_scores(composite, eligible)
n_valid = composite.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)

View File

@@ -27,7 +27,7 @@ import numpy as np
import pandas as pd
from numpy.lib.stride_tricks import sliding_window_view
from strategies.base import BarStrategy
from strategies.base import BarStrategy, mask_scores
# ---------------------------------------------------------------------------
@@ -60,10 +60,10 @@ def sharpe_momentum(close: pd.DataFrame) -> pd.DataFrame:
return momentum_12_1(close) / _returns(close).rolling(252, min_periods=180).std()
def low_beta(close: pd.DataFrame, window: int = 252) -> pd.DataFrame:
def low_beta(close: pd.DataFrame, window: int = 252, eligible=None) -> pd.DataFrame:
"""Negative rolling beta to the equal-weight average of the panel."""
r = _returns(close)
m = r.mean(axis=1).fillna(0.0)
m = mask_scores(r, eligible).mean(axis=1).fillna(0.0)
minp = window * 180 // 252
e_xy = r.mul(m, axis=0).rolling(window, min_periods=minp).mean()
e_x = r.rolling(window, min_periods=minp).mean()
@@ -73,12 +73,12 @@ def low_beta(close: pd.DataFrame, window: int = 252) -> pd.DataFrame:
def idiosyncratic_vol(close: pd.DataFrame, beta_window: int = 252,
vol_window: int = 63) -> pd.DataFrame:
vol_window: int = 63, eligible=None) -> pd.DataFrame:
"""Negative 63-day volatility of returns net of the equal-weight market
times a trailing (lagged) 252-day beta; Ang, Hodrick, Xing & Zhang (2006)."""
r = _returns(close)
m = r.mean(axis=1).fillna(0.0)
beta = -low_beta(close, beta_window).shift(1)
m = mask_scores(r, eligible).mean(axis=1).fillna(0.0)
beta = -low_beta(close, beta_window, eligible).shift(1)
resid = r - beta.mul(m, axis=0)
return -resid.rolling(vol_window, min_periods=40).std()
@@ -111,28 +111,29 @@ def _rank(df: pd.DataFrame) -> pd.DataFrame:
return df.rank(axis=1, pct=True)
def _composite(*factors: pd.DataFrame) -> pd.DataFrame:
return sum(_rank(f) for f in factors) / len(factors)
def _composite(*factors: pd.DataFrame, eligible=None) -> pd.DataFrame:
return sum(_rank(mask_scores(f, eligible)) for f in factors) / len(factors)
def signal_overnight_recovery(bars):
return _composite(overnight_momentum(bars["open"], bars["close"]), recovery(bars["close"]))
return _composite(overnight_momentum(bars["open"], bars["close"]), recovery(bars["close"]), eligible=bars.get("eligible"))
def signal_lowrisk_momentum(bars):
c = bars["close"]
return _composite(amplitude_cut_momentum(bars["high"], bars["low"], c), low_beta(c),
price_volume_corr(c, bars["volume"]), sharpe_momentum(c))
return _composite(amplitude_cut_momentum(bars["high"], bars["low"], c), low_beta(c, eligible=bars.get("eligible")),
price_volume_corr(c, bars["volume"]), sharpe_momentum(c), eligible=bars.get("eligible"))
def signal_lowbeta_sharpe_mom(bars):
c = bars["close"]
return _composite(low_beta(c), sharpe_momentum(c))
return _composite(low_beta(c, eligible=bars.get("eligible")), sharpe_momentum(c), eligible=bars.get("eligible"))
def signal_low_risk(bars):
c = bars["close"]
return _composite(idiosyncratic_vol(c), low_beta(c))
return _composite(idiosyncratic_vol(c, eligible=bars.get("eligible")),
low_beta(c, eligible=bars.get("eligible")), eligible=bars.get("eligible"))
# name -> (bar fields, score function)
@@ -202,6 +203,8 @@ class RobustMomentumStrategy(BarStrategy):
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:
close = bars["close"]
panel = {f: bars[f].reindex(index=close.index, columns=close.columns) for f in self.fields}
if "eligible" in bars:
panel["eligible"] = bars["eligible"]
score = self._score(panel)
# names without a price are not eligible; a backtest also passes
# "eligible" (point-in-time index membership) so signals can use a

View File

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class TrendFollowingStrategy(Strategy):
class TrendFollowingStrategy(StockStrategy):
"""
Per-stock trend following with momentum ranking.
@@ -21,21 +21,22 @@ class TrendFollowingStrategy(Strategy):
self.momentum_period = momentum_period
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Per-stock trend filter: price > MA
ma = data.rolling(self.ma_window).mean()
in_uptrend = data > ma
# Momentum score among trending stocks
momentum = data.pct_change(self.momentum_period)
momentum = data.pct_change(self.momentum_period, fill_method=None)
# Mask out stocks not in uptrend
momentum_filtered = momentum.where(in_uptrend, np.nan)
# Rank and select top_n
momentum_filtered = mask_scores(momentum_filtered, eligible)
n_valid = momentum_filtered.notna().sum(axis=1)
enough = n_valid >= 1
rank = momentum_filtered.rank(axis=1, ascending=False, na_option="bottom")
rank = mask_scores(momentum_filtered, eligible).rank(axis=1, ascending=False, na_option="bottom")
effective_n = n_valid.clip(upper=self.top_n)
top_mask = (rank <= effective_n.values.reshape(-1, 1)) & enough.values.reshape(-1, 1)
# Ensure we only pick stocks that are actually in uptrend

View File

@@ -46,6 +46,8 @@ from strategies.factor_combo import SIGNAL_REGISTRY
class TrendRiderV6(TrendRiderV5):
"""Stock-sleeve TrendRider with V5 regime engine."""
accepts_eligibility = True
def __init__(
self,
*args,
@@ -98,10 +100,10 @@ class TrendRiderV6(TrendRiderV5):
self.leverage_overlay_symbol, *self.risk_off_basket})
return [c for c in prices.columns if c not in non_stock]
def _stock_top_n_weights(self, prices: pd.DataFrame, universe: list[str]) -> pd.DataFrame:
def _stock_top_n_weights(self, prices: pd.DataFrame, universe: list[str], eligible=None) -> pd.DataFrame:
"""Top-N selection by signal, inv-vol weighted within selection."""
stock_panel = prices[universe]
sig = self.signal_func(stock_panel)
sig = self.signal_func(stock_panel, eligible=eligible)
# Top-N by signal rank (highest score = top)
rank = sig.rank(axis=1, ascending=False, na_option="bottom")
n_valid = sig.notna().sum(axis=1)
@@ -154,7 +156,7 @@ class TrendRiderV6(TrendRiderV5):
return {best: 1.0}
# ---- Override generate_signals ----
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible=None) -> pd.DataFrame:
if self.signal not in data.columns:
raise ValueError(f"Required regime signal {self.signal!r} not in data.")
universe = self._resolve_universe(data)
@@ -163,7 +165,7 @@ class TrendRiderV6(TrendRiderV5):
# 1) Build sleeve weights — stock sleeve, anchor sleeve
# (defensive sleeve is single-pick, computed per-bar inside the loop)
stock_w = self._stock_top_n_weights(data, universe)
stock_w = self._stock_top_n_weights(data, universe, eligible)
anchor_w = pd.DataFrame(0.0, index=data.index, columns=[self.moderate_anchor])
if self.moderate_anchor in data.columns:
anchor_w[self.moderate_anchor] = 1.0

View File

@@ -18,21 +18,15 @@ Three sequential layers, each PIT-safe:
Restore when gain drops below 20% (hysteresis band = 10%).
Entry price = yesterday's close at symbol change (PIT-safe).
Why profit-take works on leveraged ETFs
---------------------------------------
3x ETFs (TQQQ, UPRO) suffer from volatility drag: daily rebalancing
erodes multi-day compound returns proportional to variance. After a
+30% gain, the position sits on a large base where subsequent
volatility causes disproportionate drag. Clearing the position:
1. Locks in geometric gains before vol drag erodes them.
2. Reduces the base on which the drag operates.
3. Forces rebalancing from an asset that has become "overweight."
This is not alpha from prediction — it is alpha from mechanical
rebalancing of a negatively-convex instrument, a structural effect
documented in the leveraged ETF literature.
Research boundary
-----------------
Profit-taking is an empirical timing rule. Daily leverage compounding alone
neither proves that the 30% threshold earns alpha nor makes it independent
of the development window. Compare against no profit-taking and a simple
trend rule using the shared cash ledger and out-of-period stress scenarios.
The following legacy figures used the earlier execution model and are not
comparable to cash-ledger-pit-v2 results:
Empirical 10y (2016-05-16 to 2026-05-13, 10bps one-way cost):
Ann 54.7%, Vol 24.2%, Sharpe(rf=5%) 1.72, MaxDD -25.7%,
Sortino 2.23, Calmar 2.13.

View File

@@ -0,0 +1,139 @@
import unittest
import json
import tempfile
from pathlib import Path
from unittest import mock
import numpy as np
import pandas as pd
import data_manager
import trader
from execution import simulate_book, rebalance, portfolio_value
from execution import compute_trades, market_permissions
from signals import session_targets
from strategies.momentum import MomentumStrategy
from strategies.momentum_quality import rolling_max_drawdown
from strategies.composite_alpha import CompositeAlphaStrategy
from strategies.robust_momentum import RobustMomentumStrategy
class ExecutionContracts(unittest.TestCase):
def test_a_halt_cannot_be_sold_at_the_carried_mark(self):
dates = pd.bdate_range("2026-01-05", periods=5)
raw = pd.DataFrame({"A": [100, np.nan, np.nan, 10, 10]}, index=dates)
targets = pd.DataFrame({"A": [1, 0, 0, 0, 0]}, index=dates)
result = simulate_book(targets, raw, 10000, transaction_cost=0, min_trade_value=0)
self.assertAlmostEqual(result["equity"].iloc[-1], 1000)
self.assertEqual([t["date"] for t in result["trade_log"]],
[str(dates[0].date()), str(dates[3].date())])
def test_fractional_and_integer_orders_are_funded_after_all_fees(self):
prices = {"A": 73., "B": 29.}
for integer in [False, True]:
state = {"holdings": {}, "cash": 1000., "trade_log": []}
rebalance(state, {"A": .5, "B": .5}, prices, set(prices),
fixed_fee=2, fee_base=1.88, fee_per_share=.009,
integer_shares=integer, min_trade_value=0)
self.assertGreaterEqual(state["cash"], 0)
equity = portfolio_value(state["holdings"], prices, state["cash"])
self.assertAlmostEqual(equity, 1000 - sum(t["commission"] for t in state["trade_log"]), places=2)
def test_batch_replay_matches_session_ledger_with_a_halt_and_drift(self):
dates = pd.bdate_range("2026-01-05", periods=5)
prices = pd.DataFrame({"A": [100, 110, np.nan, 70, 90], "B": [20, 21, 22, 22, 23]}, index=dates)
targets = pd.DataFrame([[.5, .5], [.5, .5], [0, 1], [0, 1], [.5, .5]], index=dates, columns=prices.columns)
options = dict(transaction_cost=.001, fixed_fee=2., integer_shares=True, resize_band=.1)
batch = simulate_book(targets, prices, 10000, **options)
state = trader.init_state("us", "test", 10000)
fees = {"tx_cost": .001, "fixed_fee": 2.}
for day in dates:
trader._run_session(state, "us", day, "B", prices.ffill(), prices, fees, True, targets.loc[day])
self.assertAlmostEqual(state["daily_equity"][str(day.date())], batch["equity"].loc[day], places=2)
self.assertEqual(state["trade_log"], batch["trade_log"])
def test_contributions_are_executed_and_fees_affect_unit_returns(self):
dates = pd.bdate_range("2026-01-05", periods=3)
prices = pd.DataFrame({"A": [100., 100., 100.]}, index=dates)
weights = pd.DataFrame(1., index=dates, columns=["A"])
base = simulate_book(weights, prices, 1000, transaction_cost=0, fixed_fee=2)
injected = simulate_book(weights, prices, 1000, transaction_cost=0, fixed_fee=2,
contributions=pd.Series([0, 1000, 0], index=dates))
self.assertEqual(len(base["trade_log"]), 1)
self.assertEqual(len(injected["trade_log"]), 2)
self.assertAlmostEqual(injected["equity"].iloc[-1], 1996)
self.assertLess(injected["unit_equity"].iloc[-1], base["unit_equity"].iloc[-1])
def test_missing_open_never_sees_same_day_close(self):
close = pd.DataFrame({"A": [100., 200.]})
raw_open = pd.DataFrame({"A": [np.nan, np.nan]})
self.assertTrue(data_manager._fill_open(close, raw_open).isna().all().all())
def test_china_limit_permissions_tax_and_star_minimum(self):
raw = pd.Series({"600000.SS": 11., "000001.SZ": 9., "688001.SS": 12.})
buy, sell, tax = market_permissions("cn", pd.Timestamp("2026-09-30"), raw, raw * 0 + 10)
self.assertNotIn("600000.SS", buy)
self.assertNotIn("000001.SZ", sell)
self.assertNotIn("688001.SS", buy)
self.assertEqual(tax, .0005)
self.assertEqual(market_permissions("cn", pd.Timestamp("2023-08-25"), raw, raw * 0 + 10)[2], .001)
orders = compute_trades({}, 10000, {"688001.SS": .7}, {"688001.SS": 23.},
integer_shares=True, lot_size=100)
self.assertEqual(orders[0]["shares_delta"], 304)
class SignalContracts(unittest.TestCase):
def test_live_refresh_updates_the_membership_snapshot_before_selection(self):
with tempfile.TemporaryDirectory() as tmp:
cache = Path(tmp) / "sp500_history.json"
cache.write_text(json.dumps({"date": "2000-01-01", "intervals": {"OLD": [["2020-01-01", None]]}}))
history = {"OLD": [["2020-01-01", "2026-09-30"]], "NEW": [["2026-09-30", None]]}
with mock.patch.object(trader.universe_history, "CACHE_DIR", tmp), \
mock.patch.object(trader.universe_history, "build_sp500_history", return_value=history), \
mock.patch.object(trader, "strategy_universe", return_value=(["NEW"], "SPY")), \
mock.patch.object(data_manager, "update"):
trader.refresh_panels("us", ["momentum"], "test")
member = trader.index_members("us", pd.DatetimeIndex(["2026-10-01"]))
self.assertTrue(member.iloc[0]["NEW"])
self.assertFalse(member.iloc[0]["OLD"])
def test_close_signal_is_unaffected_by_its_execution_days_close(self):
dates = pd.bdate_range("2024-01-01", periods=30)
c = pd.DataFrame({"A": np.arange(100., 130.), "B": np.arange(130., 100., -1)}, index=dates)
s = MomentumStrategy(lookback=5, skip=1, top_n=1)
base = session_targets(s, c)
shocked = c.copy(); shocked.iloc[20:] = shocked.iloc[20:] * [0.1, 10]
actual = session_targets(s, shocked)
pd.testing.assert_frame_equal(base.iloc[:21], actual.iloc[:21])
def test_trader_and_backtest_use_identical_membership_histories(self):
rng = np.random.default_rng(42)
dates = pd.bdate_range("2024-01-01", periods=370)
close = pd.DataFrame(100 * np.exp(rng.normal(.0005, .02, (370, 25)).cumsum(0)),
index=dates, columns=[f"S{i}" for i in range(25)])
opens = close.shift(1).fillna(100)
member = pd.DataFrame(True, index=dates, columns=close.columns)
member.iloc[:320, 0] = False
member.iloc[330:, 1] = False
bars = {"close": close, "open": opens}
strategy = RobustMomentumStrategy("overnight_recovery", top_n=5)
expected = session_targets(strategy, close, opens, bars, member)
with mock.patch.object(trader, "index_members", lambda m, d: member.reindex(d)):
actual = trader.session_signals("us", "overnight_recovery_top10", list(close), dates[-20:],
close, opens, bars=bars, strategy=strategy)
pd.testing.assert_frame_equal(actual, expected.reindex(index=actual.index, columns=actual.columns))
self.assertTrue((actual["S1"] == 0).all())
def test_quality_prefers_a_smaller_drawdown_and_needs_only_one_window(self):
p = pd.DataFrame({"small": [100, 110, 99, 108], "large": [100, 110, 55, 108]})
dd = rolling_max_drawdown(p, 4)
self.assertGreater(dd.iloc[-1]["small"], dd.iloc[-1]["large"])
self.assertAlmostEqual(dd.iloc[-1]["small"], -.1)
def test_missing_regime_benchmark_is_an_error(self):
with self.assertRaisesRegex(ValueError, "benchmark"):
CompositeAlphaStrategy().generate_signals(pd.DataFrame({"A": [1, 2]}))
if __name__ == "__main__":
unittest.main()

View File

@@ -77,6 +77,7 @@ class LiveBookTests(unittest.TestCase):
mock.patch.object(trader, "strategy_universe", lambda m, n: (["AAA", "BBB"], "SPY")), \
mock.patch.object(trader, "pit_candidates", lambda m, n, s: []), \
mock.patch.object(trader, "load_strategy_prices", lambda *a, **k: (close, close, close)), \
mock.patch.object(data_manager, "load_raw", lambda *a, **k: None), \
mock.patch.object(trader, "session_signals", lambda m, n, t, days, c, o: equal.loc[days]):
return trader._process_sessions(args, state, refresh=False)
@@ -151,20 +152,17 @@ class ReportingTests(unittest.TestCase):
equity = pd.Series(10_000 * 1.0003 ** np.arange(400), index=idx)
schedule = {"amount": 5_000, "frequency": "yearly_months", "months": [2, 8], "startYear": 1}
plain = bridge._build_strategy_result("x", equity, [], None, 10_000)
injected = bridge._build_strategy_result("x", equity, [], schedule, 10_000)
self.assertAlmostEqual(injected["metrics"]["totalReturn"], plain["metrics"]["totalReturn"])
flows = injected["capitalFlows"]
self.assertEqual(flows["invested"], 10_000 + 5_000 * len(injected["injections"]))
self.assertAlmostEqual(flows["profit"], flows["finalValue"] - flows["invested"], places=2)
with self.assertRaisesRegex(ValueError, "order replay"):
bridge._build_strategy_result("x", equity, [], schedule, 10_000)
def test_ruined_portfolio_stays_at_zero(self):
def test_unfunded_leverage_is_rejected(self):
class TwiceLevered:
def generate_signals(self, data):
return pd.DataFrame(2.0, index=data.index, columns=data.columns)
prices = pd.DataFrame({"X": [10.0, 10.0, 4.0, 5.0]}, index=pd.bdate_range("2026-01-01", periods=4))
equity = backtest(TwiceLevered(), prices, initial_capital=100)
self.assertTrue((equity.iloc[2:] == 0).all(), equity)
with self.assertRaisesRegex(ValueError, "unfunded"):
backtest(TwiceLevered(), prices, initial_capital=100)
if __name__ == "__main__":

View File

@@ -155,12 +155,11 @@ class BacktestUniverseTests(unittest.TestCase):
panel = pd.DataFrame(1.0, index=idx, columns=["AAA", "BBB", "SPY", "TQQQ"])
fake_universe = {"us": {"fetch": lambda: ["AAA"], "benchmark": "SPY",
"benchmark_label": "SPY"}}
members = pd.DataFrame(True, index=idx, columns=["AAA", "BBB"])
with mock.patch.object(backtest_runner, "UNIVERSES", fake_universe), \
mock.patch.object(backtest_runner.uh, "pit_tickers_ever", lambda m, since=None: ["AAA", "BBB"]), \
mock.patch.object(backtest_runner.uh, "mask_prices_pit", lambda p, m: p), \
mock.patch.object(backtest_runner.data_manager, "update",
lambda m, t, fields=None: {f: panel for f in fields}):
data = backtest_runner.load_stock_panels("us", ["momentum"], None)
mock.patch.object(backtest_runner.uh, "pit_membership", lambda m, days: members.reindex(days).fillna(False)), \
mock.patch.object(backtest_runner, "_panels", lambda *a: {"close": panel, "open": panel}):
data = backtest_runner.load_stock_panels("us", ["momentum"], None, refresh=False)
self.assertEqual(data["stocks"], ["AAA", "BBB"])
@@ -180,15 +179,13 @@ class MembershipTests(unittest.TestCase):
bars["open"]["S00"] = bars["close"]["S00"]
days = bars["close"].index[-5:]
others = [c for c in bars["close"].columns if c != "S00"]
members = pd.DataFrame(True, index=days[:-1], columns=bars["close"].columns)
members["S00"] = False # joins the index on the last day
with mock.patch.object(trader, "index_members", lambda m, d: members.loc[d]):
runs = trader.session_universes("us", "momentum", others + ["S00"], days)
members = pd.DataFrame(True, index=bars["close"].index, columns=bars["close"].columns)
members.loc[members.index < days[-1], "S00"] = False
with mock.patch.object(trader, "index_members", lambda m, d: members.reindex(d)):
weights = trader.session_signals("us", "momentum", others + ["S00"], days,
bars["close"], bars["open"])
self.assertEqual([len(r) for _, r in runs], [4, 1])
self.assertNotIn("S00", runs[0][0])
self.assertTrue((weights.loc[days[:-1], "S00"] == 0).all())
# The historical observations were public before index admission.
self.assertGreater(weights.loc[days[-1], "S00"], 0)
def test_changes_table_with_two_level_headers_is_found(self):

View File

@@ -3,6 +3,7 @@ kept to their own columns, shares that drift like separate accounts, and the
live trader's session timing."""
import unittest
from unittest import mock
import numpy as np
import pandas as pd
@@ -82,6 +83,17 @@ class SleevePortfolioTests(unittest.TestCase):
got = trader.bar_session_weights(strat, bars, days)
pd.testing.assert_frame_equal(got, full)
def test_trader_truncates_all_bar_fields_before_padding_sleeves(self):
bars = _bars()
days = bars["close"].index[-4:-2]
stocks = [c for c in bars["close"] if c not in ETF_UNIVERSE]
member = pd.DataFrame(True, index=bars["close"].index, columns=stocks)
expected = us_three_sleeve().generate_signals({f: b.loc[:days[-1]] for f, b in bars.items()}).loc[days]
with mock.patch.object(trader, "index_members", lambda m, d: member.reindex(d)):
actual = trader.session_signals("us", "us_three_sleeve", list(bars["close"]), days,
bars["close"], bars["open"])
pd.testing.assert_frame_equal(actual, expected.reindex(columns=actual.columns))
def test_registered_as_a_us_mixed_strategy(self):
self.assertIsInstance(trader.STRATEGY_REGISTRY["us_three_sleeve"](), SleevePortfolioStrategy)
self.assertEqual(set(trader.strategy_extra_tickers("us_three_sleeve")), set(ETF_UNIVERSE))

503
trader.py
View File

@@ -40,6 +40,9 @@ import pandas as pd
import yfinance as yf
import data_manager
from signals import session_targets
from execution import (ENGINE_VERSION, compute_trades, execute_trades, portfolio_value, rebalance, market_permissions,
_allocate_lots, _per_trade_commission)
import universe_history
from strategies.base import BarStrategy
from strategies.agent_review import AgentReviewStrategy
@@ -312,6 +315,54 @@ STRATEGY_REGISTRY = {
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
}
# Execution-only candidates retain their baseline signals and a separate record.
EXECUTION_VARIANTS = {
"overnight_recovery_top10_rb20": ("overnight_recovery_top10", "us"),
"us_three_sleeve_rb20": ("us_three_sleeve", "us"),
"us_momentum_qqq_trend_rb20": ("us_momentum_qqq_trend", "us"),
"lowbeta_sharpe_mom_top20_rb20": ("lowbeta_sharpe_mom_top20", "cn"),
}
def _resize_variant(factory):
def build(**kwargs):
strategy = factory(**kwargs)
strategy.execution_resize_band = .20
return strategy
return build
for _variant, (_base, _market) in EXECUTION_VARIANTS.items():
STRATEGY_REGISTRY[_variant] = _resize_variant(STRATEGY_REGISTRY[_base])
def canonical_strategy_name(name):
name = name.removeprefix("sim_")
return EXECUTION_VARIANTS.get(name, (name, None))[0]
def strategy_resize_band(name):
return .20 if name.removeprefix("sim_") in EXECUTION_VARIANTS else RESIZE_BAND
LIVE_BOOKS["validation_2026_10"] = {
"label": "修复后前瞻对照(2026-10-02 起)",
"start": {"us": "2026-10-02", "cn": "2026-10-02"},
"capital": {"us": 10_000.0, "cn": 70_000.0},
"fee_per_order": {"us": 2.0, "cn": 5.0},
"fee_schedule": {
"us": {"tx_cost": .001, "fixed_fee": 2., "fee_base": 1.88, "fee_per_share": .009},
"cn": {"tx_cost": .001, "fixed_fee": 5., "fee_base": 0., "fee_per_share": 0.},
},
"strategies": {
"us": ["overnight_recovery_top10", "overnight_recovery_top10_rb20",
"us_three_sleeve", "us_three_sleeve_rb20",
"us_momentum_qqq_trend", "us_momentum_qqq_trend_rb20"],
"cn": ["lowbeta_sharpe_mom_top20", "lowbeta_sharpe_mom_top20_rb20"],
},
}
ETF_STRATEGY_UNIVERSES = {
"trend_rider_v3_us": sorted(set(ETF_UNIVERSE)),
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
@@ -347,18 +398,17 @@ AGENT_REVIEW_STRATEGIES = {"overnight_recovery_agent_top10"}
# Registered strategies the live monitor does not run, with the reason.
LIVE_EXCLUDED_STRATEGIES = {
"buy_and_hold": "equal weight across ~500 names is not investable with whole "
"shares at live capital (every slice is below the minimum trade)",
"buy_and_hold": "benchmark is reported separately from strategy forward accounts",
}
DEFAULT_MONITOR_STRATEGIES = [
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES and name not in EXECUTION_VARIANTS
]
def strategy_extra_tickers(strategy_name: str) -> list[str]:
"""Fixed ETF tickers a mixed stock+ETF strategy needs beside its stock universe."""
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), []))
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(canonical_strategy_name(strategy_name), []))
def strategy_fields(strategy_name: str) -> tuple[str, ...]:
@@ -370,6 +420,8 @@ def strategy_fields(strategy_name: str) -> tuple[str, ...]:
def strategy_supports_market(strategy_name: str, market: str) -> bool:
"""ETF-based strategies trade USD-listed ETFs, so they only run in the US book."""
base_name = strategy_name.removeprefix("sim_")
if base_name in EXECUTION_VARIANTS:
return market == EXECUTION_VARIANTS[base_name][1]
if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS \
or base_name in AGENT_REVIEW_STRATEGIES:
return market == "us"
@@ -383,7 +435,7 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
trade fixed USD/HK ETF baskets and use SPY as the regime benchmark.
Mixed strategies (e.g. V6) get the stock universe + extra ETF tickers.
"""
base_name = strategy_name.removeprefix("sim_")
base_name = canonical_strategy_name(strategy_name)
if base_name in ETF_STRATEGY_UNIVERSES:
return ETF_STRATEGY_UNIVERSES[base_name], "SPY"
@@ -400,66 +452,69 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
def index_members(market: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
"""Point-in-time index membership (``days`` x every ticker that was a member
on one of them), or None for a market without membership history."""
if market == "us":
mask = universe_history.membership_mask(days, tickers=universe_history.all_tickers_ever())
elif market == "cn":
snaps = universe_history.load_csi300_history()
mask = universe_history.csi300_membership_mask(
days, universe_history.all_csi300_tickers_ever(snaps), snaps)
else:
if market not in ("us", "cn"):
return None
return mask.loc[:, mask.any()]
def session_universes(market: str, strategy_name: str, tickers: list[str],
days: pd.DatetimeIndex) -> list[tuple[list[str], pd.DatetimeIndex]]:
"""Split ``days`` into runs of sessions that share one stock universe.
The latest session trades ``tickers`` (today's list). Earlier sessions
trade the index members of their own day: a replay or catch-up over past
sessions with today's list would buy names before they joined the index,
i.e. names that were added because they had risen. ETF strategies keep
their fixed basket.
"""
if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES or len(days) < 2:
return [(list(tickers), days)]
members = index_members(market, days[:-1])
if members is None:
return [(list(tickers), days)]
extras = set(strategy_extra_tickers(strategy_name))
runs: list[tuple[list[str], list]] = []
for day in days:
if day == days[-1]:
universe = sorted(tickers)
else:
universe = sorted(set(members.columns[members.loc[day].to_numpy()]) | extras)
if runs and runs[-1][0] == universe:
runs[-1][1].append(day)
else:
runs.append((universe, [day]))
return [(u, pd.DatetimeIndex(d)) for u, d in runs]
return universe_history.pit_membership(market, days)
def pit_candidates(market: str, strategy_name: str, since: pd.Timestamp | None) -> list[str]:
"""Tickers that were index members on any day from ``since`` to today, so a
catch-up or replay has prices for names that have left the index since."""
days = pd.bdate_range(since, pd.Timestamp.today().normalize()) if since is not None else []
days = pd.bdate_range(since or "2000-01-01", pd.Timestamp.today().normalize())
if len(days) == 0 or strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES:
return []
members = index_members(market, days)
return [] if members is None else list(members.columns)
def make_strategy(name: str, market: str):
strategy = STRATEGY_REGISTRY[name.removeprefix("sim_")]()
if isinstance(strategy, CompositeAlphaStrategy):
strategy.benchmark = UNIVERSES[market]["benchmark"]
if isinstance(strategy, BuyAndHoldStrategy):
strategy.asset = UNIVERSES[market]["benchmark"]
return strategy
def strategy_signal_tickers(name: str, market: str) -> list[str]:
strategy = make_strategy(name, market)
if isinstance(strategy, CompositeAlphaStrategy):
return [strategy.benchmark]
return [strategy.asset] if isinstance(strategy, BuyAndHoldStrategy) else []
def session_signals(market: str, strategy_name: str, tickers: list[str], days: pd.DatetimeIndex,
close: pd.DataFrame, open_data: pd.DataFrame) -> pd.DataFrame:
"""Target weights for each day in ``days``, each computed on that day's
point-in-time universe (see ``session_universes``); columns are the union."""
parts = []
for universe, run in session_universes(market, strategy_name, tickers, days):
universe = filter_tradable_tickers(close, universe)
bars = strategy_bars(market, strategy_name, universe, close, open_data)
parts.append(_signals_for_sessions(strategy_name, open_data[universe], run, bars))
return pd.concat(parts).fillna(0.0)
close: pd.DataFrame, open_data: pd.DataFrame,
*, bars: dict | None = None, member: pd.DataFrame | None = None,
strategy=None) -> pd.DataFrame:
if len(days) == 0:
return pd.DataFrame(index=days, columns=tickers, dtype=float)
close = close.loc[:days[-1]]
benchmark = "SPY" if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
if benchmark in close and benchmark in open_data:
valid_calendar = close[benchmark].notna() | open_data[benchmark].reindex(close.index).notna()
close = close.loc[valid_calendar]
if days[-1] not in close.index:
close = close.reindex(close.index.union(days))
pure_etf = strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES
if member is None and not pure_etf:
member = index_members(market, close.index)
extras = strategy_extra_tickers(strategy_name) + strategy_signal_tickers(strategy_name, market)
cols = (list(member.columns) + extras if member is not None else list(tickers) + extras)
cols = sorted(set(filter_tradable_tickers(close, cols)))
eligible = (member.reindex(index=close.index, columns=cols).fillna(False)
if member is not None else pd.DataFrame(True, index=close.index, columns=cols))
for t in extras:
if t in eligible:
eligible[t] = True
strategy = strategy if strategy is not None else make_strategy(strategy_name, market)
if bars is None:
bars = strategy_bars(market, strategy_name, cols, close, open_data)
if bars is not None:
bars = {f: b.reindex(index=close.index, columns=cols) for f, b in bars.items()}
targets = session_targets(strategy, close[cols], open_data.reindex(index=close.index, columns=cols),
bars, eligible)
return targets.reindex(days).fillna(0.0)
def strategy_data_market(market: str, strategy_name: str) -> str:
@@ -483,7 +538,7 @@ def convert_quote_currency(panel: pd.DataFrame) -> pd.DataFrame:
if not hk:
return panel
if HKD_FX_TICKER in panel.columns:
fx = panel[HKD_FX_TICKER].ffill().bfill().fillna(HKD_PEG_MIDPOINT)
fx = panel[HKD_FX_TICKER].ffill().fillna(HKD_PEG_MIDPOINT)
else:
print(f" WARNING: {HKD_FX_TICKER} not in panel; converting HK prices at "
f"the peg midpoint {HKD_PEG_MIDPOINT}", file=sys.stderr)
@@ -501,6 +556,13 @@ def price_request(market: str, tickers: list[str]) -> list[str]:
return request
def refresh_membership(market: str) -> None:
if market == "us":
universe_history.load_sp500_history()
elif market == "cn":
universe_history.load_csi300_history()
def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
benchmark: str, with_open: bool = False, refresh: bool = True):
"""Load a strategy's prices in its portfolio currency, refreshing them first
@@ -511,9 +573,10 @@ def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
stale forward-filled price from a real one.
"""
data_market = strategy_data_market(market, strategy_name)
fields = ["close", "open"] if with_open else ["close"]
fields = ["close", "open", "volume"] if with_open else ["close", "volume"]
fields += [f for f in strategy_fields(strategy_name) if f not in fields]
if refresh:
refresh_membership(market)
request = price_request(market, list(tickers) + [benchmark])
panels = data_manager.update(data_market, request, fields=fields)
else:
@@ -609,10 +672,6 @@ def init_state(market: str, strategy_name: str, capital: float) -> dict:
# Core logic
# ---------------------------------------------------------------------------
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
stock_value = sum(holdings.get(t, 0) * prices.get(t, 0) for t in holdings)
return cash + stock_value
def strategy_bars(market: str, strategy_name: str, tickers: list[str],
close: pd.DataFrame, open_data: pd.DataFrame | None) -> dict | None:
@@ -643,192 +702,6 @@ def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeInde
return strategy.generate_signals(hist).reindex(days).fillna(0.0)
def _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
lot_size: int, reserve: float) -> dict:
"""Whole-lot share targets: round each move toward zero, then spend the
remaining cash one lot at a time on the names furthest below target,
as long as the lot brings them nearer (nearest-lot rounding)."""
final, spend = {}, 0.0
for t, want in desired.items():
cur = holdings.get(t, 0.0)
if want <= 0:
final[t] = 0.0 # full exit, odd lot included
else:
final[t] = cur + int((want - cur) / lot_size) * lot_size
spend += (final[t] - cur) * prices[t]
budget = cash - spend - reserve
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
for gap, t in reversed(short):
lot_value = lot_size * prices[t]
if gap > 0.5 * lot_value and lot_value <= budget:
final[t] += lot_size
budget -= lot_value
return final
def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0,
integer_shares: bool = False, lot_size: int = 1,
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
"""
Compute trades needed to move from current holdings to target weights.
If integer_shares=True, positions move in whole multiples of ``lot_size``
(e.g. 100-share A-share board lots): each move is rounded toward zero and
leftover cash then buys one more lot for the names furthest below target
when that lot brings them nearer; a position whose target is zero is
always sold in full, odd lot included.
A held position with a nonzero target is not resized while it is within
``resize_band`` (fraction of its target value) of the target.
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
Returns list of {ticker, shares_delta, direction, est_value}.
"""
total = portfolio_value(holdings, prices, cash)
frozen = frozen or set()
desired = {}
for ticker in sorted(set(target_weights) | set(holdings)):
if ticker in frozen:
continue
price = prices.get(ticker, 0.0)
if price <= 0:
continue
target_weight = target_weights.get(ticker, 0.0)
target_value = total * target_weight
current_shares = holdings.get(ticker, 0.0)
if (current_shares > 0 and target_weight > 0
and abs(target_value - current_shares * price) < resize_band * target_value):
continue
desired[ticker] = target_value / price
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
reserve=0.002 * total + 10.0 * len(desired))
if integer_shares else desired)
raw = []
for ticker, target_shares in targets.items():
price = prices[ticker]
current_shares = holdings.get(ticker, 0.0)
delta = target_shares - current_shares
trade_value = abs(delta * price)
if trade_value < min_trade_value:
continue
if integer_shares and abs(delta) < 1:
continue
raw.append({
"ticker": ticker,
"shares_delta": int(round(delta)) if integer_shares else round(delta, 4),
"direction": "BUY" if delta > 0 else "SELL",
"est_value": round(trade_value, 2),
"price": round(price, 6),
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
})
return raw
def _per_trade_commission(
shares: float,
price: float,
tx_cost: float,
fixed_fee: float,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
) -> float:
"""Commission for one trade.
Matches the IBKR-style tiered formula used by the backtest engine:
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
With fee_base=0 and fee_per_share=0 this degenerates to the flat
fixed-fee model (legacy behavior).
"""
bps_cost = abs(shares) * price * tx_cost
per_trade = fee_base + fee_per_share * abs(shares)
floor = max(fixed_fee, per_trade)
return bps_cost + floor
def execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
trade_date: str = "", integer_shares: bool = False,
lot_size: int = 1) -> None:
"""Execute trades: update holdings and cash in state, append to trade_log.
When integer_shares=True, sells are executed first to free up cash,
then buys are executed only if sufficient cash is available.
Per-trade commission supports both the legacy flat ``fixed_fee`` and
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
schedule used by the backtest engine.
"""
holdings = state["holdings"]
cash = state["cash"]
# When using integer shares, execute sells first to free cash for buys
sells = [t for t in trades if t["shares_delta"] < 0]
buys = [t for t in trades if t["shares_delta"] > 0]
ordered = sells + buys if integer_shares else trades
us_fee_per_share = fee_per_share
for trade in ordered:
ticker = trade["ticker"]
delta = trade["shares_delta"]
price = prices.get(ticker, trade["price"])
cost = abs(delta * price)
# The per-share schedule applies to US-listed shares only; HK lines
# (often < $1/share) pay the proportional cost and the ticket floor.
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
commission = _per_trade_commission(
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
if delta > 0:
# BUY — skip if insufficient cash in integer mode
if integer_shares and (cost + commission) > cash:
# Try buying fewer shares that we can afford, accounting for
# the per-share variable component of the commission.
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
if affordable_price_unit <= 0:
continue
affordable = int((cash - max(fixed_fee, fee_base)) / affordable_price_unit)
affordable = affordable // lot_size * lot_size
if affordable < 1:
continue
delta = affordable
cost = abs(delta * price)
commission = _per_trade_commission(
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
cash -= (cost + commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta
else:
# SELL
cash += (cost - commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
# Remove zero holdings
if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 0.001):
del holdings[ticker]
state["trade_log"].append({
"date": trade_date,
"action": "BUY" if delta > 0 else "SELL",
"ticker": ticker,
"shares": abs(delta) if integer_shares else round(abs(delta), 4),
"price": round(price, 6),
"value": round(cost, 2),
"commission": round(commission, 2),
})
state["cash"] = round(cash, 2)
if integer_shares:
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
else:
state["holdings"] = {k: round(v, 4) for k, v in holdings.items() if abs(v) >= 0.001}
def record_daily_snapshot(state: dict, date_str: str, prices: dict,
trades: list[dict], prev_equity: float) -> None:
@@ -865,6 +738,7 @@ def record_daily_snapshot(state: dict, date_str: str, prices: dict,
})
entry = {
"engine_version": state.get("engine_version"),
"date": date_str,
"equity": round(total, 2),
"cash": round(cash, 2),
@@ -927,6 +801,8 @@ def _book_capital(args, market: str) -> float:
def _strategy_fees(args, market: str) -> dict:
book = getattr(args, "book", None)
if book:
if "fee_schedule" in LIVE_BOOKS[book]:
return dict(LIVE_BOOKS[book]["fee_schedule"][market])
return {"tx_cost": 0.0, "fixed_fee": LIVE_BOOKS[book]["fee_per_order"][market],
"fee_base": 0.0, "fee_per_share": 0.0}
tier = MARKET_FEE_TIERED.get(market, {})
@@ -949,18 +825,19 @@ def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
depend on data after t), so one pass equals running each day separately.
"""
strategy = STRATEGY_REGISTRY[strategy_name]()
if isinstance(strategy, BarStrategy):
return bar_session_weights(strategy, bars, days)
hist = open_data.loc[:days[-1]]
dummy = pd.DataFrame([hist.iloc[-1].values], index=[hist.index[-1] + pd.Timedelta(days=1)],
columns=hist.columns)
weights = strategy.generate_signals(pd.concat([hist, dummy]))
return weights.shift(-1).reindex(days).fillna(0.0)
close = bars["close"].loc[:days[-1]] if bars is not None else hist
if days[-1] not in close.index:
close = close.reindex(close.index.union(days))
if bars is not None:
bars = {f: b.reindex(close.index) for f, b in bars.items()}
return session_targets(strategy, close, hist.reindex(close.index), bars).reindex(days).fillna(0.0)
def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
close_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series) -> dict:
integer_shares: bool, signal_row: pd.Series,
volume_row: pd.Series | None = None) -> dict:
"""Execute ``signal_row`` (targets from ``day``'s open; a BarStrategy: bars
through the previous session, see ``session_signals``) at ``day``'s close
and record the day.
@@ -969,6 +846,10 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
valued but not traded.
"""
day_str = str(day.date())
if state.get("engine_version") != ENGINE_VERSION:
state.setdefault("engine_changes", []).append({"first_session": day_str, "version": ENGINE_VERSION,
"recorded_at": datetime.now(timezone.utc).isoformat()})
state["engine_version"] = ENGINE_VERSION
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
@@ -980,15 +861,18 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
frozen = {t for t in all_held if t not in raw_row.index or pd.isna(raw_row[t])}
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
trades = compute_trades(
state["holdings"], state["cash"], target_weights, close_prices,
min_trade_value=max(50, pre_value * 0.001),
integer_shares=integer_shares, lot_size=lot_size, frozen=frozen,
resize_band=RESIZE_BAND,
)
previous_rows = close_data.loc[close_data.index < day]
previous = previous_rows.iloc[-1] if len(previous_rows) else pd.Series(dtype=float)
allowed_buy, allowed_sell, sell_tax = market_permissions(market, day, raw_row, previous, volume_row)
n_log = len(state["trade_log"])
execute_trades(state, trades, close_prices, trade_date=day_str,
integer_shares=integer_shares, lot_size=lot_size, **fees)
actual = rebalance(state, target_weights, close_prices, set(raw_row.dropna().index),
transaction_cost=fees["tx_cost"], fixed_fee=fees["fixed_fee"],
fee_base=fees.get("fee_base", 0), fee_per_share=fees.get("fee_per_share", 0),
integer_shares=integer_shares, lot_size=lot_size,
trade_date=day_str, resize_band=strategy_resize_band(state["strategy"]),
can_buy=allowed_buy, can_sell=allowed_sell, sell_tax=sell_tax)
trades = [{**t, "direction": t["action"], "est_value": t["value"],
"shares_delta": t["shares"] * (1 if t["action"] == "BUY" else -1)} for t in actual]
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
previous = [d for d in sorted(state["daily_equity"]) if d < day_str]
@@ -1031,7 +915,7 @@ def cmd_morning(args):
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
# Download data (close + open)
close_data, open_data, _ = load_strategy_prices(
close_data, open_data, close_raw = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True,
refresh=getattr(args, "refresh", True),
)
@@ -1053,9 +937,8 @@ def cmd_morning(args):
print(f"{'='*60}")
# Run strategy on open prices (a BarStrategy: on completed bars)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
row = _signals_for_sessions(strategy_name, open_data[tickers],
pd.DatetimeIndex([today]), bars).loc[today]
row = session_signals(market, strategy_name, tickers, pd.DatetimeIndex([today]),
close_raw, open_data).loc[today]
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
# Get today's open prices for trade computation
@@ -1069,7 +952,7 @@ def cmd_morning(args):
open_prices, min_trade_value=max(50, total * 0.001),
integer_shares=args.integer_shares,
lot_size=MARKET_LOT_SIZE.get(market, 1),
resize_band=RESIZE_BAND)
resize_band=strategy_resize_band(strategy_name))
# Store pending
state["pending_trades"] = {
@@ -1140,7 +1023,7 @@ def cmd_evening(args):
return
# Get close prices
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
tickers = filter_tradable_tickers(close_data, tickers)
target_date = pd.Timestamp(trade_date)
@@ -1166,24 +1049,16 @@ def cmd_evening(args):
# Recompute trades with close prices for execution
target_weights = pending.get("target_weights", {})
lot_size = MARKET_LOT_SIZE.get(market, 1)
exec_trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares, lot_size=lot_size,
)
execute_trades(state, exec_trades, close_prices, trade_date=trade_date,
integer_shares=args.integer_shares, lot_size=lot_size,
**_strategy_fees(args, market))
first_log = len(state["trade_log"])
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
_run_session(state, market, target_date, benchmark, close_data, close_raw,
_strategy_fees(args, market), args.integer_shares,
pd.Series(target_weights, dtype=float),
volume_row=volume.reindex(close_data.index).loc[target_date] if volume is not None else None)
exec_trades = [{**t, "direction": t["action"], "est_value": t["value"]}
for t in state["trade_log"][first_log:]]
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][trade_date] = round(post_value, 2)
# Record daily snapshot so daily_log stays complete even on no-trade days
eq_vals = list(state["daily_equity"].values())
prev_eq = eq_vals[-2] if len(eq_vals) >= 2 else state["initial_capital"]
record_daily_snapshot(state, trade_date, close_prices, exec_trades, prev_eq)
state["pending_trades"] = None
state["last_evening"] = trade_date
save_state(state, market, strategy_name, book)
@@ -1225,7 +1100,7 @@ def cmd_status(args):
# Get latest prices
tickers, benchmark = strategy_universe(market, strategy_name)
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
last_date = close_data.index[-1]
all_held = list(state["holdings"].keys())
@@ -1536,13 +1411,14 @@ def cmd_simulate(args):
# Load both open and close data
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, start)))
close_data, open_data, _ = load_strategy_prices(
close_data, open_data, close_raw = load_strategy_prices(
market, strategy_name, request, benchmark, with_open=True,
)
tickers = filter_tradable_tickers(close_data, tickers)
# Date range
trading_days = close_data.index[(close_data.index >= start) & (close_data.index <= end)]
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
trading_days = sessions[(sessions >= start) & (sessions <= end)]
if len(trading_days) == 0:
print(f"No trading days found between {args.start} and {args.end}")
@@ -1554,7 +1430,7 @@ def cmd_simulate(args):
# Pre-compute strategy weights for the full period (much faster than per-day),
# each day on its point-in-time index members
full_weights = session_signals(market, strategy_name, tickers, trading_days,
close_data, open_data)
close_raw, open_data)
print(f"\n{'='*70}")
print(f" SIMULATION: {strategy_name} | {market.upper()}")
@@ -1566,52 +1442,16 @@ def cmd_simulate(args):
total_trades = 0
total_commission = 0.0
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
for i, day in enumerate(trading_days):
day_str = str(day.date())
# Morning: get target weights from pre-computed matrix
if day in full_weights.index:
row = full_weights.loc[day]
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
else:
target_weights = {}
# Get open and close prices
all_held = list(set(list(target_weights.keys()) + list(state["holdings"].keys())))
open_prices = get_prices_for_date(all_held, day, open_data)
close_prices = get_prices_for_date(all_held + [benchmark], day, close_data)
if not close_prices:
continue
# Compute portfolio value before trades (at close, reflecting overnight changes)
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
# Compute and execute trades at close prices
lot_size = MARKET_LOT_SIZE.get(market, 1)
trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares, lot_size=lot_size,
resize_band=RESIZE_BAND,
)
execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=day_str, integer_shares=args.integer_shares,
lot_size=lot_size)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][day_str] = round(post_value, 2)
# Record daily snapshot (holdings + operations)
prev_eq_val = args.capital if i == 0 else list(state["daily_equity"].values())[-2] if len(state["daily_equity"]) >= 2 else args.capital
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq_val)
day_trades = len(trades)
day_commission = sum(
t.get("commission", 0) for t in state["trade_log"][-day_trades:]
) if day_trades else 0
stats = _run_session(state, market, day, benchmark, close_data, close_raw,
_strategy_fees(args, market), args.integer_shares,
full_weights.loc[day],
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
post_value = stats["value"]
day_trades, day_commission = stats["trades"], stats["commission"]
total_trades += day_trades
total_commission += day_commission
@@ -1935,6 +1775,7 @@ def cmd_monitor(args):
def refresh_panels(market: str, strategy_names: list[str], label: str) -> None:
"""Refresh every price panel the strategies read, once, for the union of their tickers."""
refresh_membership(market)
requests: dict[str, set[str]] = {}
fields: dict[str, set[str]] = {}
for name in strategy_names:
@@ -2001,10 +1842,12 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
f"{todo[0].date()} .. {todo[-1].date()}")
fees = _strategy_fees(args, market)
signals = session_signals(market, strategy_name, tickers, todo, close_data, open_data)
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
signals = session_signals(market, strategy_name, tickers, todo, close_raw, open_data)
for day in todo:
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
args.integer_shares, signal_row=signals.loc[day])
args.integer_shares, signal_row=signals.loc[day],
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "

View File

@@ -173,14 +173,14 @@ def _cache_path() -> str:
return os.path.join(CACHE_DIR, "sp500_history.json")
def load_sp500_history(force_refresh: bool = False) -> dict[str, list[list[str | None]]]:
def load_sp500_history(force_refresh: bool = False, *, refresh: bool = True) -> dict[str, list[list[str | None]]]:
"""Load cached membership history, or rebuild if stale (>1 day old)."""
path = _cache_path()
if not force_refresh and os.path.exists(path):
try:
with open(path) as f:
data = json.load(f)
if data.get("date") == str(date.today()):
if not refresh or data.get("date") == str(date.today()):
return data["intervals"]
except Exception:
pass
@@ -285,13 +285,13 @@ def baostock_to_yahoo(code: str) -> str:
return f"{num}.{'SS' if ex == 'sh' else 'SZ'}"
def load_csi300_history(current: list[str] | None = None) -> dict[str, list[str]]:
def load_csi300_history(current: list[str] | None = None, *, refresh: bool = True) -> dict[str, list[str]]:
"""Monthly CSI 300 snapshots; appends ``current`` (or today's list) if it changed."""
path = _csi300_path()
with open(path) as f:
data = json.load(f)
snaps = data["snapshots"]
if current is None:
if current is None and refresh:
try:
from universe import get_csi300
current = get_csi300()
@@ -309,6 +309,16 @@ def load_csi300_history(current: list[str] | None = None) -> dict[str, list[str]
return snaps
def pit_membership(market: str, dates: pd.DatetimeIndex, *, refresh: bool = False) -> pd.DataFrame:
if market == "us":
intervals = load_sp500_history(refresh=refresh)
return membership_mask(dates, intervals, sorted(intervals))
if market == "cn":
snapshots = load_csi300_history(refresh=refresh)
return csi300_membership_mask(dates, all_csi300_tickers_ever(snapshots), snapshots)
raise ValueError(f"No PIT membership for {market}")
def csi300_membership_mask(dates: pd.DatetimeIndex, tickers: list[str],
snapshots: dict[str, list[str]] | None = None) -> pd.DataFrame:
"""Boolean mask (dates x tickers): True while the ticker was in the CSI 300."""

View File

@@ -261,9 +261,12 @@ export function LivePage() {
function emptyHint(book: LiveBook | undefined, market: StrategyMarket): string {
if (!book?.startDate) return "暂无在线策略。运行 quant 的 trader.py auto 后再刷新。";
const currency = currencyForMarket(market);
const fee = book.feePerOrder ? ` · 每笔 ${formatMoney(book.feePerOrder[market], currency)}` : "";
const schedule = book.feeSchedule?.[market];
const fee = schedule
? ` · 佣金最低 ${formatMoney(schedule.fixed_fee, currency)},比例成本 ${(schedule.tx_cost * 100).toFixed(2)}%`
: book.feePerOrder ? ` · 每单佣金 ${formatMoney(book.feePerOrder[market], currency)}` : "";
return `${book.label}:${book.strategies[market].length} 个策略,各 ${formatMoney(book.capital[market], currency)}${fee},`
+ `${book.startDate[market]} 收盘后出现首日记录。`;
+ `${book.startDate[market]} 起首个交易日收盘后出现记录。`;
}
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {

View File

@@ -594,6 +594,15 @@ function EquityCurveChart({
)}
</div>
</div>
{result.meta.warnings?.length ? (
<details className="backtestAssumptions">
<summary>回测数据与成交假设</summary>
<ul>{result.meta.warnings.map((message) => <li key={message}>{message}</li>)}</ul>
</details>
) : null}
{result.meta.errors?.length ? (
<p role="alert">未完成回测:{result.meta.errors.map((e) => `${e.strategy}: ${e.error}`).join(";")}</p>
) : null}
<div className="legendRow">
{result.strategies.length === 0 && (
<span className="legendEmpty">从左侧选取策略加入对比</span>

View File

@@ -17,7 +17,7 @@ function ensureCacheDir(): void {
}
function configHash(config: BacktestConfig): string {
const serialized = stableStringify(config);
const serialized = stableStringify({ engine: "cash-ledger-pit-v2", config });
return createHash("md5").update(serialized).digest("hex").slice(0, 12);
}

View File

@@ -77,7 +77,7 @@ export interface InjectionEvent {
amount: number;
}
// Backtest metrics are time-weighted (computed on the no-injection curve).
// Backtest metrics use unit NAV after executing contributions and their fees.
// The bridge serializes NaN as null, so every field may be null.
export interface StrategyMetrics {
totalReturn: number | null;
@@ -107,7 +107,7 @@ export interface StrategyResult {
label: string;
// Money curve (includes injections when a schedule exists).
equityCurve: EquityPoint[];
// Same strategy without injections; metrics are computed on this curve.
// Same strategy without injections, supplied for comparison.
baseEquityCurve: EquityPoint[] | null;
metrics: StrategyMetrics;
capitalFlows?: CapitalFlows;
@@ -121,6 +121,11 @@ export interface StrategyResult {
}
export interface BacktestMeta {
requestedYears?: number;
engineVersion?: string;
warnings?: string[];
coverage?: { minimum: number; median: number } | null;
errors?: { strategy: string; error: string }[];
market: StrategyMarket;
years: number;
initialCapital: number;
@@ -163,6 +168,9 @@ export interface TradeLogEntry {
// A live book: a cohort of strategies, each with its own account, started on
// one date with one capital and fee schedule. id "" is the original book.
export interface LiveBook {
feeSchedule?: Partial<Record<StrategyMarket, {
tx_cost: number; fixed_fee: number; fee_base: number; fee_per_share: number;
}>> | null;
id: string;
label: string;
startDate: Record<StrategyMarket, string> | null;