feat: robust-momentum strategies live; fix live universe, lots and UI backtests

Strategies
- BarStrategy protocol (close/open/high/low/volume bars; row d uses bars
  through d-1) and RobustMomentumStrategy: overnight_recovery_top10 (US),
  lowrisk_momentum_top15 and lowbeta_sharpe_mom_top20 (CN), top-N with
  rank hysteresis, 10-day rebalance, weights drift between rebalances.
- trader: strategy_fields/strategy_bars/bar_session_weights; one signal
  path (_signals_for_sessions) replaces generate_target_weights.

Live fixes
- CSI 300 constituents come from China Securities Index's file; the
  Wikipedia table was 122 names off the real index.
- S&P 500 change log moved to "Historical components of the S&P 500".
- data_manager: tickers missing from a partial Yahoo batch are retried
  alone before being marked unavailable (CEG, GEV, SNDK ... were dropped).
  update() takes fields=[...] and stores high/low/volume panels.
- Whole-lot orders go to the nearest lot within cash (CN books held ~62%
  cash); held names are resized only beyond RESIZE_BAND (10%), so books no
  longer pay a ticket fee to trade daily drift.

Web backtests
- backtest_runner.py replaces the duplicated bridge/precompute code: PIT
  membership in both markets (data/csi300_history.json), stock universe
  limited to index members (ETFs and benchmarks in the shared panel were
  pickable), and the book kept like the live trader (simulate_live_book:
  signal timing, drift, order floor, resize band, fees on orders placed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 19:12:02 +08:00
parent 0e378efa67
commit cc1366b2dc
16 changed files with 16027 additions and 606 deletions

View File

@@ -9,7 +9,7 @@ quant-trading: a quantitative backtesting framework and live forward-testing tra
The repository has two halves that share one data directory and one `.env`:
- **Python engine** (repo root, `uv`): strategies, backtests, `trader.py`, research scripts.
- **Web UI and API** (`web/`, Node + React + Vite): the API spawns `uv run python bridge.py {list,backtest,live}` at the repo root and reads `data/precomputed/backtest_results.json`, written by `precompute_backtest.py`.
- **Web UI and API** (`web/`, Node + React + Vite): the API spawns `uv run python bridge.py {list,backtest,live}` at the repo root and reads `data/precomputed/backtest_results.json`, written by `precompute_backtest.py`. Both backtest paths run through `backtest_runner.py`.
`bridge.py`'s JSON output and `web/src/shared/strategyTypes.ts` are one contract: change both in the same commit.
@@ -40,6 +40,13 @@ uv run python trader.py simulate --integer-shares --fixed-fee 2.0 ... # Integ
# Precompute the strategy page's backtests (writes data/precomputed/)
uv run python precompute_backtest.py
# Factor research harness (PIT universe, next-open fills, phase averaging; data in data/research/)
uv run python -m research.lab.fetch_us # 20y OHLCV for every S&P 500 ever-member
uv run python -m research.lab.fetch_cn_yahoo # CSI 300/500 ever-members (membership from baostock snapshot)
PYTHONPATH=. uv run python -m research.lab.factor_scan --market us # or cn_hs300
PYTHONPATH=. uv run python -m research.lab.stepwise --market us --objective maximin
PYTHONPATH=. uv run python -m research.lab.evaluate --market us # candidates vs champions
# Setup
uv sync # Install/sync dependencies
cp .env.example .env # Then fill IPADS_API_KEY and QUANT_MARKET_PROXY
@@ -55,15 +62,17 @@ uv run python -m unittest discover tests
## Architecture
**Universe provider** (`universe.py`): Dynamically fetches index constituents (S&P 500 from Wikipedia, CSI 300 from Wikipedia) and converts to Yahoo Finance ticker format. Constituent lists are cached daily in `data/universe_*.json`. New markets are added by registering in `UNIVERSES` dict.
**Universe provider** (`universe.py`): Dynamically fetches index constituents (S&P 500 from Wikipedia; CSI 300 from China Securities Index's published constituent file, falling back to the latest `data/csi300_history.json` snapshot, then Wikipedia, whose table lagged the index by ~60 names in 2026) and converts to Yahoo Finance ticker format. Constituent lists are cached daily in `data/universe_*.json`. New markets are added by registering in `UNIVERSES` dict.
**Data manager** (`data_manager.py`): Persistent price storage in `data/{market}.csv` (close) and `data/{market}_open.csv` (open). First run downloads 10 years of history; subsequent runs append only new trading days (fast bulk fetch for gaps ≤7 days). New tickers from index rebalances are backfilled automatically. Tickers with >50% missing data are dropped. Pass `with_open=True` to also maintain open price files.
**Point-in-time membership** (`universe_history.py`): S&P 500 intervals rebuilt from Wikipedia's "Historical components of the S&P 500" change log into `data/sp500_history.json`; CSI 300 monthly snapshots in `data/csi300_history.json` (seeded from baostock 2008–2026, appended whenever the current list changes). `mask_prices_pit(prices, market)` masks non-member dates.
**Backtest engine** (`main.py`): Orchestrates data loading, strategy execution, and visualization. The `backtest()` function is vectorized — it takes a strategy and price DataFrame, applies transaction costs (proportional + optional fixed per-trade fee) via turnover, and returns an equity curve. Supports two execution modes: `close` (classic) and `open-close` (signal on open prices, execute at close).
**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}`.
**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). Available strategies: `recovery_mom_top10`, `recovery_mom_top20`, `momentum`, `momentum_quality`, `dual_momentum`, `inverse_vol`, `trend_following`, `buy_and_hold`, plus 32 factor-combo strategies (`fc_{signal}_{freq}` — see `strategies/factor_combo.py`).
**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`).
**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.
**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`), and the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`.
**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.
**Strategies**:
- `buy_and_hold.py` — Equal-weight static allocation
@@ -76,7 +85,8 @@ uv run python -m unittest discover tests
- `momentum_quality.py` — Momentum + return consistency + low drawdown
- `adaptive_momentum.py` — Momentum weighted by inverse volatility
- `recovery_momentum.py` — Recovery (price/63d low) + 12-1mo momentum composite. Best US performer.
- `factor_combo.py` — Configurable factor-combination strategies with daily/weekly/biweekly/monthly rebalancing. US champions: `rec_mfilt+deep_upvol` (50.7% CAGR monthly), `ma200+mom7m+rec126`, `rec_mfilt+ma200`, `mom7m+rec126`. CN champions: `up_cap+quality_mom` (26.1% CAGR monthly), `down_resil+qual_mom`, `rec63+mom_gap`, `up_cap+mom_gap`. All registered in trader.py as `fc_{signal}_{freq}` (e.g., `fc_rec_mfilt_deep_upvol_monthly`). 32 new strategies total.
- `robust_momentum.py` — Bar strategies from the 2026-09 PIT research (report: https://claude.ai/artifact/Mo9nrzs5SWj5FfyshERyWS): US `overnight_recovery` (overnight-return momentum + distance from 126d low), CN `lowrisk_momentum` (amplitude-cut momentum + low beta + price-volume corr + momentum/vol) and close-only `lowbeta_sharpe_mom`. Top-N with rank hysteresis, rebalance every 10 days, weights drift between rebalances.
- `factor_combo.py` — Configurable factor-combination strategies with daily/weekly/biweekly/monthly rebalancing. US champions: `rec_mfilt+deep_upvol`, `ma200+mom7m+rec126`, `rec_mfilt+ma200`, `mom7m+rec126`. CN champions: `up_cap+quality_mom`, `down_resil+qual_mom`, `rec63+mom_gap`, `up_cap+mom_gap` — these were mined on today's CSI 300 members; on PIT members they earn ~3-7% a year (2016-2026). All registered in trader.py as `fc_{signal}_{freq}` (e.g., `fc_rec_mfilt_deep_upvol_monthly`). 32 new strategies total.
**Metrics** (`metrics.py`): Standalone functions for portfolio analytics (Sharpe, Sortino, Calmar, max drawdown, etc.). `summary()` prints a formatted report and returns a dict.
@@ -84,7 +94,7 @@ uv run python -m unittest discover tests
- Strategies output **weights**, not buy/sell signals. Weights are normalized so each row sums to 1 (or 0 during warm-up).
- Warm-up periods: each strategy zeros out its first N rows where indicators are undefined.
- `data/` contains persistent price CSVs, universe JSON caches, and trader state files, plus `data/precomputed/` (precomputed backtests) and `data/web/` (watchlist, per-stock cache, backtest result cache). Not checked into git (only `data/.gitkeep` and `data/sp500_history.json` are tracked).
- `data/` contains persistent price CSVs, universe JSON caches, and trader state files, plus `data/precomputed/` (precomputed backtests), `data/web/` (watchlist, per-stock cache, backtest result cache) and `data/research/` (research panels, ~2 GB). Not checked into git (only `data/.gitkeep`, `data/sp500_history.json` and `data/csi300_history.json` are tracked).
- Python 3.12+, managed with `uv`.
- Do not show matplotlib figures when running backtests; use `--no-plot`.

197
backtest_runner.py Normal file
View File

@@ -0,0 +1,197 @@
"""
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.
"""
from __future__ import annotations
import sys
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 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:
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
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()]
def _slice_years(panels: dict[str, pd.DataFrame], years: int | None) -> dict[str, pd.DataFrame]:
if not years:
return panels
close = panels["close"]
cutoff = close.index[-1] - pd.DateOffset(years=years)
return {f: p[p.index >= cutoff] for f, p in panels.items()}
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 _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 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]
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}")
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}")
if not runs:
return None
out["strategy_runs"] = runs
return out

223
bridge.py
View File

@@ -20,29 +20,19 @@ import pandas as pd
import data_manager
import metrics
import universe_history as uh
from main import backtest
from backtest_runner import benchmark_curve, run_market
from research.permanent_yearly import equity_with_cashflows
from trader import (
strategy_extra_tickers,
strategy_supports_market,
ETF_STRATEGY_UNIVERSES,
LIVE_EXCLUDED_STRATEGIES,
MARKET_FEE_TIERED,
STRATEGY_REGISTRY,
convert_quote_currency,
price_request,
strategy_supports_market,
strategy_data_market,
filter_tradable_tickers,
strategy_supports_market,
)
MARKET_CURRENCY = {"us": "USD", "cn": "CNY"}
# 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
from universe import UNIVERSES
# ---------------------------------------------------------------------------
@@ -440,6 +430,25 @@ STRATEGY_META = {
"params": {},
"markets": ["us"],
},
# --- Robust momentum (2026-09 PIT research): US pick, CN picks ---
"overnight_recovery_top10": {
"label": "Robust: Overnight Mom + Recovery Top 10",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
"lowrisk_momentum_top15": {
"label": "Robust: Low-Risk Momentum Top 15",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
"lowbeta_sharpe_mom_top20": {
"label": "Robust: Low-Beta + Sharpe Mom Top 20",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
}
@@ -515,28 +524,6 @@ def compute_drawdown_periods(equity: pd.Series, threshold: float = -0.05) -> lis
return periods
def compute_rebalance_trades(weights: pd.DataFrame, threshold: float = 0.01) -> list[dict]:
"""Extract significant trade events from weight changes."""
diff = weights.diff().fillna(0.0)
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
def compute_rebalance_win_rate(equity: pd.Series, trade_events: list[dict]) -> float | None:
"""Fraction of inter-rebalance periods with positive cumulative return.
@@ -600,15 +587,6 @@ def compute_periodic_returns(equity: pd.Series) -> tuple[dict, dict]:
return monthly, yearly
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()]
def build_injection_schedule(equity: pd.Series, schedule: dict) -> tuple[pd.Series, list[dict]]:
"""Build a contributions Series from injection schedule config.
@@ -725,159 +703,18 @@ def cmd_backtest(args: argparse.Namespace) -> None:
_log(f"Running backtest: market={market}, years={years}, capital={capital}")
_log(f"Strategies: {strategy_names}")
# Determine data needs
# Group strategies by their data source
stock_strategies = []
etf_strategies = []
for name in strategy_names:
if name not in STRATEGY_REGISTRY:
known = [n for n in strategy_names if n in STRATEGY_REGISTRY]
for name in set(strategy_names) - set(known):
_log(f"WARNING: Unknown strategy '{name}', skipping")
continue
base_name = name.removeprefix("sim_")
if base_name in ETF_STRATEGY_UNIVERSES:
etf_strategies.append(name)
else:
stock_strategies.append(name)
results = []
# Process stock strategies
if stock_strategies:
universe = UNIVERSES[market]
with _redirect_stdout_to_stderr():
tickers = universe["fetch"]()
benchmark_ticker = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
# PIT universe: for US market, include all tickers that were EVER
# S&P 500 members so that removed stocks are present in price data.
pit_intervals = None
if market == "us":
with _redirect_stdout_to_stderr():
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
_log(f"PIT universe: {len(all_tickers)} tickers (current + historical members)")
else:
all_tickers = sorted(set(tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
_log(f"WARNING: {market.upper()} market lacks PIT membership history; "
"backtest uses current index constituents (survivorship bias possible)")
_log(f"Fetching price data for {len(all_tickers)} tickers...")
with _redirect_stdout_to_stderr():
data = data_manager.update(market, all_tickers, with_open=False)
if isinstance(data, tuple):
data = data[0]
if years:
cutoff = data.index[-1] - pd.DateOffset(years=years)
data = data[data.index >= cutoff]
# Apply PIT mask: set prices to NaN for dates when a stock was NOT
# an index member. Strategies naturally skip NaN entries.
if pit_intervals is not None:
_log("Applying PIT membership mask (survivorship-bias fix)...")
data = uh.mask_prices(data, pit_intervals)
tickers = filter_tradable_tickers(data, tickers)
# For PIT mode, expand tickers to all columns with valid data
# (historical members that have been masked are NaN when not members,
# but have valid data during their membership period).
if pit_intervals is not None:
all_data_tickers = [
t for t in data.columns
if t != benchmark_ticker and data[t].notna().any()
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)
for run in (raw["strategy_runs"] if raw else [])
]
tickers = all_data_tickers
_log(f"Universe: {len(tickers)} tradable stocks, period: {data.index[0].date()} to {data.index[-1].date()}")
for name in stock_strategies:
_log(f" Running: {name}")
# Registry defaults are the single source of strategy parameters,
# shared with the live trader.
strategy = STRATEGY_REGISTRY[name]()
# Mixed stock+ETF strategies also read their fixed ETFs.
cols = tickers + [t for t in strategy_extra_tickers(name)
if t in data.columns and t not in tickers]
equity = backtest(
strategy, data[cols],
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
min_trade_value=MIN_TRADE_VALUE,
**MARKET_FEE_TIERED.get(market, {}),
)
# Get weight signals for trade extraction
weights = strategy.generate_signals(data[cols])
weights = weights.reindex(data[cols].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
result_entry = _build_strategy_result(
name, equity, trade_events, injection_schedule, capital
)
results.append(result_entry)
# Benchmark over exactly the strategies' dates
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
else:
benchmark_label = "SPY"
bench_curve = []
# Process ETF strategies
if etf_strategies:
for name in etf_strategies:
_log(f" Running ETF strategy: {name}")
base_name = name.removeprefix("sim_")
etf_tickers = ETF_STRATEGY_UNIVERSES[base_name]
all_etf = sorted(set(etf_tickers + ["SPY"]))
with _redirect_stdout_to_stderr():
etf_data = data_manager.update("etfs", price_request(market, all_etf), with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
etf_data = convert_quote_currency(etf_data)
if years:
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
etf_data = etf_data[etf_data.index >= cutoff]
strategy = STRATEGY_REGISTRY[name]()
tradable = filter_tradable_tickers(etf_data, etf_tickers)
equity = backtest(
strategy, etf_data[tradable],
initial_capital=capital,
transaction_cost=tx_cost,
fixed_fee=fixed_fee,
min_trade_value=MIN_TRADE_VALUE,
**MARKET_FEE_TIERED.get(market, {}),
)
weights = strategy.generate_signals(etf_data[tradable])
weights = weights.reindex(etf_data[tradable].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
result_entry = _build_strategy_result(
name, equity, trade_events, injection_schedule, capital
)
results.append(result_entry)
# If no stock strategies were run, use SPY as benchmark from ETF data
if not stock_strategies and len(etf_strategies) > 0:
benchmark_label = "SPY (Benchmark)"
with _redirect_stdout_to_stderr():
spy_data = data_manager.update("etfs", ["SPY"], with_open=False)
if isinstance(spy_data, tuple):
spy_data = spy_data[0]
if years:
cutoff = spy_data.index[-1] - pd.DateOffset(years=years)
spy_data = spy_data[spy_data.index >= cutoff]
if "SPY" in spy_data.columns:
bench_curve = benchmark_curve(spy_data["SPY"], spy_data.index, capital)
benchmark_label = raw["benchmark_label"] if raw else "SPY"
bench_curve = raw["benchmark_curve"] if raw else []
output = {
"meta": {

14803
data/csi300_history.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -265,94 +265,148 @@ def _fill_open(close: pd.DataFrame, open_raw: pd.DataFrame | None) -> pd.DataFra
return aligned.fillna(close)
def update(market: str, tickers: list[str],
with_open: bool = False) -> pd.DataFrame | tuple[pd.DataFrame, pd.DataFrame]:
YAHOO_FIELDS = {"close": "Close", "open": "Open", "high": "High", "low": "Low", "volume": "Volume"}
def _backfill(tickers: list[str], start: str, yahoo_fields: list[str],
index: pd.DatetimeIndex) -> tuple[dict[str, pd.DataFrame], list[str]]:
"""Download new tickers over ``index``; return (panels, tickers still missing).
yfinance reports a ticker it failed to fetch (rate limit, proxy error) the
same way as a ticker it has no data for, so tickers missing from the first
batch are downloaded once more on their own before being given up on.
"""
Refresh the stored panel for ``tickers`` and return it forward-filled.
def fetch(batch: list[str]) -> dict[str, pd.DataFrame]:
raw = _download(batch, start=start, fields=yahoo_fields)
close = raw["Close"].dropna(axis=1, how="all").reindex(index=index).dropna(axis=1, how="all")
return {f: raw.get(f, pd.DataFrame()).reindex(index=index, columns=close.columns)
for f in yahoo_fields}
got = fetch(tickers)
missing = [t for t in tickers if t not in got["Close"].columns]
if missing and len(missing) < len(tickers):
retry = fetch(missing)
if not retry["Close"].empty:
got = {f: pd.concat([got[f], retry[f]], axis=1) for f in yahoo_fields}
missing = [t for t in missing if t not in retry["Close"].columns]
return got, missing
def update(market: str, tickers: list[str], with_open: bool = False,
fields: list[str] | None = None):
"""
Refresh the stored panels for ``tickers`` and return them forward-filled.
- First run: downloads 10 years of history.
- Every run: re-downloads the last ``REFRESH_DAYS`` calendar days for the
requested tickers already in the panel and overwrites them.
- New tickers are backfilled over the panel's full date range; tickers
Yahoo has no data for are remembered and retried weekly.
- A field requested for the first time (e.g. ``high``) is downloaded for
every ticker in the stored close panel.
Returns the close panel, or ``(close, open)`` when ``with_open`` is True.
Returns the close panel, ``(close, open)`` when ``with_open`` is True, or
``{field: panel}`` when ``fields`` is given. Open/high/low fall back to the
close where a bar is missing; volume is returned raw (NaN = no print).
"""
fields = ["Close", "Open"] if with_open else ["Close"]
names = list(fields) if fields is not None else (["close", "open"] if with_open else ["close"])
if "close" not in names:
names = ["close"] + names
unknown = [f for f in names if f not in YAHOO_FIELDS]
if unknown:
raise ValueError(f"Unsupported market data field(s): {unknown}")
yahoo = [YAHOO_FIELDS[f] for f in names]
with _panel_lock(market):
close_raw = load_raw(market, "close")
open_raw = load_raw(market, "open") if with_open else None
raw = {f: load_raw(market, f) for f in names}
unavailable = _load_unavailable(market)
today = datetime.now()
if close_raw is None:
if raw["close"] is None:
start = (today - timedelta(days=365 * 10)).strftime("%Y-%m-%d")
downloaded = _download(tickers, start=start, fields=fields)
close_raw = _merge_refresh(None, downloaded["Close"], None)
if with_open:
open_raw = _merge_refresh(None, downloaded.get("Open", pd.DataFrame()), None)
downloaded = _download(tickers, start=start, fields=yahoo)
for f, y in zip(names, yahoo):
raw[f] = _merge_refresh(None, downloaded.get(y, pd.DataFrame()), None)
else:
if with_open and open_raw is None:
print("--- Downloading Open prices for the full panel (first time) ---")
start = close_raw.index[0].strftime("%Y-%m-%d")
opens = _download(list(close_raw.columns), start=start, fields=["Open"])
open_raw = _merge_refresh(None, opens["Open"], None)
close_raw = raw["close"]
first = [(f, y) for f, y in zip(names, yahoo) if raw[f] is None]
if first:
print(f"--- Downloading {[y for _, y in first]} for the full panel (first time) ---")
got = _download(list(close_raw.columns), start=close_raw.index[0].strftime("%Y-%m-%d"),
fields=[y for _, y in first])
for f, y in first:
raw[f] = _merge_refresh(None, got[y], None)
known = [t for t in tickers if t in close_raw.columns]
if known:
window_start = close_raw.index[-1] - pd.Timedelta(days=REFRESH_DAYS)
fresh = _download(known, start=window_start.strftime("%Y-%m-%d"), fields=fields)
fresh = _download(known, start=window_start.strftime("%Y-%m-%d"), fields=yahoo)
if not fresh["Close"].empty:
close_raw = _merge_refresh(close_raw, fresh["Close"], window_start)
if with_open:
open_raw = _merge_refresh(open_raw, fresh.get("Open", pd.DataFrame()), window_start)
for f, y in zip(names, yahoo):
raw[f] = _merge_refresh(raw[f], fresh.get(y, pd.DataFrame()), window_start)
else:
print("--- Refresh download returned no data; keeping stored panel ---")
cutoff = (today - timedelta(days=UNAVAILABLE_RETRY_DAYS)).strftime("%Y-%m-%d")
new_tickers = [t for t in tickers
if t not in close_raw.columns and unavailable.get(t, "") < cutoff]
if t not in raw["close"].columns and unavailable.get(t, "") < cutoff]
if new_tickers:
start = close_raw.index[0].strftime("%Y-%m-%d")
backfill = _download(new_tickers, start=start, fields=fields)
downloaded_any = not backfill["Close"].dropna(how="all").empty
got = backfill["Close"].dropna(axis=1, how="all")
got = got.reindex(index=close_raw.index)
got = got.dropna(axis=1, how="all")
for t in new_tickers:
if t in got.columns:
got, missing = _backfill(new_tickers, raw["close"].index[0].strftime("%Y-%m-%d"),
yahoo, raw["close"].index)
added = list(got["Close"].columns)
for t in added:
unavailable.pop(t, None)
elif downloaded_any:
# Only a ticker missing from an otherwise successful
# download is unavailable; a failed request is retried.
# A batch that returned nothing at all is a failed request, not
# evidence the tickers have no data: retry those next run.
if added:
for t in missing:
unavailable[t] = today.strftime("%Y-%m-%d")
if not got.empty:
close_raw = pd.concat([close_raw, got], axis=1)
if with_open and open_raw is not None:
opens = backfill.get("Open", pd.DataFrame())
opens = opens.reindex(index=open_raw.index, columns=got.columns)
open_raw = pd.concat([open_raw, opens], axis=1)
print(f"--- Backfilled {got.shape[1]} new tickers ---")
missing = len(new_tickers) - got.shape[1]
if added:
for f, y in zip(names, yahoo):
panel = got[y].reindex(index=raw[f].index)
raw[f] = pd.concat([raw[f], panel], axis=1)
print(f"--- Backfilled {len(added)} new tickers ---")
if missing:
print(f"--- {missing} tickers have no Yahoo data; retry after "
print(f"--- {len(missing)} tickers have no Yahoo data; retry after "
f"{UNAVAILABLE_RETRY_DAYS} days ---")
close_raw = close_raw.loc[:, ~close_raw.columns.duplicated()]
_write_atomic(close_raw, _data_path(market, "close"))
print(f"--- Saved {close_raw.shape[0]} days x {close_raw.shape[1]} tickers to "
for f in names:
raw[f] = raw[f].loc[:, ~raw[f].columns.duplicated()]
_write_atomic(raw[f], _data_path(market, f))
print(f"--- Saved {raw['close'].shape[0]} days x {raw['close'].shape[1]} tickers to "
f"{_data_path(market, 'close')} ---")
if with_open and open_raw is not None:
open_raw = open_raw.loc[:, ~open_raw.columns.duplicated()]
_write_atomic(open_raw, _data_path(market, "open"))
_save_unavailable(market, unavailable)
close = close_raw.ffill()
close = raw["close"].ffill()
out = {"close": close}
for f in names:
if f in ("open", "high", "low"):
out[f] = _fill_open(close, raw[f])
elif f == "volume":
out[f] = raw[f].reindex(index=close.index, columns=close.columns)
if fields is not None:
return out
if with_open:
return close, _fill_open(close, open_raw)
return close, out["open"]
return close
def load_fields(market: str, fields: list[str]) -> dict[str, pd.DataFrame]:
"""Stored panels without refreshing, aligned and filled the way ``update`` returns them."""
close = load(market, "close")
out = {"close": close}
for f in fields:
if f == "close":
continue
stored = load_raw(market, f)
if f == "volume":
out[f] = (stored.reindex(index=close.index, columns=close.columns)
if stored is not None else pd.DataFrame(np.nan, index=close.index, columns=close.columns))
else:
out[f] = _fill_open(close, stored)
return out
def rebuild(market: str, with_open: bool = True, years: int = 10) -> None:
"""Re-download the full history of every ticker in a stored panel.

128
main.py
View File

@@ -9,6 +9,7 @@ import factor_attribution
import metrics
import universe_history as uh
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
from strategies.base import BarStrategy
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.inverse_vol import InverseVolatilityStrategy
@@ -25,6 +26,99 @@ from universe import UNIVERSES
# Backtest engine
# ---------------------------------------------------------------------------
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)
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))
def backtest(
strategy,
data: pd.DataFrame,
@@ -35,6 +129,8 @@ def backtest(
fee_per_share: float = 0.0,
open_data: pd.DataFrame | None = None,
min_trade_value: float = 0.0,
bars: dict[str, pd.DataFrame] | None = None,
resize_band: float = 0.0,
) -> pd.Series:
"""
Vectorized backtest.
@@ -60,38 +156,32 @@ def backtest(
With fee_base=1.88 + fee_per_share=0.009 + fixed_fee=2.0 you get an
IBKR-style schedule: max(2, 1.88 + 0.009 * shares).
open_data : pd.DataFrame, optional
Open prices. When provided, enables open-to-close execution mode.
Open prices. When provided, the backtest keeps the book the way the
live trader does: its signal timing (``strategy_positions``), drifting
holdings, whole orders and ``resize_band`` (``simulate_live_book``).
min_trade_value : float
Per-ticker rebalances smaller than ``max(min_trade_value, 0.1% of
equity)`` are not charged the per-trade fee, mirroring the live
trader, which never places such orders. 0 (default) charges every
weight change.
bars : dict, optional
``{field: DataFrame}`` panels for a BarStrategy (close/open/high/low/volume).
Returns
-------
pd.Series
Daily equity curve.
"""
positions = strategy_positions(strategy, data, open_data=open_data, bars=bars)
if open_data is not None:
# Open-to-close mode:
# Strategy's shift(1) on open prices gives: weights[t] = f(open_{t-1})
# But open_t is known at morning of day t, so undo shift to get f(open_t)
# Then execute at close_t, earning close_t → close_{t+1}
weights = strategy.generate_signals(open_data)
weights = weights.shift(-1).fillna(0.0)
weights = weights.reindex(data.index).fillna(0.0)
positions = weights
equity, _ = simulate_live_book(
positions, data, initial_capital, transaction_cost, fixed_fee, fee_base,
fee_per_share, min_trade_value, resize_band,
)
return equity
# Returns earned: close[t] → close[t+1], weighted by positions decided at open[t]
close_returns = data.pct_change().fillna(0.0)
portfolio_returns = (close_returns * positions.shift(1).fillna(0.0)).sum(axis=1)
else:
# Classic close-to-close mode
weights = strategy.generate_signals(data)
weights = weights.reindex(data.index).fillna(0.0)
positions = weights
daily_returns = data.pct_change().fillna(0.0)
# 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

View File

@@ -25,32 +25,15 @@ import os
import sys
from datetime import datetime
import numpy as np
import pandas as pd
import data_manager
import metrics
import universe_history as uh
from backtest_runner import run_market
from bridge import (
MIN_TRADE_VALUE,
STRATEGY_META,
_build_strategy_result,
_sanitize_for_json,
benchmark_curve,
compute_rebalance_trades,
)
from main import backtest
from trader import (
strategy_extra_tickers,
strategy_supports_market,
ETF_STRATEGY_UNIVERSES,
MONITOR_CAPITAL,
STRATEGY_REGISTRY,
convert_quote_currency,
filter_tradable_tickers,
price_request,
)
from universe import UNIVERSES
from trader import MONITOR_CAPITAL, STRATEGY_REGISTRY, strategy_supports_market
# Initial capital per market, in local currency — the same amounts the live
@@ -102,171 +85,6 @@ def _equity_series_from_curve(curve: list[dict]) -> pd.Series:
return pd.Series(values, index=dates)
def run_market_raw(
market: str,
strategy_names: list[str],
years: int,
capital: float,
tx_cost: float,
fixed_fee: float,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
) -> dict | None:
"""Run all strategies once; return raw equity series + metadata per strategy."""
stock_strategies = []
etf_strategies = []
for name in strategy_names:
base_name = name.removeprefix("sim_")
if base_name in ETF_STRATEGY_UNIVERSES:
etf_strategies.append(name)
else:
stock_strategies.append(name)
raw_strategies: list[dict] = []
benchmark_label = "SPY"
bench_curve = []
start_date = None
end_date = None
# --- Stock strategies ---
if stock_strategies:
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark_ticker = universe["benchmark"]
benchmark_label = universe["benchmark_label"]
pit_intervals = None
if market == "us":
pit_intervals = uh.load_sp500_history()
historical_tickers = uh.all_tickers_ever(pit_intervals)
all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
log(f"PIT universe: {len(all_tickers)} tickers")
else:
all_tickers = sorted(set(tickers + [benchmark_ticker])
| {t for n in stock_strategies for t in strategy_extra_tickers(n)})
log(f"WARNING: {market.upper()} lacks PIT history; using current constituents")
log(f"Fetching price data for {len(all_tickers)} tickers...")
data = data_manager.update(market, all_tickers, with_open=False)
if isinstance(data, tuple):
data = data[0]
if years:
cutoff = data.index[-1] - pd.DateOffset(years=years)
data = data[data.index >= cutoff]
if pit_intervals is not None:
log("Applying PIT membership mask...")
data = uh.mask_prices(data, pit_intervals)
tickers = [
t for t in data.columns
if t != benchmark_ticker and data[t].notna().any()
]
else:
tickers = filter_tradable_tickers(data, tickers)
log(f"Universe: {len(tickers)} stocks, {data.index[0].date()} to {data.index[-1].date()}")
for name in stock_strategies:
log(f" Running: {name}")
try:
strategy = STRATEGY_REGISTRY[name]()
# Mixed stock+ETF strategies also read their fixed ETFs.
cols = tickers + [t for t in strategy_extra_tickers(name)
if t in data.columns and t not in tickers]
equity = backtest(
strategy, data[cols],
initial_capital=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,
)
weights = strategy.generate_signals(data[cols])
weights = weights.reindex(data[cols].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
raw_strategies.append({"name": name, "equity": equity, "trades": trade_events})
log(f" OK: {name} (final: ${equity.iloc[-1]:,.0f})")
except Exception as e:
log(f" FAILED: {name}: {e}")
# Benchmark on exactly the strategies' dates
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
start_date = data.index[0].strftime("%Y-%m-%d")
end_date = data.index[-1].strftime("%Y-%m-%d")
# --- ETF strategies ---
if etf_strategies:
for name in etf_strategies:
log(f" Running ETF strategy: {name}")
try:
base_name = name.removeprefix("sim_")
etf_tickers = ETF_STRATEGY_UNIVERSES[base_name]
all_etf = sorted(set(etf_tickers + ["SPY"]))
etf_data = data_manager.update("etfs", price_request(market, all_etf), with_open=False)
if isinstance(etf_data, tuple):
etf_data = etf_data[0]
etf_data = convert_quote_currency(etf_data)
if years:
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
etf_data = etf_data[etf_data.index >= cutoff]
strategy = STRATEGY_REGISTRY[name]()
tradable = filter_tradable_tickers(etf_data, etf_tickers)
equity = backtest(
strategy, etf_data[tradable],
initial_capital=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,
)
weights = strategy.generate_signals(etf_data[tradable])
weights = weights.reindex(etf_data[tradable].index).fillna(0.0)
trade_events = compute_rebalance_trades(weights)
raw_strategies.append({"name": name, "equity": equity, "trades": trade_events})
log(f" OK: {name} (final: ${equity.iloc[-1]:,.0f})")
except Exception as e:
log(f" FAILED: {name}: {e}")
if not stock_strategies:
benchmark_label = "SPY (Benchmark)"
spy_data = data_manager.update("etfs", ["SPY"], with_open=False)
if isinstance(spy_data, tuple):
spy_data = spy_data[0]
if years:
cutoff = spy_data.index[-1] - pd.DateOffset(years=years)
spy_data = spy_data[spy_data.index >= cutoff]
if "SPY" in spy_data.columns:
bench_curve = benchmark_curve(spy_data["SPY"], spy_data.index, capital)
start_date = spy_data.index[0].strftime("%Y-%m-%d")
end_date = spy_data.index[-1].strftime("%Y-%m-%d")
if not raw_strategies:
return None
return {
"market": market,
"strategy_runs": raw_strategies,
"benchmark_label": benchmark_label,
"benchmark_curve": bench_curve,
"start_date": start_date,
"end_date": end_date,
"tx_cost": tx_cost,
"fixed_fee": fixed_fee,
}
def build_scenario_for_market(
raw: dict,
scenario: dict,
@@ -358,7 +176,7 @@ def main():
log("=" * 60)
# US fee schedule (IBKR-style tiered):
# fee = max(2.0, 1.88 + 0.009 * shares)
us_raw = run_market_raw(
us_raw = run_market(
"us", us_strategies,
years=args.years, capital=us_capital,
tx_cost=0.001, fixed_fee=2.0,
@@ -368,7 +186,7 @@ def main():
log("=" * 60)
log(f"CN MARKET: {len(cn_strategies)} strategies @ ¥{cn_capital:,.0f}")
log("=" * 60)
cn_raw = run_market_raw(
cn_raw = run_market(
"cn", cn_strategies,
years=args.years, capital=cn_capital,
tx_cost=0.001, fixed_fee=5.0,

View File

@@ -10,5 +10,6 @@ dependencies = [
"numpy>=2.4.0",
"pandas>=2.3.3",
"scipy>=1.17.1",
"xlrd>=2.0.2",
"yfinance>=1.0",
]

View File

@@ -1,5 +1,8 @@
from abc import ABC, abstractmethod
import pandas as pd
class Strategy(ABC):
"""
Abstract base class for all strategies.
@@ -15,3 +18,32 @@ class Strategy(ABC):
containing the trading signals (e.g., 'buy', 'hold', 'sell').
"""
pass
class BarStrategy(Strategy):
"""Strategy on completed daily bars with several fields.
``fields`` names the panels it reads among close/open/high/low/volume.
``generate_signals(bars)`` takes ``{field: DataFrame}`` (a bare close
DataFrame is accepted when only "close" is needed) and returns weights
whose row t uses bars through t-1, like every Strategy. Backtests may add
``bars["eligible"]`` (bool, point-in-time index membership): prices stay
unmasked so signals see full histories, and only eligible names are held.
Timing differs from close-price strategies in the live trader: those read
the session's open as their latest price, while a BarStrategy only uses
completed bars, so the weights of row t trade at the close of t.
"""
fields: tuple[str, ...] = ("close",)
def generate_signals(self, data):
bars = data if isinstance(data, dict) else {"close": data}
missing = [f for f in self.fields if f not in bars]
if missing:
raise ValueError(f"{type(self).__name__} needs bar fields {missing}")
return self.generate_weights(bars)
@abstractmethod
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:
"""Weights (dates x assets), row t using bars through t-1."""

View File

@@ -0,0 +1,194 @@
"""
Robust momentum strategies from the 2026-09 point-in-time factor research
(``research/lab``). They were selected by their worst 5-year Sharpe over
2006/2010-2026 on PIT index members with next-open execution, not by their
best window.
Each score averages cross-sectional percentile ranks of its factors. Holdings
are the top ``top_n`` names, re-selected every ``rebal_freq`` rows; a held
name stays while its rank is within ``top_n * (1 + buffer)`` (hysteresis cuts
turnover without giving up the signal). Positions are equal-weighted on
rebalance rows and drift with prices in between, so winners are not trimmed
daily (worth ~3-4 pp a year for overnight_recovery against daily re-weighting).
Signals:
overnight_recovery (US) overnight-return momentum (12m, skip 1m)
+ distance from the 126-day low
lowrisk_momentum (CN) amplitude-cut momentum + low beta
+ negative price-volume correlation + momentum/vol
lowbeta_sharpe_mom (CN) low beta + momentum/vol (close prices only)
"""
import numpy as np
import pandas as pd
from numpy.lib.stride_tricks import sliding_window_view
from strategies.base import BarStrategy
# ---------------------------------------------------------------------------
# Factors: higher = more attractive; row t uses data through t
# ---------------------------------------------------------------------------
def _returns(close: pd.DataFrame) -> pd.DataFrame:
return close.pct_change(fill_method=None)
def overnight_momentum(open_: pd.DataFrame, close: pd.DataFrame,
window: int = 252, skip: int = 21) -> pd.DataFrame:
"""Sum of log overnight returns (open / previous close), Lou-Polk-Skouras."""
overnight = np.log(open_ / close.ffill().shift(1))
return overnight.rolling(window, min_periods=180).sum().shift(skip)
def recovery(close: pd.DataFrame, window: int = 126) -> pd.DataFrame:
c = close.ffill()
return c / c.rolling(window, min_periods=window).min() - 1
def momentum_12_1(close: pd.DataFrame) -> pd.DataFrame:
c = close.ffill()
return c.shift(21) / c.shift(252) - 1
def sharpe_momentum(close: pd.DataFrame) -> pd.DataFrame:
"""12-1 month return divided by 12-month daily volatility."""
return momentum_12_1(close) / _returns(close).rolling(252, min_periods=180).std()
def low_beta(close: pd.DataFrame, window: int = 252) -> pd.DataFrame:
"""Negative rolling beta to the equal-weight average of the panel."""
r = _returns(close)
m = r.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()
e_y = m.rolling(window, min_periods=minp).mean()
var_y = (m ** 2).rolling(window, min_periods=minp).mean() - e_y ** 2
return -(e_xy - e_x.mul(e_y, axis=0)).div(var_y, axis=0)
def price_volume_corr(close: pd.DataFrame, volume: pd.DataFrame) -> pd.DataFrame:
"""Negative 21-day correlation of price and dollar volume."""
return -close.ffill().rolling(21, min_periods=15).corr(close * volume)
def amplitude_cut_momentum(high: pd.DataFrame, low: pd.DataFrame, close: pd.DataFrame,
window: int = 160, keep: float = 0.7) -> pd.DataFrame:
"""Sum of daily returns over the ``keep`` share of lowest-amplitude
(high/low - 1) days in the window (开源证券, 2020): calm up-moves persist,
noisy ones reverse."""
amp = (high / low - 1).to_numpy(dtype=float)
r = _returns(close).to_numpy(dtype=float)
out = np.full(r.shape, np.nan)
if len(r) >= window:
A = sliding_window_view(np.nan_to_num(amp, nan=np.inf), window, axis=0)
R = sliding_window_view(np.nan_to_num(r, nan=0.0), window, axis=0)
for t0 in range(0, A.shape[0], 256): # chunks bound memory
a, rr = A[t0:t0 + 256], R[t0:t0 + 256]
thr = np.nanquantile(np.where(np.isinf(a), np.nan, a), keep, axis=2, keepdims=True)
out[t0 + window - 1:t0 + window - 1 + a.shape[0]] = np.where(a <= thr, rr, 0.0).sum(axis=2)
valid = pd.DataFrame(amp, index=close.index).notna().rolling(window).sum() >= 0.8 * window
return pd.DataFrame(out, index=close.index, columns=close.columns).where(valid.to_numpy())
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 signal_overnight_recovery(bars):
return _composite(overnight_momentum(bars["open"], bars["close"]), recovery(bars["close"]))
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))
def signal_lowbeta_sharpe_mom(bars):
c = bars["close"]
return _composite(low_beta(c), sharpe_momentum(c))
# name -> (bar fields, score function)
SIGNALS = {
"overnight_recovery": (("close", "open"), signal_overnight_recovery),
"lowrisk_momentum": (("close", "high", "low", "volume"), signal_lowrisk_momentum),
"lowbeta_sharpe_mom": (("close",), signal_lowbeta_sharpe_mom),
}
# ---------------------------------------------------------------------------
# Selection
# ---------------------------------------------------------------------------
def select_with_buffer(score: pd.DataFrame, top_n: int, rebal_freq: int,
buffer: float, warmup: int,
prices: pd.DataFrame | None = None) -> pd.DataFrame:
"""Equal weights on rebalance rows (every ``rebal_freq`` from ``warmup``);
in between they drift with ``prices`` (row k uses prices through k), or
stay constant when ``prices`` is None. Not yet shifted."""
S = score.to_numpy(dtype=float)
n, m = S.shape
W = np.full((n, m), np.nan)
held = np.zeros(m, dtype=bool)
for r in range(warmup, n, rebal_freq):
row = S[r]
valid = ~np.isnan(row)
w = np.zeros(m)
if valid.sum() >= top_n:
order = np.where(valid)[0][np.argsort(-row[valid], kind="stable")]
rank = np.full(m, np.inf)
rank[order] = np.arange(len(order))
keep = [i for i in np.where(held)[0] if rank[i] < top_n * (1 + buffer)]
chosen = sorted(keep, key=lambda i: rank[i])[:top_n]
taken = set(chosen)
for i in order:
if len(chosen) >= top_n:
break
if i not in taken:
chosen.append(i)
w[chosen] = 1.0 / len(chosen)
W[r] = w
held = w > 0
weights = pd.DataFrame(W, index=score.index, columns=score.columns).ffill().fillna(0.0)
weights.iloc[:warmup] = 0.0
if prices is not None:
px = prices.reindex_like(weights).ffill()
anchor = px.where(pd.Series(~np.isnan(W).all(axis=1), index=weights.index), axis=0).ffill()
grown = weights * (px / anchor).fillna(1.0)
weights = grown.div(grown.sum(axis=1).replace(0.0, np.nan), axis=0).fillna(0.0)
return weights
class RobustMomentumStrategy(BarStrategy):
def __init__(self, signal: str, top_n: int = 10, rebal_freq: int = 10,
buffer: float = 2.0, warmup: int = 252):
if signal not in SIGNALS:
raise ValueError(f"Unknown signal {signal!r}; available: {list(SIGNALS)}")
self.signal = signal
self.fields, self._score = SIGNALS[signal]
self.top_n = top_n
self.rebal_freq = rebal_freq
self.buffer = buffer
self.warmup = warmup
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}
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
# stock's full price history while only members are picked
eligible = close.notna()
if "eligible" in bars:
eligible &= bars["eligible"].reindex_like(close).fillna(False).astype(bool)
score = score.where(eligible)
weights = select_with_buffer(score, self.top_n, self.rebal_freq, self.buffer, self.warmup,
prices=close)
return weights.shift(1).fillna(0.0)

View File

@@ -0,0 +1,187 @@
"""Contracts added with the robust-momentum strategies: bar strategies are
causal and trade on the live trader's timing, whole-lot orders track targets,
panel backfills retry, and point-in-time CSI 300 membership."""
import unittest
from unittest import mock
import numpy as np
import pandas as pd
import data_manager
import trader
import universe_history as uh
from main import backtest, simulate_live_book, strategy_positions
from strategies.robust_momentum import SIGNALS, RobustMomentumStrategy, select_with_buffer
def _bars(n_days: int = 420, n_assets: int = 30, seed: int = 0) -> dict[str, pd.DataFrame]:
rng = np.random.default_rng(seed)
idx = pd.bdate_range("2024-01-01", periods=n_days)
cols = [f"S{i:02d}" for i in range(n_assets)]
r = rng.normal(0.0004, 0.02, (n_days, n_assets))
close = pd.DataFrame(100 * np.exp(np.cumsum(r, axis=0)), index=idx, columns=cols)
gap = rng.normal(0, 0.005, (n_days, n_assets))
opens = close.shift(1).bfill() * np.exp(gap)
high = np.maximum(opens, close) * (1 + rng.uniform(0, 0.02, (n_days, n_assets)))
low = np.minimum(opens, close) * (1 - rng.uniform(0, 0.02, (n_days, n_assets)))
volume = pd.DataFrame(rng.uniform(1e5, 1e6, (n_days, n_assets)), index=idx, columns=cols)
return {"close": close, "open": opens, "high": high, "low": low, "volume": volume}
class RobustMomentumTests(unittest.TestCase):
def test_weights_on_a_day_ignore_that_day_and_later_bars(self):
bars = _bars()
cut = bars["close"].index[380]
for signal in SIGNALS:
strat = RobustMomentumStrategy(signal, top_n=5, rebal_freq=5, buffer=1.0)
base = strat.generate_signals(bars)
shocked = {f: b.copy() for f, b in bars.items()}
for f in shocked:
shocked[f].loc[cut:] *= 3.0
again = strat.generate_signals(shocked)
pd.testing.assert_frame_equal(base.loc[:cut], again.loc[:cut], obj=signal)
self.assertGreater(base.loc[cut].sum(), 0.99, signal)
def test_held_names_stay_while_inside_the_buffer(self):
idx = pd.bdate_range("2025-01-01", periods=3)
score = pd.DataFrame([[5, 4, 3, 2, 1], [1, 2, 5, 4, 3], [1, 2, 5, 4, 3]],
index=idx, columns=list("ABCDE"), dtype=float)
w = select_with_buffer(score, top_n=2, rebal_freq=1, buffer=1.0, warmup=0)
self.assertEqual(set(w.columns[w.iloc[0] > 0]), {"A", "B"})
# Day 2: A ranks 5th (outside 2 * (1 + 1) = 4) and leaves; B ranks 4th and stays.
self.assertEqual(set(w.columns[w.iloc[1] > 0]), {"B", "C"})
def test_bar_strategy_trades_at_the_close_after_its_signal_day(self):
bars = _bars()
strat = RobustMomentumStrategy("lowbeta_sharpe_mom", top_n=5, rebal_freq=5)
weights = strat.generate_signals(bars)
held = strategy_positions(strat, bars["close"], open_data=bars["open"], bars=bars)
pd.testing.assert_frame_equal(held, weights.shift(1).fillna(0.0))
equity = backtest(strat, bars["close"], initial_capital=1000, open_data=bars["open"], bars=bars)
self.assertEqual(len(equity), len(bars["close"]))
def test_trader_session_signals_use_bars_through_the_previous_day(self):
bars = _bars()
days = bars["close"].index[-3:]
strat = RobustMomentumStrategy("overnight_recovery", top_n=5, rebal_freq=1)
full = strat.generate_signals(bars).loc[days]
got = trader.bar_session_weights(strat, bars, days)
pd.testing.assert_frame_equal(got, full)
# Morning run: today's bar does not exist yet.
today = days[-1] + pd.offsets.BDay()
morning = trader.bar_session_weights(strat, bars, pd.DatetimeIndex([today]))
self.assertAlmostEqual(morning.loc[today].sum(), 1.0)
class LiveBookSimulationTests(unittest.TestCase):
def test_holdings_drift_and_only_resizes_beyond_the_band_trade(self):
idx = pd.bdate_range("2026-01-05", periods=5)
data = pd.DataFrame({"A": [100, 100, 125, 125, 125], "B": [100] * 5, "C": [100] * 5},
index=idx, dtype=float)
third = [0, 1 / 3, 1 / 3, 1 / 3]
held = pd.DataFrame({"A": third + [0.0], "B": third + [0.5], "C": third + [0.5]}, index=idx)
equity, traded = simulate_live_book(held, data, 1000.0, transaction_cost=0.0,
resize_band=0.10)
orders = (traded.abs() > 0).sum(axis=1).tolist()
# Buy all three; A +25% drifts to 38.5% (15% over target): trim A only,
# B and C at 30.8% stay inside the band; then exit A, top up B and C.
self.assertEqual(orders, [0, 3, 0, 1, 3])
self.assertLess(traded.loc[idx[3], "A"], 0)
self.assertAlmostEqual(equity.iloc[2], 1000 * (1 + 0.25 / 3), places=6)
def test_orders_below_the_floor_are_skipped_but_exits_are_not(self):
idx = pd.bdate_range("2026-01-05", periods=3)
data = pd.DataFrame({"A": [10.0, 10.0, 10.0], "B": [10.0, 10.0, 10.0]}, index=idx)
held = pd.DataFrame({"A": [0, 0.004, 0.0], "B": [0, 0.5, 0.5]}, index=idx)
_, traded = simulate_live_book(held, data, 10_000.0, min_trade_value=50.0)
self.assertEqual(traded["A"].abs().sum(), 0.0) # a $40 entry is below $50
self.assertGreater(traded["B"].abs().sum(), 0.0)
class LotAllocationTests(unittest.TestCase):
def test_leftover_cash_buys_the_lot_that_brings_a_name_nearest_its_target(self):
prices = {"A": 45.0, "B": 13.0}
# 70k book, 50/50: A wants 777.8 shares, B 2692.3. Rounding down leaves
# 4.9k idle; it buys A's 8th lot (3.5k short of target vs half a lot
# 2.25k), after which B's extra lot no longer fits.
trades = trader.compute_trades({}, 70_000.0, {"A": 0.5, "B": 0.5}, prices,
integer_shares=True, lot_size=100)
shares = {t["ticker"]: t["shares_delta"] for t in trades}
self.assertEqual(shares, {"A": 800, "B": 2600})
spent = sum(s * prices[t] for t, s in shares.items())
self.assertLessEqual(spent, 70_000.0)
def test_a_lot_far_above_target_is_not_bought(self):
trades = trader.compute_trades({}, 10_000.0, {"A": 0.3, "B": 0.7}, {"A": 70.0, "B": 1.0},
integer_shares=True, lot_size=100)
# A's target is 3000 = 0.43 of a 7000 lot: stay out.
self.assertNotIn("A", {t["ticker"] for t in trades})
def test_small_drift_is_not_traded_but_entries_and_exits_are(self):
holdings = {"A": 10.0, "B": 10.0}
prices = {"A": 105.0, "B": 95.0, "C": 50.0}
trades = trader.compute_trades(holdings, 0.0, {"A": 0.5, "B": 0.5}, prices,
min_trade_value=1.0, resize_band=0.10)
self.assertEqual(trades, [])
trades = trader.compute_trades(holdings, 0.0, {"A": 0.5, "C": 0.5}, prices,
min_trade_value=1.0, resize_band=0.10)
self.assertEqual({t["ticker"]: t["direction"] for t in trades}, {"B": "SELL", "C": "BUY"})
class PanelBackfillTests(unittest.TestCase):
def test_tickers_missing_from_a_partial_batch_are_retried_before_giving_up(self):
idx = pd.bdate_range("2026-01-05", periods=4)
calls = []
def fake_download(tickers, start, end=None, fields=None):
calls.append(list(tickers))
ok = [t for t in tickers if t == "AAA" or (t == "BBB" and len(calls) > 1)]
frame = pd.DataFrame({t: [1.0, 2.0, 3.0, 4.0] for t in ok}, index=idx)
return {f: frame for f in fields}
with mock.patch.object(data_manager, "_download", fake_download):
got, missing = data_manager._backfill(["AAA", "BBB", "CCC"], "2026-01-05",
["Close", "Volume"], idx)
self.assertEqual(sorted(got["Close"].columns), ["AAA", "BBB"])
self.assertEqual(missing, ["CCC"])
self.assertEqual(calls[1], ["BBB", "CCC"])
class BacktestUniverseTests(unittest.TestCase):
def test_stock_universe_excludes_benchmarks_and_etfs_sharing_the_panel(self):
import backtest_runner
idx = pd.bdate_range("2026-01-05", periods=3)
panel = pd.DataFrame(1.0, index=idx, columns=["AAA", "BBB", "SPY", "TQQQ"])
fake_universe = {"us": {"fetch": lambda: ["AAA"], "benchmark": "SPY",
"benchmark_label": "SPY"}}
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)
self.assertEqual(data["stocks"], ["AAA", "BBB"])
class MembershipTests(unittest.TestCase):
def test_csi300_snapshot_applies_until_the_next_one(self):
snaps = {"2026-01-01": ["600000.SS", "000001.SZ"], "2026-02-01": ["600000.SS"]}
dates = pd.to_datetime(["2025-12-31", "2026-01-15", "2026-02-02"])
mask = uh.csi300_membership_mask(dates, ["600000.SS", "000001.SZ", "510300.SS"], snaps)
self.assertEqual(mask["000001.SZ"].tolist(), [False, True, False])
self.assertEqual(mask["600000.SS"].tolist(), [False, True, True])
# Never-member columns (benchmarks) are not masked.
self.assertTrue(mask["510300.SS"].all())
def test_changes_table_with_two_level_headers_is_found(self):
cols = pd.MultiIndex.from_tuples([("Effective Date", "Effective Date"), ("Added", "Ticker"),
("Removed", "Ticker"), ("Reason", "Reason")])
table = pd.DataFrame([["September 21, 2026", "BE", "TAP", "cap"]], columns=cols)
other = pd.DataFrame({"x": [1]})
changes = uh._find_changes_table([other, table])
self.assertEqual(list(changes.columns), ["Date", "Added_Ticker", "Removed_Ticker", "Reason_Reason"])
if __name__ == "__main__":
unittest.main()

226
trader.py
View File

@@ -40,6 +40,7 @@ import pandas as pd
import yfinance as yf
import data_manager
from strategies.base import BarStrategy
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.factor_combo import FactorComboStrategy
@@ -55,6 +56,7 @@ from strategies.permanent import (
TrendRiderV4,
)
from strategies.recovery_momentum import RecoveryMomentumStrategy
from strategies.robust_momentum import RobustMomentumStrategy
from strategies.trend_following import TrendFollowingStrategy
from strategies.trend_rider_v5 import TrendRiderV5
from strategies.trend_rider_voltgt import (
@@ -95,6 +97,12 @@ MARKET_FEE_TIERED = {
# board lots of 100.
MARKET_LOT_SIZE = {"us": 1, "cn": 100}
# A position already held is resized only once it is this far from its target
# (relative to the target value); entries and exits always trade. Without the
# band the books paid a ticket fee almost daily to trade weight drift that
# the backtests never trade.
RESIZE_BAND = 0.10
# Default live capital per market, in the market's own currency.
MONITOR_CAPITAL = {"us": 10_000.0, "cn": 70_000.0}
@@ -226,6 +234,16 @@ STRATEGY_REGISTRY = {
"recovery_quality_blend_top10": lambda **kw: RecoveryQualityBlendStrategy(top_n=10),
"improved_mom_quality_top20": lambda **kw: ImprovedMomentumQualityStrategy(top_n=20),
"improved_mom_quality_top10": lambda **kw: ImprovedMomentumQualityStrategy(top_n=10),
# --- Robust momentum (2026-09 PIT research; bar strategies) ---
"overnight_recovery_top10": lambda **kw: RobustMomentumStrategy(
"overnight_recovery", top_n=10, rebal_freq=10, buffer=2.0,
),
"lowrisk_momentum_top15": lambda **kw: RobustMomentumStrategy(
"lowrisk_momentum", top_n=15, rebal_freq=10, buffer=2.0,
),
"lowbeta_sharpe_mom_top20": lambda **kw: RobustMomentumStrategy(
"lowbeta_sharpe_mom", top_n=20, rebal_freq=10, buffer=1.0,
),
# --- TrendRiderV6: stock-picking + V5 regime engine ---
"trend_rider_v6": lambda **kw: TrendRiderV6(),
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
@@ -273,6 +291,12 @@ def strategy_extra_tickers(strategy_name: str) -> list[str]:
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), []))
def strategy_fields(strategy_name: str) -> tuple[str, ...]:
"""Bar fields a strategy reads; close-price strategies read ("close",)."""
strategy = STRATEGY_REGISTRY[strategy_name.removeprefix("sim_")]()
return tuple(strategy.fields) if isinstance(strategy, BarStrategy) else ("close",)
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_")
@@ -351,14 +375,15 @@ 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 += [f for f in strategy_fields(strategy_name) if f not in fields]
if refresh:
request = price_request(market, list(tickers) + [benchmark])
result = data_manager.update(data_market, request, with_open=with_open)
close, open_data = result if with_open else (result, None)
panels = data_manager.update(data_market, request, fields=fields)
else:
close = data_manager.load(data_market, "close")
open_data = (data_manager._fill_open(close, data_manager.load_raw(data_market, "open"))
if with_open else None)
panels = data_manager.load_fields(data_market, fields)
close = panels["close"]
open_data = panels["open"] if with_open else None
close_raw = data_manager.load_raw(data_market, "close").reindex(
index=close.index, columns=close.columns,
)
@@ -438,85 +463,102 @@ def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
return cash + stock_value
def generate_target_weights(strategy, open_data: pd.DataFrame, target_date) -> dict:
"""
Run strategy on full open price history, extract target_date's weights.
def strategy_bars(market: str, strategy_name: str, tickers: list[str],
close: pd.DataFrame, open_data: pd.DataFrame | None) -> dict | None:
"""Stored bar panels a BarStrategy reads (refreshed by load_strategy_prices),
aligned to ``close``; None for close-price strategies."""
fields = strategy_fields(strategy_name)
if not isinstance(STRATEGY_REGISTRY[strategy_name.removeprefix("sim_")](), BarStrategy):
return None
stored = data_manager.load_fields(strategy_data_market(market, strategy_name),
[f for f in fields if f not in ("close", "open")])
bars = {"close": close[tickers]}
if "open" in fields:
bars["open"] = open_data[tickers]
for f in fields:
if f not in bars:
bars[f] = stored[f].reindex(index=close.index, columns=tickers)
return bars
Strategies have an internal shift(1) designed for close prices: weights[t]
uses data up to t-1. With open prices (observable same-day), we want the
signal that uses data up to today. To get this, we append a dummy future
row so the strategy computes one extra day of signals, then we read that
last row — which incorporates today's open prices.
"""
# Append a dummy row one day after the last date so the strategy's
# shift(1) produces a valid signal row that uses target_date's open
dummy_date = open_data.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
[open_data.iloc[-1].values],
index=[dummy_date],
columns=open_data.columns,
)
extended = pd.concat([open_data, dummy_row])
weights = strategy.generate_signals(extended)
def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeIndex) -> pd.DataFrame:
"""Targets traded at the close of each day in ``days``: row d uses bars through d-1."""
hist = {f: b.loc[:days[-1]] for f, b in bars.items()}
close = hist["close"]
if days[-1] not in close.index:
# morning run before today's bar exists: add an empty row for today
hist = {f: pd.concat([b, pd.DataFrame(np.nan, index=[days[-1]], columns=b.columns)])
for f, b in hist.items()}
return strategy.generate_signals(hist).reindex(days).fillna(0.0)
# The dummy_date row in weights now uses data up to target_date (the real last day)
# Read the dummy row — that's today's actual signal
if dummy_date in weights.index:
row = weights.loc[dummy_date]
elif target_date in weights.index:
# Fallback: use target_date row (which uses data up to target_date - 1)
row = weights.loc[target_date]
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:
idx = weights.index.get_indexer([target_date], method="ffill")
if idx[0] >= 0:
row = weights.iloc[idx[0]]
else:
return {}
# Return only non-zero weights
return {t: float(w) for t, w in row.items() if w > 1e-6}
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) -> list[dict]:
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, share deltas are rounded toward zero to whole
multiples of ``lot_size`` (e.g. 100-share A-share board lots); a position
whose target is zero is always sold in full, odd lot included.
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)
raw = []
frozen = frozen or set()
all_tickers = set(list(target_weights.keys()) + list(holdings.keys()))
for ticker in sorted(all_tickers):
desired = {}
for ticker in sorted(set(target_weights) | set(holdings)):
if ticker in frozen:
continue
target_weight = target_weights.get(ticker, 0.0)
price = prices.get(ticker, 0.0)
if price <= 0:
continue
target_weight = target_weights.get(ticker, 0.0)
target_value = total * target_weight
target_shares = target_value / price
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
if integer_shares:
if target_weight <= 0 and current_shares > 0:
delta = -current_shares # full exit may include an odd lot
else:
# Round toward zero: never overshoot cash or over-sell.
delta = int(delta / lot_size) * lot_size
trade_value = abs(delta * price)
if trade_value < min_trade_value:
continue
@@ -735,25 +777,32 @@ def _strategy_fees(args, market: str) -> dict:
def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
days: pd.DatetimeIndex) -> pd.DataFrame:
"""Target weights for many sessions in one pass: row ``d`` uses opens up to ``d``.
days: pd.DatetimeIndex, bars: dict | None = None) -> pd.DataFrame:
"""Target weights traded at the close of each day in ``days``, in one pass.
Equivalent to ``generate_target_weights`` per day for causal strategies
(weights at t never depend on data after t), which every registered
strategy must be.
A close-price strategy reads opens as its prices: the row for ``d`` uses
opens up to ``d`` (a dummy row after the last open makes the strategy's
internal shift(1) yield that row). A BarStrategy uses completed bars
through d-1. Every registered strategy must be causal (weights at t never
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_REGISTRY[strategy_name]().generate_signals(pd.concat([hist, dummy]))
weights = strategy.generate_signals(pd.concat([hist, dummy]))
return weights.shift(-1).reindex(days).fillna(0.0)
def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp,
tickers: list[str], benchmark: str, close_data: pd.DataFrame,
open_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series | None = None) -> dict:
"""Signal on ``day``'s open, execute at ``day``'s close, record the day.
integer_shares: bool, signal_row: pd.Series | None = None,
bars: dict | None = None) -> dict:
"""Signal on ``day``'s open (a BarStrategy: bars through the previous
session), execute at ``day``'s close, record the day.
Tickers without a real closing print on ``day`` (forward-filled price) are
valued but not traded. ``signal_row`` supplies precomputed target weights.
@@ -761,9 +810,8 @@ def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp
day_str = str(day.date())
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
if signal_row is None:
strategy = STRATEGY_REGISTRY[strategy_name]()
target_weights = generate_target_weights(strategy, open_data.loc[:day, tickers], day)
else:
signal_row = _signals_for_sessions(strategy_name, open_data[tickers],
pd.DatetimeIndex([day]), bars).loc[day]
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
all_held = sorted(set(target_weights) | set(state["holdings"]))
@@ -778,6 +826,7 @@ def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp
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,
)
n_log = len(state["trade_log"])
execute_trades(state, trades, close_prices, trade_date=day_str,
@@ -840,9 +889,11 @@ def cmd_morning(args):
print(f" Strategy: {strategy_name} | Market: {market.upper()}")
print(f"{'='*60}")
# Run strategy on open prices
strategy = STRATEGY_REGISTRY[strategy_name]()
target_weights = generate_target_weights(strategy, open_data[tickers], today)
# 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]
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
# Get today's open prices for trade computation
open_prices = get_prices_for_date(
@@ -854,7 +905,8 @@ def cmd_morning(args):
trades = compute_trades(state["holdings"], state["cash"], target_weights,
open_prices, min_trade_value=max(50, total * 0.001),
integer_shares=args.integer_shares,
lot_size=MARKET_LOT_SIZE.get(market, 1))
lot_size=MARKET_LOT_SIZE.get(market, 1),
resize_band=RESIZE_BAND)
# Store pending
state["pending_trades"] = {
@@ -1335,18 +1387,8 @@ def cmd_simulate(args):
# Pre-compute strategy weights for the full period (much faster than per-day)
# Append dummy row so shift(1) inside strategy produces valid last-day signal
strategy = STRATEGY_REGISTRY[strategy_name]()
open_tickers = open_data[tickers]
dummy_date = open_tickers.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
[open_tickers.iloc[-1].values], index=[dummy_date], columns=open_tickers.columns
)
extended = pd.concat([open_tickers, dummy_row])
raw_weights = strategy.generate_signals(extended)
# Strategy's shift(1): raw_weights[t] uses data up to t-1
# For date t, the signal using open[t] is in raw_weights[t+1]
# So shift(-1) to align: full_weights[t] = signal using open[t]
full_weights = raw_weights.shift(-1).fillna(0.0)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
full_weights = _signals_for_sessions(strategy_name, open_data[tickers], trading_days, bars)
print(f"\n{'='*70}")
print(f" SIMULATION: {strategy_name} | {market.upper()}")
@@ -1385,6 +1427,7 @@ def cmd_simulate(args):
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,
@@ -1710,13 +1753,16 @@ 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."""
requests: dict[str, set[str]] = {}
fields: dict[str, set[str]] = {}
for name in strategy_names:
tickers, benchmark = strategy_universe(market, name)
requests.setdefault(strategy_data_market(market, name), set()).update(
data_market = strategy_data_market(market, name)
requests.setdefault(data_market, set()).update(
price_request(market, list(tickers) + [benchmark]))
fields.setdefault(data_market, {"close", "open"}).update(strategy_fields(name))
for data_market, request in requests.items():
print(f"[{label}] Refreshing {data_market} panel for {len(request)} tickers")
data_manager.update(data_market, sorted(request), with_open=True)
data_manager.update(data_market, sorted(request), fields=sorted(fields[data_market]))
def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = None,
@@ -1759,12 +1805,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 = (_signals_for_sessions(strategy_name, open_data[tickers], todo)
if len(todo) > 1 else None)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
signals = _signals_for_sessions(strategy_name, open_data[tickers], todo, bars)
for day in todo:
r = _run_session(state, strategy_name, market, day, tickers, benchmark,
close_data, open_data, close_raw, fees, args.integer_shares,
signal_row=signals.loc[day] if signals is not None else None)
signal_row=signals.loc[day], bars=bars)
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

@@ -76,45 +76,70 @@ def get_sp500() -> list[str]:
# Yahoo Finance format: Shanghai → XXXXXX.SS, Shenzhen → XXXXXX.SZ
# Wikipedia CSI 300 page has a table with stock codes and exchanges.
CSI300_CONS_URL = ("https://oss-ch.csindex.com.cn/static/html/csindex/public/uploads/"
"file/autofile/cons/000300cons.xls")
def _csi300_official() -> list[str]:
"""Current constituents from China Securities Index's published file."""
req = urllib.request.Request(CSI300_CONS_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
payload = resp.read()
df = pd.read_excel(io.BytesIO(payload), dtype=str)
code_col = next(c for c in df.columns if "Constituent Code" in c)
exch_col = next(c for c in df.columns if c.startswith("交易所Exchange"))
tickers = [f"{code}.{'SS' if '上海' in exch else 'SZ'}"
for code, exch in zip(df[code_col], df[exch_col])]
if len(tickers) < 250:
raise RuntimeError(f"CSI 300 file has only {len(tickers)} rows")
return sorted(set(tickers))
def _csi300_latest_snapshot() -> list[str]:
with open(os.path.join(CACHE_DIR, "csi300_history.json")) as f:
snaps = json.load(f)["snapshots"]
return snaps[max(snaps)]
def _csi300_wikipedia() -> list[str]:
tables = _fetch_html_tables("https://en.wikipedia.org/wiki/CSI_300_Index")
# The constituent table has ~300 rows and a "Ticker" column like "SSE: 600519"
df = next((t for t in tables if len(t) >= 200 and "Ticker" in t.columns), None)
if df is None:
raise RuntimeError("Could not find CSI 300 constituent table on Wikipedia")
tickers = []
for raw in df["Ticker"].astype(str):
parts = raw.split(":")
if len(parts) == 2 and parts[1].strip().isdigit():
suffix = ".SS" if parts[0].strip().upper() == "SSE" else ".SZ"
tickers.append(parts[1].strip() + suffix)
return sorted(set(tickers))
def get_csi300() -> list[str]:
"""Fetch current CSI 300 constituents from Wikipedia."""
"""Current CSI 300 constituents.
Source order: China Securities Index's published constituent file, then
the latest snapshot in data/csi300_history.json, then Wikipedia (whose
table lagged the real index by ~60 names in 2026).
"""
cached = _read_cache("csi300")
if cached:
print(f"--- Loaded CSI 300 universe from cache ({len(cached)} tickers) ---")
return cached
print("--- Fetching CSI 300 constituents from Wikipedia ---")
url = "https://en.wikipedia.org/wiki/CSI_300_Index"
tables = _fetch_html_tables(url)
# Find the constituent table — it has ~300 rows and a "Ticker" column
# with format like "SSE: 600519" or "SZSE: 300750"
df = None
for t in tables:
if len(t) >= 200 and "Ticker" in t.columns:
df = t
break
if df is None:
raise RuntimeError("Could not find CSI 300 constituent table on Wikipedia")
tickers = []
for _, row in df.iterrows():
raw = str(row["Ticker"]) # e.g. "SSE: 600519" or "SZSE: 300750"
parts = raw.split(":")
if len(parts) != 2:
for label, source in (("csindex.com.cn", _csi300_official),
("stored CSI 300 history", _csi300_latest_snapshot),
("Wikipedia", _csi300_wikipedia)):
try:
tickers = source()
except Exception as exc: # noqa: BLE001 — fall through to the next source
print(f"--- CSI 300 from {label} failed: {exc} ---")
continue
exchange_prefix = parts[0].strip().upper()
code = parts[1].strip()
if not code.isdigit():
continue
suffix = ".SS" if exchange_prefix == "SSE" else ".SZ"
tickers.append(code + suffix)
tickers = sorted(set(tickers))
_write_cache("csi300", tickers)
print(f"--- Got {len(tickers)} CSI 300 tickers ---")
print(f"--- Got {len(tickers)} CSI 300 tickers from {label} ---")
return tickers
raise RuntimeError("No CSI 300 constituent source available")
# ---------------------------------------------------------------------------

View File

@@ -20,6 +20,7 @@ import os
import urllib.request
from datetime import date, datetime
import numpy as np
import pandas as pd
CACHE_DIR = "data"
@@ -30,21 +31,26 @@ _HEADERS = {"User-Agent": "Mozilla/5.0 (quant-backtest)"}
# Fetch + parse Wikipedia
# ---------------------------------------------------------------------------
def _fetch_sp500_tables() -> tuple[pd.DataFrame, pd.DataFrame]:
"""Return (current_list, changes_log) from the S&P 500 Wikipedia page."""
url = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
SP500_LIST_URL = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
# The change log moved off the list page in 2026; the list page is still tried
# first in case it moves back.
SP500_CHANGES_URLS = [
SP500_LIST_URL,
"https://en.wikipedia.org/wiki/Historical_components_of_the_S%26P_500",
]
def _read_tables(url: str) -> list[pd.DataFrame]:
req = urllib.request.Request(url, headers=_HEADERS)
with urllib.request.urlopen(req) as resp:
html = resp.read().decode("utf-8")
tables = pd.read_html(io.StringIO(html))
current = tables[0]
change_tables = [
t for t in tables[1:]
if any("Added" in "_".join(map(str, c if isinstance(c, tuple) else (c,))) for c in t.columns)
]
if not change_tables:
raise ValueError("S&P 500 change log table not found on the Wikipedia page")
changes = change_tables[0]
return pd.read_html(io.StringIO(html))
def _find_changes_table(tables: list[pd.DataFrame]) -> pd.DataFrame | None:
for t in tables:
if any("Added" in "_".join(map(str, c if isinstance(c, tuple) else (c,))) for c in t.columns):
changes = t.copy()
changes.columns = [
"_".join(c).strip() if isinstance(c, tuple) else c
for c in changes.columns
@@ -52,12 +58,24 @@ def _fetch_sp500_tables() -> tuple[pd.DataFrame, pd.DataFrame]:
changes.columns = [
c.replace("Effective Date_Effective Date", "Date") for c in changes.columns
]
return changes
return None
def _fetch_sp500_tables() -> tuple[pd.DataFrame, pd.DataFrame]:
"""Return (current_list, changes_log) from Wikipedia."""
current = _read_tables(SP500_LIST_URL)[0]
for url in SP500_CHANGES_URLS:
changes = _find_changes_table(_read_tables(url))
if changes is not None:
return current, changes
raise ValueError("S&P 500 change log table not found on Wikipedia")
def _normalize_ticker(t: str) -> str:
"""Yahoo Finance ticker format: BRK.B → BRK-B."""
return str(t).replace(".", "-").strip()
# Some change-log cells carry a trailing " |" separator.
return str(t).split("|")[0].replace(".", "-").strip()
# ---------------------------------------------------------------------------
@@ -243,3 +261,101 @@ def mask_prices(prices: pd.DataFrame,
"""
mask = membership_mask(prices.index, intervals, tickers=list(prices.columns))
return prices.where(mask)
# ---------------------------------------------------------------------------
# CSI 300 (China A-shares)
# ---------------------------------------------------------------------------
#
# data/csi300_history.json holds monthly constituent snapshots
# {"snapshots": {"YYYY-MM-DD": [yahoo tickers], ...}}
# seeded from baostock's query_hs300_stocks (first trading day of each month,
# 2008 onward). A snapshot applies from its date until the next one, so an
# index change is picked up within a month. load_csi300_history() appends a
# snapshot whenever the current constituent list (universe.get_csi300) differs
# from the latest one, which keeps the file current without baostock.
def _csi300_path() -> str:
return os.path.join(CACHE_DIR, "csi300_history.json")
def baostock_to_yahoo(code: str) -> str:
"""'sh.600000' -> '600000.SS', 'sz.000001' -> '000001.SZ'."""
ex, num = code.split(".")
return f"{num}.{'SS' if ex == 'sh' else 'SZ'}"
def load_csi300_history(current: list[str] | None = None) -> 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:
try:
from universe import get_csi300
current = get_csi300()
except Exception as exc: # noqa: BLE001 — offline runs use the stored history
print(f"--- WARNING: CSI 300 list unavailable ({exc}); using stored history ---")
current = None
if current:
last = snaps[max(snaps)]
today = str(date.today())
if sorted(current) != sorted(last) and today > max(snaps):
snaps[today] = sorted(current)
with open(path, "w") as f:
json.dump(data, f, indent=0)
print(f"--- CSI 300 constituents changed; stored snapshot {today} ---")
return snaps
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."""
snaps = snapshots if snapshots is not None else load_csi300_history()
keys = sorted(snaps)
col = {t: i for i, t in enumerate(tickers)}
arr = np.zeros((len(dates), len(tickers)), dtype=bool)
for i, k in enumerate(keys):
start = pd.Timestamp(k)
end = pd.Timestamp(keys[i + 1]) if i + 1 < len(keys) else dates[-1] + pd.Timedelta(days=1)
rows = (dates >= start) & (dates < end)
cols = [col[t] for t in snaps[k] if t in col]
if rows.any() and cols:
arr[np.ix_(rows, cols)] = True
mask = pd.DataFrame(arr, index=dates, columns=tickers)
# Tickers never in the index (benchmarks, ETFs) pass through unmasked.
ever = {t for lst in snaps.values() for t in lst}
for t in tickers:
if t not in ever:
mask[t] = True
return mask
def all_csi300_tickers_ever(snapshots: dict[str, list[str]] | None = None,
since: str | None = None) -> list[str]:
snaps = snapshots if snapshots is not None else load_csi300_history()
return sorted({t for k, lst in snaps.items() if since is None or k >= since for t in lst})
# ---------------------------------------------------------------------------
# Market-generic helpers
# ---------------------------------------------------------------------------
def pit_tickers_ever(market: str, since: str | None = None) -> list[str] | None:
"""Every ticker that was an index member (since ``since``), or None when the
market has no membership history."""
if market == "us":
return all_tickers_ever()
if market == "cn":
return all_csi300_tickers_ever(since=since)
return None
def mask_prices_pit(prices: pd.DataFrame, market: str) -> pd.DataFrame:
"""NaN out (date, ticker) pairs outside point-in-time index membership."""
if market == "us":
return mask_prices(prices)
if market == "cn":
return prices.where(csi300_membership_mask(prices.index, list(prices.columns)))
return prices

11
uv.lock generated
View File

@@ -770,6 +770,7 @@ dependencies = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "scipy" },
{ name = "xlrd" },
{ name = "yfinance" },
]
@@ -780,6 +781,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.4.0" },
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "scipy", specifier = ">=1.17.1" },
{ name = "xlrd", specifier = ">=2.0.2" },
{ name = "yfinance", specifier = ">=1.0" },
]
@@ -935,6 +937,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "xlrd"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" },
]
[[package]]
name = "yfinance"
version = "1.0"