ecfdd56d8f
Backtest runner: added per-trade fee simulation (maker 2bps, taker 5bps).
Each trade now records pnl_gross, pnl_net, and fee. New --no-fees flag
excludes fees from PnL. Output includes pnl_gross/pnl_gross_pct and
fees_total alongside existing pnl (net). Regenerated all 12 backtests.
Server: added /api/backtest/{name}/csv endpoint — returns trades as CSV
with columns time,side,size,price,pnl_gross,pnl_net,fee.
Content-Disposition: attachment triggers browser download.
Dashboard: added "Inc. fees" checkbox toggle in backtest detail panel.
Unchecking shows gross PnL (before fees). "↓ CSV" button downloads
the trade history. Both hidden when detail is closed.
109 lines
6.6 KiB
Python
109 lines
6.6 KiB
Python
"""
|
||
Backtest runner — 7 strategies, 30 days simulated, saves to JSON.
|
||
"""
|
||
import argparse, json, os, random, sys
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
from common.metrics import sharpe, sortino, max_drawdown, win_rate
|
||
|
||
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||
|
||
# Fee rates for backtest simulation (matching paper trader)
|
||
TAKER_FEE = 0.0005 # 5 bps per side
|
||
MAKER_FEE = 0.0002 # 2 bps per side
|
||
|
||
CONFIGS = {
|
||
"ofi": {"name":"Order Book Imbalance","desc":"L2 bid/ask skew — buys when bids dominate","alloc":100.0,"daily_ret":0.0012,"daily_vol":0.014,"fee_model":"taker"},
|
||
"iceberg": {"name":"Iceberg Detection","desc":"Whale TWAP accumulation detection","alloc":100.0,"daily_ret":0.0008,"daily_vol":0.012,"fee_model":"taker"},
|
||
"funding_arb": {"name":"Funding Rate Arbitrage","desc":"Delta-neutral carry — collects funding","alloc":100.0,"daily_ret":0.0004,"daily_vol":0.003,"fee_model":"taker"},
|
||
"pairs": {"name":"Pairs Trading","desc":"BTC/ETH spread Z-score mean reversion","alloc":100.0,"daily_ret":0.0010,"daily_vol":0.010,"fee_model":"taker"},
|
||
"avellaneda": {"name":"Avellaneda-Stoikov","desc":"Dual-sided quoting at best bid/ask · regime-adaptive","alloc":100.0,"daily_ret":0.0018,"daily_vol":0.006,"fee_model":"maker"},
|
||
"momentum": {"name":"Momentum Breakout","desc":"Bollinger Band 2σ breakout","alloc":100.0,"daily_ret":0.0010,"daily_vol":0.016,"fee_model":"taker"},
|
||
"mean_rev": {"name":"Mean Reversion","desc":"VWAP deviation — oscillates around fair value","alloc":100.0,"daily_ret":0.0009,"daily_vol":0.009,"fee_model":"taker"},
|
||
"hawkes": {"name":"Hawkes OFI","desc":"Self-exciting point process OFI — clustered order flow","alloc":100.0,"daily_ret":0.0022,"daily_vol":0.013,"fee_model":"taker"},
|
||
"deep_lob": {"name":"Deep LOB","desc":"Orderbook depth analysis — wall detection, thin-side prediction","alloc":100.0,"daily_ret":0.0016,"daily_vol":0.008,"fee_model":"maker"},
|
||
"cartea": {"name":"Cartea-Jaimungal","desc":"Stochastic control HFT — HJB equation with alpha + inventory","alloc":100.0,"daily_ret":0.0020,"daily_vol":0.010,"fee_model":"maker"},
|
||
"queue_imb": {"name":"Queue Imbalance","desc":"Weighted LOB queue dynamics — Stoikov-Sağlam framework","alloc":100.0,"daily_ret":0.0024,"daily_vol":0.012,"fee_model":"taker"},
|
||
"gueant": {"name":"Guéant Market Making","desc":"Closed-form asymptotic MM — adverse selection handling","alloc":100.0,"daily_ret":0.0018,"daily_vol":0.005,"fee_model":"maker"},
|
||
}
|
||
|
||
def simulate(key, periods=720, include_fees=True):
|
||
# Deterministic seed per strategy (hash() is randomized per Python process)
|
||
_fixed_seeds = {"ofi":42,"iceberg":43,"funding_arb":44,"pairs":45,"avellaneda":46,
|
||
"momentum":47,"mean_rev":48,"hawkes":49,"deep_lob":50,
|
||
"cartea":51,"queue_imb":52,"gueant":53}
|
||
random.seed(_fixed_seeds.get(key, 42))
|
||
cfg = CONFIGS[key]
|
||
fee_rate = MAKER_FEE if cfg.get("fee_model") == "maker" else TAKER_FEE
|
||
hr = cfg["daily_ret"]/24; hv = cfg["daily_vol"]/(24**0.5)
|
||
eq_gross=100.0; eq_net=100.0; curve_gross=[]; curve_net=[]; rets=[]; trades=[]
|
||
total_fees=0.0
|
||
dt=datetime.now()-timedelta(days=30)
|
||
for i in range(periods):
|
||
r = random.gauss(hr,hv)
|
||
if random.random()<0.02: r*=random.uniform(2,5)
|
||
before_gross=eq_gross; before_net=eq_net
|
||
eq_gross*=(1+r); eq_net*=(1+r); rets.append(r)
|
||
curve_gross.append({"t":dt.isoformat(),"v":round(eq_gross,4)})
|
||
curve_net.append({"t":dt.isoformat(),"v":round(eq_net,4)})
|
||
if abs(r)>hv:
|
||
sz=round(random.uniform(0.0005,0.002),4)
|
||
px=round(random.uniform(60000,65000),1)
|
||
fee=sz*px*fee_rate*2 # entry + exit fee
|
||
total_fees+=fee
|
||
trades.append({"time":dt.strftime("%Y-%m-%d %H:%M"),
|
||
"side":"BUY" if r>0 else "SELL","size":sz,"price":px,
|
||
"pnl_gross":round(eq_gross-before_gross,4),
|
||
"pnl_net":round(eq_gross-before_gross-fee,4),
|
||
"fee":round(fee,6)})
|
||
dt+=timedelta(hours=1)
|
||
padded=[100.0]*10+[p["v"] for p in curve_net]
|
||
total_ret_gross=eq_gross-100.0
|
||
total_ret_net=eq_net-100.0-total_fees if include_fees else eq_net-100.0
|
||
# Rebuild net equity curve with fees if fees included
|
||
if include_fees:
|
||
curve = [{"t":c["t"],"v":round(c["v"]-total_fees*(i/periods),4)} for i,c in enumerate(curve_net)]
|
||
else:
|
||
curve = curve_net
|
||
return {
|
||
"strategy":cfg["name"],"strategy_key":key,"description":cfg["desc"],"allocation":cfg["alloc"],
|
||
"start_time":curve[0]["t"],"end_time":curve[-1]["t"],"start_equity":100.0,"end_equity":round(curve[-1]["v"],4),
|
||
"pnl":round(total_ret_net,4),"pnl_pct":round(total_ret_net,4),
|
||
"pnl_gross":round(total_ret_gross,4),"pnl_gross_pct":round(total_ret_gross,4),
|
||
"fees_total":round(total_fees,4),"fee_model":cfg.get("fee_model","taker"),
|
||
"ann_return_pct":round(total_ret_net*12,2),
|
||
"sharpe":round(sharpe(rets,periods=8760),4),"sortino":round(sortino(rets,periods=8760),4),
|
||
"max_dd":round(max_drawdown(padded),4),"max_dd_pct":round(max_drawdown(padded)*100,2),
|
||
"win_rate":round(win_rate(trades),4),"total_trades":len(trades),
|
||
"equity_curve":curve,"trades":trades[-100:],"num_periods":periods,
|
||
"generated_at":datetime.now().isoformat(),
|
||
}
|
||
|
||
def save(r):
|
||
ts=datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
p=RESULTS_DIR/f"{r['strategy_key']}_{ts}.json"
|
||
with open(p,"w") as f: json.dump(r,f,indent=2,default=str)
|
||
print(f" Saved: {p}")
|
||
|
||
def main():
|
||
p=argparse.ArgumentParser()
|
||
p.add_argument("--strategy","-s",choices=list(CONFIGS)+["all"],default="all")
|
||
p.add_argument("--no-fees",action="store_true",help="Exclude simulated fees from PnL")
|
||
a=p.parse_args()
|
||
keys=list(CONFIGS) if a.strategy=="all" else [a.strategy]
|
||
include_fees=not a.no_fees
|
||
print("="*60)
|
||
print(f" FTDT Quant Lab — Backtest Runner ({len(keys)} strategies)")
|
||
print(f" Fees: {'INCLUDED (default)' if include_fees else 'EXCLUDED (--no-fees)'}")
|
||
print("="*60)
|
||
for k in keys:
|
||
cfg=CONFIGS[k]; print(f"\n Running: {cfg['name']}...")
|
||
r=simulate(k, include_fees=include_fees); save(r)
|
||
print(f" Net PnL: {r['pnl_pct']:+.2f}% | Gross: {r['pnl_gross_pct']:+.2f}% | Fees: ${r['fees_total']:.2f} | Sharpe: {r['sharpe']:.2f} | Win: {r['win_rate']:.0%}")
|
||
print("\n"+"="*60); print(" Results in backtests/results/"); print(" View at: https://ftdt.io/cv (Backtest tab)"); print("="*60)
|
||
|
||
if __name__=="__main__": main()
|