fix(report): correct periodic returns, injection metrics and live view

- Monthly/yearly returns chain from the previous period's close (the first
  trading day of every period was dropped; up to ~9pp/yr error).
- With cash injections, metrics/drawdowns/periodic returns are time-weighted
  on the no-injection curve; capitalFlows reports invested/profit separately.
- Benchmarks cover exactly the strategies' dates.
- CAGR uses calendar time, so gaps in live records do not inflate it.
- Backtests skip the per-trade fee for rebalances below the live minimum
  order and keep a ruined portfolio at zero.
- live: exact strategy match, ineligible strategies hidden, currency,
  startDate, same-window benchmark, stale-price fallback, equity anchored at
  the initial capital.
- PIT membership falls back to the cached history when the Wikipedia change
  log cannot be parsed (the table is no longer on the page).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 01:16:33 +08:00
parent 11f4dd95dd
commit 1697bc1641
5 changed files with 333 additions and 146 deletions

View File

@@ -38,7 +38,13 @@ def _fetch_sp500_tables() -> tuple[pd.DataFrame, pd.DataFrame]:
html = resp.read().decode("utf-8")
tables = pd.read_html(io.StringIO(html))
current = tables[0]
changes = tables[1]
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]
changes.columns = [
"_".join(c).strip() if isinstance(c, tuple) else c
for c in changes.columns
@@ -161,7 +167,16 @@ def load_sp500_history(force_refresh: bool = False) -> dict[str, list[list[str |
except Exception:
pass
print("--- Rebuilding S&P 500 membership history from Wikipedia ---")
intervals = build_sp500_history()
try:
intervals = build_sp500_history()
except Exception as exc: # noqa: BLE001 — page layout changes must not break backtests
if not os.path.exists(path):
raise
with open(path) as f:
cached = json.load(f)
print(f"--- WARNING: rebuild failed ({exc}); using membership history "
f"cached on {cached.get('date')} ---")
return cached["intervals"]
os.makedirs(CACHE_DIR, exist_ok=True)
with open(path, "w") as f:
json.dump({"date": str(date.today()), "intervals": intervals}, f)