Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |