/ LiveMode / src / trades.jl
trades.jl
  1  import .SimMode: maketrade, trade!
  2  using .SimMode: @maketrade, iscashenough, cost
  3  using .Misc.TimeToLive: safettl
  4  using .Misc: toprecision
  5  
  6  @doc """ Checks and filters trades based on a timestamp.
  7  
  8  $(TYPEDSIGNATURES)
  9  
 10  The function checks if the response is a list. If not, it issues a warning and returns `nothing`.
 11  If a `since` timestamp is provided, it filters out trades that occurred before this timestamp.
 12  The function returns the filtered list of trades or the original response if no `since` timestamp is provided.
 13  
 14  """
 15  function _check_and_filter(resp; ai, since, kind="")
 16      if resp isa Vector
 17          if isnothing(since)
 18              resp
 19          else
 20              filter(t -> trade_timestamp(t, exchangeid(ai)) >= since, resp)
 21          end
 22      elseif pyisinstance(resp, pybuiltins.list)
 23          if isnothing(since)
 24              resp
 25          else
 26              out = pylist()
 27              for t in resp
 28                  trade_timestamp(t, exchangeid(ai)) >= since && out.append(t)
 29              end
 30              out
 31          end
 32      else
 33          @warn "Couldn't fetch $kind trades for $(raw(ai))"
 34          return nothing
 35      end
 36  end
 37  
 38  function trade_timestamp(v, eid::EIDType)
 39      pyconvert(Int, resp_trade_timestamp(v, eid)) |> TimeTicks.dtstamp
 40  end
 41  
 42  @doc """ Fetches and filters the user's trades.
 43  
 44  $(TYPEDSIGNATURES)
 45  
 46  The function fetches the user's trades using the `fetch_my_trades` function.
 47  If a `since` timestamp is provided, it filters out trades that occurred before this timestamp using the `_check_and_filter` function.
 48  The function returns the filtered list of trades or the original response if no `since` timestamp is provided.
 49  
 50  """
 51  function live_my_trades(s::LiveStrategy, ai; since=nothing, kwargs...)
 52      resp = fetch_my_trades(s, ai; since, kwargs...)
 53      _check_and_filter(resp; ai, since)
 54  end
 55  
 56  @doc """ Fetches and filters trades for a specific order
 57  
 58  $(TYPEDSIGNATURES)
 59  
 60  This function fetches the trades associated with a specific order using the `fetch_order_trades` function.
 61  If a `since` timestamp is provided, it filters out trades that occurred before this timestamp using the `_check_and_filter` function.
 62  The function returns the filtered list of trades or the original response if no `since` timestamp is provided.
 63  
 64  """
 65  function live_order_trades(s::LiveStrategy, ai, id; since=nothing, kwargs...)
 66      resp = fetch_order_trades(s, ai, id; since, kwargs...)
 67      _check_and_filter(resp; ai, since, kind="order")
 68  end
 69  
 70  @doc "A named tuple representing the ccxt fields of a trade."
 71  const Trf = NamedTuple(
 72      Symbol(f) => f for f in (
 73          "id",
 74          "timestamp",
 75          "datetime",
 76          "symbol",
 77          "order",
 78          "type",
 79          "side",
 80          "takerOrMaker",
 81          "price",
 82          "amount",
 83          "cost",
 84          "fee",
 85          "fees",
 86          "currency",
 87          "rate",
 88      )
 89  )
 90  
 91  function inlimits(v, ai, lim_sym)
 92      lims = ai.limits
 93      min = getproperty(lims, lim_sym).min
 94      max = getproperty(lims, lim_sym).max
 95  
 96      if v < min || v > max
 97          @warn "create trade: value outside bounds" lim_sym v min max
 98          false
 99      else
100          true
101      end
102  end
103  
104  @doc "A cache for storing market symbols by currency with a time-to-live of 360 seconds."
105  const MARKETS_BY_CUR = safettl(Tuple{ExchangeID,String}, Vector{String}, Second(360))
106  function anyprice(cur::String, sym, exc)
107      try
108          v = lastprice(sym, exc)
109          v <= zero(v) || return v
110          for sym in @lget! MARKETS_BY_CUR (exchangeid(exc), cur) [
111              k for k in Iterators.reverse(collect(keys(exc.markets))) if startswith(k, cur)
112          ]
113              v = lastprice(sym, exc)
114              v <= zero(v) || return v
115          end
116          return 0.0
117      catch
118          return 0.0
119      end
120  end
121  
122  _feebysign(rate, cost) = rate >= 0.0 ? cost : -cost
123  @doc """ Calculates the fee from a fee dictionary
124  
125  $(TYPEDSIGNATURES)
126  
127  This function calculates the fee from a fee dictionary.
128  It retrieves the rate and cost from the fee dictionary and then uses the `_feebysign` function to calculate the fee based on the rate and cost.
129  
130  """
131  function _getfee(fee_dict, cost=get_float(fee_dict, "cost"))
132      rate = get_float(fee_dict, "rate")
133      _feebysign(rate, cost)
134  end
135  
136  @doc """ Determines the fee cost based on the currency
137  
138  $(TYPEDSIGNATURES)
139  
140  This function determines the fee cost based on the currency specified in the fee dictionary.
141  If the currency matches the quote currency, it returns the fee in quote currency.
142  If the currency matches the base currency, it returns the fee in base currency.
143  If the currency doesn't match either, it returns zero for both.
144  
145  """
146  function _feecost(
147      fee_dict, ai, ::EIDType=exchangeid(ai); qc_py=@pystr(qc(ai)), bc_py=@pystr(bc(ai))
148  )
149      cur = get_py(fee_dict, "currency")
150      @debug "live fee cost" _module = LogCreateTrade cur qc_py bc_py
151      if pyeq(Bool, cur, qc_py)
152          @debug "live fee cost: quote currency" _module = LogCreateTrade _getfee(fee_dict)
153          (_getfee(fee_dict), 0.0)
154      elseif pyeq(Bool, cur, bc_py)
155          @debug "live fee cost: base currency" _module = LogCreateTrade _getfee(fee_dict)
156          (0.0, _getfee(fee_dict))
157      else
158          (0.0, 0.0)
159      end
160  end
161  
162  # This tries to always convert the fees in quote currency
163  # function _feecost_quote(s, ai, bc_price, date, qc_py=@pystr(qc(ai)), bc_py=@pystr(bc(ai)))
164  #     if pyeq(Bool, cur, bc_py)
165  #         _feebysign(rate, cost * bc_price)
166  #     else
167  #         # Fee currency is neither quote nor base, fetch the price from the candle
168  #         # of the related spot pair with the trade quote currency at the trade date
169  #         try
170  #             spot_pair = "$cur/$qc_py"
171  #             price = @something priceat(s, ai, date; sym=spot_pair, step=:close) anyprice(
172  #                 string(cur), spot_pair, exchange(ai)
173  #             )
174  #             _feebysign(rate, cost * price)
175  #         catch
176  #         end
177  #     end
178  # end
179  
180  @doc """ Determines the currency of the fee based on the order side
181  
182  $(TYPEDSIGNATURES)
183  
184  This function determines the currency of the fee based on the side of the order.
185  It uses the `feeSide` property of the market associated with the order.
186  The function returns `:base` if the fee is in the base currency and `:quote` if the fee is in the quote currency.
187  
188  """
189  function trade_feecur(ai, side::Type{<:OrderSide})
190      # Default to get since it should be the most common
191      feeside = get(market(ai), "feeSide", "get")
192      if feeside == "get"
193          if side == Buy
194              :base
195          else
196              :quote
197          end
198      elseif feeside == "give"
199          if side == Sell
200              :base
201          else
202              :quote
203          end
204      elseif feeside == "quote"
205          :quote
206      elseif feeside == "base"
207          :base
208      else
209          :quote
210      end
211  end
212  
213  @doc """ Calculates the default trade fees based on the order side
214  
215  $(TYPEDSIGNATURES)
216  
217  This function calculates the default trade fees based on the side of the order and the current market conditions.
218  It uses the `trade_feecur` function to determine the currency of the fee and then calculates the fee based on the amount and cost of the trade.
219  
220  """
221  function _default_trade_fees(
222      ai, side::Type{<:OrderSide}; fees_base, fees_quote, actual_amount, net_cost
223  )
224      feecur = trade_feecur(ai, side)
225      default_fees = maxfees(ai)
226      if feecur == :base
227          fees_base += actual_amount * default_fees
228      else
229          fees_quote += net_cost * default_fees
230      end
231      (fees_quote, fees_base)
232  end
233  
234  market(ai) = exchange(ai).markets[raw(ai)]
235  @doc """ Determines the trade fees based on the response and side of the order
236  
237  $(TYPEDSIGNATURES)
238  
239  This function determines the trade fees based on the response from the exchange and the side of the order.
240  It first checks if the response contains a fee dictionary. If it does, it calculates the fee cost based on the dictionary.
241  If the response does not contain a fee dictionary but contains a list of fees, it calculates the total fee cost from the list.
242  If the response does not contain either, it calculates the default trade fees.
243  
244  """
245  function _tradefees(resp, side, ai; actual_amount, net_cost)
246      eid = exchangeid(ai)
247      v = resp_trade_fee(resp, eid)
248      if pyisinstance(v, pybuiltins.dict)
249          @debug "live trade fees: " _module = LogCreateTrade _feecost(v, ai, eid)
250          return _feecost(v, ai, eid)
251      end
252      v = resp_trade_fees(resp, eid)
253      fees_quote, fees_base = 0.0, 0.0
254      if pyisinstance(v, pybuiltins.list) && !isempty(v)
255          qc_py = @pystr(qc(ai))
256          bc_py = @pystr(bc(ai))
257          for fee in v
258              (q, b) = _feecost(fee, ai, eid; qc_py, bc_py)
259              fees_quote += q
260              fees_base += b
261          end
262      end
263      if iszero(fees_quote) && iszero(fees_base)
264          (fees_quote, fees_base) = _default_trade_fees(
265              ai, side; fees_base, fees_quote, actual_amount, net_cost
266          )
267      end
268      @debug "live trade fees" _module = LogCreateTrade fees_quote fees_base
269      return (fees_quote, fees_base)
270  end
271  
272  _addfees(net_cost, fees_quote, ::IncreaseOrder) = net_cost + fees_quote
273  _addfees(net_cost, fees_quote, ::ReduceOrder) = net_cost - fees_quote
274  
275  @doc """ Checks if the trade symbol matches the order symbol
276  
277  $(TYPEDSIGNATURES)
278  
279  This function checks if the trade symbol from the response matches the symbol of the order.
280  If they do not match, it issues a warning and returns `false`.
281  
282  """
283  function isordersymbol(ai, o, resp, eid::EIDType; getter=resp_trade_symbol)::Bool
284      pyeq(Bool, getter(resp, eid), @pystr(raw(ai))) || begin
285          @warn "Mismatching trade for $(raw(ai))($(resp_trade_symbol(resp, eid))), order: $(o.asset), refusing construction."
286          return false
287      end
288  end
289  
290  @doc """ Checks if the response is of the expected type
291  
292  $(TYPEDSIGNATURES)
293  
294  This function checks if the response from the exchange is of the expected type.
295  If the response is not of the expected type, it issues a warning and returns `false`.
296  
297  """
298  function isordertype(ai, o, resp, ::EIDType; type=pybuiltins.dict)::Bool
299      if !pyisinstance(resp, type)
300          @warn "Invalid response for order $(raw(ai)), order: $o, refusing construction."
301          false
302      else
303          true
304      end
305  end
306  
307  @doc """ Checks if the trade id matches the order id
308  
309  $(TYPEDSIGNATURES)
310  
311  This function checks if the trade id from the response matches the id of the order.
312  If they do not match, it issues a warning and returns `false`.
313  
314  """
315  function isorderid(ai, o, resp, eid::EIDType; getter=resp_trade_order)::Bool
316      if string(getter(resp, eid)) != o.id
317          @warn "Mismatching id $(raw(ai))($(resp_trade_order(resp, eid))), order: $(o.id), refusing construction."
318          false
319      else
320          true
321      end
322  end
323  
324  @doc """ Checks if the trade side matches the order side
325  
326  $(TYPEDSIGNATURES)
327  
328  This function checks if the side of the trade from the response matches the side of the order.
329  If they do not match, it issues a warning and returns `false`.
330  
331  """
332  function isorderside(side, o)::Bool
333      if side != orderside(o)
334          @warn "Mismatching trade side $side and order side $(orderside(o)), refusing construction."
335          false
336      else
337          true
338      end
339  end
340  
341  function divergentprice(o::AnyMarketOrder, actual_price)
342      false
343  end
344  
345  function divergentprice(o::AnyBuyOrder, actual_price; rtol=0.05)
346      !isapprox(actual_price, o.price; rtol) && actual_price > o.price
347  end
348  
349  function divergentprice(o::AnySellOrder, actual_price; rtol=0.05)
350      !isapprox(actual_price, o.price; rtol) && actual_price < o.price
351  end
352  
353  @doc """ Checks if the trade price is valid
354  
355  $(TYPEDSIGNATURES)
356  
357  This function checks if the trade price from the response is approximately equal to the order price or if the order is a market order.
358  If the price is far off from the order price, it issues a warning.
359  The function also checks if the price is greater than zero, issuing a warning and returning `false` if it's not.
360  
361  """
362  function isorderprice(s, ai, actual_price, o; rtol=0.05, resp)::Bool
363      if divergentprice(o, actual_price; rtol)
364          @warn "create trade: trade price far off from order price" o.price exc_price =
365              actual_price ai nameof(s) o o.id @caller(20)
366          false
367      elseif actual_price <= 0.0 || !isfinite(actual_price)
368          @warn "create trade: invalid price" nameof(s) ai tradeid = resp_trade_id(
369              resp, exchangeid(ai)
370          ) o
371          false
372      else
373          true
374      end
375  end
376  
377  @doc """ Checks if the trade amount is valid
378  
379  $(TYPEDSIGNATURES)
380  
381  This function checks if the trade amount from the response is greater than zero.
382  If it's not, it issues a warning and returns `false`.
383  
384  """
385  function isorderamount(s, ai, actual_amount; resp)::Bool
386      if actual_amount <= 0.0 || !isfinite(actual_amount)
387          @warn "create trade: invalid amount" nameof(s) ai tradeid = resp_trade_id(
388              resp, exchangeid(ai)
389          )
390          false
391      else
392          true
393      end
394  end
395  
396  @doc """ Warns if the local cash is not enough for the trade
397  
398  $(TYPEDSIGNATURES)
399  
400  This function checks if the local cash is enough for the trade.
401  If it's not, it issues a warning.
402  
403  """
404  function _warn_cash(s, ai, o; actual_amount)
405      if !iscashenough(s, ai, actual_amount, o)
406          @warn "make trade: creating trade but local cash is not enough" cash(ai) o.id actual_amount
407      end
408  end
409  
410  @doc """ Constructs a trade based on the order and response
411  
412  $(TYPEDSIGNATURES)
413  
414  This function constructs a trade based on the order and the response from the exchange.
415  It performs several checks on the response, such as checking the type, symbol, id, side, price, and amount.
416  If any of these checks fail, the function returns `nothing`.
417  Otherwise, it calculates the fees, warns if the local cash is not enough for the trade, and constructs the trade.
418  
419  """
420  function maketrade(s::LiveStrategy, o, ai; resp, trade::Option{Trade}=nothing, kwargs...)
421      eid = exchangeid(ai)
422      if trade isa Trade
423          return trade
424      end
425      if !isordertype(ai, o, resp, eid) ||
426          !isordersymbol(ai, o, resp, eid) ||
427          !isorderid(ai, o, resp, eid)
428          @debug "maketrade: failed" _module = LogCreateTrade ai isordertype(ai, o, resp, eid) isordersymbol(ai, o, resp, eid) isorderid(ai, o, resp, eid)
429          return nothing
430      end
431      side = _ccxt_sidetype(resp, eid; o)
432      if !isorderside(side, o)
433          @debug "maketrade: wrong side" _module = LogCreateTrade ai side o
434          return nothing
435      end
436      actual_amount = resp_trade_amount(resp, eid)
437      actual_price = resp_trade_price(resp, eid)
438  
439      isorderprice(s, ai, actual_price, o; resp)
440      inlimits(actual_price, ai, :price)
441  
442      if actual_amount <= 0.0 || !isfinite(actual_amount)
443          @debug "make trade: amount value absent from trade or wrong ($actual_amount)), using cost." _module =
444              LogCreateTrade ai actual_amount resp
445          net_cost = resp_trade_cost(resp, eid)
446          actual_amount = toprecision(net_cost / actual_price, ai.precision.amount)
447          if !isorderamount(s, ai, actual_amount; resp)
448              @debug "make trade: wrong amount" _module = LogCreateTrade ai actual_amount
449              return nothing
450          end
451      else
452          net_cost = let c = cost(actual_price, actual_amount)
453              toprecision(c, ai.precision.price)
454          end
455      end
456      inlimits(net_cost, ai, :cost)
457      inlimits(actual_amount, ai, :amount)
458  
459      _warn_cash(s, ai, o; actual_amount)
460      date = @something pytodate(resp, eid) now()
461  
462      fees_quote, fees_base = _tradefees(resp, side, ai; actual_amount, net_cost)
463      size = _addfees(net_cost, fees_quote, o)
464  
465      @debug "Constructing trade" _module = LogCreateTrade cash = cash(ai, posside(o)) ai = raw(
466          ai
467      ) s = nameof(s)
468      @maketrade
469  end