""" 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) v1 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