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

121
tests/test_live_pipeline.py Normal file
View File

@@ -0,0 +1,121 @@
"""Contracts of the live pipeline: price panel refresh, session gating,
order sizing, and how results are reported."""
import unittest
from datetime import datetime, timezone
import numpy as np
import pandas as pd
import bridge
import data_manager
import trader
from main import backtest
def _panel(rows: dict[str, list], start: str = "2026-09-14") -> pd.DataFrame:
idx = pd.bdate_range(start, periods=len(next(iter(rows.values()))))
return pd.DataFrame(rows, index=idx, dtype=float)
class PanelRefreshTests(unittest.TestCase):
def test_refresh_overwrites_stale_window_and_keeps_missing_bars_raw(self):
stored = _panel({"AAA": [10, 11, 11, 11, 12], "BBB": [5, 5, 5, 5, 5]})
window_start = stored.index[2]
fresh = pd.DataFrame(
{"AAA": [11.5, np.nan, 12.2]}, index=stored.index[2:],
)
merged = data_manager._merge_refresh(stored, fresh, window_start)
self.assertEqual(merged.loc[stored.index[2], "AAA"], 11.5)
self.assertTrue(np.isnan(merged.loc[stored.index[3], "AAA"]))
self.assertEqual(merged.loc[stored.index[4], "AAA"], 12.2)
# Tickers not in the refresh keep their stored values.
self.assertEqual(merged.loc[stored.index[3], "BBB"], 5)
def test_refresh_rescales_history_after_split_adjustment(self):
stored = _panel({"AAA": [100, 102, 104, 106, 108, 110, 112]})
window_start = stored.index[2]
# Yahoo now reports the whole window divided by 2 (2:1 split adjusted).
fresh = stored.loc[window_start:, ["AAA"]] / 2
merged = data_manager._merge_refresh(stored, fresh, window_start)
returns = merged["AAA"].pct_change().dropna()
self.assertTrue((returns > 0).all(), returns)
self.assertAlmostEqual(merged["AAA"].iloc[0], 50.0)
def test_rows_printed_by_a_minority_of_tickers_are_dropped(self):
panel = _panel({
"SPY": [1, 1, np.nan, 1, 1],
"UPRO": [1, 1, np.nan, 1, 1],
"GLD": [1, 1, np.nan, 1, 1],
"7200.HK": [1, 1, 1, 1, 1],
})
cleaned = data_manager._drop_sparse_rows(panel)
self.assertNotIn(panel.index[2], cleaned.index)
self.assertEqual(len(cleaned), 4)
class SessionAndOrderTests(unittest.TestCase):
def test_todays_bar_is_not_a_close_until_the_session_settles(self):
raw = _panel({"SPY": [1, 1, 1]}, start="2026-09-22")
today = raw.index[-1]
intraday = datetime(2026, 9, 24, 17, 0, tzinfo=timezone.utc) # 13:00 New York
settled = datetime(2026, 9, 24, 20, 30, tzinfo=timezone.utc) # 16:30 New York
self.assertNotIn(today, trader.completed_sessions(raw, "us", "SPY", now=intraday))
self.assertIn(today, trader.completed_sessions(raw, "us", "SPY", now=settled))
def test_days_without_a_benchmark_print_are_not_sessions(self):
raw = _panel({"SPY": [1, np.nan, 1], "7200.HK": [1, 1, 1]}, start="2026-09-02")
later = datetime(2026, 10, 1, tzinfo=timezone.utc)
sessions = trader.completed_sessions(raw, "etfs", "SPY", now=later)
self.assertNotIn(raw.index[1], sessions)
def test_a_share_orders_use_board_lots_and_exit_odd_lots(self):
trades = trader.compute_trades(
holdings={"600000.SS": 150}, cash=100_000.0,
target_weights={"600519.SS": 0.5}, prices={"600519.SS": 170.0, "600000.SS": 10.0},
min_trade_value=50, integer_shares=True, lot_size=100,
)
by_ticker = {t["ticker"]: t["shares_delta"] for t in trades}
self.assertEqual(by_ticker["600519.SS"] % 100, 0)
self.assertEqual(by_ticker["600000.SS"], -150)
def test_tickers_without_a_fresh_price_are_not_traded(self):
trades = trader.compute_trades(
holdings={"UPRO": 10}, cash=5_000.0, target_weights={"TQQQ": 1.0},
prices={"UPRO": 150.0, "TQQQ": 80.0}, frozen={"UPRO"},
)
self.assertEqual([t["ticker"] for t in trades], ["TQQQ"])
class ReportingTests(unittest.TestCase):
def test_periodic_returns_include_the_first_day_of_each_period(self):
idx = pd.to_datetime(["2026-01-30", "2026-02-02", "2026-02-27", "2026-03-02"])
equity = pd.Series([100.0, 110.0, 110.0, 121.0], index=idx)
monthly, yearly = bridge.compute_periodic_returns(equity)
self.assertAlmostEqual(monthly["2026-02"], 0.10)
self.assertAlmostEqual(monthly["2026-03"], 0.10)
self.assertAlmostEqual(yearly["2026"], 0.21)
def test_injected_cash_is_not_reported_as_performance(self):
idx = pd.bdate_range("2024-01-02", periods=400)
equity = pd.Series(10_000 * 1.0003 ** np.arange(400), index=idx)
schedule = {"amount": 5_000, "frequency": "yearly_months", "months": [2, 8], "startYear": 1}
plain = bridge._build_strategy_result("x", equity, [], None, 10_000)
injected = bridge._build_strategy_result("x", equity, [], schedule, 10_000)
self.assertAlmostEqual(injected["metrics"]["totalReturn"], plain["metrics"]["totalReturn"])
flows = injected["capitalFlows"]
self.assertEqual(flows["invested"], 10_000 + 5_000 * len(injected["injections"]))
self.assertAlmostEqual(flows["profit"], flows["finalValue"] - flows["invested"], places=2)
def test_ruined_portfolio_stays_at_zero(self):
class TwiceLevered:
def generate_signals(self, data):
return pd.DataFrame(2.0, index=data.index, columns=data.columns)
prices = pd.DataFrame({"X": [10.0, 10.0, 4.0, 5.0]}, index=pd.bdate_range("2026-01-01", periods=4))
equity = backtest(TwiceLevered(), prices, initial_capital=100)
self.assertTrue((equity.iloc[2:] == 0).all(), equity)
if __name__ == "__main__":
unittest.main()