File size: 7,014 Bytes
094a5f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a56c89
 
 
 
 
 
 
 
 
 
 
 
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""
Optimizer.jl — Walk-forward optimization engine.
No includes. BacktestConfig/run_backtest/BacktestResult received via QuantEngine.
"""
module Optimizer

using Statistics, Random

export walk_forward_optimize, OptimResult

mutable struct OptimResult
    strategy_name::String; symbol::String; timeframe::String
    optimal_params::Dict{String,Float64}
    oos_sharpe_mean::Float64; oos_sharpe_std::Float64
    oos_win_rate::Float64; oos_max_dd::Float64; oos_pf_mean::Float64
    oos_trades::Int; wf_efficiency::Float64; robustness::Float64
    is_viable::Bool; reasons::Vector{String}; oos_sharpes::Vector{Float64}
end

OptimResult(n,s,t) = OptimResult(n,s,t,Dict{String,Float64}(),
    0.0,0.0,0.0,0.0,0.0,0,0.0,0.0,false,String[],Float64[])

function walk_forward_optimize(
    signal_fn::Function,
    param_grid::Dict{String,Vector{Float64}},
    open_p::Vector{Float64}, high::Vector{Float64},
    low::Vector{Float64},    close::Vector{Float64},
    volume::Vector{Float64}, timeframe::String,
    strategy_name::String,   symbol::String;
    run_bt_fn::Function,          # run_backtest injected from QuantEngine
    bt_cfg_fn::Function,          # BacktestConfig() constructor injected
    n_windows::Int=5, is_ratio::Float64=0.70,
    min_trades::Int=30, min_sharpe::Float64=0.5,
    max_combos::Int=300,
)::OptimResult
    result = OptimResult(strategy_name, symbol, timeframe)
    n = length(close)
    n < 200 && (push!(result.reasons,"Need ≥200 bars, got $n"); return result)
    isempty(param_grid) && (param_grid = Dict{String,Vector{Float64}}())

    cfg      = bt_cfg_fn()
    combos   = _build_combos(param_grid, max_combos)
    windows  = _windows(n, n_windows)
    isempty(windows) && (push!(result.reasons,"No WF windows"); return result)

    win_params=Vector{Dict{String,Float64}}()
    is_sharpes=Float64[]; oos_sharpes=Float64[]
    oos_results=[]

    for (is_s,is_e,oos_s,oos_e) in windows
        best_p=nothing; best_sh=-Inf
        for p in combos
            r = _run(signal_fn,run_bt_fn,cfg,
                     open_p[is_s:is_e],high[is_s:is_e],
                     low[is_s:is_e],close[is_s:is_e],
                     volume[is_s:is_e],p,timeframe)
            r.is_valid && r.n_trades>=min_trades && r.sharpe>best_sh && (best_sh=r.sharpe; best_p=p)
        end
        best_p===nothing && continue
        push!(win_params,best_p); push!(is_sharpes,best_sh)
        oos_r = _run(signal_fn,run_bt_fn,cfg,
                     open_p[oos_s:oos_e],high[oos_s:oos_e],
                     low[oos_s:oos_e],close[oos_s:oos_e],
                     volume[oos_s:oos_e],best_p,timeframe)
        push!(oos_results,oos_r); push!(oos_sharpes,oos_r.sharpe)
    end

    isempty(oos_results) && (push!(result.reasons,"No valid WF windows"); return result)
    result.oos_sharpes = oos_sharpes

    valid = filter(r->r.is_valid && r.n_trades>=min_trades, oos_results)
    if !isempty(valid)
        sh=[r.sharpe for r in valid]
        result.oos_sharpe_mean=mean(sh); result.oos_sharpe_std=std(sh)
        result.oos_win_rate=mean([r.win_rate for r in valid])
        result.oos_max_dd=mean([r.max_dd for r in valid])
        pfs=filter(x->x<100,[r.profit_factor for r in valid])
        result.oos_pf_mean=isempty(pfs) ? 0.0 : mean(pfs)
        result.oos_trades=sum(r.n_trades for r in valid)
    end
    if !isempty(is_sharpes) && !isempty(oos_sharpes)
        mis=mean(is_sharpes); mos=mean(oos_sharpes)
        result.wf_efficiency = mis>0 ? mos/mis : 0.0
    end
    result.optimal_params = _vote(win_params, oos_sharpes)
    result.robustness = _robustness(result, min_trades)
    result.is_viable, result.reasons = _viability(result, min_trades, min_sharpe)
    return result
end

function _run(sig_fn,run_bt,cfg,o,h,l,c,v,params,tf)
    try
        sigs = sig_fn(o,h,l,c,v,params)
        return run_bt(o,h,l,c,v,sigs,tf,cfg)
    catch e1
        # Strategy failed — run with flat signals to get a valid struct back
        try
            r = run_bt(o,h,l,c,v,zeros(Int,length(c)),tf,cfg)
            r.is_valid  = false
            r.error_msg = string(e1)
            return r
        catch e2
            # Even the fallback failed — return manually constructed invalid result
            return run_bt(o[1:2],h[1:2],l[1:2],c[1:2],v[1:2],
                          zeros(Int,2),tf,cfg)  # will hit n<50 guard → is_valid=false
        end
    end
end

function _build_combos(grid::Dict{String,Vector{Float64}}, max_c::Int)::Vector{Dict{String,Float64}}
    isempty(grid) && return [Dict{String,Float64}()]
    ks=collect(keys(grid)); vs=[grid[k] for k in ks]
    all_c=Dict{String,Float64}[]
    function recurse(i,current)
        if i>length(ks); push!(all_c,copy(current)); return; end
        for v in vs[i]; current[ks[i]]=v; recurse(i+1,current); end
    end
    recurse(1,Dict{String,Float64}())
    length(all_c)>max_c && (all_c=all_c[randperm(length(all_c))[1:max_c]])
    return all_c
end

function _windows(n::Int,nw::Int)::Vector{Tuple{Int,Int,Int,Int}}
    osz=max(50,n÷(nw*2)); wins=Tuple{Int,Int,Int,Int}[]
    for i in 0:(nw-1)
        oe=n-i*osz; os=oe-osz+1; ie=os-1
        ie-1<100||oe-os<50 && continue
        push!(wins,(1,ie,os,oe))
    end
    return reverse(wins)
end

function _vote(pl::Vector{Dict{String,Float64}}, oos::Vector{Float64})::Dict{String,Float64}
    isempty(pl) && return Dict{String,Float64}()
    length(pl)==1 && return pl[1]
    w=max.(0.0,oos[1:length(pl)]); tw=sum(w)
    w = tw>0 ? w./tw : fill(1.0/length(pl),length(pl))
    ks=collect(keys(pl[1])); result=Dict{String,Float64}()
    for k in ks
        vals=[p[k] for p in pl if haskey(p,k)]
        wi=w[1:length(vals)]
        si=sortperm(vals); cv=cumsum(wi[si])
        mi=findfirst(x->x>=0.5,cv)
        result[k]=vals[si[mi!==nothing ? mi : end]]
    end
    return result
end

function _robustness(r::OptimResult, mt::Int)::Float64
    s=clamp(r.wf_efficiency,0.0,1.0)*40.0
    r.oos_sharpe_mean>0 && (s+=clamp(1.0-r.oos_sharpe_std/(r.oos_sharpe_mean+1e-9),0.0,1.0)*30.0)
    s+=clamp(r.oos_trades/max(1,mt*10),0.0,1.0)*20.0
    r.oos_pf_mean>1 && (s+=clamp((r.oos_pf_mean-1)/2,0.0,1.0)*10.0)
    return round(s;digits=1)
end

function _viability(r::OptimResult,mt::Int,ms::Float64)::Tuple{Bool,Vector{String}}
    reasons=String[]
    r.oos_sharpe_mean<ms  && push!(reasons,"OOS Sharpe $(round(r.oos_sharpe_mean;digits=2)) < $ms")
    r.oos_trades<mt       && push!(reasons,"Too few OOS trades: $(r.oos_trades) < $mt")
    r.oos_max_dd>30.0     && push!(reasons,"High avg DD: $(round(r.oos_max_dd;digits=1))%")
    r.wf_efficiency<0.3   && push!(reasons,"Low WFE: $(round(r.wf_efficiency;digits=2))")
    r.oos_pf_mean<1.1     && push!(reasons,"PF $(round(r.oos_pf_mean;digits=2)) < 1.1")
    viable=isempty(reasons)
    viable && push!(reasons,"✅ Sharpe=$(round(r.oos_sharpe_mean;digits=2)) DD=$(round(r.oos_max_dd;digits=1))% WFE=$(round(r.wf_efficiency;digits=2)) Score=$(r.robustness)/100")
    return viable,reasons
end

end # module Optimizer