/ Exchanges / src / tickers.jl
tickers.jl
  1  using .Misc.Lang: @get, @multiget, @lget!, Option, safenotify, safewait
  2  using .Misc: config, NoMargin, DFT
  3  using .Misc.ConcurrentCollections: ConcurrentDict
  4  using .Misc: waitforcond
  5  using Instruments: isfiatquote, spotpair
  6  using .Python: @pystr, @pyconst, pyfetch_timeout, pylist, pytruth
  7  using ExchangeTypes: decimal_to_size, excDecimalPlaces, excSignificantDigits, excTickSize
  8  
  9  @doc """A leveraged pair is a pair like `BTC3L/USD`.
 10  - `:yes` : Leveraged pairs will not be filtered.
 11  - `:only` : ONLY leveraged will be kept.
 12  - `:from` : Selects non leveraged pairs, that also have at least one leveraged sibling.
 13  """
 14  const LEVERAGED_PAIR_OPTIONS = (:yes, :only, :from)
 15  
 16  @doc """Quote id of the market."""
 17  quoteid(mkt) = @multiget mkt "quoteId" "quote" "n/a"
 18  @doc "True if `id` is a quote id."
 19  isquote(id, qc) = lowercase(id) == qc
 20  @doc "True if `mkt` is a leveraged market."
 21  ismargin(mkt) = Bool(@get mkt "margin" false)
 22  
 23  @doc "True if `pair` is a leveraged pair."
 24  function has_leverage(pair, pairs_with_leverage)
 25      !isleveragedpair(pair) && pair ∈ pairs_with_leverage
 26  end
 27  @doc "Constructor that returns a function that checks if a pair is leveraged."
 28  function leverage_func(exc, with_leveraged, verbose=true)
 29      # Leveraged `:from` filters the pairlist taking non leveraged pairs, IF
 30      # they have a leveraged counterpart
 31      if with_leveraged == :from
 32          verbose && @warn "Filtering by leveraged, are you sure?"
 33          pairs_with_leverage = Set()
 34          for k in keys(exc.markets)
 35              dlv = deleverage_pair(k)
 36              k !== dlv && push!(pairs_with_leverage, dlv)
 37          end
 38          (pair) -> has_leverage(pair, pairs_with_leverage)
 39      else
 40          Returns(true)
 41      end
 42  end
 43  @doc "True if symbol `sym` has a quote volume less than `min_vol`."
 44  function hasvolume(sym, spot; tickers, min_vol)
 45      if spot ∈ keys(tickers)
 46          quotevol(tickers[spot]) <= min_vol
 47      else
 48          quotevol(tickers[sym]) <= min_vol
 49      end
 50  end
 51  
 52  marketsid(args...; kwargs...) = error("not implemented")
 53  @doc "Get the exchange market ids."
 54  marketsid(exc::Exchange, args...; kwargs...) = keys(tickers(exc, args...; kwargs...))
 55  @doc "Get the tickers matching quote currency `quot`."
 56  tickers(quot::Symbol, args...; kwargs...) = tickers(exc, quot, args...; kwargs...)
 57  
 58  aspair(k, v) = k => v
 59  askey(k, _) = k
 60  asvalue(_, v) = v
 61  
 62  @doc """Get the exchange tickers.
 63  
 64  $(TYPEDSIGNATURES)
 65  
 66  - `exc`: an Exchange object to fetch the tickers from.
 67  - `quot`: only choose pairs where the quote currency equals `quot`.
 68  - `min_vol`: the minimum volume of each pair.
 69  - `skip_fiat` (optional, default is true): ignore fiat/fiat pairs.
 70  - `with_margin` (optional, default is the result of `config.margin != NoMargin()`): only choose pairs enabled for margin trading.
 71  - `with_leverage` (optional, default is `:no`): if `:no`, skip all pairs where the base currency matches the `leverage_pair_rgx` regex.
 72  - `as_vec` (optional, default is false): return the pair list as a Vector instead of as a Dict.
 73  - `verbose` (optional, default is true): print detailed output about the operation.
 74  - `type` (optional, default is the result of `markettype(exc)`): the type of markets to fetch tickers for.
 75  - `cross_match` list of other exchanges where the filter pairs must also be present in
 76  
 77  """
 78  function tickers(
 79      exc::Exchange,
 80      quot;
 81      min_vol,
 82      skip_fiat=true,
 83      with_margin=config.margin != NoMargin(),
 84      with_leverage=:no,
 85      as_vec=false,
 86      verbose=true,
 87      type=markettype(exc),
 88      cross_match::Tuple{Vararg{Symbol}}=(),
 89  ) # ::Union{Dict,Vector}
 90      # swap exchange in case of futures
 91      @tickers! type
 92      pairlist = []
 93      quot = string(quot)
 94  
 95      lquot = lowercase(quot)
 96  
 97      as = ifelse(as_vec, askey, aspair)
 98      pushas(p, k, v, _) = push!(p, as(k, v))
 99      pushifquote(p, k, v, q) = isquote(quoteid(v), q) && pushas(p, k, v, nothing)
100      addto = ifelse(isempty(quot), pushas, pushifquote)
101      leverage_check = leverage_func(exc, with_leverage, verbose)
102      notinmarket(sym) = any(sym ∉ keys(getexchange!(e).markets) for e in cross_match)
103      # TODO: all this checks should be decomposed into functions transducer style
104      function skip_check(sym, spot, islev, mkt)
105          notinmarket(sym) ||
106              (with_leverage == :no && islev) ||
107              (with_leverage == :only && !islev) ||
108              !leverage_check(spot) ||
109              hasvolume(sym, spot; tickers, min_vol) ||
110              (skip_fiat && isfiatpair(spot)) ||
111              (with_margin && Bool(@get(mkt, "margin", false)))
112      end
113  
114      let markets = exc.markets
115          for (sym, mkt) in tickers
116              mkt = get(markets, sym, nothing)
117              isnothing(mkt) && continue
118              spot = spotsymbol(sym, mkt)
119              islev = isleveragedpair(spot)
120              skip_check(sym, spot, islev, mkt) && continue
121              addto(pairlist, sym, mkt, lquot)
122          end
123      end
124  
125      function result(pairlist, as_vec)
126          isempty(pairlist) &&
127              verbose &&
128              @warn "No pairs found, check quote currency ($quot) and min volume parameters ($min_vol)."
129          isempty(quot) && return pairlist
130          if as_vec
131              unique!(pairlist)
132              return sort!(pairlist; by=k -> quotevol(tickers[k]))
133          end
134          Dict(pairlist)
135      end
136      result(pairlist, as_vec)
137  end
138  
139  @doc "Caches markets (1minute)."
140  const marketsCache1Min = safettl(String, Py, Minute(1))
141  @doc "Caches tickers (10seconds)."
142  const tickersCache10Sec = safettl(String, Py, Second(10))
143  @doc "Caches active states (1minute)."
144  const activeCache1Min = safettl(String, Bool, Minute(1))
145  @doc "Lock held when fetching tickers (per ticker)."
146  const tickersLockDict = ConcurrentDict(Dict{String,ReentrantLock}())
147  @doc "Retrieves a cached market (1minute) or fetches it from exchange.
148  
149  $(TYPEDSIGNATURES)
150  "
151  function market!(pair, exc::Exchange)
152      @lget! marketsCache1Min pair exc.py.market(pair)
153  end
154  market!(a::AbstractAsset, args...) = market!(a.raw, args...)
155  
156  _tickerfunc(exc) = first(exc, :fetchTickerWs, :fetchTicker)
157  @doc """Fetch the ticker for a specific pair from an exchange.
158  
159  $(TYPEDSIGNATURES)
160  
161  The `ticker!` function takes the following parameters:
162  
163  - `pair`: a string representing the currency pair to fetch the ticker for.
164  - `exc`: an Exchange object to fetch the ticker from.
165  - `timeout` (optional, default is 3 seconds): the maximum time to wait for the ticker fetch operation.
166  - `func` (optional, default is the result of `_tickerfunc(exc)`): the function to use to fetch the ticker.
167  """
168  function ticker!(
169      pair, exc::Exchange; timeout=Second(3), func=_tickerfunc(exc), delay=Second(1)
170  )
171      l = @lget!(tickersLockDict, pair, ReentrantLock())
172      waitforcond(l.cond_wait, timeout)
173      if islocked(l)
174          waitforcond(l.cond_wait, timeout)
175          return @get tickersCache10Sec pair pydict()
176      else
177          @lock l begin
178              fetch_func = first(exc, :fetchTicker)
179              @lget! tickersCache10Sec pair begin
180                  v = nothing::Option{Py}
181                  tries = 0
182                  while tries < 3
183                      tries += 1
184                      def_func = pyisTrue(func == fetch_func) ? Returns(missing) : fetch_func
185                      v = pyfetch_timeout(func, exc.fetchTicker, timeout, pair)
186                      if v isa PyException
187                          @error "Fetch ticker error: $v" offline = isoffline() func pair
188                          v = pylist()
189                          isoffline() && break
190                      else
191                          break
192                      end
193                      sleep(delay)
194                  end
195                  safenotify(l.cond_wait)
196                  v
197              end
198          end
199      end
200  end
201  ticker!(a::AbstractAsset, args...; kwargs...) = ticker!(a.raw, args...; kwargs...)
202  @doc """Fetch the latest price for a specific pair from an exchange.
203  
204  $(TYPEDSIGNATURES)
205  
206  - `pair`: a string representing the currency pair to fetch the latest price for.
207  - `exc`: an Exchange object to fetch the latest price from.
208  - `kwargs` (optional): any additional keyword arguments are passed on to the underlying fetch operation.
209  """
210  function lastprice(pair::AbstractString, exc::Exchange; kwargs...)
211      tick = ticker!(pair, exc; kwargs...)
212      lastprice(exc, tick, pair)
213  end
214  
215  function lastprice(exc::Exchange, tick, pair="")
216      if !pytruth(tick)
217          sym = try
218              @coalesce get(tick, "symbol", missing) pair
219          catch
220          end
221          @warn "exchanges: failed to fetch ticker" pair nameof(exc)
222          0.0
223      else
224          lp = tick["last"]
225          if !pytruth(lp)
226              ask = tick["ask"]
227              bid = tick["bid"]
228              if pytruth(ask) && pytruth(bid)
229                  (pytofloat(ask) + pytofloat(bid)) / 2
230              else
231                  close = tick["close"]
232                  if pytruth(close)
233                      pytofloat(close)
234                  else
235                      vwap = tick["vwap"]
236                      if pytruth(vwap)
237                          pytofloat(vwap)
238                      else
239                          high = tick["high"]
240                          low = tick["low"]
241                          if pytruth(high) && pytruth(low)
242                              (pytofloat(high) + pytofloat(low)) / 2
243                          else
244                              @warn "lastprice failed" nameof(exc) get(tick, "symbol", "")
245                              0.0
246                          end
247                      end
248                  end
249              end
250          else
251              lp |> pytofloat
252          end
253      end
254  end
255  
256  function default_amount_precision(exc)
257      if exc.precision == excDecimalPlaces
258          8
259      elseif exc.precision == excSignificantDigits
260          9
261      elseif exc.precision == excTickSize
262          1e-8
263      end
264  end
265  
266  function default_price_precision(exc)
267      if exc.precision == excDecimalPlaces
268          2
269      elseif exc.precision == excSignificantDigits
270          3
271      elseif exc.precision == excTickSize
272          1e-2
273      end
274  end
275  
276  function _get_precision(exc, mkt, k)
277      v = mkt[k]
278      if !pyisnone(v)
279          pytofloat(v)
280      elseif k in ("amount", "base")
281          default_amount_precision(exc)
282      else # price cost quote
283          default_price_precision(exc)
284      end
285  end
286  
287  @doc "Precision of the (base, quote) currencies of the market.
288  
289  $(TYPEDSIGNATURES)
290  "
291  function market_precision(pair::AbstractString, exc::Exchange)
292      mkt = exc.markets[pair]["precision"]
293      p_amount = decimal_to_size(_get_precision(exc, mkt, "amount"), exc.precision; exc)
294      p_price = decimal_to_size(_get_precision(exc, mkt, "price"), exc.precision; exc)
295      (; amount=p_amount, price=p_price)
296  end
297  market_precision(a::AbstractAsset, args...) = market_precision(a.raw, args...)
298  
299  py_str_to_float(n::DFT) = n
300  function py_str_to_float(py::Py)
301      (x -> Base.parse(Float64, x))(pyconvert(String, py))
302  end
303  
304  const DEFAULT_LEVERAGE = (; min=0.0, max=100.0)
305  const DEFAULT_AMOUNT = (; min=1e-15, max=Inf)
306  const DEFAULT_PRICE = (; min=1e-15, max=Inf)
307  const DEFAULT_COST = (; min=1e-15, max=Inf)
308  const DEFAULT_FIAT_COST = (; min=1e-8, max=Inf)
309  
310  _min_from_precision(::Nothing) = nothing
311  _min_from_precision(v::Int) = 1.0 / 10.0^v
312  _min_from_precision(v::Real) = v
313  function _minmax_pair(mkt, l, prec, default)
314      k = string(l)
315      Symbol(l) => (;
316          min=(@something pyconvert(Option{DFT}, get(mkt[k], "min", nothing)) _min_from_precision(
317              prec
318          ) default.min),
319          max=(@something pyconvert(Option{DFT}, get(mkt[k], "max", nothing)) default.max),
320      )
321  end
322  
323  @doc """Fetch the market limits for a specific pair from an exchange.
324  
325  $(TYPEDSIGNATURES)
326  
327  - `pair`: a string representing the currency pair to fetch the market limits for.
328  - `exc`: an Exchange object to fetch the market limits from.
329  - `precision` (optional, default is `price=nothing, amount=nothing`): a named tuple specifying the precision for price and amount.
330  - `default_leverage` (optional, default is `DEFAULT_LEVERAGE`): the default leverage to use if not specified in the market data.
331  - `default_amount` (optional, default is `DEFAULT_AMOUNT`): the default amount to use if not specified in the market data.
332  - `default_price` (optional, default is `DEFAULT_PRICE`): the default price to use if not specified in the market data.
333  - `default_cost` (optional, default is `DEFAULT_COST` for non-fiat quote pairs and `DEFAULT_FIAT_COST` for fiat quote pairs): the default cost to use if not specified in the market data.
334  """
335  function market_limits(
336      pair::AbstractString,
337      exc::Exchange;
338      precision=(; price=nothing, amount=nothing),
339      default_leverage=DEFAULT_LEVERAGE,
340      default_amount=DEFAULT_AMOUNT,
341      default_price=DEFAULT_PRICE,
342      default_cost=(isfiatquote(pair) ? DEFAULT_FIAT_COST : DEFAULT_COST),
343  )
344      mkt = exc.markets[pair]["limits"]
345      (;
346          (
347              _minmax_pair(mkt, "leverage", nothing, default_leverage),
348              _minmax_pair(mkt, "amount", precision.amount, default_amount),
349              _minmax_pair(mkt, "price", precision.price, default_price),
350              _minmax_pair(mkt, "cost", nothing, default_cost),
351          )...
352      )
353  end
354  function market_limits(a::AbstractAsset, args...; kwargs...)
355      market_limits(a.raw, args...; kwargs...)
356  end
357  
358  @doc """Check if a currency pair is active on an exchange.
359  
360  $(TYPEDSIGNATURES)
361  """
362  function is_pair_active(pair::AbstractString, exc::Exchange)
363      @lget! activeCache1Min pair begin
364          pyconvert(Bool, market!(pair, exc)["active"])
365      end
366  end
367  is_pair_active(a::AbstractAsset, args...) = is_pair_active(a.raw, args...)
368  
369  _default_fees(exc, side) = @something get(exc.fees, side, nothing) 0.01
370  function _fees_byside(exc, mkt, side)
371      @something get(mkt, string(side), nothing) _default_fees(exc, Symbol(side))
372  end
373  @doc """Fetch the market fees for a specific pair from an exchange.
374  
375  $(TYPEDSIGNATURES)
376  
377  - `pair`: a string representing the currency pair to fetch the market fees for.
378  - `exc` (optional, default is the current exchange): an Exchange object to fetch the market fees from.
379  - `only_taker` (optional, default is `nothing`): a boolean indicating whether to fetch only the taker fee. If `nothing`, both maker and taker fees are fetched.
380  
381  """
382  function market_fees(
383      pair::AbstractString, exc::Exchange; only_taker::Union{Bool,Nothing}=nothing
384  )
385      m = exc.markets[pair]
386      if isnothing(only_taker)
387          taker = get(m, "taker", nothing)
388          if isnothing(taker)
389              # Fall back to fees from spot market
390              m = get(exc.markets, spotpair(pair), nothing)
391              if isnothing(m)
392                  # always ensure
393                  @warn "Failed to fetch $pair fees from $(exc.name), using default fees."
394                  taker = _default_fees(exc, :taker)
395                  maker = _default_fees(exc, :maker)
396              else
397                  taker = _fees_byside(exc, m, :taker)
398                  maker = _fees_byside(exc, m, :maker)
399              end
400          else
401              maker = _fees_byside(exc, m, :maker)
402          end
403          (; taker, maker, min=min(taker, maker), max=max(taker, maker))
404      elseif only_taker
405          _fees_byside(exc, m, :taker)
406      else
407          _fees_byside(exc, m, :maker)
408      end
409  end
410  market_fees(a::AbstractAsset, args...; kwargs...) = market_fees(a.raw, args...; kwargs...)