chore: backtest engine fee model, metrics, and strategy fixes
- main.py: add IBKR-style tiered fee schedule (fee_base + fee_per_share), PIT universe support, and open-to-close execution improvements - metrics.py: add raw_summary helper for JSON-safe metric export - Misc strategy fixes: deprecation warnings, NaN handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
74
main.py
74
main.py
@@ -7,6 +7,7 @@ import pandas as pd
|
||||
import data_manager
|
||||
import factor_attribution
|
||||
import metrics
|
||||
import universe_history as uh
|
||||
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
|
||||
from strategies.buy_and_hold import BuyAndHoldStrategy
|
||||
from strategies.dual_momentum import DualMomentumStrategy
|
||||
@@ -30,6 +31,8 @@ def backtest(
|
||||
initial_capital: float = 100_000,
|
||||
transaction_cost: float = 0.001,
|
||||
fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0,
|
||||
fee_per_share: float = 0.0,
|
||||
open_data: pd.DataFrame | None = None,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
@@ -46,14 +49,17 @@ def backtest(
|
||||
transaction_cost : float
|
||||
One-way cost per unit of turnover (e.g. 0.001 = 10 bps).
|
||||
fixed_fee : float
|
||||
Fixed dollar cost per individual trade (each buy or sell).
|
||||
Floor of the per-trade fee (e.g. 2.0 = $2 minimum per buy/sell).
|
||||
With fee_per_share=0 (default), this is also the actual per-trade fee.
|
||||
fee_base : float
|
||||
Fixed component of a per-share tiered fee schedule. The actual
|
||||
per-trade fee is ``max(fixed_fee, fee_base + fee_per_share * shares)``.
|
||||
fee_per_share : float
|
||||
Per-share variable component of the tiered fee (e.g. 0.009 = $0.009/share).
|
||||
With fee_base=1.88 + fee_per_share=0.009 + fixed_fee=2.0 you get an
|
||||
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:
|
||||
- Morning: observe open prices → run strategy → decide weights
|
||||
- Evening: execute all trades at close prices
|
||||
Strategies have an internal shift(1) designed for close prices.
|
||||
Since open prices are observable same-day (before close), we undo
|
||||
that shift so signals use today's open and execute at today's close.
|
||||
Open prices. When provided, enables open-to-close execution mode.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -86,13 +92,32 @@ def backtest(
|
||||
turnover = positions.diff().abs().sum(axis=1).fillna(0.0)
|
||||
portfolio_returns -= turnover * transaction_cost
|
||||
|
||||
# Fixed per-trade fee: count positions with non-zero weight change
|
||||
if fixed_fee > 0:
|
||||
# Per-trade fee. Supports both flat ($2/trade) and tiered (IBKR-style)
|
||||
# schedules: fee = max(fixed_fee, fee_base + fee_per_share * shares).
|
||||
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
|
||||
weight_changes = positions.diff().fillna(0.0)
|
||||
n_trades = (weight_changes.abs() > 1e-8).sum(axis=1)
|
||||
# Build running equity to convert dollar fees to return impact
|
||||
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
|
||||
fee_impact = (n_trades * fixed_fee) / equity_running.shift(1).fillna(initial_capital)
|
||||
eq_prev = equity_running.shift(1).fillna(initial_capital)
|
||||
|
||||
if fee_per_share > 0:
|
||||
# Convert per-ticker weight change into share count traded.
|
||||
# dollar_traded[i, t] = |w[i,t] - w[i,t-1]| * equity[t-1]
|
||||
# shares_traded[i, t] = dollar_traded / price[i, t]
|
||||
dollar_traded = weight_changes.abs().mul(eq_prev, axis=0)
|
||||
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(
|
||||
lower=fixed_fee,
|
||||
)
|
||||
trade_mask = weight_changes.abs() > 1e-8
|
||||
per_trade_fee = per_trade_fee.where(trade_mask, 0.0)
|
||||
daily_fee = per_trade_fee.sum(axis=1)
|
||||
else:
|
||||
n_trades = (weight_changes.abs() > 1e-8).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
|
||||
@@ -184,7 +209,16 @@ def main() -> None:
|
||||
tickers = universe["fetch"]()
|
||||
benchmark = universe["benchmark"]
|
||||
benchmark_label = universe["benchmark_label"]
|
||||
all_tickers = sorted(set(tickers + [benchmark]))
|
||||
|
||||
# PIT universe: include all historical index members for US market
|
||||
pit_intervals = None
|
||||
if args.market == "us":
|
||||
pit_intervals = uh.load_sp500_history()
|
||||
historical_tickers = uh.all_tickers_ever(pit_intervals)
|
||||
all_tickers = sorted(set(tickers + historical_tickers + [benchmark]))
|
||||
print(f"--- PIT universe: {len(all_tickers)} tickers (current + historical members) ---")
|
||||
else:
|
||||
all_tickers = sorted(set(tickers + [benchmark]))
|
||||
|
||||
result = data_manager.update(args.market, all_tickers, with_open=use_open)
|
||||
if use_open:
|
||||
@@ -200,8 +234,18 @@ def main() -> None:
|
||||
open_data = open_data[open_data.index >= cutoff]
|
||||
print(f"--- Sliced to last {args.years} years: {data.index[0].date()} to {data.index[-1].date()} ---")
|
||||
|
||||
# Filter tickers to only those in the data
|
||||
tickers = [t for t in tickers if t in data.columns]
|
||||
# Apply PIT mask: NaN out prices for non-member dates
|
||||
if pit_intervals is not None:
|
||||
print("--- Applying PIT membership mask (survivorship-bias fix) ---")
|
||||
data = uh.mask_prices(data, pit_intervals)
|
||||
if open_data is not None:
|
||||
open_data = uh.mask_prices(open_data, pit_intervals)
|
||||
|
||||
# Filter tickers to only those with any valid data
|
||||
if pit_intervals is not None:
|
||||
tickers = [t for t in data.columns if t != benchmark and data[t].notna().any()]
|
||||
else:
|
||||
tickers = [t for t in tickers if t in data.columns]
|
||||
print(f"--- Universe: {len(tickers)} stocks + {benchmark} benchmark ---")
|
||||
|
||||
top_n = args.top_n if args.top_n else max(5, len(tickers) // 10)
|
||||
|
||||
Reference in New Issue
Block a user