Spaces:
Sleeping
Sleeping
File size: 5,996 Bytes
094a5f6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | """
BacktestEngine.jl — Vectorized backtest engine.
No includes. Receives Indicators module via QuantEngine parent scope.
"""
module BacktestEngine
using Statistics
export run_backtest, BacktestResult, BacktestConfig
# Indicators injected by QuantEngine before this module is used
# atr() is accessed via the parent module's scope at call time
const BARS_PER_YEAR = Dict(
"1m"=>525_600,"3m"=>175_200,"5m"=>105_120,"15m"=>35_040,"30m"=>17_520,
"1h"=>8_760,"2h"=>4_380,"4h"=>2_190,"6h"=>1_460,"12h"=>730,
"1d"=>252,"1w"=>52,
)
Base.@kwdef struct BacktestConfig
initial_equity :: Float64 = 10_000.0
commission_pct :: Float64 = 0.0002
slippage_pct :: Float64 = 0.0001
risk_per_trade :: Float64 = 0.01
atr_mult :: Float64 = 2.0
max_pos_pct :: Float64 = 0.20
atr_period :: Int = 14
end
mutable struct BacktestResult
total_return :: Float64; cagr :: Float64
sharpe :: Float64; sortino :: Float64; calmar :: Float64
max_dd :: Float64; max_dd_bars :: Int
n_trades :: Int; n_wins :: Int; win_rate :: Float64
profit_factor :: Float64; avg_win_pct :: Float64; avg_loss_pct :: Float64
expectancy :: Float64; avg_bars_held :: Float64
max_consec_wins:: Int; max_consec_loss:: Int
final_equity :: Float64; total_comm :: Float64
equity_curve :: Vector{Float64}
n_bars :: Int; is_valid :: Bool; error_msg :: String
end
BacktestResult(; n_bars=0, is_valid=false, error_msg="") = BacktestResult(
0.0,0.0,0.0,0.0,0.0,0.0,0, 0,0,0.0,0.0,0.0,0.0,0.0,0.0,0,0,
10_000.0,0.0, Float64[], n_bars,is_valid,error_msg)
function run_backtest(
open_p::Vector{Float64}, high::Vector{Float64}, low::Vector{Float64},
close::Vector{Float64}, volume::Vector{Float64}, signals::Vector{Int},
timeframe::String="1h", cfg::BacktestConfig=BacktestConfig(),
atr_fn::Function=identity, # passed from QuantEngine to avoid circular dep
)::BacktestResult
n = length(close)
n < 50 && return BacktestResult(; n_bars=n, error_msg="Need ≥50 bars, got $n")
atr_v = atr_fn(high, low, close, cfg.atr_period)
equity = cfg.initial_equity
eq = fill(cfg.initial_equity, n)
tpnls = Vector{Float64}(undef, n÷2+1)
twins = Vector{Bool}(undef, n÷2+1)
tbars = Vector{Int}(undef, n÷2+1)
tents = Vector{Float64}(undef, n÷2+1)
tszs = Vector{Float64}(undef, n÷2+1)
nt = 0; tcomm = 0.0
pos=0; epx=0.0; psz=0.0; spx=0.0; ebar=1; ltrade=0
@inbounds for i in 2:n
px=close[i]; sig=signals[i]
if pos != 0
hit = (pos==1 && low[i]<=spx) || (pos==-1 && high[i]>=spx)
if hit
ep = spx*(1.0+cfg.slippage_pct*pos)
pnl = pos*(ep-epx)*psz; comm=(epx+ep)*psz*cfg.commission_pct
nt+=1; tpnls[nt]=pnl-comm; twins[nt]=pnl>comm
tbars[nt]=i-ebar; tents[nt]=epx; tszs[nt]=psz
tcomm+=comm; equity+=pnl-comm; pos=0; ltrade=i
end
end
if pos!=0 && (sig==0 || sig==-pos)
ep=px*(1.0+cfg.slippage_pct*pos)
pnl=pos*(ep-epx)*psz; comm=(epx+ep)*psz*cfg.commission_pct
nt+=1; tpnls[nt]=pnl-comm; twins[nt]=pnl>comm
tbars[nt]=i-ebar; tents[nt]=epx; tszs[nt]=psz
tcomm+=comm; equity+=pnl-comm; pos=0; ltrade=i
end
if pos==0 && sig!=0 && (i-ltrade)>=1
ep=px*(1.0+cfg.slippage_pct*sig)
av = isnan(atr_v[i]) ? px*0.01 : atr_v[i]
dist=cfg.atr_mult*av
sz=min(equity*cfg.risk_per_trade/max(dist,1e-8), equity*cfg.max_pos_pct/ep)
sz=max(sz,1e-8)
pos=sig; epx=ep; psz=sz; spx=ep-sig*dist; ebar=i
end
eq[i] = equity + (pos!=0 ? pos*(close[i]-epx)*psz : 0.0)
end
if pos!=0
ep=close[n]; pnl=pos*(ep-epx)*psz; comm=(epx+ep)*psz*cfg.commission_pct
nt+=1; tpnls[nt]=pnl-comm; twins[nt]=pnl>comm
tbars[nt]=n-ebar; tents[nt]=epx; tszs[nt]=psz
tcomm+=comm; equity+=pnl-comm; eq[n]=equity
end
return _metrics(eq, tpnls[1:nt], twins[1:nt], tbars[1:nt],
tents[1:nt], tszs[1:nt], tcomm, n, timeframe, cfg)
end
function _metrics(eq,pnls,wins,bars,ents,szs,tcomm,n_bars,tf,cfg)
init=cfg.initial_equity; final=eq[end]; bpy=get(BARS_PER_YEAR,tf,252)
r=BacktestResult(;n_bars,is_valid=true)
r.equity_curve=eq; r.final_equity=final; r.total_comm=tcomm
r.total_return=(final-init)/init*100.0
yrs=n_bars/bpy
r.cagr = yrs>0&&final>0 ? ((final/init)^(1.0/yrs)-1.0)*100.0 : 0.0
peak=eq[1]; mxdd=0.0; ddr=0; mxddb=0
for v in eq
peak=max(peak,v); dd=(peak-v)/peak; mxdd=max(mxdd,dd)
v<peak ? (ddr+=1; mxddb=max(mxddb,ddr)) : (ddr=0)
end
r.max_dd=mxdd*100.0; r.max_dd_bars=mxddb
rets=diff(eq)./eq[1:end-1]; filter!(!isnan,rets)
if length(rets)>1
mu=mean(rets); sg=std(rets)
ds_v=filter(x->x<0,rets); ds=length(ds_v)>1 ? std(ds_v) : sg
af=sqrt(Float64(bpy))
r.sharpe=sg>0 ? mu/sg*af : 0.0; r.sortino=ds>0 ? mu/ds*af : 0.0
r.calmar=r.max_dd>0 ? r.cagr/r.max_dd : 0.0
end
r.n_trades=length(pnls)
r.n_trades==0 && return r
nw=count(wins); r.n_wins=nw; r.win_rate=nw/r.n_trades*100.0
gw=sum(pnls[wins]); gl=abs(sum(pnls[.!wins]))
r.profit_factor=gl>0 ? gw/gl : (gw>0 ? Inf : 0.0)
pct=pnls./(ents.*szs.+1e-10).*100.0
r.avg_win_pct = nw>0 ? mean(pct[wins]) : 0.0
r.avg_loss_pct = (r.n_trades-nw)>0 ? mean(pct[.!wins]) : 0.0
r.expectancy=r.win_rate/100.0*r.avg_win_pct+(1-r.win_rate/100.0)*r.avg_loss_pct
r.avg_bars_held=mean(Float64.(bars))
r.max_consec_wins=_maxrun(wins); r.max_consec_loss=_maxrun(.!wins)
return r
end
function _maxrun(b::Vector{Bool})::Int
mx=run=0; for v in b; v ? (run+=1;mx=max(mx,run)) : (run=0); end; return mx
end
end # module BacktestEngine
|