1082 lines
31 KiB
Python
1082 lines
31 KiB
Python
# piker: trading gear for hackers
|
|
# Copyright (C)
|
|
# Guillermo Rodriguez (aka ze jefe)
|
|
# Tyler Goodlet
|
|
# (in stewardship for pikers)
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
Binance clients for http and ws APIs.
|
|
|
|
"""
|
|
from __future__ import annotations
|
|
from collections import ChainMap
|
|
from contextlib import (
|
|
asynccontextmanager as acm,
|
|
AsyncExitStack,
|
|
)
|
|
from datetime import datetime
|
|
from pprint import pformat
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
Type,
|
|
)
|
|
import hmac
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
import trio
|
|
from pendulum import (
|
|
now,
|
|
)
|
|
import httpx
|
|
import numpy as np
|
|
|
|
from piker import config
|
|
from piker.clearing._messages import (
|
|
Order,
|
|
)
|
|
from piker.accounting import (
|
|
Asset,
|
|
digits_to_dec,
|
|
MktPair,
|
|
)
|
|
from piker.types import Struct
|
|
from piker.data import (
|
|
def_iohlcv_fields,
|
|
match_from_pairs,
|
|
)
|
|
from piker.brokers import (
|
|
resproc,
|
|
SymbolNotFound,
|
|
get_logger,
|
|
)
|
|
from .venues import (
|
|
PAIRTYPES,
|
|
Pair,
|
|
MarketType,
|
|
_spot_url,
|
|
_futes_url,
|
|
_testnet_futes_url,
|
|
_testnet_spot_url,
|
|
)
|
|
|
|
log = get_logger('piker.brokers.binance')
|
|
|
|
|
|
def get_config() -> dict[str, Any]:
|
|
conf: dict
|
|
path: Path
|
|
conf, path = config.load(
|
|
conf_name='brokers',
|
|
touch_if_dne=True,
|
|
)
|
|
section: dict = conf.get('binance')
|
|
if not section:
|
|
log.warning(
|
|
f'No config section found for binance in {path}'
|
|
)
|
|
return {}
|
|
|
|
return section
|
|
|
|
|
|
log = get_logger(__name__)
|
|
|
|
# Broker specific ohlc schema (rest)
|
|
# XXX TODO? some additional fields are defined in the docs:
|
|
# https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data
|
|
|
|
# _ohlc_dtype = [
|
|
# ('close_time', int),
|
|
# ('quote_vol', float),
|
|
# ('num_trades', int),
|
|
# ('buy_base_vol', float),
|
|
# ('buy_quote_vol', float),
|
|
# ('ignore', float),
|
|
# ]
|
|
|
|
|
|
class OHLC(Struct):
|
|
'''
|
|
Description of the flattened OHLC quote format.
|
|
|
|
For schema details see:
|
|
https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-streams
|
|
|
|
'''
|
|
time: int # epoch in ms
|
|
|
|
open: float
|
|
high: float
|
|
low: float
|
|
close: float
|
|
volume: float
|
|
|
|
close_time: int
|
|
|
|
quote_vol: float
|
|
num_trades: int
|
|
buy_base_vol: float
|
|
buy_quote_vol: float
|
|
ignore: int
|
|
|
|
|
|
# convert datetime obj timestamp to unixtime in milliseconds
|
|
def binance_timestamp(
|
|
when: datetime
|
|
) -> int:
|
|
return int((when.timestamp() * 1000) + (when.microsecond / 1000))
|
|
|
|
|
|
class Client:
|
|
'''
|
|
Async ReST API client using `trio` + `httpx` B)
|
|
|
|
Supports all of the spot, margin and futures endpoints depending
|
|
on method.
|
|
|
|
'''
|
|
def __init__(
|
|
self,
|
|
|
|
venue_sessions: dict[
|
|
str, # venue key
|
|
tuple[httpx.AsyncClient, str] # session, eps path
|
|
],
|
|
conf: dict[str, Any],
|
|
# TODO: change this to `Client.[mkt_]venue: MarketType`?
|
|
mkt_mode: MarketType = 'spot',
|
|
|
|
) -> None:
|
|
self.conf = conf
|
|
|
|
# build out pair info tables for each market type
|
|
# and wrap in a chain-map view for search / query.
|
|
self._spot_pairs: dict[str, Pair] = {} # spot info table
|
|
self._ufutes_pairs: dict[str, Pair] = {} # usd-futures table
|
|
self._venue2pairs: dict[str, dict] = {
|
|
'spot': self._spot_pairs,
|
|
'usdtm_futes': self._ufutes_pairs,
|
|
}
|
|
|
|
self._venue2assets: dict[
|
|
str,
|
|
dict[str, dict] | None,
|
|
] = {
|
|
# NOTE: only the spot table contains a dict[str, Asset]
|
|
# since others (like futes, opts) can just do lookups
|
|
# from a list of names to the spot equivalent.
|
|
'spot': {},
|
|
'usdtm_futes': {},
|
|
# 'coinm_futes': {},
|
|
}
|
|
|
|
# NOTE: only stick in the spot table for now until exchange info
|
|
# is loaded, since at that point we'll suffix all the futes
|
|
# market symbols for use by search. See `.exch_info()`.
|
|
self._pairs: ChainMap[str, Pair] = ChainMap()
|
|
|
|
# global client "venue selection" mode.
|
|
# set this when you want to switch venues and not have to
|
|
# specify the venue for the next request.
|
|
self.mkt_mode: MarketType = mkt_mode
|
|
|
|
# per-mkt-venue API client table
|
|
self.venue_sesh = venue_sessions
|
|
|
|
# lookup for going from `.mkt_mode: str` to the config
|
|
# subsection `key: str`
|
|
self.venue2configkey: dict[str, str] = {
|
|
'spot': 'spot',
|
|
'margin': 'spot',
|
|
'usdtm_futes': 'futes',
|
|
# 'coinm_futes': 'futes',
|
|
}
|
|
self.confkey2venuekeys: dict[str, list[str]] = {
|
|
'spot': ['spot'], # 'margin'],
|
|
'futes': ['usdtm_futes'],
|
|
}
|
|
|
|
def _mk_sig(
|
|
self,
|
|
data: dict,
|
|
venue: str,
|
|
|
|
) -> str:
|
|
|
|
# look up subconfig (spot or futes) section using
|
|
# venue specific key lookup to figure out which mkt
|
|
# we need a key for.
|
|
section_name: str = self.venue2configkey[venue]
|
|
subconf: dict | None = self.conf.get(section_name)
|
|
if subconf is None:
|
|
raise config.ConfigurationError(
|
|
f'binance configuration is missing a `{section_name}` section '
|
|
'to define the creds for auth-ed endpoints!?'
|
|
)
|
|
|
|
# XXX: Info on security and authentification
|
|
# https://binance-docs.github.io/apidocs/#endpoint-security-type
|
|
if not (api_secret := subconf.get('api_secret')):
|
|
raise config.NoSignature(
|
|
"Can't generate a signature without setting up credentials"
|
|
)
|
|
|
|
query_str: str = '&'.join([
|
|
f'{key}={value}'
|
|
for key, value in data.items()
|
|
])
|
|
|
|
msg_auth = hmac.new(
|
|
api_secret.encode('utf-8'),
|
|
query_str.encode('utf-8'),
|
|
hashlib.sha256
|
|
)
|
|
return msg_auth.hexdigest()
|
|
|
|
# TODO: factor all these _api methods into a single impl
|
|
# which looks up the parent path for eps depending on a
|
|
# mkt_mode: MarketType input!
|
|
async def _api(
|
|
self,
|
|
endpoint: str, # ReST endpoint key
|
|
params: dict,
|
|
|
|
method: str = 'get',
|
|
venue: str|None = None, # if None use `.mkt_mode` state
|
|
signed: bool = False,
|
|
allow_testnet: bool = False,
|
|
|
|
) -> dict[str, Any]:
|
|
'''
|
|
Make a ReST API request via
|
|
- a /api/v3/ SPOT, or
|
|
- /fapi/v3/ USD-M FUTURES, or
|
|
- /api/v3/ SPOT/MARGIN
|
|
|
|
account/market endpoint request depending on either passed in
|
|
`venue: str` or the current setting `.mkt_mode: str` setting,
|
|
default `'spot'`.
|
|
|
|
|
|
Docs per venue API:
|
|
|
|
SPOT: market-data and spot-account-trade eps use this
|
|
---- endpoing parent path:
|
|
- https://binance-docs.github.io/apidocs/spot/en/#market-data-endpoints
|
|
- https://binance-docs.github.io/apidocs/spot/en/#spot-account-trade
|
|
|
|
MARGIN: and advancecd spot account eps:
|
|
------
|
|
- https://binance-docs.github.io/apidocs/spot/en/#margin-account-trade
|
|
- https://binance-docs.github.io/apidocs/spot/en/#listen-key-spot
|
|
- https://binance-docs.github.io/apidocs/spot/en/#spot-algo-endpoints
|
|
|
|
USD-M FUTES:
|
|
-----------
|
|
- https://binance-docs.github.io/apidocs/futures/en/#market-data-endpoints
|
|
|
|
'''
|
|
venue_key: str = venue or self.mkt_mode
|
|
|
|
if signed:
|
|
params['signature'] = self._mk_sig(
|
|
params,
|
|
venue=venue_key,
|
|
)
|
|
|
|
# Check if we're configured to route order requests to the
|
|
# venue equivalent's testnet.
|
|
use_testnet: bool = False
|
|
section_name: str = self.venue2configkey[venue_key]
|
|
if subconf := self.conf.get(section_name):
|
|
use_testnet = (
|
|
subconf.get('use_testnet', False)
|
|
and allow_testnet
|
|
)
|
|
|
|
if (
|
|
use_testnet
|
|
and method not in {
|
|
'klines',
|
|
'exchangeInfo',
|
|
}
|
|
):
|
|
# NOTE: only use testnet if user set brokers.toml config
|
|
# var to true **and** it's not one of the market data
|
|
# endpoints since we basically never want to display the
|
|
# test net feeds, we only are using it for testing order
|
|
# ctl machinery B)
|
|
venue_key += '_testnet'
|
|
|
|
client: httpx.AsyncClient
|
|
path: str
|
|
client, path = self.venue_sesh[venue_key]
|
|
meth: Callable = getattr(client, method)
|
|
resp = await meth(
|
|
url=path + endpoint,
|
|
params=params,
|
|
timeout=float('inf'),
|
|
)
|
|
return resproc(resp, log)
|
|
|
|
async def _cache_pairs(
|
|
self,
|
|
venue: str,
|
|
|
|
) -> None:
|
|
# lookup internal mkt-specific pair table to update
|
|
pair_table: dict[str, Pair] = self._venue2pairs[venue]
|
|
|
|
# make API request(s)
|
|
resp = await self._api(
|
|
'exchangeInfo',
|
|
params={}, # NOTE: retrieve all symbols by default
|
|
# XXX: MUST explicitly pass the routing venue since we
|
|
# don't know the routing mode but want to cache market
|
|
# infos across all venues
|
|
venue=venue,
|
|
allow_testnet=False, # XXX: never use testnet for symbol lookups
|
|
)
|
|
|
|
mkt_pairs = resp['symbols']
|
|
if not mkt_pairs:
|
|
raise SymbolNotFound(f'No market pairs found!?:\n{resp}')
|
|
|
|
pairs_view_subtable: dict[str, Pair] = {}
|
|
|
|
for item in mkt_pairs:
|
|
filters_ls: list = item.pop('filters', False)
|
|
if filters_ls:
|
|
filters = {}
|
|
for entry in filters_ls:
|
|
ftype = entry['filterType']
|
|
filters[ftype] = entry
|
|
|
|
item['filters'] = filters
|
|
|
|
pair_type: Type = PAIRTYPES[venue]
|
|
try:
|
|
pair: Pair = pair_type(**item)
|
|
except Exception as e:
|
|
e.add_note(
|
|
"\nDon't panic, prolly stupid binance changed their symbology schema again..\n"
|
|
'Check out their API docs here:\n\n'
|
|
'https://binance-docs.github.io/apidocs/spot/en/#exchange-information'
|
|
)
|
|
raise
|
|
pair_table[pair.symbol.upper()] = pair
|
|
|
|
# update an additional top-level-cross-venue-table
|
|
# `._pairs: ChainMap` for search B0
|
|
pairs_view_subtable[pair.bs_fqme] = pair
|
|
|
|
# XXX WOW: TURNS OUT THIS ISN'T TRUE !?
|
|
# > (populate `Asset` table for spot mkts only since it
|
|
# > should be a superset of any other venues such as
|
|
# > futes or margin)
|
|
if venue == 'spot':
|
|
dst_sectype: str = 'crypto_currency'
|
|
|
|
elif venue in {'usdtm_futes'}:
|
|
dst_sectype: str = 'future'
|
|
if pair.contractType == 'PERPETUAL':
|
|
dst_sectype: str = 'perpetual_future'
|
|
|
|
spot_asset_table: dict[str, Asset] = self._venue2assets['spot']
|
|
ven_asset_table: dict[str, Asset] = self._venue2assets[venue]
|
|
|
|
if (
|
|
(name := pair.quoteAsset) not in spot_asset_table
|
|
):
|
|
spot_asset_table[pair.bs_src_asset] = Asset(
|
|
name=name,
|
|
atype='crypto_currency',
|
|
tx_tick=digits_to_dec(pair.quoteAssetPrecision),
|
|
)
|
|
|
|
if (
|
|
(name := pair.baseAsset) not in ven_asset_table
|
|
):
|
|
if venue != 'spot':
|
|
assert dst_sectype != 'crypto_currency'
|
|
|
|
ven_asset_table[pair.bs_dst_asset] = Asset(
|
|
name=name,
|
|
atype=dst_sectype,
|
|
tx_tick=digits_to_dec(pair.baseAssetPrecision),
|
|
)
|
|
|
|
# log.warning(
|
|
# f'Assets not YET found in spot set: `{pformat(dne)}`!?'
|
|
# )
|
|
# NOTE: make merged view of all market-type pairs but
|
|
# use market specific `Pair.bs_fqme` for keys!
|
|
# this allows searching for market pairs with different
|
|
# suffixes easily, for ex. `BTCUSDT.USDTM.PERP` will show
|
|
# up when a user uses the search endpoint with pattern
|
|
# `btc` B)
|
|
self._pairs.maps.append(pairs_view_subtable)
|
|
|
|
if venue == 'spot':
|
|
return
|
|
|
|
# TODO: maybe use this assets response for non-spot venues?
|
|
# -> issue is we do the exch_info queries conc, so we can't
|
|
# guarantee order for inter-table lookups..
|
|
# if venue ep delivers an explicit set of assets copy just
|
|
# ensure they are also already listed in the spot equivs.
|
|
# assets: list[dict] = resp.get('assets', ())
|
|
# for entry in assets:
|
|
# name: str = entry['asset']
|
|
# spot_asset_table: dict[str, Asset] = self._venue2assets['spot']
|
|
# if name not in spot_asset_table:
|
|
# log.warning(
|
|
# f'COULDNT FIND ASSET {name}\n{entry}\n'
|
|
# f'ADDING AS FUTES ONLY!?'
|
|
# )
|
|
# asset_table: dict[str, Asset] = self._venue2assets[venue]
|
|
# asset_table[name] = spot_asset_table.get(name)
|
|
|
|
async def exch_info(
|
|
self,
|
|
sym: str | None = None,
|
|
|
|
venue: MarketType | None = None,
|
|
expiry: str | None = None,
|
|
|
|
) -> dict[str, Pair] | Pair:
|
|
'''
|
|
Fresh exchange-pairs info query for symbol ``sym: str``.
|
|
|
|
Depending on `mkt_type` different api eps are used:
|
|
- spot:
|
|
https://binance-docs.github.io/apidocs/spot/en/#exchange-information
|
|
- usd futes:
|
|
https://binance-docs.github.io/apidocs/futures/en/#check-server-time
|
|
- coin futes:
|
|
https://binance-docs.github.io/apidocs/delivery/en/#exchange-information
|
|
|
|
'''
|
|
pair_table: dict[str, Pair] = self._venue2pairs[
|
|
venue
|
|
or
|
|
self.mkt_mode
|
|
]
|
|
if (
|
|
expiry
|
|
and 'perp' not in expiry.lower()
|
|
):
|
|
sym: str = f'{sym}_{expiry}'
|
|
|
|
if (
|
|
sym
|
|
and (cached_pair := pair_table.get(sym))
|
|
):
|
|
return cached_pair
|
|
|
|
venues: list[str] = ['spot', 'usdtm_futes']
|
|
if venue:
|
|
venues: list[str] = [venue]
|
|
|
|
# batch per-venue download of all exchange infos
|
|
async with trio.open_nursery() as tn:
|
|
for ven in venues:
|
|
tn.start_soon(
|
|
self._cache_pairs,
|
|
ven,
|
|
)
|
|
|
|
if sym:
|
|
return pair_table[sym]
|
|
else:
|
|
return self._pairs
|
|
|
|
async def get_assets(
|
|
self,
|
|
venue: str | None = None,
|
|
|
|
) -> dict[str, Asset]:
|
|
if (
|
|
venue
|
|
and venue != 'spot'
|
|
):
|
|
venues = [venue]
|
|
else:
|
|
venues = ['usdtm_futes']
|
|
|
|
ass_table: dict[str, Asset] = self._venue2assets['spot']
|
|
|
|
# merge in futes contracts with a sectype suffix
|
|
for venue in venues:
|
|
ass_table |= self._venue2assets[venue]
|
|
|
|
return ass_table
|
|
|
|
|
|
async def get_mkt_pairs(self) -> dict[str, Pair]:
|
|
'''
|
|
Flatten the multi-venue (chain) map of market pairs
|
|
to a fqme indexed table for data layer caching.
|
|
|
|
'''
|
|
flat: dict[str, Pair] = {}
|
|
for venmap in self._pairs.maps:
|
|
for bs_fqme, pair in venmap.items():
|
|
flat[pair.bs_fqme] = pair
|
|
|
|
return flat
|
|
|
|
# TODO: unused except by `brokers.core.search_symbols()`?
|
|
async def search_symbols(
|
|
self,
|
|
pattern: str,
|
|
limit: int = None,
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
fq_pairs: dict[str, Pair] = await self.exch_info()
|
|
|
|
# TODO: cache this list like we were in
|
|
# `open_symbol_search()`?
|
|
# keys: list[str] = list(fq_pairs)
|
|
|
|
return match_from_pairs(
|
|
pairs=fq_pairs,
|
|
query=pattern.upper(),
|
|
score_cutoff=50,
|
|
)
|
|
|
|
def pair2venuekey(
|
|
self,
|
|
pair: Pair,
|
|
) -> str:
|
|
return {
|
|
'USDTM': 'usdtm_futes',
|
|
'SPOT': 'spot',
|
|
# 'COINM': 'coin_futes',
|
|
# ^-TODO-^ bc someone might want it..?
|
|
}[pair.venue]
|
|
|
|
async def bars(
|
|
self,
|
|
mkt: MktPair,
|
|
|
|
start_dt: datetime | None = None,
|
|
end_dt: datetime | None = None,
|
|
|
|
as_np: bool = True,
|
|
|
|
) -> list[tuple] | np.ndarray:
|
|
|
|
# NOTE: diff market-venues have diff datums limits:
|
|
# - spot max is 1k
|
|
# https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data
|
|
# - usdm futes max is 1500
|
|
# https://binance-docs.github.io/apidocs/futures/en/#kline-candlestick-data
|
|
limits: dict[str, int] = {
|
|
'spot': 1000,
|
|
'usdtm_futes': 1500,
|
|
}
|
|
limit = limits[self.mkt_mode]
|
|
|
|
if end_dt is None:
|
|
end_dt = now('UTC').add(minutes=1)
|
|
|
|
if start_dt is None:
|
|
start_dt = end_dt.start_of(
|
|
'minute').subtract(minutes=limit)
|
|
|
|
start_time = binance_timestamp(start_dt)
|
|
end_time = binance_timestamp(end_dt)
|
|
|
|
bs_pair: Pair = self._pairs[mkt.bs_fqme.upper()]
|
|
|
|
# https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data
|
|
bars = await self._api(
|
|
'klines',
|
|
params={
|
|
# NOTE: always query using their native symbology!
|
|
'symbol': mkt.bs_mktid.upper(),
|
|
'interval': '1m',
|
|
'startTime': start_time,
|
|
'endTime': end_time,
|
|
'limit': limit
|
|
},
|
|
venue=self.pair2venuekey(bs_pair),
|
|
allow_testnet=False,
|
|
)
|
|
new_bars: list[tuple] = []
|
|
for i, bar_list in enumerate(bars):
|
|
|
|
bar = OHLC(*bar_list)
|
|
bar.typecast()
|
|
|
|
row = []
|
|
for j, (name, ftype) in enumerate(def_iohlcv_fields[1:]):
|
|
|
|
# TODO: maybe we should go nanoseconds on all
|
|
# history time stamps?
|
|
if name == 'time':
|
|
# convert to epoch seconds: float
|
|
row.append(bar.time / 1000.0)
|
|
|
|
else:
|
|
row.append(getattr(bar, name))
|
|
|
|
new_bars.append((i,) + tuple(row))
|
|
|
|
if not as_np:
|
|
return bars
|
|
|
|
return np.array(
|
|
new_bars,
|
|
dtype=def_iohlcv_fields,
|
|
)
|
|
|
|
# TODO: maybe drop? Do we need this if we can simply request it
|
|
# over the user stream wss?
|
|
# async def get_positions(
|
|
# self,
|
|
# symbol: str,
|
|
# recv_window: int = 60000
|
|
|
|
# ) -> tuple:
|
|
|
|
# positions = {}
|
|
# volumes = {}
|
|
|
|
# params = dict([
|
|
# ('symbol', symbol),
|
|
# ('recvWindow', recv_window),
|
|
# ('timestamp', binance_timestamp(now()))
|
|
# ])
|
|
# resp = await self._api(
|
|
# 'allOrders',
|
|
# params=params,
|
|
# signed=True
|
|
# )
|
|
# log.info(f'done. len {len(resp)}')
|
|
|
|
# return positions, volumes
|
|
|
|
async def get_deposits(
|
|
self,
|
|
recv_window: int = 60000
|
|
) -> list:
|
|
|
|
# TODO: can't we drop this since normal dicts are
|
|
# ordered implicitly in mordern python?
|
|
params = dict([
|
|
('recvWindow', recv_window),
|
|
('timestamp', binance_timestamp(now()))
|
|
])
|
|
return await self._sapi(
|
|
'capital/deposit/hisrec',
|
|
params=params,
|
|
signed=True,
|
|
)
|
|
|
|
async def get_withdrawls(
|
|
self,
|
|
recv_window: int = 60000
|
|
|
|
) -> list:
|
|
|
|
params = dict([
|
|
('recvWindow', recv_window),
|
|
('timestamp', binance_timestamp(now()))
|
|
])
|
|
return await self._sapi(
|
|
'capital/withdraw/history',
|
|
params=params,
|
|
signed=True,
|
|
)
|
|
|
|
async def get_open_orders(
|
|
self,
|
|
symbol: str | None = None,
|
|
|
|
) -> list[Order]:
|
|
'''
|
|
Get all open orders for venue-account.
|
|
|
|
WARNING: apparently not specifying the symbol is given
|
|
a much heavier API "weight" meaning you shouldn't call it
|
|
often to avoid getting throttled as per:
|
|
|
|
'https://binance-docs.github.io/apidocs/futures/en/#current-all-open-orders-user_data
|
|
|
|
|
|
'''
|
|
params: dict[str, Any] = {
|
|
'timestamp': binance_timestamp(now()),
|
|
}
|
|
if symbol is not None:
|
|
params['symbol'] = symbol
|
|
|
|
resp = await self._api(
|
|
'openOrders',
|
|
params=params,
|
|
signed=True,
|
|
method='get',
|
|
allow_testnet=True,
|
|
)
|
|
# figure out which venue (in FQME terms) we're using
|
|
# since that normally maps 1-to-1 with the account (right?)
|
|
venue: str = self.mkt_mode.rstrip('_futes')
|
|
|
|
orders: list[Order] = []
|
|
for entry in resp:
|
|
oid: str = entry['clientOrderId']
|
|
symbol: str = entry['symbol']
|
|
|
|
# build out a fqme-styled key that should map to a pair
|
|
# entry in `._pairs` cross-venue table.
|
|
bs_mktid, _, expiry = entry['symbol'].partition('_')
|
|
bs_mktid += f'.{venue.upper()}'
|
|
|
|
if expiry:
|
|
bs_mktid += f'.{expiry}'
|
|
else:
|
|
bs_mktid += '.PERP'
|
|
|
|
# should never key error if we've got it right B)
|
|
pair: Pair = self._pairs[bs_mktid]
|
|
|
|
orders.append(
|
|
Order(
|
|
oid=oid,
|
|
symbol=pair.bs_fqme.lower(),
|
|
|
|
action=entry['side'].lower(),
|
|
price=float(entry['price']),
|
|
size=float(entry['origQty']),
|
|
|
|
exec_mode='live',
|
|
account=f'binance.{venue}',
|
|
)
|
|
)
|
|
return orders
|
|
|
|
async def submit_limit(
|
|
self,
|
|
symbol: str,
|
|
side: str, # sell / buy
|
|
quantity: float,
|
|
price: float,
|
|
|
|
oid: int | None = None,
|
|
tif: str = 'GTC',
|
|
recv_window: int = 60000,
|
|
|
|
# iceberg_quantity: float | None = None,
|
|
resp_type: str = 'ACK',
|
|
|
|
# TODO: this is probably useful for doing stops, maybe we
|
|
# can set it only on dark-stops?
|
|
# close_all: bool = False,
|
|
|
|
modify: bool = False,
|
|
|
|
) -> str:
|
|
'''
|
|
Submit or modify a live limit order to ze binance.
|
|
|
|
For modify see:
|
|
- spot:
|
|
- futes https://binance-docs.github.io/apidocs/futures/en/#modify-order-trade
|
|
|
|
'''
|
|
# lookup the binance-native symbol from search table
|
|
bs_mktid: str = self._pairs[symbol.upper()].symbol
|
|
params: dict = dict([
|
|
('symbol', bs_mktid),
|
|
('side', side.upper()),
|
|
('type', 'LIMIT'),
|
|
('timeInForce', tif),
|
|
('quantity', quantity),
|
|
('price', price),
|
|
('recvWindow', recv_window),
|
|
('newOrderRespType', resp_type),
|
|
('timestamp', binance_timestamp(now()))
|
|
|
|
# ('closeAll', close_all),
|
|
])
|
|
|
|
method: str = 'post'
|
|
|
|
# NOTE: modifies only require diff key for user oid:
|
|
# https://binance-docs.github.io/apidocs/futures/en/#modify-order-trade
|
|
if modify:
|
|
assert oid
|
|
params['origClientOrderId'] = oid
|
|
method: str = 'put'
|
|
|
|
elif oid:
|
|
params['newClientOrderId'] = oid
|
|
|
|
log.info(
|
|
'Submitting ReST order request:\n'
|
|
f'{pformat(params)}'
|
|
)
|
|
resp = await self._api(
|
|
'order',
|
|
params=params,
|
|
signed=True,
|
|
method=method,
|
|
venue=self.mkt_mode,
|
|
allow_testnet=True,
|
|
)
|
|
|
|
# ensure our id is tracked by them
|
|
if (
|
|
oid
|
|
and not modify
|
|
):
|
|
assert oid == resp['clientOrderId']
|
|
|
|
reqid: str = resp['orderId']
|
|
return reqid
|
|
|
|
async def submit_cancel(
|
|
self,
|
|
symbol: str,
|
|
oid: str,
|
|
|
|
recv_window: int = 60000
|
|
|
|
) -> None:
|
|
bs_mktid: str = self._pairs[symbol.upper()].symbol
|
|
params = dict([
|
|
('symbol', bs_mktid),
|
|
# ('orderId', oid),
|
|
('origClientOrderId', oid),
|
|
('recvWindow', recv_window),
|
|
('timestamp', binance_timestamp(now()))
|
|
])
|
|
|
|
log.cancel(
|
|
'Submitting ReST order cancel: {oid}\n'
|
|
f'{pformat(params)}'
|
|
)
|
|
await self._api(
|
|
'order',
|
|
params=params,
|
|
signed=True,
|
|
method='delete',
|
|
allow_testnet=True,
|
|
)
|
|
|
|
async def get_listen_key(self) -> str:
|
|
|
|
resp = await self._api(
|
|
# 'userDataStream', # spot
|
|
'listenKey',
|
|
params={},
|
|
method='post',
|
|
signed=True,
|
|
allow_testnet=True,
|
|
)
|
|
return resp['listenKey']
|
|
|
|
async def keep_alive_key(self, listen_key: str) -> None:
|
|
await self._api(
|
|
# 'userDataStream',
|
|
'listenKey',
|
|
params={'listenKey': listen_key},
|
|
method='put',
|
|
allow_testnet=True,
|
|
)
|
|
|
|
async def close_listen_key(self, listen_key: str) -> None:
|
|
await self._api(
|
|
# 'userDataStream',
|
|
'listenKey',
|
|
params={'listenKey': listen_key},
|
|
method='delete',
|
|
allow_testnet=True,
|
|
)
|
|
|
|
@acm
|
|
async def manage_listen_key(self):
|
|
|
|
async def periodic_keep_alive(
|
|
self,
|
|
listen_key: str,
|
|
timeout=60 * 29 # 29 minutes
|
|
):
|
|
while True:
|
|
await trio.sleep(timeout)
|
|
await self.keep_alive_key(listen_key)
|
|
|
|
key = await self.get_listen_key()
|
|
|
|
async with trio.open_nursery() as n:
|
|
n.start_soon(periodic_keep_alive, self, key)
|
|
yield key
|
|
n.cancel_scope.cancel()
|
|
|
|
await self.close_listen_key(key)
|
|
|
|
|
|
_venue_urls: dict[str, str] = {
|
|
'spot': (
|
|
_spot_url,
|
|
'/api/v3/',
|
|
),
|
|
'spot_testnet': (
|
|
_testnet_spot_url,
|
|
'/fapi/v1/'
|
|
),
|
|
# margin and extended spot endpoints session.
|
|
# TODO: did this ever get implemented fully?
|
|
# 'margin': (
|
|
# _spot_url,
|
|
# '/sapi/v1/'
|
|
# ),
|
|
|
|
'usdtm_futes': (
|
|
_futes_url,
|
|
'/fapi/v1/',
|
|
),
|
|
|
|
'usdtm_futes_testnet': (
|
|
_testnet_futes_url,
|
|
'/fapi/v1/',
|
|
),
|
|
|
|
# TODO: for anyone who actually needs it ;P
|
|
# 'coin_futes': ()
|
|
}
|
|
|
|
|
|
def init_api_keys(
|
|
client: Client,
|
|
conf: dict[str, Any],
|
|
) -> None:
|
|
'''
|
|
Set up per-venue API keys each http client according to the user's
|
|
`brokers.conf`.
|
|
|
|
For ex, to use spot-testnet and live usdt futures APIs:
|
|
|
|
```toml
|
|
[binance]
|
|
# spot test net
|
|
spot.use_testnet = true
|
|
spot.api_key = '<spot_api_key_from_binance_account>'
|
|
spot.api_secret = '<spot_api_key_password>'
|
|
|
|
# futes live
|
|
futes.use_testnet = false
|
|
accounts.usdtm = 'futes'
|
|
futes.api_key = '<futes_api_key_from_binance>'
|
|
futes.api_secret = '<futes_api_key_password>''
|
|
|
|
# if uncommented will use the built-in paper engine and not
|
|
# connect to `binance` API servers for order ctl.
|
|
# accounts.paper = 'paper'
|
|
```
|
|
|
|
'''
|
|
for key, subconf in conf.items():
|
|
if api_key := subconf.get('api_key', ''):
|
|
venue_keys: list[str] = client.confkey2venuekeys[key]
|
|
|
|
venue_key: str
|
|
client: httpx.AsyncClient
|
|
for venue_key in venue_keys:
|
|
client, _ = client.venue_sesh[venue_key]
|
|
|
|
api_key_header: dict = {
|
|
# taken from official:
|
|
# https://github.com/binance/binance-futures-connector-python/blob/main/binance/api.py#L47
|
|
"Content-Type": "application/json;charset=utf-8",
|
|
|
|
# TODO: prolly should just always query and copy
|
|
# in the real latest ver?
|
|
"User-Agent": "binance-connector/6.1.6smbz6",
|
|
"X-MBX-APIKEY": api_key,
|
|
}
|
|
client.headers.update(api_key_header)
|
|
|
|
# if `.use_tesnet = true` in the config then
|
|
# also add headers for the testnet session which
|
|
# will be used for all order control
|
|
if subconf.get('use_testnet', False):
|
|
testnet_sesh, _ = client.venue_sesh[
|
|
venue_key + '_testnet'
|
|
]
|
|
testnet_sesh.headers.update(api_key_header)
|
|
|
|
|
|
@acm
|
|
async def get_client(
|
|
mkt_mode: MarketType = 'spot',
|
|
) -> Client:
|
|
'''
|
|
Construct an single `piker` client which composes multiple underlying venue
|
|
specific API clients both for live and test networks.
|
|
|
|
'''
|
|
venue_sessions: dict[
|
|
str, # venue key
|
|
tuple[httpx.AsyncClient, str] # session, eps path
|
|
] = {}
|
|
async with AsyncExitStack() as client_stack:
|
|
for name, (base_url, path) in _venue_urls.items():
|
|
api: httpx.AsyncClient = await client_stack.enter_async_context(
|
|
httpx.AsyncClient(
|
|
base_url=base_url,
|
|
# headers={},
|
|
|
|
# TODO: is there a way to numerate this?
|
|
# https://www.python-httpx.org/advanced/clients/#why-use-a-client
|
|
# connections=4
|
|
)
|
|
)
|
|
venue_sessions[name] = (
|
|
api,
|
|
path,
|
|
)
|
|
|
|
conf: dict[str, Any] = get_config()
|
|
# for creating API keys see,
|
|
# https://www.binance.com/en/support/faq/how-to-create-api-keys-on-binance-360002502072
|
|
client = Client(
|
|
venue_sessions=venue_sessions,
|
|
conf=conf,
|
|
mkt_mode=mkt_mode,
|
|
)
|
|
init_api_keys(
|
|
client=client,
|
|
conf=conf,
|
|
)
|
|
fq_pairs: dict[str, Pair] = await client.exch_info()
|
|
assert fq_pairs
|
|
log.info(
|
|
f'Loaded multi-venue `Client` in mkt_mode={client.mkt_mode!r}\n\n'
|
|
f'Symbology Summary:\n'
|
|
f'------ - ------\n'
|
|
f'spot: {len(client._spot_pairs)}\n'
|
|
f'usdtm_futes: {len(client._ufutes_pairs)}\n'
|
|
'------ - ------\n'
|
|
f'total: {len(client._pairs)}\n'
|
|
)
|
|
yield client
|