fix: unify backtest execution and add forward validation candidates
Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band. Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
253
main.py
253
main.py
@@ -10,6 +10,8 @@ import metrics
|
||||
import universe_history as uh
|
||||
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
|
||||
from strategies.base import BarStrategy
|
||||
from signals import session_targets
|
||||
from execution import simulate_book
|
||||
from strategies.buy_and_hold import BuyAndHoldStrategy
|
||||
from strategies.dual_momentum import DualMomentumStrategy
|
||||
from strategies.inverse_vol import InverseVolatilityStrategy
|
||||
@@ -27,96 +29,40 @@ from universe import UNIVERSES
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def strategy_positions(strategy, data: pd.DataFrame, open_data: pd.DataFrame | None = None,
|
||||
bars: dict[str, pd.DataFrame] | None = None) -> pd.DataFrame:
|
||||
"""Weights actually held over (close t-1, close t] for each row t.
|
||||
|
||||
Close mode (``open_data`` is None): row t's weights use closes through t-1
|
||||
and trade at the close of t-1 — the same bar the signal saw.
|
||||
Live mode (``open_data`` given), matching trader.py:
|
||||
* close-price strategies read the session's open as their latest price
|
||||
and trade at that session's close;
|
||||
* a BarStrategy reads completed bars through t-1 and trades at the close
|
||||
of t.
|
||||
"""
|
||||
if isinstance(strategy, BarStrategy):
|
||||
weights = strategy.generate_signals(bars if bars is not None else {"close": data})
|
||||
weights = weights.reindex(data.index).fillna(0.0)
|
||||
return weights.shift(1).fillna(0.0) if open_data is not None else weights
|
||||
if open_data is not None:
|
||||
# The strategy's shift(1) on opens gives weights[t] = f(open_{t-1});
|
||||
# undo it so the row-t decision uses open_t, then hold it from close t.
|
||||
weights = strategy.generate_signals(open_data).shift(-1).fillna(0.0)
|
||||
return weights.reindex(data.index).fillna(0.0).shift(1).fillna(0.0)
|
||||
return strategy.generate_signals(data).reindex(data.index).fillna(0.0)
|
||||
bars: dict[str, pd.DataFrame] | None = None,
|
||||
eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
"""Weights held over (close t-1, close t], after a feasible prior close fill."""
|
||||
if eligible is None and bars is not None:
|
||||
eligible = bars.get("eligible")
|
||||
return session_targets(strategy, data, open_data, bars, eligible).shift(1).fillna(0.0)
|
||||
|
||||
|
||||
def simulate_live_book(held: pd.DataFrame, data: pd.DataFrame, initial_capital: float,
|
||||
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
min_trade_value: float = 0.0,
|
||||
resize_band: float = 0.0) -> tuple[pd.Series, pd.DataFrame]:
|
||||
"""Day-by-day book kept the way trader.py keeps it.
|
||||
|
||||
``held`` row t holds the target weights for (close t-1, close t]. Holdings
|
||||
drift with prices; at each close the book trades toward the next row's
|
||||
targets: entries and exits always, a held name only once it is at least
|
||||
``resize_band`` (relative to its target) away. Orders smaller than
|
||||
``max(min_trade_value, 0.1% of equity)`` are skipped unless they close a
|
||||
position. Costs are charged on the orders placed.
|
||||
|
||||
Returns (equity, traded weight per ticker on the row whose holdings changed).
|
||||
"""
|
||||
targets = held.reindex(index=data.index, columns=data.columns).fillna(0.0).to_numpy()
|
||||
prices = data.ffill().to_numpy(dtype=float)
|
||||
rets = np.nan_to_num(data.ffill().pct_change(fill_method=None).to_numpy(dtype=float), nan=0.0)
|
||||
per_share = np.array([0.0 if str(c).endswith(".HK") else fee_per_share for c in data.columns])
|
||||
n, m = targets.shape
|
||||
w = np.zeros(m)
|
||||
value = float(initial_capital)
|
||||
equity = np.empty(n)
|
||||
traded = np.zeros((n, m))
|
||||
for t in range(n):
|
||||
if value <= 0:
|
||||
equity[t:] = 0.0
|
||||
break
|
||||
gross = w * (1.0 + rets[t])
|
||||
growth = gross.sum() + (1.0 - w.sum()) # idle cash earns nothing
|
||||
value *= growth
|
||||
w = gross / growth if growth > 0 else np.zeros(m)
|
||||
if t + 1 < n:
|
||||
tgt = targets[t + 1]
|
||||
new = w.copy()
|
||||
entry_exit = (tgt <= 0) | (w <= 0)
|
||||
resize = ~entry_exit & (np.abs(tgt - w) >= resize_band * tgt)
|
||||
change = (entry_exit | resize) & (np.abs(tgt - w) > 0)
|
||||
new[change] = tgt[change]
|
||||
if new.sum() > 1.0: # kept winners leave less room
|
||||
buys = change & (new > w)
|
||||
room = max(1.0 - new[~buys].sum(), 0.0)
|
||||
new[buys] *= room / new[buys].sum() if new[buys].sum() > 0 else 0.0
|
||||
delta = new - w
|
||||
floor = (max(min_trade_value, 0.001 * value) / value) if min_trade_value > 0 else 1e-12
|
||||
skip = (np.abs(delta) < floor) & (new > 0)
|
||||
delta[skip] = 0.0
|
||||
new[skip] = w[skip]
|
||||
orders = np.abs(delta) > 1e-12
|
||||
if orders.any():
|
||||
dollars = np.abs(delta) * value
|
||||
cost = dollars.sum() * transaction_cost
|
||||
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
shares = np.where(prices[t] > 0, dollars / prices[t], 0.0)
|
||||
fee = np.maximum(fixed_fee, fee_base + per_share * shares)
|
||||
cost += fee[orders].sum()
|
||||
if cost >= value: # fees ate the book
|
||||
equity[t:] = 0.0
|
||||
break
|
||||
w = new * value / (value - cost)
|
||||
value -= cost
|
||||
traded[t + 1] = delta
|
||||
equity[t] = value
|
||||
return (pd.Series(equity, index=data.index),
|
||||
pd.DataFrame(traded, index=data.index, columns=data.columns))
|
||||
min_trade_value: float = 0.0, resize_band: float = 0.0,
|
||||
*, fresh_prices: pd.DataFrame | None = None,
|
||||
integer_shares: bool = False, lot_size: int = 1):
|
||||
targets = held.reindex_like(data).shift(-1).fillna(0.0)
|
||||
# No decision after the last row is represented by `held`; preserve the book.
|
||||
result = simulate_book(targets.iloc[:-1], data.iloc[:-1], initial_capital,
|
||||
fresh_prices=fresh_prices.iloc[:-1] if fresh_prices is not None else None,
|
||||
transaction_cost=transaction_cost, fixed_fee=fixed_fee,
|
||||
fee_base=fee_base, fee_per_share=fee_per_share,
|
||||
min_trade_value=min_trade_value, resize_band=resize_band,
|
||||
integer_shares=integer_shares, lot_size=lot_size)
|
||||
traded = pd.DataFrame(0.0, index=data.index, columns=data.columns)
|
||||
for trade in result["trade_log"]:
|
||||
i = data.index.get_loc(pd.Timestamp(trade["date"]))
|
||||
value = result["pretrade_equity"].iloc[i]
|
||||
traded.iloc[i + 1, data.columns.get_loc(trade["ticker"])] += (
|
||||
trade["value"] / value * (1 if trade["action"] == "BUY" else -1)) if value else 0.0
|
||||
marks = data.ffill().iloc[-1].dropna().to_dict()
|
||||
from execution import portfolio_value
|
||||
last = portfolio_value(result["holdings"], marks,
|
||||
float(result["cash"].iloc[-1]) if len(result["cash"]) else initial_capital)
|
||||
equity = pd.concat([result["equity"], pd.Series([last], index=data.index[-1:])])
|
||||
return equity, traded
|
||||
|
||||
|
||||
def backtest(
|
||||
@@ -133,7 +79,7 @@ def backtest(
|
||||
resize_band: float = 0.0,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
Vectorized backtest.
|
||||
Cash-funded backtest with next-session execution and drifting holdings.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -173,62 +119,8 @@ def backtest(
|
||||
Daily equity curve.
|
||||
"""
|
||||
positions = strategy_positions(strategy, data, open_data=open_data, bars=bars)
|
||||
if open_data is not None:
|
||||
equity, _ = simulate_live_book(
|
||||
positions, data, initial_capital, transaction_cost, fixed_fee, fee_base,
|
||||
fee_per_share, min_trade_value, resize_band,
|
||||
)
|
||||
return equity
|
||||
|
||||
# A missing close carries the last price (zero return), as before.
|
||||
daily_returns = data.ffill().pct_change(fill_method=None).fillna(0.0)
|
||||
portfolio_returns = (daily_returns * positions).sum(axis=1)
|
||||
|
||||
# Turnover cost: sum of absolute weight changes each day
|
||||
turnover = positions.diff().abs().sum(axis=1).fillna(0.0)
|
||||
portfolio_returns -= turnover * transaction_cost
|
||||
|
||||
# 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)
|
||||
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.
|
||||
# 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)
|
||||
# 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,
|
||||
)
|
||||
per_trade_fee = per_trade_fee.where(charged, 0.0)
|
||||
daily_fee = per_trade_fee.sum(axis=1)
|
||||
else:
|
||||
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)
|
||||
equity, _ = simulate_live_book(positions, data, initial_capital, transaction_cost,
|
||||
fixed_fee, fee_base, fee_per_share, min_trade_value, resize_band)
|
||||
return equity
|
||||
|
||||
|
||||
@@ -309,52 +201,34 @@ def main() -> None:
|
||||
"--attribution-export", default=None,
|
||||
help="Directory to export factor attribution CSVs",
|
||||
)
|
||||
parser.add_argument("--offline", action="store_true", help="Use local prices and membership without refreshing")
|
||||
args = parser.parse_args()
|
||||
initial_capital = args.capital if args.capital is not None else 10_000
|
||||
use_open = args.execution == "open-close"
|
||||
|
||||
universe = UNIVERSES[args.market]
|
||||
tickers = universe["fetch"]()
|
||||
tickers = [] if args.offline else universe["fetch"]()
|
||||
benchmark = universe["benchmark"]
|
||||
benchmark_label = universe["benchmark_label"]
|
||||
|
||||
# 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:
|
||||
data, open_data = result
|
||||
else:
|
||||
data = result
|
||||
open_data = None
|
||||
|
||||
if args.years:
|
||||
cutoff = data.index[-1] - pd.DateOffset(years=args.years)
|
||||
data = data[data.index >= cutoff]
|
||||
if open_data is not 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()} ---")
|
||||
|
||||
# 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 ---")
|
||||
membership = uh.pit_membership(args.market, pd.DatetimeIndex([pd.Timestamp.today()]), refresh=not args.offline)
|
||||
all_tickers = sorted(set(tickers) | set(membership.columns) | {benchmark})
|
||||
fields = ["close", "open", "volume"]
|
||||
panels = (data_manager.load_fields(args.market, fields) if args.offline else
|
||||
data_manager.update(args.market, all_tickers, fields=fields))
|
||||
data = data_manager.load_raw(args.market).reindex_like(panels["close"])
|
||||
sessions = data.index[data[benchmark].notna()]
|
||||
data = data.loc[sessions]
|
||||
open_data = panels["open"].reindex_like(data) if use_open else None
|
||||
member = uh.pit_membership(args.market, data.index).reindex(columns=data.columns, fill_value=False)
|
||||
member[benchmark] = True
|
||||
tickers = sorted(t for t in member.columns if t != benchmark and member[t].any())
|
||||
if len(data) <= 315:
|
||||
raise ValueError("Need 315 pre-window trading sessions")
|
||||
requested = data.index[-1] - pd.DateOffset(years=args.years) if args.years else data.index[0]
|
||||
start = max(requested, data.index[315])
|
||||
days = data.index[data.index >= start]
|
||||
print(f"--- Scoring {days[0].date()} to {days[-1].date()}; prior history is retained ---")
|
||||
|
||||
top_n = args.top_n if args.top_n else max(5, len(tickers) // 10)
|
||||
print(f"--- Selective strategies will pick top {top_n} stocks ---")
|
||||
@@ -369,7 +243,7 @@ def main() -> None:
|
||||
|
||||
strategies = {
|
||||
# --- Original strategies ---
|
||||
"Buy & Hold (EW)": (BuyAndHoldStrategy(), data[tickers], open_tickers),
|
||||
"Buy & Hold": (BuyAndHoldStrategy(asset=benchmark), data[[benchmark]], open_data[[benchmark]] if open_data is not None else None),
|
||||
"Momentum": (MomentumStrategy(lookback=252, skip=21, top_n=top_n), data[tickers], open_tickers),
|
||||
"Inverse Volatility": (InverseVolatilityStrategy(vol_window=20), data[tickers], open_tickers),
|
||||
"Multi-Factor": (MultiFactorStrategy(tickers=tickers, benchmark=benchmark, top_n=top_n), data, open_all),
|
||||
@@ -386,12 +260,19 @@ def main() -> None:
|
||||
results: dict[str, pd.Series] = {}
|
||||
for name, (strategy, strat_data, strat_open) in strategies.items():
|
||||
print(f"\nRunning: {name}")
|
||||
results[name] = backtest(strategy, strat_data, initial_capital=initial_capital,
|
||||
fixed_fee=args.fixed_fee, open_data=strat_open)
|
||||
targets = session_targets(strategy, strat_data, strat_open,
|
||||
eligible=member.reindex_like(strat_data))
|
||||
run = simulate_book(targets.loc[days], strat_data.loc[days], initial_capital,
|
||||
previous_prices=strat_data.ffill().shift(1).loc[days],
|
||||
volume=panels["volume"].reindex(index=days, columns=strat_data.columns),
|
||||
market=args.market, integer_shares=True,
|
||||
lot_size=100 if args.market == "cn" else 1,
|
||||
fixed_fee=args.fixed_fee, min_trade_value=50, resize_band=.10)
|
||||
anchor = pd.Series([initial_capital], index=[days[0] - pd.Timedelta(days=1)])
|
||||
results[name] = pd.concat([anchor, run["equity"]])
|
||||
|
||||
# Add benchmark (normalized to same initial capital)
|
||||
bench = data[benchmark].dropna()
|
||||
results[benchmark_label] = (bench / bench.iloc[0]) * initial_capital
|
||||
bench = data[benchmark].reindex(days).ffill().dropna()
|
||||
results[benchmark_label] = pd.concat([anchor, bench / bench.iloc[0] * initial_capital])
|
||||
|
||||
results_df = pd.DataFrame(results)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user