strat.jl
1 using Core: LineInfoNode 2 # using .Misc: config_path 3 using Engine.Strategies: strategy!, SortedDict 4 using Engine.Misc: config_path, TOML, user_dir 5 using Engine.Misc.Lang: @lget! 6 using MacroTools 7 using MacroTools: postwalk 8 using REPL.TerminalMenus 9 10 @doc "Ask for a strategy name." 11 ask_name(name=nothing) = begin 12 name = @something name Base.prompt("Strategy name: ") 13 string(name) |> uppercasefirst 14 end 15 16 @doc """ Path of `to` relative to `path`. """ 17 function relative_path(to, path, offset=2) 18 to_dir = dirname(to) |> basename 19 splits = splitpath(path) 20 for (n, c) in enumerate(Iterators.reverse(splits)) 21 if c == to_dir 22 return joinpath(splits[(end - n + offset):end]...) 23 end 24 end 25 path 26 end 27 28 _menu(arr) = 29 let idx = request(RadioMenu(arr; pagesize=5)) 30 arr[idx] 31 end 32 @doc """ Interactively asks the user to configure a strategy. 33 34 $(TYPEDSIGNATURES) 35 36 This function prompts the user to select options for the timeframe, exchange, quote currency, and margin mode. 37 It then creates and returns a configuration object based on the user's selections. 38 """ 39 function _askconfig(; kwargs...) 40 println("\nTimeframe:") 41 min_timeframe = _menu(["1m", "5m", "15m", "1h", "1d"]) 42 println("\nSelect exchange by:") 43 excby = _menu(["volume", "markets", "nokyc"]) 44 exs = if excby == "volume" 45 ["binance", "bitforex", "okx", "xt", "coinbase"] 46 elseif excby == "markets" 47 ["yobit", "gateio", "binance", "hitbtc", "kucoin"] 48 else 49 ["bitrue", "phemex", "hitbtc", "coinex", "latoken"] 50 end 51 println() 52 exchange = _menu(exs) 53 println("\nQuote currency:") 54 qc = _menu(["USDT", "USDC", "BTC", "ETH", "DOGE"]) |> Symbol 55 println("\nMargin mode:") 56 mm = _menu(["NoMargin", "Isolated"]) |> Symbol 57 margin = eval(:($mm))() 58 Config(; min_timeframe, exchange, qc, margin, kwargs...) 59 end 60 61 @doc """ Checks if `name` is a valid strategy name. """ 62 function isvalidname(name) 63 occursin(r"^([a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*)$", string(name)) 64 end 65 @doc """ Generates a new strategy with a given name and configuration. 66 67 $(TYPEDSIGNATURES) 68 69 This function creates a new strategy with a given name and configuration. 70 It prompts the user for necessary information, creates a new project, and sets up the environment for the strategy. 71 It also updates the user's configuration file with the new strategy's information. 72 """ 73 function _generate_strategy( 74 name=nothing, 75 cfg=nothing; 76 user_config_path=config_path(), 77 ask=true, 78 load=true, 79 deps=String[], 80 kwargs..., 81 ) 82 user_path = realpath(user_config_path) |> dirname 83 if !isfile(user_config_path) 84 if Base.prompt( 85 "User config file at $user_config_path not found, create new one? [y]/n" 86 ) != "n" 87 mkpath(user_path) 88 touch(user_config_path) 89 end 90 end 91 strategies_path = joinpath(user_path, "strategies") 92 mkpath(strategies_path) 93 local strat_name, strat_dir 94 while true 95 strat_name = ask_name(name) 96 if !isvalidname(strat_name) 97 @error "Strategy name should not have special chars" 98 continue 99 end 100 strat_dir = joinpath(strategies_path, strat_name) 101 if isdir(strat_dir) || isfile(joinpath(strategies_path, string(strat_name, ".jl"))) 102 @error "Strategy with name $strat_name already exists" 103 continue 104 end 105 break 106 end 107 cfg = @something cfg ask ? _askconfig(; kwargs...) : Config(; kwargs...) 108 strat_sym = Symbol(strat_name) 109 Pkg.generate(strat_dir; io=devnull) 110 if ask && Base.prompt("\nActivate strategy project at $(strat_dir)? [y]/n") != "n" 111 Pkg.activate(strat_dir; io=devnull) 112 end 113 if ask 114 deps_list = Base.prompt("\nAdd project dependencies (comma separated)") 115 append!(deps, split(deps_list, ","; keepempty=false)) 116 end 117 if !isempty(deps) 118 prev = Base.active_project() 119 try 120 Pkg.activate(strat_dir; io=devnull) 121 Pkg.add(deps) 122 catch e 123 @error e 124 end 125 Pkg.activate(prev) 126 println() 127 end 128 strat_file = copy_template!(strat_name, strat_dir, strategies_path; cfg) 129 user_config = TOML.parsefile(user_config_path) 130 if haskey(user_config, strat_name) 131 @warn "Deleting conflicting config entry for $strat_name" 132 delete!(user_config, strat_name) 133 end 134 let sources = @lget! user_config "sources" Dict{String,Any}() 135 strat_cfg = Dict( 136 "include_file" => relative_path(strat_dir, strat_file, 3), 137 "margin" => repr(typeof(cfg.margin)), 138 "mode" => "Sim", 139 ) 140 strat_project_file = joinpath(strat_dir, "Project.toml") 141 strat_project_toml = TOML.parsefile(strat_project_file) 142 strat_project_toml["strategy"] = strat_cfg 143 open(strat_project_file, "w") do f 144 TOML.print(f, strat_project_toml) 145 end 146 if strat_name in keys(sources) 147 @warn "Overwriting source entry for $strat_name" 148 end 149 sources[strat_name] = relative_path(user_config_path, strat_project_file) 150 cfg.sources = SortedDict(Symbol(k) => v for (k, v) in sources) 151 cfg.path = realpath(strat_project_file) 152 open(user_config_path, "w") do f 153 TOML.print(f, SortedDict(user_config)) 154 end 155 @info "Config file updated" 156 end 157 if (ask && Base.prompt("\nLoad strategy? [y]/n") != "n") || (!ask && load) 158 if !isdefined(Main, :Revise) 159 if Base.prompt("\n Load revise before loading the strategy? [y]/n") != "n" 160 @eval Main using Revise 161 end 162 end 163 config!("strategy"; cfg, cfg.path) 164 strategy!(strat_sym, cfg) 165 end 166 end 167 168 function generate_strategy(args...; kwargs...) 169 try 170 _generate_strategy(args...; kwargs...) 171 catch e 172 if e isa InterruptException 173 print("CTRL-C Interrupted") 174 else 175 rethrow(e) 176 end 177 end 178 end 179 180 @doc """ Copies a template to create a new strategy file 181 182 $(TYPEDSIGNATURES) 183 184 This function takes a strategy name, directory, and path along with a configuration object. 185 It reads a template file and modifies it according to the provided configuration. 186 The modified template is then written to a new strategy file in the specified directory. 187 The function returns the path to the newly created strategy file. 188 189 """ 190 function copy_template!(strat_name, strat_dir, strat_path; cfg) 191 tpl_file = joinpath(strat_path, "Template.jl") 192 @assert isfile(tpl_file) "Template file not found at $strat_path" 193 194 tpl_expr = Meta.parse(read(tpl_file, String)) 195 @info "New Strategy" name = strat_name exchange = cfg.exchange timeframe = string( 196 cfg.min_timeframe 197 ) 198 tpl_expr = postwalk( 199 x -> @capture(x, module name_ 200 body__ 201 end) ? :(module $(Symbol(strat_name)) 202 $(body...) 203 end) : x, tpl_expr 204 ) 205 tpl_expr = postwalk( 206 x -> @capture(x, DESCRIPTION = v_) ? :(DESCRIPTION = $(strat_name)) : x, tpl_expr 207 ) 208 tpl_expr = postwalk(x -> if @capture(x, EXC = v_) 209 :(EXC = $(QuoteNode(cfg.exchange))) 210 else 211 x 212 end, tpl_expr) 213 tpl_expr = postwalk( 214 x -> @capture(x, MARGIN = v_) ? :(MARGIN = $(typeof(cfg.margin))) : x, tpl_expr 215 ) 216 tpl_expr = postwalk( 217 x -> @capture(x, TF = v_) ? :(TF = @tf_str($(string(cfg.min_timeframe)))) : x, 218 tpl_expr, 219 ) 220 221 rmlinums!(tpl_expr) 222 strat_file = joinpath(strat_dir, "src", string(strat_name, ".jl")) 223 open(strat_file, "w") do f 224 src = string(tpl_expr) 225 src = replace(src, r"#=.*=#\s*" => "") 226 print(f, src) 227 end 228 return strat_file 229 end 230 231 @doc """ Removes line numbers from an expression 232 233 $(TYPEDSIGNATURES) 234 235 This function traverses an expression and removes any `LineNumberNode` or `LineInfoNode` instances it encounters. 236 It also skips any macro calls to prevent unwanted side effects. 237 The function modifies the expression in-place and returns the modified expression. 238 239 """ 240 function rmlinums!(expr) 241 if expr isa LineNumberNode || expr isa LineInfoNode 242 return missing 243 end 244 if hasproperty(expr, :head) && expr.head == :macrocall 245 return expr 246 end 247 if hasproperty(expr, :args) 248 args = expr.args 249 n = 1 250 while n <= length(args) 251 if ismissing(rmlinums!(args[n])) 252 deleteat!(args, n) 253 else 254 n += 1 255 end 256 end 257 end 258 expr 259 end 260 261 function _confirm_del(where) 262 Base.prompt("Really delete strategy located at $(where)? [n]/y") == "y" 263 end 264 265 @doc """ Removes a strategy based on the provided name or path 266 267 $(TYPEDSIGNATURES) 268 269 This function takes a strategy name or path as input. 270 If the input is a valid path, it deletes the strategy located at that path. 271 If the input is a valid strategy name, it deletes the strategy with that name. 272 The function also prompts the user for confirmation before deleting a strategy. 273 274 """ 275 function remove_strategy(subj=nothing) 276 where = @something subj Base.prompt("Strategy name (or project path): ") 277 strat_name = "" 278 if ispath(where) 279 rp = if endswith(where, ".toml") 280 dirname(where) 281 else 282 where 283 end |> realpath 284 if _confirm_del(rp) 285 rm(rp; recursive=true) 286 strat_name = if endswith(where, ".toml") 287 basename(dirname(rp)) 288 else 289 splitext(basename(where))[1] 290 end 291 @info "Strategy removed" 292 else 293 @info "Removal canceled" 294 end 295 else 296 udir = user_dir() 297 file_strat = joinpath(udir, string(where, ".jl")) 298 if isfile(file_strat) 299 if _confirm_del(file_strat) 300 rm(file_strat) 301 strat_name = where 302 @info "Strategy removed" 303 else 304 @info "Removal canceled" 305 end 306 else 307 proj_strat = joinpath(udir, "strategies", string(where)) 308 if isdir(proj_strat) 309 if _confirm_del(proj_strat) 310 rm(proj_strat; recursive=true) 311 strat_name = where 312 @info "Strategy removed" 313 else 314 @info "Removal canceled" 315 end 316 else 317 @error "Input is neither a project path nor a strategy name" input = where 318 end 319 end 320 end 321 _delete_strat_entry(strat_name) 322 end 323 324 @doc """ Deletes a strategy entry from the user configuration 325 326 $(TYPEDSIGNATURES) 327 328 This function takes a strategy name as input. 329 If the name is not empty, it prompts the user for confirmation to remove the corresponding entry from the user configuration. 330 If confirmed, it deletes the strategy entry from the user configuration and the sources dictionary, if it exists. 331 The updated configuration is then written back to the configuration file. 332 333 """ 334 function _delete_strat_entry(name) 335 if !isempty(name) 336 if Base.prompt("Remove user config entry $name? [n]/y") == "y" 337 cp = config_path() 338 user_config = TOML.parsefile(cp) 339 delete!(user_config, name) 340 sources = get(user_config, "sources", (;)) 341 if sources isa AbstractDict 342 delete!(sources, name) 343 end 344 open(cp, "w") do f 345 TOML.print(f, SortedDict(user_config)) 346 end 347 end 348 end 349 end