quant-knowledge-extractor / src /BacktestEngine.jl
cyberkyne's picture
Upload 22 files
094a5f6 verified
"""
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