Files
quant-trading/universe.py
Gahow Wang cc1366b2dc 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>
2026-09-25 19:12:02 +08:00

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)",
},
}