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

26
main.py
View File

@@ -34,6 +34,7 @@ def backtest(
fee_base: float = 0.0,
fee_per_share: float = 0.0,
open_data: pd.DataFrame | None = None,
min_trade_value: float = 0.0,
) -> pd.Series:
"""
Vectorized backtest.
@@ -60,6 +61,11 @@ def backtest(
IBKR-style schedule: max(2, 1.88 + 0.009 * shares).
open_data : pd.DataFrame, optional
Open prices. When provided, enables open-to-close execution mode.
min_trade_value : float
Per-ticker rebalances smaller than ``max(min_trade_value, 0.1% of
equity)`` are not charged the per-trade fee, mirroring the live
trader, which never places such orders. 0 (default) charges every
weight change.
Returns
-------
@@ -98,6 +104,11 @@ def backtest(
weight_changes = positions.diff().fillna(0.0)
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
eq_prev = equity_running.shift(1).fillna(initial_capital)
if min_trade_value > 0:
threshold = np.maximum(min_trade_value, 0.001 * eq_prev.abs())
charged = weight_changes.abs().mul(eq_prev.abs(), axis=0).ge(threshold, axis=0)
else:
charged = weight_changes.abs() > 1e-8
if fee_per_share > 0:
# Convert per-ticker weight change into share count traded.
@@ -107,20 +118,27 @@ def backtest(
shares_traded = dollar_traded.div(data).replace(
[np.inf, -np.inf], 0.0,
).fillna(0.0)
per_trade_fee = (fee_base + fee_per_share * shares_traded).clip(
# The per-share schedule applies to US-listed shares only.
per_share = pd.Series(fee_per_share, index=data.columns)
per_share[[c for c in data.columns if str(c).endswith(".HK")]] = 0.0
per_trade_fee = (fee_base + shares_traded.mul(per_share, axis=1)).clip(
lower=fixed_fee,
)
trade_mask = weight_changes.abs() > 1e-8
per_trade_fee = per_trade_fee.where(trade_mask, 0.0)
per_trade_fee = per_trade_fee.where(charged, 0.0)
daily_fee = per_trade_fee.sum(axis=1)
else:
n_trades = (weight_changes.abs() > 1e-8).sum(axis=1)
n_trades = charged.sum(axis=1)
daily_fee = n_trades * fixed_fee
fee_impact = daily_fee / eq_prev
portfolio_returns -= fee_impact
equity = (1 + portfolio_returns).cumprod() * initial_capital
# A portfolio that loses everything stays at zero; compounding negative
# equity would otherwise produce meaningless values and metrics.
ruined = (equity <= 0).cummax()
if ruined.any():
equity = equity.where(~ruined, 0.0)
return equity