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>
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
"""
|
|
Stock universe providers — fetch index constituents for backtesting.
|
|
|
|
Each function returns a dict with 'tickers' (list[str]) and 'benchmark' (str)
|
|
compatible with Yahoo Finance ticker format. Results are cached locally so
|
|
Wikipedia / web scrapes only happen once per day.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
from datetime import date
|
|
|
|
import pandas as pd
|
|
|
|
_HEADERS = {"User-Agent": "Mozilla/5.0 (quant-backtest)"}
|
|
|
|
|
|
def _fetch_html_tables(url: str) -> list[pd.DataFrame]:
|
|
"""Fetch HTML tables from a URL with a proper User-Agent."""
|
|
req = urllib.request.Request(url, headers=_HEADERS)
|
|
with urllib.request.urlopen(req) as resp:
|
|
html = resp.read().decode("utf-8")
|
|
return pd.read_html(io.StringIO(html))
|
|
|
|
CACHE_DIR = "data"
|
|
|
|
|
|
def _read_cache(name: str) -> list[str] | None:
|
|
path = os.path.join(CACHE_DIR, f"universe_{name}.json")
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
if data.get("date") == str(date.today()):
|
|
return data["tickers"]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _write_cache(name: str, tickers: list[str]) -> None:
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
path = os.path.join(CACHE_DIR, f"universe_{name}.json")
|
|
with open(path, "w") as f:
|
|
json.dump({"date": str(date.today()), "tickers": tickers}, f)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# S&P 500
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_sp500() -> list[str]:
|
|
"""Fetch current S&P 500 constituents from Wikipedia."""
|
|
cached = _read_cache("sp500")
|
|
if cached:
|
|
print(f"--- Loaded S&P 500 universe from cache ({len(cached)} tickers) ---")
|
|
return cached
|
|
|
|
print("--- Fetching S&P 500 constituents from Wikipedia ---")
|
|
url = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
|
|
tables = _fetch_html_tables(url)
|
|
df = tables[0]
|
|
tickers = sorted(df["Symbol"].str.replace(".", "-", regex=False).tolist())
|
|
_write_cache("sp500", tickers)
|
|
print(f"--- Got {len(tickers)} S&P 500 tickers ---")
|
|
return tickers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CSI 300
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# 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]:
|
|
"""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
|
|
|
|
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
|
|
_write_cache("csi300", tickers)
|
|
print(f"--- Got {len(tickers)} CSI 300 tickers from {label} ---")
|
|
return tickers
|
|
raise RuntimeError("No CSI 300 constituent source available")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UNIVERSES = {
|
|
"us": {
|
|
"fetch": get_sp500,
|
|
"benchmark": "SPY",
|
|
"benchmark_label": "SPY (Benchmark)",
|
|
},
|
|
"cn": {
|
|
"fetch": get_csi300,
|
|
# Yahoo's 000300.SS index series is truncated (and at times a single
|
|
# row); the CSI 300 ETF has a continuous history back to 2012.
|
|
"benchmark": "510300.SS",
|
|
"benchmark_label": "CSI 300 ETF 510300 (Benchmark)",
|
|
},
|
|
}
|