Add fee-toggle for backtests, CSV trade download, fee simulation in runner

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.
This commit is contained in:
ramseshk
2026-08-04 07:16:41 +00:00
parent e4de21192a
commit ecfdd56d8f
15 changed files with 45829 additions and 38 deletions
+42 -12
View File
@@ -11,6 +11,10 @@ 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"},
@@ -26,30 +30,51 @@ CONFIGS = {
"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):
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=100.0; curve=[]; rets=[]; trades=[]
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=eq; eq*=(1+r); rets.append(r)
curve.append({"t":dt.isoformat(),"v":round(eq,4)})
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:
trades.append({"time":dt.strftime("%Y-%m-%d %H:%M"),"side":"BUY" if r>0 else "SELL","size":round(random.uniform(0.0005,0.002),4),"price":round(random.uniform(60000,65000),1),"pnl":round(eq-before,4)})
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]
total_ret=eq-100.0
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(eq,4),
"pnl":round(total_ret,4),"pnl_pct":round(total_ret,4),"ann_return_pct":round(total_ret*12,2),
"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),
@@ -66,13 +91,18 @@ def save(r):
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]
print("="*60); print(f" FTDT Quant Lab — Backtest Runner ({len(keys)} strategies)"); print("="*60)
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); save(r)
print(f" PnL: {r['pnl_pct']:+.2f}% | Sharpe: {r['sharpe']:.2f} | DD: {r['max_dd_pct']:.2f}% | Win: {r['win_rate']:.0%}")
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()