backtest.jl
1 using Executors: orderscount 2 using Executors: isoutof_orders 3 using .Instances.Data.DFUtils: lastdate 4 using .Misc.LoggingExtras 5 using Base: with_logger 6 using .st: universe, current_total, trades_count 7 using Pbar: @withpbar!, @pbupdate!, ProgressBar, addjob!, ProgressJob, pbar!, Progress, pbar 8 using .Progress: DescriptionColumn, CompletedColumn, SeparatorColumn, ProgressColumn, AbstractColumn 9 using Pbar.Term.Segments: Segment 10 using Pbar.Term.Measures: Measure 11 using Pbar.Term.Progress: Progress 12 13 import .Misc: start!, stop! 14 15 # Custom column to display trades and balance 16 struct StatsColumn <: AbstractColumn 17 job::ProgressJob 18 segments::Vector{Segment} 19 measure::Measure 20 style::String 21 trades::Ref{Int} 22 balance::Ref{DFT} 23 24 function StatsColumn(job::ProgressJob; style="blue", trades=Ref{Int}(), balance=Ref{DFT}()) 25 txt = Segment("Trades: 0 | Balance: 0.0", style) 26 return new(job, [txt], txt.measure, style, trades, balance) 27 end 28 end 29 30 function Progress.update!(col::StatsColumn, color::String, args...) 31 txt = Segment("Trades: $(col.trades[]) | Balance: $(col.balance[])", col.style) 32 return txt.text 33 end 34 35 @doc """Backtest a strategy `strat` using context `ctx` iterating according to the specified timeframe. 36 37 $(TYPEDSIGNATURES) 38 39 On every iteration, the strategy is queried for the _current_ timestamp. 40 The strategy should only access data up to this point. 41 Example: 42 - Timeframe iteration: `1s` 43 - Strategy minimum available timeframe `1m` 44 Iteration gives time `1999-12-31T23:59:59` to the strategy: 45 The strategy (that can only lookup up to `1m` precision) 46 looks-up data until the timestamp `1999-12-31T23:58:00` which represents the 47 time until `23:59:00`. 48 Therefore we have to shift by one period down, the timestamp returned by `apply`: 49 ```julia 50 julia> t = TimeTicks.apply(tf"1m", dt"1999-12-31T23:59:59") 51 1999-12-31T23:59:00 # we should not access this timestamp 52 julia> t - tf"1m".period 53 1999-12-31T23:58:00 # this is the correct candle timestamp that we can access 54 ``` 55 To avoid this mistake, use the function `available(::TimeFrame, ::DateTime)`, instead of apply. 56 """ 57 function start!( 58 s::Strategy{Sim}, ctx::Context; trim_universe=false, doreset=true, resetctx=true, show_progress=:off 59 ) 60 # ensure that universe data start at the same time 61 @ifdebug _resetglobals!(s) 62 if trim_universe 63 let data = st.coll.flatten(st.universe(s)) 64 !check_alignment(data) && trim!(data) 65 end 66 end 67 if resetctx 68 tt.current!(ctx.range, ctx.range.start + call!(s, WarmupPeriod())) 69 end 70 if doreset 71 st.reset!(s) 72 end 73 update_mode = s.attrs[:sim_update_mode]::ExecAction 74 logger = if s[:sim_debug] 75 current_logger() 76 else 77 MinLevelLogger(current_logger(), s[:log_level]) 78 end 79 80 with_logger(logger) do 81 if show_progress !== :off 82 # Create custom columns for the progress bar 83 mycols = [DescriptionColumn, CompletedColumn, SeparatorColumn, ProgressColumn] 84 trades = Ref{Int}() 85 balance = Ref{DFT}() 86 cols_kwargs = Dict() 87 88 # Add stats columns if show_progress is :full 89 if show_progress === :full 90 push!(mycols, StatsColumn) 91 cols_kwargs[:StatsColumn] = Dict(:style=>"blue bold", :trades=>trades, :balance=>balance) 92 end 93 94 wp = call!(s, WarmupPeriod()) 95 wp_steps = trunc(Int, wp / ctx.range.step) 96 trimmed_start = min(ctx.range.stop, ctx.range.start + wp_steps * ctx.range.step) 97 trimmed_range = trimmed_start:ctx.range.step:ctx.range.stop 98 pbar!(; columns=mycols, columns_kwargs=cols_kwargs, width=140) 99 balance[] = current_total(s) 100 101 # Define update function based on show_progress mode 102 update_stats = if show_progress === :full 103 () -> begin 104 trades[] = trades_count(s) 105 balance[] = current_total(s) 106 end 107 else 108 () -> nothing 109 end 110 111 @withpbar! trimmed_range desc="Backtesting" begin 112 # Iterate over the trimmed_range to respect warmup trimming when showing progress 113 for date in trimmed_range 114 isoutof_orders(s) && begin 115 @deassert all(iszero(ai) for ai in universe(s)) 116 break 117 end 118 update!(s, date, update_mode) 119 call!(s, date, ctx) 120 update_stats() 121 @debug "sim: iter" s.cash ltxzero(s.cash) isempty(s.holdings) orderscount(s) 122 @pbupdate! 123 end 124 end 125 else 126 for date in ctx.range 127 isoutof_orders(s) && begin 128 @deassert all(iszero(ai) for ai in universe(s)) 129 break 130 end 131 update!(s, date, update_mode) 132 call!(s, date, ctx) 133 @debug "sim: iter" s.cash ltxzero(s.cash) isempty(s.holdings) orderscount(s) 134 end 135 end 136 end 137 s 138 end 139 140 @doc """ 141 Backtest with context of all data loaded in the strategy universe. 142 143 $(TYPEDSIGNATURES) 144 145 Backtest the strategy with the context of all data loaded in the strategy universe. This function ensures that the universe data starts at the same time. If `trim_universe` is true, it trims the data to ensure alignment. If `doreset` is true, it resets the strategy before starting the backtest. The backtest is performed using the specified `ctx` context. 146 147 """ 148 start!(s::Strategy{Sim}; kwargs...) = start!(s, Context(s); kwargs...) 149 150 @doc """ 151 Starts the strategy with the given count. 152 153 $(TYPEDSIGNATURES) 154 155 Starts the strategy with the given count. 156 If `count` is greater than 0, it sets the start and end timestamps based on the count and the strategy's timeframe. 157 Otherwise, it sets the start and end timestamps based on the last timestamp in the strategy's universe. 158 159 """ 160 function start!(s::Strategy{Sim}, count::Integer; tf=s.timeframe, kwargs...) 161 if count > 0 162 from = ohlcv(first(s.universe)).timestamp[begin] 163 to = from + tf.period * count 164 else 165 to = ohlcv(last(s.universe)).timestamp[end] 166 from = to + tf.period * count 167 end 168 ctx = Context(Sim(), tf, from, to) 169 start!(s, ctx; kwargs...) 170 end 171 172 @doc """Returns the latest date in the given strategy's universe. 173 174 $(TYPEDSIGNATURES) 175 176 Iterates over the strategy's universe to find the date of the last data point. Returns the latest date as a `DateTime` object. 177 178 """ 179 _todate(s) = begin 180 to = typemin(DateTime) 181 for ai in s.universe 182 this_date = lastdate(ai) 183 if this_date > to 184 to = this_date 185 end 186 end 187 return to 188 end 189 190 @doc """ Starts the strategy simulation from a specific date to another. 191 192 $(TYPEDSIGNATURES) 193 194 This function initializes a simulation context with the given timeframe and date range, then starts the strategy with this context. 195 196 """ 197 function start!(s::Strategy{Sim}, from::DateTime, to::DateTime=_todate(s); kwargs...) 198 ctx = Context(Sim(), s.timeframe, from, to) 199 start!(s, ctx; kwargs...) 200 end 201 202 stop!(::Strategy{Sim}) = nothing 203 204 backtest!(s::Strategy{Sim}, args...; kwargs...) = begin 205 @warn "DEPRECATED: use `start!`" 206 start!(s, args...; kwargs...) 207 end 208