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

@@ -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")
# ---------------------------------------------------------------------------