diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..70bba55 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,36 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/CHANGELOG b/CHANGELOG index a4db05e..c874132 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,71 @@ +2.0.3 +-) Implemented Liquidations endpoint (REST) + +2.0.2 +-) Use private host for auth-based requests + +2.0.1 +-) Added User Settings Write/Read/Delete endpoints (REST) +-) Added Balance Available for Orders/Offers endpoint (REST) +-) Added Alerts endpoints (REST) +-) Fixed trades handling error + +2.0.0 +-) Implemented Movement endpoints (REST) +-) Fixed unawaited stop +-) Changed account's trade execution (te) and trade update (tu) handling + +1.3.4 +-) Fixed undefined p_sub issue in subscription_manager.py +-) Added submit cancel all funding orders endpoint (REST) +-) Added get all exchange pairs endpoint (REST) + +1.3.3 +-) Fixed socket.send() issue (IndexError: deque index out of range) + +1.3.2 +-) Implemented Merchants endpoints (REST) + +1.3.1 +-) Handle exception of asyncio.get_event_loop() | Related to v1.2.8 + +1.3.0 +-) Adjusted get_trades() to allow symbol to be None and get trades for all symbols + +1.2.8 +-) Bugfix - It is possible to call bfx.ws.run() from an already running event loop + +1.2.7 +-) Added ws support for Python 3.9 and 3.10 + +1.2.6 +-) Updated websockets to 9.1 + +1.2.5 +-) Adjusted get_order_history() rest endpoint + +1.2.4 +-) Added example of MARKET order with price=None + +1.2.3 +-) Tests adjusted + +1.2.2 +-) WS bugfix (exception InvalidStatusCode not handled) + +1.2.1 +-) Added orderbook implementation example (ws) + +1.2.0 +-) Implemented Margin Info (rest) +-) Implemented claim position (rest) +-) When max_retries == 0 continue forever to retry (websocket) + +1.1.15 +-) Added 'ids' parameter to get_order_history() +-) Added an example to show how it is possible to spawn multiple bfx ws instances to comply with the open subscriptions number constraint (max. 25) +-) Implemented Funding Trades (rest) + 1.1.14 -) bfx_websockets.py ERRORS dictionary now contains a message for error number 10305 diff --git a/bfxapi/__init__.py b/bfxapi/__init__.py index f5fbc80..5b6918e 100644 --- a/bfxapi/__init__.py +++ b/bfxapi/__init__.py @@ -3,9 +3,10 @@ This module is used to interact with the bitfinex api """ from .version import __version__ -from .client import Client +from .client import Client, PUB_REST_HOST, PUB_WS_HOST, REST_HOST, WS_HOST from .models import (Order, Trade, OrderBook, Subscription, Wallet, - Position, FundingLoan, FundingOffer, FundingCredit) + Position, FundingLoan, FundingOffer, FundingCredit, + Movement) from .websockets.generic_websocket import GenericWebsocket, Socket from .websockets.bfx_websocket import BfxWebsocket from .utils.decimal import Decimal diff --git a/bfxapi/client.py b/bfxapi/client.py index fff114c..1d2d57c 100644 --- a/bfxapi/client.py +++ b/bfxapi/client.py @@ -5,13 +5,9 @@ a websocket client and a rest interface client # pylint: disable-all -import asyncio - from .websockets.bfx_websocket import BfxWebsocket from .rest.bfx_rest import BfxRest - -REST_HOST = 'https://api-pub.bitfinex.com/v2' -WS_HOST = 'wss://api-pub.bitfinex.com/ws/2' +from .constants import * class Client: """ diff --git a/bfxapi/constants.py b/bfxapi/constants.py new file mode 100644 index 0000000..a0c6525 --- /dev/null +++ b/bfxapi/constants.py @@ -0,0 +1,4 @@ +REST_HOST = 'https://api.bitfinex.com/v2' +WS_HOST = 'wss://api.bitfinex.com/ws/2' +PUB_REST_HOST = 'https://api-pub.bitfinex.com/v2' +PUB_WS_HOST = 'wss://api-pub.bitfinex.com/ws/2' \ No newline at end of file diff --git a/bfxapi/examples/rest/create_funding.py b/bfxapi/examples/rest/create_funding.py index c6213bb..db16826 100644 --- a/bfxapi/examples/rest/create_funding.py +++ b/bfxapi/examples/rest/create_funding.py @@ -1,18 +1,21 @@ import os import sys import asyncio -import time sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Create funding requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST ) async def create_funding(): diff --git a/bfxapi/examples/rest/create_order.py b/bfxapi/examples/rest/create_order.py index b987cff..94e9640 100644 --- a/bfxapi/examples/rest/create_order.py +++ b/bfxapi/examples/rest/create_order.py @@ -4,18 +4,23 @@ import asyncio import time sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST +from bfxapi.models import OrderType API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Create order requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST ) async def create_order(): - response = await bfx.rest.submit_order("tBTCUSD", 10, 0.1) + response = await bfx.rest.submit_order(symbol="tBTCUSD", amount=10, price=None, market_type=OrderType.MARKET) # response is in the form of a Notification object for o in response.notify_info: # each item is in the form of an Order object diff --git a/bfxapi/examples/rest/get_authenticated_data.py b/bfxapi/examples/rest/get_authenticated_data.py index 052b22d..a557de2 100644 --- a/bfxapi/examples/rest/get_authenticated_data.py +++ b/bfxapi/examples/rest/get_authenticated_data.py @@ -5,14 +5,18 @@ import time sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Retrieving authenticated data requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST ) now = int(round(time.time() * 1000)) @@ -39,7 +43,7 @@ async def log_active_positions(): [ print (p) for p in positions ] async def log_trades(): - trades = await bfx.rest.get_trades('tBTCUSD', 0, then) + trades = await bfx.rest.get_trades(symbol='tBTCUSD', start=0, end=then) print ("Trades:") [ print (t) for t in trades] @@ -79,6 +83,15 @@ async def log_funding_credits_history(): print ("Funding credit history:") [ print (c) for c in credit ] +async def log_margin_info(): + margin_info = await bfx.rest.get_margin_info('tBTCUSD') + print(margin_info) + sym_all = await bfx.rest.get_margin_info('sym_all') # list of Margin Info + for margin_info in sym_all: + print(margin_info) + base = await bfx.rest.get_margin_info('base') + print(base) + async def run(): await log_wallets() await log_active_orders() @@ -90,6 +103,7 @@ async def run(): await log_funding_offer_history() await log_funding_credits() await log_funding_credits_history() + await log_margin_info() t = asyncio.ensure_future(run()) diff --git a/bfxapi/examples/rest/get_liquidations.py b/bfxapi/examples/rest/get_liquidations.py new file mode 100644 index 0000000..f7dd896 --- /dev/null +++ b/bfxapi/examples/rest/get_liquidations.py @@ -0,0 +1,21 @@ +import os +import sys +import asyncio +import time +sys.path.append('../../../') + +from bfxapi import Client, PUB_REST_HOST + +bfx = Client( + logLevel='INFO', + rest_host=PUB_REST_HOST +) + +now = int(round(time.time() * 1000)) +then = now - (1000 * 60 * 60 * 24 * 10) # 10 days ago + +async def get_liquidations(): + liquidations = await bfx.rest.get_liquidations(start=then, end=now) + print(liquidations) + +asyncio.get_event_loop().run_until_complete(get_liquidations()) diff --git a/bfxapi/examples/rest/get_public_data.py b/bfxapi/examples/rest/get_public_data.py index eae9f94..6d1e246 100644 --- a/bfxapi/examples/rest/get_public_data.py +++ b/bfxapi/examples/rest/get_public_data.py @@ -5,9 +5,13 @@ import time sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving public data requires public hosts bfx = Client( logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) now = int(round(time.time() * 1000)) diff --git a/bfxapi/examples/ws/get_seed_trades.py b/bfxapi/examples/rest/get_seed_trades.py similarity index 60% rename from bfxapi/examples/ws/get_seed_trades.py rename to bfxapi/examples/rest/get_seed_trades.py index fb34dab..6f9cbf8 100644 --- a/bfxapi/examples/ws/get_seed_trades.py +++ b/bfxapi/examples/rest/get_seed_trades.py @@ -4,9 +4,13 @@ import asyncio sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving seed trades requires public hosts bfx = Client( - logLevel='INFO' + logLevel='INFO', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) async def get_seeds(): diff --git a/bfxapi/examples/rest/merchant.py b/bfxapi/examples/rest/merchant.py new file mode 100644 index 0000000..5477588 --- /dev/null +++ b/bfxapi/examples/rest/merchant.py @@ -0,0 +1,37 @@ +import os +import sys +import asyncio +sys.path.append('../../../') +from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST + +API_KEY=os.getenv("BFX_KEY") +API_SECRET=os.getenv("BFX_SECRET") + +# Submitting invoices requires private hosts +bfx = Client( + API_KEY=API_KEY, + API_SECRET=API_SECRET, + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST +) + +async def run(): + await bfx.rest.submit_invoice(amount='2.0', currency='USD', pay_currencies=['BTC', 'ETH'], order_id='order123', webhook='https://example.com/api/v3/order/order123', + redirect_url='https://example.com/api/v3/order/order123', customer_info_nationality='DE', + customer_info_resid_country='GB', customer_info_resid_city='London', customer_info_resid_zip_code='WC2H 7NA', + customer_info_resid_street='5-6 Leicester Square', customer_info_resid_building_no='23 A', + customer_info_full_name='John Doe', customer_info_email='john@example.com', duration=86339) + + invoices = await bfx.rest.get_invoices() + print(invoices) + + # await bfx.rest.complete_invoice(id=invoices[0]['id'], pay_ccy='BTC', deposit_id=1357996) + + unlinked_deposits = await bfx.rest.get_unlinked_deposits(ccy='BTC') + print(unlinked_deposits) + + +t = asyncio.ensure_future(run()) +asyncio.get_event_loop().run_until_complete(t) diff --git a/bfxapi/examples/rest/transfer_wallet.py b/bfxapi/examples/rest/transfer_wallet.py index 631ea3b..a0c59ce 100644 --- a/bfxapi/examples/rest/transfer_wallet.py +++ b/bfxapi/examples/rest/transfer_wallet.py @@ -1,18 +1,21 @@ import os import sys import asyncio -import time sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Transfer wallet requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST ) async def transfer_wallet(): diff --git a/bfxapi/examples/ws/cancel_order.py b/bfxapi/examples/ws/cancel_order.py index 70511db..e37ad00 100644 --- a/bfxapi/examples/ws/cancel_order.py +++ b/bfxapi/examples/ws/cancel_order.py @@ -3,14 +3,18 @@ import sys sys.path.append('../../../') from bfxapi import Client, Order +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Canceling orders requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST ) @bfx.ws.on('order_closed') diff --git a/bfxapi/examples/ws/connect.py b/bfxapi/examples/ws/connect.py index 4cb21bb..b63cd7d 100644 --- a/bfxapi/examples/ws/connect.py +++ b/bfxapi/examples/ws/connect.py @@ -3,9 +3,12 @@ import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST bfx = Client( - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) @bfx.ws.on('error') diff --git a/bfxapi/examples/ws/connect_auth.py b/bfxapi/examples/ws/connect_auth.py index fac67e3..a01c6d3 100644 --- a/bfxapi/examples/ws/connect_auth.py +++ b/bfxapi/examples/ws/connect_auth.py @@ -2,7 +2,8 @@ import os import sys sys.path.append('../../../') -from bfxapi import Client, Order +from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") @@ -11,6 +12,8 @@ bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST, dead_man_switch=True, # <-- kill all orders if this connection drops channel_filter=['wallet'] # <-- only receive wallet updates ) diff --git a/bfxapi/examples/ws/full_orderbook.py b/bfxapi/examples/ws/full_orderbook.py new file mode 100644 index 0000000..baac656 --- /dev/null +++ b/bfxapi/examples/ws/full_orderbook.py @@ -0,0 +1,83 @@ +import sys +import time +from collections import OrderedDict +sys.path.append('../../../') + +from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST + +# Retrieving orderbook requires public hosts +bfx = Client( + manageOrderBooks=True, + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST +) + +class OrderBook: + def __init__(self, snapshot): + self.bids = OrderedDict() + self.asks = OrderedDict() + self.load(snapshot) + + def load(self, snapshot): + for record in snapshot: + if record[2] >= 0: + self.bids[record[0]] = { + 'count': record[1], + 'amount': record[2] + } + else: + self.asks[record[0]] = { + 'count': record[1], + 'amount': record[2] + } + + def update(self, record): + # count is 0 + if record[1] == 0: + if record[2] == 1: + # remove from bids + del self.bids[record[0]] + elif record[2] == -1: + # remove from asks + del self.asks[record[0]] + elif record[1] > 0: + if record[2] > 0: + # update bids + if record[0] not in self.bids: + self.bids[record[0]] = {} + self.bids[record[0]]['count'] = record[1] + self.bids[record[0]]['amount'] = record[2] + elif record[2] < 0: + # update asks + if record[0] not in self.asks: + self.asks[record[0]] = {} + self.asks[record[0]]['count'] = record[1] + self.asks[record[0]]['amount'] = record[2] + +obs = {} + +@bfx.ws.on('error') +def log_error(err): + print ("Error: {}".format(err)) + +@bfx.ws.on('order_book_update') +def log_update(data): + obs[data['symbol']].update(data['data']) + +@bfx.ws.on('order_book_snapshot') +def log_snapshot(data): + obs[data['symbol']] = OrderBook(data['data']) + +async def start(): + await bfx.ws.subscribe('book', 'tBTCUSD') + +bfx.ws.on('connected', start) +bfx.ws.run() + +for n in range(0, 10): + time.sleep(2) + for key in obs: + print(f"Printing {key} orderbook...") + print(f"{obs[key].bids}\n") + print(f"{obs[key].asks}\n") diff --git a/bfxapi/examples/ws/multiple_instances.py b/bfxapi/examples/ws/multiple_instances.py new file mode 100644 index 0000000..cf1dc04 --- /dev/null +++ b/bfxapi/examples/ws/multiple_instances.py @@ -0,0 +1,180 @@ +""" +This is an example of how it is possible to spawn multiple +bfx ws instances to comply with the open subscriptions number constraint (max. 25) + +(https://docs.bitfinex.com/docs/requirements-and-limitations) +""" + +import sys +sys.path.append('../../../') +import asyncio +from functools import partial +import websockets as ws +from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +import math +import random + +MAX_CHANNELS = 25 + + +def get_random_list_of_tickers(): + tickers = ["FILUST", "FTTUSD", "FTTUST", "FUNUSD", "GNOUSD", "GNTUSD", "GOTEUR", "GOTUSD", "GTXUSD", "ZRXUSD"] + return random.sample(tickers, 1) + + +class Instance: + def __init__(self, _id): + self.id = _id + self.bfx = Client(logLevel='INFO', ws_host=PUB_WS_HOST, rest_host=PUB_REST_HOST) + self.subscriptions = {'trades': {}, 'ticker': {}} + self.is_ready = False + + def run(self): + self.bfx.ws.run() + self.bfx.ws.on('error', log_error) + self.bfx.ws.on('new_trade', log_trade) + self.bfx.ws.on('new_ticker', log_ticker) + self.bfx.ws.on('subscribed', partial(on_subscribe, self)) + self.bfx.ws.on('unsubscribed', partial(on_unsubscribed, self)) + self.bfx.ws.on('connected', partial(on_connected, self)) + self.bfx.ws.on('stopped', partial(on_stopped, self)) + + async def subscribe(self, symbols): + for symbol in symbols: + print(f'Subscribing to {symbol} channel') + await self.bfx.ws.subscribe_ticker(symbol) + await self.bfx.ws.subscribe_trades(symbol) + self.subscriptions['trades'][symbol] = None + self.subscriptions['ticker'][symbol] = None + + async def unsubscribe(self, symbols): + for symbol in symbols: + if symbol in self.subscriptions['trades']: + print(f'Unsubscribing to {symbol} channel') + trades_ch_id = self.subscriptions['trades'][symbol] + ticker_ch_id = self.subscriptions['ticker'][symbol] + if trades_ch_id: + await self.bfx.ws.unsubscribe(trades_ch_id) + else: + del self.subscriptions['trades'][symbol] + if ticker_ch_id: + await self.bfx.ws.unsubscribe(ticker_ch_id) + else: + del self.subscriptions['ticker'][symbol] + + +class Routine: + is_stopped = False + + def __new__(cls, _loop, _ws, interval=1, start_delay=10): + instance = super().__new__(cls) + instance.interval = interval + instance.start_delay = start_delay + instance.ws = _ws + instance.task = _loop.create_task(instance.run()) + return instance.task + + async def run(self): + await asyncio.sleep(self.start_delay) + await self.do() + while True: + await asyncio.sleep(self.interval) + await self.do() + + async def do(self): + subbed_tickers = get_all_subscriptions_tickers() + print(f'Subscribed tickers: {subbed_tickers}') + + # if ticker is not in subbed tickers, then we subscribe to the channel + to_sub = [f"t{ticker}" for ticker in get_random_list_of_tickers() if f"t{ticker}" not in subbed_tickers] + for ticker in to_sub: + print(f'To subscribe: {ticker}') + instance = get_available_instance() + if instance and instance.is_ready: + print(f'Subscribing on instance {instance.id}') + await instance.subscribe([ticker]) + else: + instances_to_create = math.ceil(len(to_sub) / MAX_CHANNELS) + create_instances(instances_to_create) + break + + to_unsub = [f"t{ticker}" for ticker in subbed_tickers if f"t{ticker}" in get_random_list_of_tickers()] + if len(to_unsub) > 0: + print(f'To unsubscribe: {to_unsub}') + for instance in instances: + await instance.unsubscribe(to_unsub) + + def stop(self): + self.task.cancel() + self.is_stopped = True + + +instances = [] + + +def get_all_subscriptions_tickers(): + tickers = [] + for instance in instances: + for ticker in instance.subscriptions['trades']: + tickers.append(ticker) + return tickers + + +def count_open_channels(instance): + return len(instance.subscriptions['trades']) + len(instance.subscriptions['ticker']) + + +def create_instances(instances_to_create): + for _ in range(0, instances_to_create): + instance = Instance(len(instances)) + instance.run() + instances.append(instance) + + +def get_available_instance(): + for instance in instances: + if count_open_channels(instance) + 1 <= MAX_CHANNELS: + return instance + return None + + +def log_error(err): + print("Error: {}".format(err)) + + +def log_trade(trade): + print(trade) + + +def log_ticker(ticker): + print(ticker) + + +async def on_subscribe(instance, subscription): + print(f'Subscribed to {subscription.symbol} channel {subscription.channel_name}') + instance.subscriptions[subscription.channel_name][subscription.symbol] = subscription.chan_id + + +async def on_unsubscribed(instance, subscription): + print(f'Unsubscribed to {subscription.symbol} channel {subscription.channel_name}') + instance.subscriptions[subscription.channel_name][subscription.symbol] = subscription.chan_id + del instance.subscriptions[subscription.channel_name][subscription.symbol] + + +async def on_connected(instance): + print(f"Instance {instance.id} is connected") + instance.is_ready = True + + +async def on_stopped(instance): + print(f"Instance {instance.id} is dead, removing it from instances list") + instances.pop(instance.id) + + +def run(): + loop = asyncio.get_event_loop() + task = Routine(loop, ws, interval=5) + loop.run_until_complete(task) + +run() diff --git a/bfxapi/examples/ws/resubscribe_orderbook.py b/bfxapi/examples/ws/resubscribe_orderbook.py index ede112d..5e10277 100644 --- a/bfxapi/examples/ws/resubscribe_orderbook.py +++ b/bfxapi/examples/ws/resubscribe_orderbook.py @@ -1,11 +1,14 @@ -import os import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving orderbook requires public hosts bfx = Client( - logLevel='INFO' + manageOrderBooks=True, + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) @bfx.ws.on('error') diff --git a/bfxapi/examples/ws/send_order.py b/bfxapi/examples/ws/send_order.py index 43a8b2d..743934d 100644 --- a/bfxapi/examples/ws/send_order.py +++ b/bfxapi/examples/ws/send_order.py @@ -3,14 +3,18 @@ import sys sys.path.append('../../../') from bfxapi import Client, Order +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Sending order requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=WS_HOST, + rest_host=REST_HOST ) @bfx.ws.on('order_snapshot') @@ -34,7 +38,7 @@ def log_error(msg): @bfx.ws.on('authenticated') async def submit_order(auth_message): - await bfx.ws.submit_order('tBTCUSD', 19000, 0.01, Order.Type.EXCHANGE_MARKET) + await bfx.ws.submit_order(symbol='tBTCUSD', price=None, amount=0.01, market_type=Order.Type.EXCHANGE_MARKET) # If you dont want to use a decorator # ws.on('authenticated', submit_order) diff --git a/bfxapi/examples/ws/start_stop_connection.py b/bfxapi/examples/ws/start_stop_connection.py index 4e23469..4ddafbf 100644 --- a/bfxapi/examples/ws/start_stop_connection.py +++ b/bfxapi/examples/ws/start_stop_connection.py @@ -1,11 +1,13 @@ -import os import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST bfx = Client( logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) @bfx.ws.on('order_book_snapshot') diff --git a/bfxapi/examples/ws/subscribe_derivative_status.py b/bfxapi/examples/ws/subscribe_derivative_status.py index 3ddb7e5..3c5a995 100644 --- a/bfxapi/examples/ws/subscribe_derivative_status.py +++ b/bfxapi/examples/ws/subscribe_derivative_status.py @@ -1,11 +1,14 @@ -import os import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving derivative status requires public hosts bfx = Client( - logLevel='INFO' + logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) @bfx.ws.on('error') diff --git a/bfxapi/examples/ws/subscribe_orderbook.py b/bfxapi/examples/ws/subscribe_orderbook.py index 7febcf3..a9c44ab 100644 --- a/bfxapi/examples/ws/subscribe_orderbook.py +++ b/bfxapi/examples/ws/subscribe_orderbook.py @@ -3,9 +3,13 @@ import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving trades/candles requires public hosts bfx = Client( logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST, # Verifies that the local orderbook is up to date # with the bitfinex servers manageOrderBooks=True diff --git a/bfxapi/examples/ws/subscribe_tickers.py b/bfxapi/examples/ws/subscribe_tickers.py index 984fa47..616571e 100644 --- a/bfxapi/examples/ws/subscribe_tickers.py +++ b/bfxapi/examples/ws/subscribe_tickers.py @@ -1,11 +1,14 @@ -import os import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving tickers requires public hosts bfx = Client( - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) @bfx.ws.on('error') diff --git a/bfxapi/examples/ws/subscribe_trades_candles.py b/bfxapi/examples/ws/subscribe_trades_candles.py index 2e17fe3..024eb58 100644 --- a/bfxapi/examples/ws/subscribe_trades_candles.py +++ b/bfxapi/examples/ws/subscribe_trades_candles.py @@ -1,11 +1,14 @@ -import os import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import PUB_WS_HOST, PUB_REST_HOST +# Retrieving trades/candles requires public hosts bfx = Client( - logLevel='DEBUG' + logLevel='DEBUG', + ws_host=PUB_WS_HOST, + rest_host=PUB_REST_HOST ) @bfx.ws.on('error') @@ -20,6 +23,10 @@ def log_candle(candle): def log_trade(trade): print ("New trade: {}".format(trade)) +@bfx.ws.on('new_user_trade') +def log_user_trade(trade): + print ("New user trade: {}".format(trade)) + async def start(): await bfx.ws.subscribe('candles', 'tBTCUSD', timeframe='1m') await bfx.ws.subscribe('trades', 'tBTCUSD') diff --git a/bfxapi/examples/ws/update_order.py b/bfxapi/examples/ws/update_order.py index 68df31e..8eb26ae 100644 --- a/bfxapi/examples/ws/update_order.py +++ b/bfxapi/examples/ws/update_order.py @@ -3,14 +3,18 @@ import sys sys.path.append('../../../') from bfxapi import Client, Order +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Update order requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='DEBUG' + logLevel='INFO', + ws_host=WS_HOST, + rest_host=REST_HOST ) @bfx.ws.on('order_update') diff --git a/bfxapi/examples/ws/wallet_balance.py b/bfxapi/examples/ws/wallet_balance.py index c46fa1e..f4af163 100644 --- a/bfxapi/examples/ws/wallet_balance.py +++ b/bfxapi/examples/ws/wallet_balance.py @@ -2,14 +2,18 @@ import os import sys sys.path.append('../../../') from bfxapi import Client +from bfxapi.constants import WS_HOST, REST_HOST API_KEY=os.getenv("BFX_KEY") API_SECRET=os.getenv("BFX_SECRET") +# Checking wallet balances requires private hosts bfx = Client( API_KEY=API_KEY, API_SECRET=API_SECRET, - logLevel='INFO' + logLevel='INFO', + ws_host=WS_HOST, + rest_host=REST_HOST ) @bfx.ws.on('wallet_snapshot') diff --git a/bfxapi/models/__init__.py b/bfxapi/models/__init__.py index 87b07ea..c7bf7b2 100644 --- a/bfxapi/models/__init__.py +++ b/bfxapi/models/__init__.py @@ -19,5 +19,9 @@ from .withdraw import Withdraw from .ticker import Ticker from .funding_ticker import FundingTicker from .ledger import Ledger +from .funding_trade import FundingTrade +from .margin_info import MarginInfo +from .margin_info_base import MarginInfoBase +from .movement import Movement -NAME = 'models' +NAME = "models" diff --git a/bfxapi/models/funding_trade.py b/bfxapi/models/funding_trade.py new file mode 100644 index 0000000..07f6ea5 --- /dev/null +++ b/bfxapi/models/funding_trade.py @@ -0,0 +1,55 @@ +""" +Module used to describe all of the different data types +""" + +class FundingTradeModel: + """ + Enum used to index the different values in a raw funding trade array + """ + ID = 0 + SYMBOL = 1 + MTS_CREATE = 2 + OFFER_ID = 3 + AMOUNT = 4 + RATE = 5 + PERIOD = 6 + +class FundingTrade: + """ + ID integer Offer ID + SYMBOL string The currency of the offer (fUSD, etc) + MTS_CREATE int Millisecond Time Stamp when the offer was created + OFFER_ID int The ID of the offer + AMOUNT float Amount the offer is for + RATE float Rate of the offer + PERIOD int Period of the offer + """ + + def __init__(self, tid, symbol, mts_create, offer_id, amount, rate, period): + self.tid = tid + self.symbol = symbol + self.mts_create = mts_create + self.offer_id = offer_id + self.amount = amount + self.rate = rate + self.period = period + + @staticmethod + def from_raw_rest_trade(raw_trade): + """ + Generate a Ticker object from a raw ticker array + """ + # [[636040,"fUST",1574077528000,41237922,-100,0.0024,2,null]] + return FundingTrade( + raw_trade[FundingTradeModel.ID], + raw_trade[FundingTradeModel.SYMBOL], + raw_trade[FundingTradeModel.MTS_CREATE], + raw_trade[FundingTradeModel.OFFER_ID], + raw_trade[FundingTradeModel.AMOUNT], + raw_trade[FundingTradeModel.RATE], + raw_trade[FundingTradeModel.PERIOD] + ) + + def __str__(self): + return "FundingTrade '{}' x {} @ {} for {} days".format( + self.symbol, self.amount, self.rate, self.period) diff --git a/bfxapi/models/margin_info.py b/bfxapi/models/margin_info.py new file mode 100644 index 0000000..ab376d7 --- /dev/null +++ b/bfxapi/models/margin_info.py @@ -0,0 +1,47 @@ +""" +Module used to describe all of the different data types +""" + +import datetime + +class MarginInfoModel: + """ + Enum used to index the different values in a raw margin info array + """ + TRADABLE_BALANCE = 0 + GROSS_BALANCE = 1 + BUY = 2 + SELL = 3 + +class MarginInfo: + """ + SYMBOL string + TRADABLE BALANCE float + GROSS_BALANCE float + BUY + SELL + """ + + def __init__(self, symbol, tradable_balance, gross_balance, buy, sell): + # pylint: disable=invalid-name + self.symbol = symbol + self.tradable_balance = tradable_balance + self.gross_balance = gross_balance + self.buy = buy + self.sell = sell + + @staticmethod + def from_raw_margin_info(raw_margin_info): + """ + Generate a MarginInfo object from a raw margin info array + """ + symbol = raw_margin_info[1] + tradable_balance = raw_margin_info[2][MarginInfoModel.TRADABLE_BALANCE] + gross_balance = raw_margin_info[2][MarginInfoModel.GROSS_BALANCE] + buy = raw_margin_info[2][MarginInfoModel.BUY] + sell = raw_margin_info[2][MarginInfoModel.SELL] + return MarginInfo(symbol, tradable_balance, gross_balance, buy, sell) + + def __str__(self): + return "Margin Info {} buy={} sell={} tradable_balance={} gross_balance={}" \ + "".format(self.symbol, self.buy, self.sell, self. tradable_balance, self. gross_balance) diff --git a/bfxapi/models/margin_info_base.py b/bfxapi/models/margin_info_base.py new file mode 100644 index 0000000..806fc5f --- /dev/null +++ b/bfxapi/models/margin_info_base.py @@ -0,0 +1,48 @@ +""" +Module used to describe all of the different data types +""" + +import datetime + +class MarginInfoBaseModel: + """ + Enum used to index the different values in a raw margin info array + """ + USER_PL = 0 + USER_SWAPS = 1 + MARGIN_BALANCE = 2 + MARGIN_NET = 3 + MARGIN_MIN = 4 + +class MarginInfoBase: + """ + USER_PL float + USER_SWAPS float + MARGIN_BALANCE float + MARGIN_NET float + MARGIN_MIN float + """ + + def __init__(self, user_pl, user_swaps, margin_balance, margin_net, margin_min): + # pylint: disable=invalid-name + self.user_pl = user_pl + self.user_swaps = user_swaps + self.margin_balance = margin_balance + self.margin_net = margin_net + self.margin_min = margin_min + + @staticmethod + def from_raw_margin_info(raw_margin_info): + """ + Generate a MarginInfoBase object from a raw margin info array + """ + user_pl = raw_margin_info[1][MarginInfoBaseModel.USER_PL] + user_swaps = raw_margin_info[1][MarginInfoBaseModel.USER_SWAPS] + margin_balance = raw_margin_info[1][MarginInfoBaseModel.MARGIN_BALANCE] + margin_net = raw_margin_info[1][MarginInfoBaseModel.MARGIN_NET] + margin_min = raw_margin_info[1][MarginInfoBaseModel.MARGIN_MIN] + return MarginInfoBase(user_pl, user_swaps, margin_balance, margin_net, margin_min) + + def __str__(self): + return "Margin Info Base user_pl={} user_swaps={} margin_balance={} margin_net={} margin_min={}" \ + "".format(self.user_pl, self.user_swaps, self.margin_balance, self.margin_net, self.margin_min) diff --git a/bfxapi/models/movement.py b/bfxapi/models/movement.py new file mode 100644 index 0000000..d212e72 --- /dev/null +++ b/bfxapi/models/movement.py @@ -0,0 +1,76 @@ +""" +Module used to describe movement data types +""" + +import time +import datetime + +class MovementModel: + """ + Enum used index the different values in a raw movement array + """ + + ID = 0 + CURRENCY = 1 + CURRENCY_NAME = 2 + MTS_STARTED = 5 + MTS_UPDATED = 6 + STATUS = 9 + AMOUNT = 12 + FEES = 13 + DESTINATION_ADDRESS = 16 + TRANSACTION_ID = 20 + +class Movement: + + """ + ID String Movement identifier + CURRENCY String The symbol of the currency (ex. "BTC") + CURRENCY_NAME String The extended name of the currency (ex. "BITCOIN") + MTS_STARTED Date Movement started at + MTS_UPDATED Date Movement last updated at + STATUS String Current status + AMOUNT String Amount of funds moved + FEES String Tx Fees applied + DESTINATION_ADDRESS String Destination address + TRANSACTION_ID String Transaction identifier + """ + + def __init__(self, mid, currency, mts_started, mts_updated, status, amount, fees, dst_address, tx_id): + self.id = mid + self.currency = currency + self.mts_started = mts_started + self.mts_updated = mts_updated + self.status = status + self.amount = amount + self.fees = fees + self.dst_address = dst_address + self.tx_id = tx_id + + self.date = datetime.datetime.fromtimestamp(mts_started/1000.0) + + + @staticmethod + def from_raw_movement(raw_movement): + """ + Parse a raw movement object into a Movement object + @return Movement + """ + + mid = raw_movement[MovementModel.ID] + currency = raw_movement[MovementModel.CURRENCY] + mts_started = raw_movement[MovementModel.MTS_STARTED] + mts_updated = raw_movement[MovementModel.MTS_UPDATED] + status = raw_movement[MovementModel.STATUS] + amount = raw_movement[MovementModel.AMOUNT] + fees = raw_movement[MovementModel.FEES] + dst_address = raw_movement[MovementModel.DESTINATION_ADDRESS] + tx_id = raw_movement[MovementModel.TRANSACTION_ID] + + return Movement(mid, currency, mts_started, mts_updated, status, amount, fees, dst_address, tx_id) + + def __str__(self): + ''' Allow us to print the Movement object in a pretty format ''' + text = "Movement <'{}' amount={} fees={} mts_created={} mts_updated={} status='{}' destination_address={} transaction_id={}>" + return text.format(self.currency, self.amount, self.fees, + self.mts_started, self.mts_updated, self.status, self.dst_address, self.tx_id) \ No newline at end of file diff --git a/bfxapi/models/order.py b/bfxapi/models/order.py index 1bb6928..1a8348c 100644 --- a/bfxapi/models/order.py +++ b/bfxapi/models/order.py @@ -67,7 +67,7 @@ class OrderFlags: as flags """ HIDDEN = 64 - CLOSE = 12 + CLOSE = 512 REDUCE_ONLY = 1024 POST_ONLY = 4096 OCO = 16384 diff --git a/bfxapi/rest/bfx_rest.py b/bfxapi/rest/bfx_rest.py index e8d23f7..fabd4f9 100644 --- a/bfxapi/rest/bfx_rest.py +++ b/bfxapi/rest/bfx_rest.py @@ -10,8 +10,8 @@ import datetime from ..utils.custom_logger import CustomLogger from ..utils.auth import generate_auth_headers, calculate_order_flags, gen_unique_cid -from ..models import Wallet, Order, Position, Trade, FundingLoan, FundingOffer -from ..models import FundingCredit, Notification, Ledger +from ..models import Wallet, Order, Position, Trade, FundingLoan, FundingOffer, FundingTrade, MarginInfoBase, MarginInfo +from ..models import FundingCredit, Notification, Ledger, Movement class BfxRest: @@ -219,6 +219,24 @@ class BfxRest: status = await self.fetch(endpoint) return status + async def get_liquidations(self, start, end, limit=100, sort=-1): + """ + Endpoint to retrieve liquidations. By default it will retrieve the most recent liquidations, + but time-specific data can be retrieved using timestamps. + + # Attributes + @param start int: millisecond start time + @param end int: millisecond end time + @param limit int: max number of items in response (max. 500) + @param sort int: if = 1 it sorts results returned with old > new + @return Array [ POS_ID, MTS, SYMBOL, AMOUNT, BASE_PRICE, IS_MATCH, IS_MARKET_SOLD, PRICE_ACQUIRED ] + """ + endpoint = "liquidations/hist" + params = "?start={}&end={}&limit={}&sort={}".format( + start, end, limit, sort) + liquidations = await self.fetch(endpoint, params=params) + return liquidations + async def get_public_pulse_hist(self, end=None, limit=25): """ View the latest pulse messages. You can specify an end timestamp to view older messages. @@ -370,6 +388,15 @@ class BfxRest: stats = await self.fetch(endpoint) return stats + async def get_conf_list_pair_exchange(self): + """ + Get list of available exchange pairs + # Attributes + @return Array [ SYMBOL ] + """ + endpoint = "conf/pub:list:pair:exchange" + pairs = await self.fetch(endpoint) + return pairs ################################################## # Authenticated Data # @@ -385,6 +412,22 @@ class BfxRest: raw_wallets = await self.post(endpoint) return [Wallet(*rw[:5]) for rw in raw_wallets] + async def get_margin_info(self, symbol='base'): + """ + Get account margin information (like P/L, Swaps, Margin Balance, Tradable Balance and others). + Use different keys (base, SYMBOL, sym_all) to retrieve different kinds of data. + + @return Array + """ + endpoint = f"auth/r/info/margin/{symbol}" + raw_margin_info = await self.post(endpoint) + if symbol == 'base': + return MarginInfoBase.from_raw_margin_info(raw_margin_info) + elif symbol == 'sym_all': + return [MarginInfo.from_raw_margin_info(record) for record in raw_margin_info] + else: + return MarginInfo.from_raw_margin_info(raw_margin_info) + async def get_active_orders(self, symbol): """ Get all of the active orders associated with API_KEY - Requires authentication. @@ -397,7 +440,7 @@ class BfxRest: raw_orders = await self.post(endpoint) return [Order.from_raw_order(ro) for ro in raw_orders] - async def get_order_history(self, symbol, start, end, limit=25, sort=-1): + async def get_order_history(self, symbol, start, end, limit=25, sort=-1, ids=None): """ Get all of the orders between the start and end period associated with API_KEY - Requires authentication. @@ -407,12 +450,22 @@ class BfxRest: @param start int: millisecond start time @param end int: millisecond end time @param limit int: max number of items in response + @param ids list of int: allows you to retrieve specific orders by order ID (ids: [ID1, ID2, ID3]) @return Array """ endpoint = "auth/r/orders/{}/hist".format(symbol) - params = "?start={}&end={}&limit={}&sort={}".format( - start, end, limit, sort) - raw_orders = await self.post(endpoint, params=params) + payload = {} + if start: + payload['start'] = start + if end: + payload['end'] = end + if limit: + payload['limit'] = limit + if sort: + payload['sort'] = sort + if ids: + payload['id'] = ids + raw_orders = await self.post(endpoint, payload) return [Order.from_raw_order(ro) for ro in raw_orders] async def get_active_position(self): @@ -439,7 +492,7 @@ class BfxRest: raw_trades = await self.post(endpoint) return [Trade.from_raw_rest_trade(rt) for rt in raw_trades] - async def get_trades(self, symbol, start, end, limit=25): + async def get_trades(self, start, end, symbol=None, limit=25): """ Get all of the trades between the start and end period associated with API_KEY - Requires authentication. @@ -451,11 +504,27 @@ class BfxRest: @param limit int: max number of items in response @return Array """ - endpoint = "auth/r/trades/{}/hist".format(symbol) + endpoint = "auth/r/trades/{}/hist".format(symbol) if symbol else "auth/r/trades/hist" params = "?start={}&end={}&limit={}".format(start, end, limit) raw_trades = await self.post(endpoint, params=params) return [Trade.from_raw_rest_trade(rt) for rt in raw_trades] + async def get_funding_trades(self, symbol, start, end, limit=25): + """ + Get all of the funding trades between the start and end period associated with API_KEY + - Requires authentication. + + # Attributes + @param symbol string: pair symbol i.e fUSD + @param start int: millisecond start time + @param end int: millisecond end time + @param limit int: max number of items in response + @return Array + """ + endpoint = "auth/r/funding/trades/{}/hist".format(symbol) + raw_trades = await self.post(endpoint) + return [FundingTrade.from_raw_rest_trade(rt) for rt in raw_trades] + async def get_funding_offers(self, symbol): """ Get all of the funding offers associated with API_KEY - Requires authentication. @@ -558,6 +627,22 @@ class BfxRest: raw_ledgers = await self.post(endpoint, params=params) return [Ledger.from_raw_ledger(rl) for rl in raw_ledgers] + async def get_movement_history(self, currency, start="", end="", limit=25): + """ + Get all of the deposits and withdraws between the start and end period associated with API_KEY + - Requires authentication. + # Attributes + @param currency string: pair symbol i.e BTC + @param start int: millisecond start time + @param end int: millisecond end time + @param limit int: max number of items in response + @return Array + """ + endpoint = "auth/r/movements/{}/hist".format(currency) + params = "?start={}&end={}&limit={}".format(start, end, limit) + raw_movements = await self.post(endpoint, params=params) + return [Movement.from_raw_movement(rm) for rm in raw_movements] + async def submit_funding_offer(self, symbol, amount, rate, period, funding_type=FundingOffer.Type.LIMIT, hidden=False): """ @@ -594,6 +679,17 @@ class BfxRest: raw_notification = await self.post(endpoint, {'id': fundingId}) return Notification.from_raw_notification(raw_notification) + async def submit_cancel_all_funding_offer(self, currency): + """ + Cancel all funding offers at once + + # Attributes + @param currency str: currency for which to cancel all offers (USD, BTC, UST ...) + """ + endpoint = "auth/w/funding/offer/cancel/all" + raw_notification = await self.post(endpoint, {'currency': currency}) + return Notification.from_raw_notification(raw_notification) + async def keep_funding(self, type, id): """ Toggle to keep funding taken. Specify loan for unused funding and credit for used funding. @@ -834,7 +930,7 @@ class BfxRest: if price_trailing != None: payload['price_trailing'] = str(price_trailing) if time_in_force != None: - payload['time_in_force'] = str(time_in_force) + payload['tif'] = str(time_in_force) if leverage != None: payload["lev"] = str(leverage) flags = calculate_order_flags( @@ -905,6 +1001,145 @@ class BfxRest: raw_notification = await self.post(endpoint, payload) return Notification.from_raw_notification(raw_notification) + async def claim_position(self, position_id, amount): + """ + The claim feature allows the use of funds you have in your Margin Wallet + to settle a leveraged position as an exchange buy or sale + + # Attributes + @param position_id: id of the position + @param amount: amount to claim + @return Array [ MTS, TYPE, MESSAGE_ID, null, [SYMBOL, POSITION_STATUS, + AMOUNT, BASE_PRICE, MARGIN_FUNDING, MARGIN_FUNDING_TYPE, PLACEHOLDER, + PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, POSITION_ID, MTS_CREATE, + MTS_UPDATE, PLACEHOLDER, POS_TYPE, PLACEHOLDER, COLLATERAL, MIN_COLLATERAL, + META], CODE, STATUS, TEXT] + """ + payload = { + "id": position_id, + "amount": f"{amount * -1}" + } + endpoint = "auth/w/position/claim" + message = await self.post(endpoint, payload) + return message + + async def get_alerts(self): + """ + Retrieve a list of active price alerts + """ + endpoint = f"auth/r/alerts" + + message = await self.post(endpoint, {}) + return message + + async def set_alert(self, type, symbol, price): + """ + Sets up a price alert at the given value + + # Attributes + @param type string + @param symbol string + @param price float + """ + endpoint = f"auth/w/alert/set" + payload = { + "type": type, + "symbol": symbol, + "price": price + } + + message = await self.post(endpoint, payload) + return message + + async def delete_alert(self, symbol, price): + """ + Delete an active alert + + # Attributes + @param symbol string + @param price float + """ + endpoint = f"auth/w/alert/price:{symbol}:{price}/del" + payload = { + "symbol": symbol, + "price": price + } + + message = await self.post(endpoint, payload) + return message + + async def calc_order_avail(self, symbol, type, lev, dir=None, rate=None): + """ + Calculate the balance available for orders/offers + + # Attributes + @param symbol str: Symbol (tBTCUSD, tBTCUST, fUSD, .... ) + @param dir int: Direction of the order (1 for by, -1 for sell) (Mandator for EXCHANGE and MARGIN type, not used for FUNDING) + @param rate str: Order price (Mandator for EXCHANGE and MARGIN type, not used for FUNDING) + @param type str: Type of the order/offer EXCHANGE, MARGIN, DERIV, or FUNDING + @param lev str: Leverage that you want to use in calculating the max order amount (DERIV only) + """ + endpoint = f"auth/calc/order/avail" + payload = { + "symbol": symbol, + "type": type, + "lev": lev + } + + if dir: + payload["dir"] = dir + + if rate: + payload["rate"] = rate + + message = await self.post(endpoint, payload) + return message + + async def write_user_settings(self, settings): + """ + Allows you to create custom settings by creating key: value pairs + + # Attributes + @param Settings object: object of keys and values to be set. Must follow regex pattern /^api:[A-Za-z0-9_-]*$/ + """ + endpoint = f"auth/w/settings/set" + payload = { + "Settings": settings + } + + message = await self.post(endpoint, payload) + return message + + async def read_user_settings(self, keys): + """ + Allows you to read custom settings by providing a key + + # Attributes + @param Keys array: the keys for which you wish to retrieve the values + """ + endpoint = f"auth/w/settings" + payload = { + "Keys": keys + } + + message = await self.post(endpoint, payload) + return message + + async def delete_user_settings(self, settings): + """ + Allows you to delete custom settings + + # Attributes + @param settings object: object of keys to be deleted followed by value 1. Must follow regex pattern /^api:[A-Za-z0-9_-]*$/ + """ + endpoint = f"auth/w/settings/del" + payload = { + "Settings": settings + } + + message = await self.post(endpoint, payload) + return message + async def get_auth_pulse_hist(self, is_public=None): """ Allows you to retrieve your private pulse history or the public pulse history with an additional UID_LIKED field. @@ -1045,3 +1280,133 @@ class BfxRest: payload['symbol'] = symbol payload['collateral'] = collateral return await self.post(endpoint, data=payload) + + ################################################## + # Merchants # + ################################################## + + async def submit_invoice(self, amount, currency, pay_currencies, order_id, webhook, redirect_url, customer_info_nationality, + customer_info_resid_country, customer_info_resid_city, customer_info_resid_zip_code, + customer_info_resid_street, customer_info_full_name, customer_info_email, + customer_info_resid_state=None, customer_info_resid_building_no=None, duration=None): + """ + Submit an invoice for payment + + # Attributes + @param amount str: Invoice amount in currency (From 0.1 USD to 1000 USD) + @param currency str: Invoice currency, currently supported: USD + @param pay_currencies list of str: Currencies in which invoice accepts the payments, supported values are BTC, ETH, UST-ETH, UST-TRX, UST-LBT, LNX, LBT + @param order_id str: Reference order identifier in merchant's platform + @param webhook str: The endpoint that will be called once the payment is completed/expired + @param redirect_url str: Merchant redirect URL, this one is used in UI to redirect customer to merchant's site once the payment is completed/expired + @param customer_info_nationality str: Customer's nationality, alpha2 code or full country name (alpha2 preffered) + @param customer_info_resid_country str: Customer's residential country, alpha2 code or full country name (alpha2 preffered) + @param customer_info_resid_city str: Customer's residential city/town + @param customer_info_resid_zip_code str: Customer's residential zip code/postal code + @param customer_info_resid_street str: Customer's residential street address + @param customer_info_full_name str: Customer's full name + @param customer_info_email str: Customer's email address + @param customer_info_resid_state str: Optional, customer's residential state/province + @param customer_info_resid_building_no str: Optional, customer's residential building number/name + @param duration int: Optional, invoice expire time in seconds, minimal duration is 5 mins (300) and maximal duration is 24 hours (86400). Default value is 15 minutes + """ + endpoint = 'auth/w/ext/pay/invoice/create' + payload = { + 'amount': amount, + 'currency': currency, + 'payCurrencies': pay_currencies, + 'orderId': order_id, + 'webhook': webhook, + 'redirectUrl': redirect_url, + 'customerInfo': { + 'nationality': customer_info_nationality, + 'residCountry': customer_info_resid_country, + 'residCity': customer_info_resid_city, + 'residZipCode': customer_info_resid_zip_code, + 'residStreet': customer_info_resid_street, + 'fullName': customer_info_full_name, + 'email': customer_info_email + }, + 'duration': duration + } + + if customer_info_resid_state: + payload['customerInfo']['residState'] = customer_info_resid_state + + if customer_info_resid_building_no: + payload['customerInfo']['residBuildingNo'] = customer_info_resid_building_no + + return await self.post(endpoint, data=payload) + + async def get_invoices(self, id=None, start=None, end=None, limit=10): + """ + List submitted invoices + + # Attributes + @param id str: Unique invoice identifier + @param start int: Millisecond start time + @param end int: Millisecond end time + @param limit int: Millisecond start time + """ + endpoint = 'auth/r/ext/pay/invoices' + payload = {} + + if id: + payload['id'] = id + + if start: + payload['start'] = start + + if end: + payload['end'] = end + + if limit: + payload['limit'] = limit + + return await self.post(endpoint, data=payload) + + async def complete_invoice(self, id, pay_ccy, deposit_id=None, ledger_id=None): + """ + Manually complete an invoice + + # Attributes + @param id str: Unique invoice identifier + @param pay_ccy str: Paid invoice currency, should be one of values under payCurrencies field on invoice + @param deposit_id int: Movement/Deposit Id linked to invoice as payment + @param ledger_id int: Ledger entry Id linked to invoice as payment, use either depositId or ledgerId + """ + endpoint = 'auth/w/ext/pay/invoice/complete' + payload = { + 'id': id, + 'payCcy': pay_ccy + } + + if deposit_id: + payload['depositId'] = deposit_id + + if ledger_id: + payload['ledgerId'] = ledger_id + + return await self.post(endpoint, data=payload) + + async def get_unlinked_deposits(self, ccy, start=None, end=None): + """ + Retrieve deposits that possibly could be linked to bitfinex pay invoices + + # Attributes + @param ccy str: Pay currency to search deposits for, supported values are: BTC, ETH, UST-ETH, UST-TRX, UST-LBT, LNX, LBT + @param start int: Millisecond start time + @param end int: Millisecond end time + """ + endpoint = 'auth/r/ext/pay/deposits/unlinked' + payload = { + 'ccy': ccy + } + + if start: + payload['start'] = start + + if end: + payload['end'] = end + + return await self.post(endpoint, data=payload) diff --git a/bfxapi/tests/test_rest_get_public_trades.py b/bfxapi/tests/test_rest_get_public_trades.py index 7de22d0..4f7a0d4 100644 --- a/bfxapi/tests/test_rest_get_public_trades.py +++ b/bfxapi/tests/test_rest_get_public_trades.py @@ -16,7 +16,7 @@ async def run(): print(trades) for trade in trades: orders_ids.append(trade[0]) - assert orders_ids == [657815316, 657815314, 657815312, 657815311, 657815309] + assert orders_ids == [657815316, 657815314, 657815312, 657815308, 657815304] # check that strictly decreasing order id condition is always respected # check that not increasing timestamp condition is always respected diff --git a/bfxapi/tests/test_ws_subscriptions.py b/bfxapi/tests/test_ws_subscriptions.py index 2928215..7e1866a 100644 --- a/bfxapi/tests/test_ws_subscriptions.py +++ b/bfxapi/tests/test_ws_subscriptions.py @@ -1,6 +1,5 @@ import pytest import json -import asyncio from .helpers import (create_stubbed_client, ws_publish_connection_init, EventWatcher) @pytest.mark.asyncio diff --git a/bfxapi/utils/decorators.py b/bfxapi/utils/decorators.py new file mode 100644 index 0000000..3a4fe9a --- /dev/null +++ b/bfxapi/utils/decorators.py @@ -0,0 +1,12 @@ +from ..utils.custom_logger import CustomLogger + + +def handle_failure(func): + async def inner_function(*args, **kwargs): + logger = CustomLogger('BfxWebsocket', logLevel="DEBUG") + try: + await func(*args, **kwargs) + except Exception as exception_message: + logger.error(exception_message) + + return inner_function diff --git a/bfxapi/version.py b/bfxapi/version.py index 33ea9de..42f2fa1 100644 --- a/bfxapi/version.py +++ b/bfxapi/version.py @@ -2,4 +2,4 @@ This module contains the current version of the bfxapi lib """ -__version__ = '1.1.14' +__version__ = '2.0.3' diff --git a/bfxapi/websockets/bfx_websocket.py b/bfxapi/websockets/bfx_websocket.py index 0c3ff80..451d975 100644 --- a/bfxapi/websockets/bfx_websocket.py +++ b/bfxapi/websockets/bfx_websocket.py @@ -12,7 +12,9 @@ from .subscription_manager import SubscriptionManager from .wallet_manager import WalletManager from .order_manager import OrderManager from ..utils.auth import generate_auth_payload +from ..utils.decorators import handle_failure from ..models import Order, Trade, OrderBook, Ticker, FundingTicker +from ..constants import PUB_WS_HOST class Flags: @@ -65,6 +67,39 @@ def _parse_trade(tData, symbol): 'symbol': symbol } + +def _parse_user_trade(tData): + return { + 'id': tData[0], + 'symbol': tData[1], + 'mts_create': tData[2], + 'order_id': tData[3], + 'exec_amount': tData[4], + 'exec_price': tData[5], + 'order_type': tData[6], + 'order_price': tData[7], + 'maker': tData[8], + 'cid': tData[11], + } + + +def _parse_user_trade_update(tData): + return { + 'id': tData[0], + 'symbol': tData[1], + 'mts_create': tData[2], + 'order_id': tData[3], + 'exec_amount': tData[4], + 'exec_price': tData[5], + 'order_type': tData[6], + 'order_price': tData[7], + 'maker': tData[8], + 'fee': tData[9], + 'fee_currency': tData[10], + 'cid': tData[11], + } + + def _parse_deriv_status_update(sData, symbol): return { 'symbol': symbol, @@ -139,6 +174,7 @@ class BfxWebsocket(GenericWebsocket): - `funding_credit_snapshot` (array): Opening funding credit balances - `balance_update` (array): When the state of a balance is changed - `new_trade` (array): A new trade on the market has been executed + - `new_user_trade` (array): A new - your - trade has been executed - `new_ticker` (Ticker|FundingTicker): A new ticker update has been published - `new_funding_ticker` (FundingTicker): A new funding ticker update has been published - `new_trading_ticker` (Ticker): A new trading ticker update has been published @@ -152,7 +188,7 @@ class BfxWebsocket(GenericWebsocket): - `unsubscribed` (Subscription): A channel has been un-subscribed """ - def __init__(self, API_KEY=None, API_SECRET=None, host='wss://api-pub.bitfinex.com/ws/2', + def __init__(self, API_KEY=None, API_SECRET=None, host=PUB_WS_HOST, manageOrderBooks=False, dead_man_switch=False, ws_capacity=25, logLevel='INFO', parse_float=float, channel_filter=[], *args, **kwargs): self.API_KEY = API_KEY @@ -266,7 +302,7 @@ class BfxWebsocket(GenericWebsocket): socketId, ERRORS[data.get('code', 10000)], data.get("msg", "")) - self._emit('error', err_string) + self._emit(Exception(err_string)) async def _system_auth_handler(self, socketId, data): if data.get('status') == 'FAILED': @@ -282,6 +318,11 @@ class BfxWebsocket(GenericWebsocket): symbol = self.subscriptionManager.get(data[0]).symbol tradeObj = _parse_trade(tData, symbol) self._emit('trade_update', tradeObj) + else: + # user trade + # [0,"tu",[738045455,"tTESTBTC:TESTUSD",1622169615771,66635385225,0.001,38175,"EXCHANGE LIMIT",39000,-1,-0.000002,"TESTBTC",1622169615685]] + tradeObj = _parse_user_trade_update(tData) + self._emit('user_trade_update', tradeObj) async def _trade_executed_handler(self, data): tData = data[2] @@ -290,6 +331,11 @@ class BfxWebsocket(GenericWebsocket): symbol = self.subscriptionManager.get(data[0]).symbol tradeObj = _parse_trade(tData, symbol) self._emit('new_trade', tradeObj) + else: + # user trade + # [0, 'te', [37558151, 'tBTCUSD', 1643542688513, 1512164914, 0.0001, 30363, 'EXCHANGE MARKET', 100000, -1, None, None, 1643542688390]] + tradeObj = _parse_user_trade(tData) + self._emit('new_user_trade', tradeObj) async def _wallet_update_handler(self, data): # [0,"wu",["exchange","USD",89134.66933283,0]] @@ -481,6 +527,7 @@ class BfxWebsocket(GenericWebsocket): else: self.logger.warn('Unknown (socketId={}) websocket response: {}'.format(socketId, msg)) + @handle_failure async def _ws_authenticate_socket(self, socketId): socket = self.sockets[socketId] socket.set_authenticated() @@ -507,6 +554,7 @@ class BfxWebsocket(GenericWebsocket): # re-subscribe to existing channels await self.subscriptionManager.resubscribe_by_socket(socket_id) + @handle_failure async def _send_auth_command(self, channel_name, data): payload = [0, channel_name, None, data] socket = self.get_authenticated_socket() @@ -538,6 +586,7 @@ class BfxWebsocket(GenericWebsocket): total += self.get_socket_capacity(socketId) return total + @handle_failure async def enable_flag(self, flag): """ Enable flag on websocket connection diff --git a/bfxapi/websockets/generic_websocket.py b/bfxapi/websockets/generic_websocket.py index afec9cc..3530ff4 100644 --- a/bfxapi/websockets/generic_websocket.py +++ b/bfxapi/websockets/generic_websocket.py @@ -13,7 +13,7 @@ from pyee import AsyncIOEventEmitter from ..utils.custom_logger import CustomLogger # websocket exceptions -from websockets.exceptions import ConnectionClosed +from websockets.exceptions import ConnectionClosed, InvalidStatusCode class AuthError(Exception): """ @@ -44,7 +44,7 @@ class Socket(): def set_authenticated(self): self.isAuthenticated = True - + def set_unauthenticated(self): self.isAuthenticated = False @@ -84,6 +84,10 @@ class GenericWebsocket: thread and connection. """ self._start_new_socket() + event_loop = asyncio.get_event_loop() + if not event_loop or not event_loop.is_running(): + while True: + time.sleep(1) def get_task_executable(self): """ @@ -91,14 +95,14 @@ class GenericWebsocket: """ return self._run_socket() + def _start_new_async_socket(self): + loop = asyncio.new_event_loop() + loop.run_until_complete(self._run_socket()) + def _start_new_socket(self, socketId=None): if not socketId: socketId = len(self.sockets) - def start_loop(loop): - asyncio.set_event_loop(loop) - loop.run_until_complete(self._run_socket()) - worker_loop = asyncio.new_event_loop() - worker = Thread(target=start_loop, args=(worker_loop,)) + worker = Thread(target=self._start_new_async_socket) worker.start() return socketId @@ -128,7 +132,7 @@ class GenericWebsocket: s = Socket(sId) self.sockets[sId] = s loop = asyncio.get_event_loop() - while retries < self.max_retries and self.attempt_retry: + while self.max_retries == 0 or (retries < self.max_retries and self.attempt_retry): try: async with websockets.connect(self.host) as websocket: self.sockets[sId].set_websocket(websocket) @@ -141,7 +145,7 @@ class GenericWebsocket: await asyncio.sleep(0) message = await websocket.recv() await self.on_message(sId, message) - except (ConnectionClosed, socket.error) as e: + except (ConnectionClosed, socket.error, InvalidStatusCode) as e: self.sockets[sId].set_disconnected() if self.sockets[sId].isAuthenticated: self.sockets[sId].set_unauthenticated() @@ -190,6 +194,8 @@ class GenericWebsocket: self.events.once(event, func) def _emit(self, event, *args, **kwargs): + if type(event) == Exception: + self.logger.error(event) self.events.emit(event, *args, **kwargs) async def on_error(self, error): @@ -202,7 +208,7 @@ class GenericWebsocket: """ This is used by the HF data server. """ - self.stop() + await self.stop() async def on_open(self): """ diff --git a/bfxapi/websockets/order_manager.py b/bfxapi/websockets/order_manager.py index 2f9cbbf..91ab04b 100644 --- a/bfxapi/websockets/order_manager.py +++ b/bfxapi/websockets/order_manager.py @@ -202,7 +202,7 @@ class OrderManager: if price_trailing != None: payload['price_trailing'] = str(price_trailing) if time_in_force != None: - payload['time_in_force'] = str(time_in_force) + payload['tif'] = str(time_in_force) if leverage != None: payload['lev'] = str(leverage) flags = calculate_order_flags( diff --git a/bfxapi/websockets/subscription_manager.py b/bfxapi/websockets/subscription_manager.py index c90c073..b516867 100644 --- a/bfxapi/websockets/subscription_manager.py +++ b/bfxapi/websockets/subscription_manager.py @@ -62,6 +62,7 @@ class SubscriptionManager: channel = raw_ws_data.get("channel") chan_id = raw_ws_data.get("chanId") key = raw_ws_data.get("key", None) + p_sub = None get_key = "{}_{}".format(channel, key or symbol) if chan_id in self.subscriptions_chanid: # subscription has already existed in the past diff --git a/docs/ws_v2.md b/docs/ws_v2.md index d173659..f0cc3bb 100644 --- a/docs/ws_v2.md +++ b/docs/ws_v2.md @@ -58,6 +58,7 @@ https://github.com/Crypto-toolbox/btfxwss - `notification` (Notification): incoming account notification - `error` (array): error from the websocket - `order_closed` (Order, Trade): when an order has been closed + - `order_update` (Order, Trade): when an order has been updated - `order_new` (Order, Trade): when an order has been created but not closed. Note: will not be called if order is executed and filled instantly - `order_confirmed` (Order, Trade): When an order has been submitted and received - `wallet_snapshot` (array[Wallet]): Initial wallet balances (Fired once) @@ -75,10 +76,12 @@ https://github.com/Crypto-toolbox/btfxwss - `funding_credit_snapshot` (array): Opening funding credit balances - `balance_update` (array): When the state of a balance is changed - `new_trade` (array): A new trade on the market has been executed + - `new_user_trade` (array): A new - your - trade has been executed - `new_ticker` (Ticker|FundingTicker): A new ticker update has been published - `new_funding_ticker` (FundingTicker): A new funding ticker update has been published - `new_trading_ticker` (Ticker): A new trading ticker update has been published - `trade_update` (array): A trade on the market has been updated + - `user_trade_update` (array): A - your - trade has been updated - `new_candle` (array): A new candle has been produced - `margin_info_updates` (array): New margin information has been broadcasted - `funding_info_updates` (array): New funding information has been broadcasted diff --git a/requirements.txt b/requirements.txt index 346acb6..5bb3aee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ asyncio==3.4.3 -websockets==8.1 +websockets==9.1 pylint==2.3.0 -pytest-asyncio==0.10.0 +pytest-asyncio==0.15.1 six==1.12.0 pyee==8.0.1 aiohttp==3.4.4 -isort==4.3.21 +isort==4.3.21 \ No newline at end of file diff --git a/setup.py b/setup.py index 7de3efd..126d5c1 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ from os import path here = path.abspath(path.dirname(__file__)) setup( name='bitfinex-api-py', - version='1.1.14', + version='2.0.3', description='Official Bitfinex Python API', long_description='A Python reference implementation of the Bitfinex API for both REST and websocket interaction', long_description_content_type='text/markdown', @@ -49,7 +49,7 @@ setup( # deps installed by pip install_requires=[ 'asyncio~=3.0', - 'websockets~=8.0', + 'websockets>=8,<10', 'aiohttp~=3.0', 'pyee~=8.0' ],