/ Planar / src / strat.jl
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