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:
89
universe.py
89
universe.py
@@ -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 ---")
|
||||
return tickers
|
||||
_write_cache("csi300", tickers)
|
||||
print(f"--- Got {len(tickers)} CSI 300 tickers from {label} ---")
|
||||
return tickers
|
||||
raise RuntimeError("No CSI 300 constituent source available")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user