diff --git a/examples/deribit_book.py b/examples/deribit_book.py new file mode 100644 index 0000000..d98c838 --- /dev/null +++ b/examples/deribit_book.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +import asyncio +import json +import logging +from ssc2ce import Deribit +from ssc2ce.deribit.l2_book import L2Book + +conn = Deribit() + +pending = {} +books = {} + +logging.basicConfig(format='%(asctime)s %(name)s %(funcName)s %(levelname)s %(message)s', level=logging.INFO) +logger = logging.getLogger("deribit-book") + + +async def handle_instruments(data: dict): + request_id = data["id"] + del pending[request_id] + print(json.dumps(data)) + if not pending: + await subscribe_books(list(books.keys())) + + +async def handle_currencies(data: dict): + for currency in data["result"]: + symbol = currency["currency"] + instrument = symbol+"-PERPETUAL" + book = L2Book(instrument) + book.top_bid = [0., 0.] + book.top_ask = [0., 0.] + books[instrument] = book + request_id = await conn.get_instruments(symbol, kind="future", callback=handle_instruments) + pending[request_id] = symbol + + +async def get_currencies(): + await conn.get_currencies(handle_currencies) + + +async def subscribe_books(instruments: list): + await conn.send_public(request={ + "method": "public/subscribe", + "params": { + "channels": [f"book.{i}.raw" for i in instruments] + } + }) + + +async def handle_subscription(data): + method = data.get("method") + if method and method == "subscription": + params = data["params"] + channel = params["channel"] + if channel.startswith("book."): + params_data = params["data"] + instrument = params_data["instrument_name"] + book: L2Book = books[instrument] + if "prev_change_id" in params_data: + book.handle_update(params_data) + else: + book.handle_snapshot(params_data) + + if book.top_ask[0] != book.asks[0][0] or book.top_bid[0] != book.bids[0][0]: + book.top_ask = book.asks[0].copy() + book.top_bid = book.bids[0].copy() + print(f"{instrument} bid:{book.top_bid[0]} ask:{book.top_ask[0]}") + else: + print("Unknown channel", json.dumps(data)) + + +conn.on_connect_ws = get_currencies +conn.method_routes += [("subscription", handle_subscription)] + +loop = asyncio.get_event_loop() + +try: + loop.run_until_complete(conn.run_receiver()) +except KeyboardInterrupt: + print("Application closed by KeyboardInterrupt.") diff --git a/examples/deribit_private.py b/examples/deribit_private.py index e5debc6..f561c33 100644 --- a/examples/deribit_private.py +++ b/examples/deribit_private.py @@ -7,7 +7,7 @@ from uuid import uuid4 from typing import Pattern from dotenv import load_dotenv -from ssc2ce.deribit import Deribit, AuthType +from ssc2ce import Deribit, AuthType class MyApp: diff --git a/requirements.txt b/requirements.txt index 4c61d9d..cd2dd54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ aiohttp python-dotenv twine wheel +sortedcontainers diff --git a/setup.py b/setup.py index 9f3db89..502c274 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setuptools.setup( name='ssc2ce', - version="0.9.0", + version="0.10.0", author='Oleg Nedbaylo', author_email='olned64@gmail.com', description='A Set of Simple Connectors for access To Cryptocurrency Exchanges', diff --git a/ssc2ce/__init__.py b/ssc2ce/__init__.py index 7c52420..509f820 100644 --- a/ssc2ce/__init__.py +++ b/ssc2ce/__init__.py @@ -1,4 +1,5 @@ from .deribit import Deribit +from .common import AuthType from .bitfinex import Bitfinex from pkg_resources import get_distribution, DistributionNotFound diff --git a/ssc2ce/bitfinex/__init__.py b/ssc2ce/bitfinex/__init__.py new file mode 100644 index 0000000..5901f8a --- /dev/null +++ b/ssc2ce/bitfinex/__init__.py @@ -0,0 +1 @@ +from .bitfinex import Bitfinex diff --git a/ssc2ce/bitfinex.py b/ssc2ce/bitfinex/bitfinex.py similarity index 98% rename from ssc2ce/bitfinex.py rename to ssc2ce/bitfinex/bitfinex.py index da1eb70..05cd32b 100644 --- a/ssc2ce/bitfinex.py +++ b/ssc2ce/bitfinex/bitfinex.py @@ -3,8 +3,8 @@ import aiohttp -from .session import SessionWrapper -from .utils import resolve_route +from ssc2ce.common.session import SessionWrapper +from ssc2ce.common.utils import resolve_route from enum import IntEnum diff --git a/ssc2ce/common/__init__.py b/ssc2ce/common/__init__.py new file mode 100644 index 0000000..ae5724c --- /dev/null +++ b/ssc2ce/common/__init__.py @@ -0,0 +1 @@ +from .auth_type import AuthType \ No newline at end of file diff --git a/ssc2ce/common/abstract_l2_book.py b/ssc2ce/common/abstract_l2_book.py new file mode 100644 index 0000000..7535508 --- /dev/null +++ b/ssc2ce/common/abstract_l2_book.py @@ -0,0 +1,44 @@ +from sortedcontainers import SortedKeyList +from abc import abstractmethod, ABC +from .l2_book_side import L2BookSide, SortedKeyList + + +class AbstractL2Book(ABC): + """ + + """ + + def __init__(self, instrument: str): + """ + + :param instrument: + """ + self.instrument = instrument + self._bids = L2BookSide(is_bids=True) + self._asks = L2BookSide(is_bids=False) + + @abstractmethod + def handle_snapshot(self, message: dict) -> None: + """ + + :param message: + :return: + """ + pass + + @abstractmethod + def handle_update(self, message: dict) -> None: + """ + + :param message: + :return: + """ + pass + + @property + def bids(self): + return self._bids.data + + @property + def asks(self): + return self._asks.data diff --git a/ssc2ce/common.py b/ssc2ce/common/auth_type.py similarity index 100% rename from ssc2ce/common.py rename to ssc2ce/common/auth_type.py diff --git a/ssc2ce/common/exceptions.py b/ssc2ce/common/exceptions.py new file mode 100644 index 0000000..0702d9d --- /dev/null +++ b/ssc2ce/common/exceptions.py @@ -0,0 +1,8 @@ +class Ssc2ceError(Exception): + """Base class for errors.""" + + +class BrokenOrderBook(Exception): + def __init__(self, instrument, prev_change_id, change_id): + self.instrument = instrument + self.message = f"instrument:{self.instrument} expected:{change_id} != received:{prev_change_id}" diff --git a/ssc2ce/common/l2_book_side.py b/ssc2ce/common/l2_book_side.py new file mode 100644 index 0000000..14874dd --- /dev/null +++ b/ssc2ce/common/l2_book_side.py @@ -0,0 +1,63 @@ +from sortedcontainers import SortedKeyList + +VERY_SMALL_NUMBER = 1e-11 + + +class L2BookSide: + + def __init__(self, is_bids: bool): + if is_bids: + self.data = SortedKeyList(key=lambda val: -val[0]) + else: + self.data = SortedKeyList(key=lambda val: val[0]) + self.is_bids = is_bids + self.time = None + self.changes = list() + + def fill(self, source): + self.data.clear() + for item in source: + self.add(item) + + def add(self, item): + price = float(item[0]) + size = float(item[1]) + self.changes.append([price, size, size]) + self.data.add([price, size]) + + def update(self, price: float, size: float): + key = -price if self.is_bids else price + i = self.data.bisect_key_left(key) + + if 0 <= i < len(self.data): + value = self.data[i] + else: + if size <= VERY_SMALL_NUMBER: + self.changes.append([price, size, 0.0]) + return False + + self.data.add([price, size]) + self.changes.append([price, size, size]) + return True + + if size <= VERY_SMALL_NUMBER: + if value[0] == price: + old_size = self.data[i][1] + self.data.discard(value) + self.changes.append([price, size, -old_size]) + return True + else: + self.changes.append([price, size, 0.0]) + return False + + if value[0] == price: + old_size = self.data[i][1] + self.data[i][1] = size + self.changes.append([price, size, size - old_size]) + else: + self.data.add([price, size]) + self.changes.append([price, size, size]) + return True + + def delete(self, price: float): + return self.update(price, 0.0) diff --git a/ssc2ce/session.py b/ssc2ce/common/session.py similarity index 100% rename from ssc2ce/session.py rename to ssc2ce/common/session.py diff --git a/ssc2ce/utils.py b/ssc2ce/common/utils.py similarity index 100% rename from ssc2ce/utils.py rename to ssc2ce/common/utils.py diff --git a/ssc2ce/deribit/__init__.py b/ssc2ce/deribit/__init__.py new file mode 100644 index 0000000..1b0ffbb --- /dev/null +++ b/ssc2ce/deribit/__init__.py @@ -0,0 +1 @@ +from .deribit import Deribit \ No newline at end of file diff --git a/ssc2ce/deribit.py b/ssc2ce/deribit/deribit.py similarity index 97% rename from ssc2ce/deribit.py rename to ssc2ce/deribit/deribit.py index 7d24c06..99e2e7e 100644 --- a/ssc2ce/deribit.py +++ b/ssc2ce/deribit/deribit.py @@ -3,10 +3,10 @@ import aiohttp -from .exceptions import Ssc2ceError -from .common import AuthType -from .session import SessionWrapper -from .utils import resolve_route, hide_secret, IntId +from ssc2ce.common.exceptions import Ssc2ceError +from ssc2ce.common import AuthType +from ssc2ce.common.session import SessionWrapper +from ssc2ce.common.utils import resolve_route, hide_secret, IntId class Deribit(SessionWrapper): @@ -325,7 +325,7 @@ async def get_currencies(self, callback=None): """ return await self.send_public(request=dict(method="public/get_currencies", params={}), callback=callback) - async def get_instruments(self, currency: str, kind: str = None, callback=None) -> int: + async def get_instruments(self, currency: str, kind: str = None, expired: bool = False, callback=None) -> int: """ Send a request for a list available trading instruments :param currency: The currency symbol: BTC or ETH @@ -335,10 +335,11 @@ async def get_instruments(self, currency: str, kind: str = None, callback=None) """ request = {"method": "public/get_instruments", "params": { - "currency": currency + "currency": currency, + "expired": expired }} if kind: - request["kind"] = kind + request["params"]["kind"] = kind return await self.send_public(request=request, callback=callback) diff --git a/ssc2ce/deribit/l2_book.py b/ssc2ce/deribit/l2_book.py new file mode 100644 index 0000000..428a4ac --- /dev/null +++ b/ssc2ce/deribit/l2_book.py @@ -0,0 +1,67 @@ +import logging +from ssc2ce.common.abstract_l2_book import AbstractL2Book +from collections import deque + +from ssc2ce.common.exceptions import BrokenOrderBook + + +class L2Book(AbstractL2Book): + change_id = None + timestamp = None + logger = logging.getLogger(__name__) + + def __init__(self, instrument: str): + """ + + :param instrument: + """ + AbstractL2Book.__init__(self, instrument) + + def handle_snapshot(self, message: dict) -> None: + """ + + :param message: + :return: + """ + self.asks.clear() + self.bids.clear() + + self.change_id = message["change_id"] + self.timestamp = message["timestamp"] + for i in message['bids']: + self.bids.add([i[1], i[2]]) + + for i in message['asks']: + self.asks.add([i[1], i[2]]) + + def handle_update(self, message: dict) -> None: + """ + + :param message: + :return: + """ + prev_change_id = message["prev_change_id"] + if prev_change_id != self.change_id: + raise BrokenOrderBook(self.instrument, prev_change_id, self.change_id) + + self.change_id = message["change_id"] + self.timestamp = message["timestamp"] + for change in message['bids']: + if change[0] == 'new': + self._bids.add(change[1:]) + elif change[0] == 'delete': + self._bids.delete(change[1]) + else: + self._bids.update(price=change[1], size=change[2]) + + for change in message['asks']: + if change[0] == 'new': + self._asks.add(change[1:]) + elif change[0] == 'delete': + self._asks.delete(change[1]) + else: + self._asks.update(price=change[1], size=change[2]) + + +def create_l2_order_book(instrument: str) -> AbstractL2Book: + return L2Book(instrument) diff --git a/ssc2ce/exceptions.py b/ssc2ce/exceptions.py deleted file mode 100644 index d1d2c8d..0000000 --- a/ssc2ce/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class Ssc2ceError(Exception): - """Base class for errors."""