From d5a3d7cb2ef8ba5aef19fff366ec7d7e2bf60db4 Mon Sep 17 00:00:00 2001 From: "Benjamin T. Schwertfeger" <51495182+btschwertfeger@users.noreply.github.com> Date: Sat, 10 Jun 2023 13:50:25 +0200 Subject: [PATCH] A the Spot order book client (`kraken.spot.OrderbookClient`) (#106) --- .../workflows/{release.yml => release.yaml} | 0 .pre-commit-config.yaml | 1 - CHANGELOG.md | 18 +- README.md | 4 +- docs/index.rst | 10 +- docs/src/about/license.rst | 3 +- docs/src/examples/spot_orderbook.rst | 6 +- .../futures/{futures_rest.rst => rest.rst} | 4 +- .../{futures_websocket.rst => websockets.rst} | 4 +- docs/src/issues.rst | 2 + docs/src/spot/{spot_rest.rst => rest.rst} | 4 +- docs/src/spot/spot_websocket.rst | 7 - docs/src/spot/websockets.rst | 12 + examples/spot_orderbook.py | 248 +--- examples/spot_trading_bot_template.py | 9 - kraken/base_api/__init__.py | 10 +- kraken/exceptions/__init__.py | 5 + kraken/futures/__init__.py | 11 +- .../{funding/__init__.py => funding.py} | 2 +- .../futures/{market/__init__.py => market.py} | 4 +- .../futures/{trade/__init__.py => trade.py} | 4 +- kraken/futures/{user/__init__.py => user.py} | 4 +- .../{ws_client/__init__.py => ws_client.py} | 8 +- kraken/spot/__init__.py | 3 +- .../spot/{funding/__init__.py => funding.py} | 2 +- kraken/spot/{market/__init__.py => market.py} | 6 +- kraken/spot/orderbook.py | 371 ++++++ .../spot/{staking/__init__.py => staking.py} | 4 +- kraken/spot/{trade/__init__.py => trade.py} | 38 +- kraken/spot/{user/__init__.py => user.py} | 4 +- kraken/spot/websocket/__init__.py | 413 +----- .../{ws_client/__init__.py => ws_client.py} | 364 +++++- pyproject.toml | 7 +- tests/futures/conftest.py | 14 +- tests/futures/helper.py | 51 +- tests/futures/test_futures_websocket.py | 513 ++++---- tests/spot/conftest.py | 12 + tests/spot/fixture/orderbook.json | 1111 +++++++++++++++++ tests/spot/helper.py | 96 +- tests/spot/test_spot_base_api.py | 12 +- tests/spot/test_spot_orderbook.py | 153 +++ tests/spot/test_spot_websocket.py | 950 +++++++------- 42 files changed, 3125 insertions(+), 1379 deletions(-) rename .github/workflows/{release.yml => release.yaml} (100%) rename docs/src/futures/{futures_rest.rst => rest.rst} (89%) rename docs/src/futures/{futures_websocket.rst => websockets.rst} (68%) rename docs/src/spot/{spot_rest.rst => rest.rst} (92%) delete mode 100644 docs/src/spot/spot_websocket.rst create mode 100644 docs/src/spot/websockets.rst rename kraken/futures/{funding/__init__.py => funding.py} (99%) rename kraken/futures/{market/__init__.py => market.py} (99%) rename kraken/futures/{trade/__init__.py => trade.py} (99%) rename kraken/futures/{user/__init__.py => user.py} (99%) rename kraken/futures/{ws_client/__init__.py => ws_client.py} (98%) rename kraken/spot/{funding/__init__.py => funding.py} (99%) rename kraken/spot/{market/__init__.py => market.py} (98%) create mode 100644 kraken/spot/orderbook.py rename kraken/spot/{staking/__init__.py => staking.py} (98%) rename kraken/spot/{trade/__init__.py => trade.py} (96%) rename kraken/spot/{user/__init__.py => user.py} (99%) rename kraken/spot/{ws_client/__init__.py => ws_client.py} (54%) create mode 100644 tests/spot/fixture/orderbook.json create mode 100644 tests/spot/test_spot_orderbook.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yaml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/release.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64d02121..6b2bbef7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,6 @@ repos: - id: mixed-line-ending - id: requirements-txt-fixer - id: end-of-file-fixer - - id: pretty-format-json - id: detect-private-key - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 40044741..a9848017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,25 @@ ## [Unreleased](https://github.com/btschwertfeger/python-kraken-sdk/tree/HEAD) -[Full Changelog](https://github.com/btschwertfeger/python-kraken-sdk/compare/v1.2.0...HEAD) +[Full Changelog](https://github.com/btschwertfeger/python-kraken-sdk/compare/v1.3.0...HEAD) + +**Implemented enhancements:** + +- Add the `truncate` parameter to `create_order` of the Spot websocket client [\#111](https://github.com/btschwertfeger/python-kraken-sdk/issues/111) +- Add the `truncate` parameter to create_order of the Spot websocket clients' `create_order` and `cancel_order`+ `kraken.spot.Trade.edit_order` [\#113](https://github.com/btschwertfeger/python-kraken-sdk/pull/113) ([btschwertfeger](https://github.com/btschwertfeger)) + +Uncategorized merged pull requests: + +- Update `/examples/spot_orderbook.py` [\#110](https://github.com/btschwertfeger/python-kraken-sdk/pull/110) ([btschwertfeger](https://github.com/btschwertfeger)) + +## [v1.3.0](https://github.com/btschwertfeger/python-kraken-sdk/tree/v1.3.0) (2023-05-24) + +[Full Changelog](https://github.com/btschwertfeger/python-kraken-sdk/compare/v1.2.0...v1.3.0) **Breaking changes:** +- Rename `kraken.futures.User.get_unwindqueue` to `kraken.futures.User.get_unwind_queue` [\#107](https://github.com/btschwertfeger/python-kraken-sdk/issues/107) +- Prepare release v1.3.0 [\#99](https://github.com/btschwertfeger/python-kraken-sdk/pull/99) ([btschwertfeger](https://github.com/btschwertfeger)) - Change `kraken.spot.User.get_balances` and add `kraken.spot.User.get_balance` [\#98](https://github.com/btschwertfeger/python-kraken-sdk/pull/98) ([btschwertfeger](https://github.com/btschwertfeger)) - Rename `get_tradeable_asset_pair` to `get_asset_pairs` and make the `pair` parameter optional [\#93](https://github.com/btschwertfeger/python-kraken-sdk/pull/93) ([btschwertfeger](https://github.com/btschwertfeger)) - Extend typing + add `KrakenUnknownMethodError` and `KrakenBadRequestError` + Fix \#65 [\#87](https://github.com/btschwertfeger/python-kraken-sdk/pull/87) ([btschwertfeger](https://github.com/btschwertfeger)) @@ -24,6 +39,7 @@ **Closed issues:** +- Add a realtime Spot order book example [\#103](https://github.com/btschwertfeger/python-kraken-sdk/issues/103) - `kraken.spot.Trade.create_order`: documentatoin for txid outdated. [\#96](https://github.com/btschwertfeger/python-kraken-sdk/issues/96) - Create `CONTRIBUTING.md` [\#91](https://github.com/btschwertfeger/python-kraken-sdk/issues/91) - Extend the typing - using mypy [\#84](https://github.com/btschwertfeger/python-kraken-sdk/issues/84) diff --git a/README.md b/README.md index 4d8afebe..d4a5a9d6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ python3 -m pip install python-kraken-sdk ### 2. Register at Kraken and generate API Keys: - Spot Trading: https://www.kraken.com/u/security/api -- Futures Trading: https://futures.kraken.com/trade/settings/api +- Futures Trading: https://futures.kraken.com/trade/settings/api (see _[help](https://docs.futures.kraken.com/#introduction-generate-api-keys)_) - Futures Sandbox: https://demo-futures.kraken.com/settings/api ### 3. Start using the provided example scripts @@ -177,7 +177,7 @@ import time import asyncio from kraken.spot import KrakenSpotWSClient -async def main() -> None: +async def main() key = "kraken-public-key" secret = "kraken-secret-key" diff --git a/docs/index.rst b/docs/index.rst index 571b9423..d8c9e47d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,17 +12,17 @@ Welcome to Python Kraken SDK's documentation! |Release date badge| |Release version badge| |DOI badge| .. toctree:: - :maxdepth: 2 :caption: Contents: + :maxdepth: 2 src/introduction.rst src/getting_started/getting_started.rst src/examples/trading_bot_templates.rst src/issues.rst - src/spot/spot_rest.rst - src/spot/spot_websocket.rst - src/futures/futures_rest.rst - src/futures/futures_websocket.rst + src/spot/rest.rst + src/spot/websockets.rst + src/futures/rest.rst + src/futures/websockets.rst src/base_api/base_api.rst src/krakenexceptions/krakenexceptions.rst src/about/license.rst diff --git a/docs/src/about/license.rst b/docs/src/about/license.rst index c0236631..a243a3d2 100644 --- a/docs/src/about/license.rst +++ b/docs/src/about/license.rst @@ -1,7 +1,6 @@ - .. _section-license: License -======= +========= .. include:: ../../../LICENSE diff --git a/docs/src/examples/spot_orderbook.rst b/docs/src/examples/spot_orderbook.rst index 10a158ba..59a5ef80 100644 --- a/docs/src/examples/spot_orderbook.rst +++ b/docs/src/examples/spot_orderbook.rst @@ -1,12 +1,16 @@ .. The spot orderbook Maintain a valid Spot order book +-------------------------------- The following example demonstrate how to use the python-kraken-sdk to retrieve a valid realtime order book. Please see the following example snippet for more information. --------------------------------- +References: +- https://gist.github.com/btschwertfeger/6eea0eeff193f7cd1b262cfce4f0eb51 + + .. literalinclude:: ../../../examples/spot_orderbook.py :language: python :linenos: diff --git a/docs/src/futures/futures_rest.rst b/docs/src/futures/rest.rst similarity index 89% rename from docs/src/futures/futures_rest.rst rename to docs/src/futures/rest.rst index 28ed9759..5ad78ae0 100644 --- a/docs/src/futures/futures_rest.rst +++ b/docs/src/futures/rest.rst @@ -1,5 +1,5 @@ -Futures REST Clients -===================== +Futures REST +============ .. autoclass:: kraken.futures.User :members: diff --git a/docs/src/futures/futures_websocket.rst b/docs/src/futures/websockets.rst similarity index 68% rename from docs/src/futures/futures_websocket.rst rename to docs/src/futures/websockets.rst index 94df98b9..e63e3df7 100644 --- a/docs/src/futures/futures_websocket.rst +++ b/docs/src/futures/websockets.rst @@ -1,5 +1,5 @@ -Futures Websocket Client -======================== +Futures Websockets +================== .. autoclass:: kraken.futures.KrakenFuturesWSClient :members: diff --git a/docs/src/issues.rst b/docs/src/issues.rst index 27b5c907..0f3f7c63 100644 --- a/docs/src/issues.rst +++ b/docs/src/issues.rst @@ -1,6 +1,8 @@ Known Issues ============ +Issues listed here: `python-kraken-sdk/issues`_ + Futures Trading --------------- diff --git a/docs/src/spot/spot_rest.rst b/docs/src/spot/rest.rst similarity index 92% rename from docs/src/spot/spot_rest.rst rename to docs/src/spot/rest.rst index ea058c36..7336a932 100644 --- a/docs/src/spot/spot_rest.rst +++ b/docs/src/spot/rest.rst @@ -1,5 +1,5 @@ -Spot REST Clients -================== +Spot REST +========= .. autoclass:: kraken.spot.User :members: diff --git a/docs/src/spot/spot_websocket.rst b/docs/src/spot/spot_websocket.rst deleted file mode 100644 index b6c3dd24..00000000 --- a/docs/src/spot/spot_websocket.rst +++ /dev/null @@ -1,7 +0,0 @@ -Spot Websocket Client -===================== - -.. autoclass:: kraken.spot.KrakenSpotWSClient - :members: - :show-inheritance: - :inherited-members: diff --git a/docs/src/spot/websockets.rst b/docs/src/spot/websockets.rst new file mode 100644 index 00000000..374a9f62 --- /dev/null +++ b/docs/src/spot/websockets.rst @@ -0,0 +1,12 @@ +Spot Websockets +=============== + +.. autoclass:: kraken.spot.KrakenSpotWSClient + :members: + :show-inheritance: + :inherited-members: + +.. autoclass:: kraken.spot.OrderbookClient + :members: + :show-inheritance: + :inherited-members: diff --git a/examples/spot_orderbook.py b/examples/spot_orderbook.py index 941609e6..3dd0207e 100644 --- a/examples/spot_orderbook.py +++ b/examples/spot_orderbook.py @@ -5,17 +5,21 @@ # """ -This module provides an example on how to use the Spot websocket +NOTE: * The Spot Orderbook client is not released yet. It will be released + in python-kraken-sdk=v1.4.0. + * Have a look at https://gist.github.com/btschwertfeger/6eea0eeff193f7cd1b262cfce4f0eb51 + for an example that works now. + +This module provides an example on how to use the Spot Orderbook client of the python-kraken-sdk (https://github.com/btschwertfeger/python-kraken-sdk) -to retrieve and maintain a valid Spot order book for a specific -asset pair. It can be run directly without any credentials if the +to retrieve and maintain a valid Spot order book for (a) specific +asset pair(s). It can be run directly without any credentials if the python-kraken-sdk is installed. python3 -m pip install python-kraken-sdk The output when running this snippet looks like the following table and -updates the book as soon as Kraken sent any order book update. The -stdout refreshes every 0.1 seconds. +updates the book as soon as Kraken sent any order book update. Bid Volume Ask Volume 27076.00000 (8.28552127) 27076.10000 (2.85897056) @@ -31,234 +35,70 @@ This can be the basis of an order book based trading strategy where realtime data and fast price movements are considered. - -References -- https://support.kraken.com/hc/en-us/articles/360027821131-WebSocket-API-v1-How-to-maintain-a-valid-order-book -- https://docs.kraken.com/websockets/#book-checksum """ from __future__ import annotations import asyncio -import binascii -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Tuple -from kraken.spot import KrakenSpotWSClient +from kraken.spot import OrderbookClient -class Orderbook(KrakenSpotWSClient): +class Orderbook(OrderbookClient): """ - The Orderbook class inherit the subscribe function from the - KrakenSpotWSClient class. This function is used to subscribe to the - order book feed will initially send the current order book - and then send updates when anything changes. + This is a wrapper class that is used to overload the :func:`on_book_update` + function. It can also be used as a base for trading strategy. Since the + :class:`OrderbookClient` is derived from :class:`KrakenSpotWSClient` + it can also be used to access the :func:`subscribe` function and any + other provided utility. """ - def __init__(self: "Orderbook", depth: int = 10) -> None: - super().__init__() - self.__book: Dict[str, dict] = {} - self.__depth: int = depth - - async def on_message(self: "Orderbook", msg: Union[list, dict]) -> None: - """ - The on_message function is implemented in the KrakenSpotWSClient - class and used as callback to receive all messages sent by the - Kraken API. - """ - if "errorMessage" in msg: - print(msg) - return - - if "event" in msg: - # ignore heartbeat / ping - pong messages - return - - if not isinstance(msg, list): - # The order book feed only sends messages with type list, - # so we can ignore anything else. - return - - pair: str = msg[-1] - if pair not in self.__book: - self.__book[pair] = {"bid": {}, "ask": {}, "valid": True} - - if "as" in msg[1]: - # This will be triggered initially when the - # first message comes in that provides the initial snapshot - # of the current order book. - self.__update_book(pair=pair, side="ask", snapshot=msg[1]["as"]) - self.__update_book(pair=pair, side="bid", snapshot=msg[1]["bs"]) - else: - checksum: Optional[str] = None - - # This is executed every time a new update comes in. - for data in msg[1 : len(msg) - 2]: - if "a" in data: - self.__update_book(pair=pair, side="ask", snapshot=data["a"]) - elif "b" in data: - self.__update_book(pair=pair, side="bid", snapshot=data["b"]) - if "c" in data: - checksum = data["c"] - - self.__validate_checksum(pair=pair, checksum=checksum) - - def get(self: "Orderbook", pair: str) -> Optional[dict]: - """ - Returns the order book for a specific ``pair``. - - :param pair: The pair to get the order book from - :type pair: str - :return: The order book of that ``pair``. - :rtype: dict - """ - return self.__book.get(pair) - - def __update_book(self: "Orderbook", pair: str, side: str, snapshot: list) -> None: + async def on_book_update(self: "Orderbook", pair: str, message: list) -> None: """ - This functions updates the local order book based on the - information provided in ``data`` and assigns/update the - asks and bids in book. + This function is called every time the order book of ``pair`` gets + updated. - The ``data`` here looks like: - [ - ['25026.00000', '2.77183035', '1684658128.013525'], - ['25028.50000', '0.04725650', '1684658121.180535'], - ['25030.20000', '0.29527502', '1684658128.018182'], - ['25030.40000', '2.77134976', '1684658131.751539'], - ['25032.20000', '0.13978808', '1684658131.751577'] - ] - ... where the first value is the ask or bid price, the second - represents the volume and the last one is the time stamp. + The ``pair`` parameter can be used to access the updated order book + as shown in the function body below. - :param side: The side to assign the data to, - either ``ask`` or ``bid`` - :type side: str - :param data: The data that needs to be assigned. - :type data: list - """ - for entry in snapshot: - price: str = entry[0] - volume: str = entry[1] - - if float(volume) > 0.0: - # Price level exist or is new - self.__book[pair][side][price] = volume - else: - # Price level moved out of range - self.__book[pair][side].pop(price) - - if side == "ask": - self.__book[pair]["ask"] = dict( - sorted( - self.__book[pair]["ask"].items(), key=self.get_first # type: ignore[arg-type] - )[: self.__depth] - ) - - elif side == "bid": - self.__book[pair]["bid"] = dict( - sorted( - self.__book[pair]["bid"].items(), - key=self.get_first, # type: ignore[arg-type] - reverse=True, - )[: self.__depth] - ) - - def __validate_checksum(self: "Orderbook", pair: str, checksum: str) -> None: - """ - Function that validates the checksum of the orderbook as described here - https://docs.kraken.com/websockets/#book-checksum. - - :param pair: The pair that's order book checksum should be validated. + :param pair: The currency pair of the updated order book :type pair: str - :param checksum: The checksum sent by the Kraken API - :type checksum: str + :param message: The message sent by Kraken (not needed in most cases) + :type message: list """ - book: dict = self.__book[pair] - - # sort ask (desc) and bid (asc) - ask: List[tuple] = sorted(book["ask"].items(), key=self.get_first) # type: ignore[arg-type] - bid: List[tuple] = sorted( - book["bid"].items(), - key=self.get_first, # type: ignore[arg-type] - reverse=True, - ) - - local_checksum: str = "" - for price_level, volume in ask[:10]: - local_checksum += price_level.replace(".", "").lstrip("0") + volume.replace( - ".", "" - ).lstrip("0") - - for price_level, volume in bid[:10]: - local_checksum += price_level.replace(".", "").lstrip("0") + volume.replace( - ".", "" - ).lstrip("0") - - self.__book[pair]["valid"] = checksum == str( - binascii.crc32(local_checksum.encode()) - ) - # assert self.__book[pair]["valid"] - - @staticmethod - def get_first(values: tuple) -> float: - """ - This function is used as callback for the ``sorted`` method - to sort a tuple/list by its first value and while ensuring - that the values are floats and comparable. + book: Dict[str, Any] = self.get(pair=pair) + bid: List[Tuple[str, str]] = list(book["bid"].items()) + ask: List[Tuple[str, str]] = list(book["ask"].items()) + + print("Bid Volume\t\t Ask Volume") + for level in range(self.depth): + print( + f"{bid[level][0]} ({bid[level][1]}) \t {ask[level][0]} ({ask[level][1]})" + ) - :param values: A tuple of string values - :type values: tuple - :return: The first value of ``values`` as float. - :rtype: float - """ - return float(values[0]) + assert book["valid"] # ensure that the checksum is valid async def main() -> None: """ - This is the actual main function where we define the depth of the - order book and also a pair. We could subscribe to multiple pairs, - but for simplicity only XBT/USD is chosen. + Here we depth of the order book and also a pair. We could + subscribe to multiple pairs, but for simplicity only XBT/USD is chosen. - After defined some constants, the order book class can be instantiated, - which receives the order book-related messages, after we subscribed - to the book feed. + The Orderbook class can be instantiated, which receives the order + book-related messages, after we subscribed to the book feed. Finally we need some "game loop" - so we create a while loop - that runs until the KrakenSpotWSClient class encounters some error - which will be indicated by the ``exception_occur`` flag. Within this - loop we print out the order book on the console - but this is the place - where some could implement or call an order book depending strategy. + that runs as long as there is no error. """ - DEPTH: int = 10 # we can also change the depth to 100 - PAIR: str = "XBT/USD" + orderbook: Orderbook = Orderbook() - orderbook: Orderbook = Orderbook(depth=DEPTH) - await orderbook.subscribe( - subscription={"name": "book", "depth": DEPTH}, - pair=[PAIR], # we can also subscribe to more currency pairs + await orderbook.add_book( + pairs=["XBT/USD"] # we can also subscribe to more currency pairs ) while not orderbook.exception_occur: - book: Optional[dict] = orderbook.get(PAIR) - if not book or len(book["bid"]) < DEPTH or len(book["ask"]) < DEPTH: - pass - else: - bid: List[dict] = sorted( - book["bid"].items(), - key=orderbook.get_first, # type: ignore[arg-type] - reverse=True, - ) - ask: List[dict] = sorted(book["ask"].items(), key=orderbook.get_first) # type: ignore[arg-type] - print("Bid Volume\t\t Ask Volume") - for level in range(DEPTH): - print( - f"{bid[level][0]} ({bid[level][1]}) \t {ask[level][0]} ({ask[level][1]})" - ) - assert book["valid"] - - # This following sleep statement is very important to not having a million calls a second. - await asyncio.sleep(0.1) + await asyncio.sleep(10) if __name__ == "__main__": diff --git a/examples/spot_trading_bot_template.py b/examples/spot_trading_bot_template.py index ab03fe14..9b3297a5 100644 --- a/examples/spot_trading_bot_template.py +++ b/examples/spot_trading_bot_template.py @@ -22,15 +22,6 @@ from kraken.exceptions import KrakenException from kraken.spot import Funding, KrakenSpotWSClient, Market, Staking, Trade, User -logging.basicConfig( - format="%(asctime)s %(module)s,line: %(lineno)d %(levelname)8s | %(message)s", - datefmt="%Y/%m/%d %H:%M:%S", - level=logging.INFO, -) -logging.getLogger().setLevel(logging.INFO) -logging.getLogger("requests").setLevel(logging.WARNING) -logging.getLogger("urllib3").setLevel(logging.WARNING) - class TradingBot(KrakenSpotWSClient): """ diff --git a/kraken/base_api/__init__.py b/kraken/base_api/__init__.py index b57e0cef..5024f8d2 100644 --- a/kraken/base_api/__init__.py +++ b/kraken/base_api/__init__.py @@ -11,6 +11,7 @@ import json import time import urllib.parse +from functools import wraps from typing import Any, Callable, Dict, List, Optional, Type, Union from urllib.parse import urljoin from uuid import uuid1 @@ -54,6 +55,7 @@ def get_assets( """ def decorator(func: Callable) -> Callable: + @wraps(func) # required for sphinx to discover the func def wrapper(*args: Any, **kwargs: Any) -> Any: if parameter_name in kwargs: value: Any = kwargs[parameter_name] @@ -186,7 +188,6 @@ def __init__( else: self.url = urljoin(self.URL, self.API_V) - self.__nonce: int = 0 self.__key: str = key self.__secret: str = secret self.__use_custom_exceptions: bool = use_custom_exceptions @@ -247,9 +248,8 @@ def _request( or self.__secret == "" ): raise ValueError("Missing credentials.") - self.__nonce = (self.__nonce + 1) % 1 - params["nonce"] = str(int(time.time() * 1000)) + str(self.__nonce).zfill(4) + params["nonce"] = str(int(time.time() * 100_000_000)) content_type: str sign_data: str @@ -414,7 +414,6 @@ def __init__( self.__key: str = key self.__secret: str = secret - self.__nonce: int = 0 self.__use_custom_exceptions: bool = use_custom_exceptions self.__err_handler: KrakenErrorHandler = KrakenErrorHandler() @@ -482,8 +481,7 @@ def _request( or self.__secret == "" ): raise ValueError("Missing credentials") - self.__nonce = (self.__nonce + 1) % 1 - nonce: str = str(int(time.time() * 1000)) + str(self.__nonce).zfill(4) + nonce: str = str(int(time.time() * 100_000_000)) headers.update( { "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", diff --git a/kraken/exceptions/__init__.py b/kraken/exceptions/__init__.py index b894a98f..3cbe8e20 100644 --- a/kraken/exceptions/__init__.py +++ b/kraken/exceptions/__init__.py @@ -55,6 +55,7 @@ def __init__( "EGeneral:Invalid arguments:Index unavailable": self.KrakenInvalidArgumentsIndexUnavailableError, "EGeneral:Permission denied": self.KrakenPermissionDeniedError, "EGeneral:Unknown method": self.KrakenUnknownMethodError, + "EGeneral:Temporary lockout": self.KrakenTemporaryLockoutError, "EService:Unavailable": self.KrakenServiceUnavailableError, "EService:Market in cancel_only mode": self.KrakenMarketInOnlyCancelModeError, "EService:Market in post_only mode": self.KrakenMarketInOnlyPostModeError, @@ -299,6 +300,10 @@ class KrakenToManyAddressesError(Exception): class KrakenUnknownMethodError(Exception): """The endpoint or method is not known.""" + @docstring_message + class KrakenTemporaryLockoutError(Exception): + """The account was temporary locked out.""" + # ? ____CUSTOM_EXCEPTIONS_________ @docstring_message class MaxReconnectError(Exception): diff --git a/kraken/futures/__init__.py b/kraken/futures/__init__.py index ace96233..5ef438a8 100644 --- a/kraken/futures/__init__.py +++ b/kraken/futures/__init__.py @@ -4,9 +4,10 @@ # GitHub: https://github.com/btschwertfeger """This module provides the Kraken Futures clients""" + # pylint: disable=unused-import -from kraken.futures.funding import Funding -from kraken.futures.market import Market -from kraken.futures.trade import Trade -from kraken.futures.user import User -from kraken.futures.ws_client import KrakenFuturesWSClient +from .funding import Funding +from .market import Market +from .trade import Trade +from .user import User +from .ws_client import KrakenFuturesWSClient diff --git a/kraken/futures/funding/__init__.py b/kraken/futures/funding.py similarity index 99% rename from kraken/futures/funding/__init__.py rename to kraken/futures/funding.py index 171ca3e3..5f7334f0 100644 --- a/kraken/futures/funding/__init__.py +++ b/kraken/futures/funding.py @@ -7,7 +7,7 @@ from typing import Optional, Union -from ...base_api import KrakenBaseFuturesAPI +from ..base_api import KrakenBaseFuturesAPI class Funding(KrakenBaseFuturesAPI): diff --git a/kraken/futures/market/__init__.py b/kraken/futures/market.py similarity index 99% rename from kraken/futures/market/__init__.py rename to kraken/futures/market.py index 89e15a85..d8045b73 100644 --- a/kraken/futures/market/__init__.py +++ b/kraken/futures/market.py @@ -8,7 +8,7 @@ from functools import lru_cache from typing import List, Optional, Union -from ...base_api import KrakenBaseFuturesAPI, defined +from ..base_api import KrakenBaseFuturesAPI, defined class Market(KrakenBaseFuturesAPI): @@ -290,7 +290,7 @@ def get_orderbook(self: "Market", symbol: Optional[str] = None) -> dict: .. code-block:: python :linenos: - :caption: Futures Market: Get the assets order book + :caption: Futures Market: Get the assets orderbook >>> from kraken.futures import Market >>> Market().get_orderbook(symbol="PI_XBTUSD") diff --git a/kraken/futures/trade/__init__.py b/kraken/futures/trade.py similarity index 99% rename from kraken/futures/trade/__init__.py rename to kraken/futures/trade.py index ce5176e8..34ffc97a 100644 --- a/kraken/futures/trade/__init__.py +++ b/kraken/futures/trade.py @@ -8,7 +8,7 @@ from typing import List, Optional, Tuple, Union -from ...base_api import KrakenBaseFuturesAPI, defined +from ..base_api import KrakenBaseFuturesAPI, defined class Trade(KrakenBaseFuturesAPI): @@ -24,7 +24,7 @@ class Trade(KrakenBaseFuturesAPI): :type secret: str, optional :param url: Alternative URL to access the Futures Kraken API (default: https://futures.kraken.com) :type url: str, optional - :param sandbox: If set to ``True`` the URL will be https://demo-futures.kraken.com + :param sandbox: If set to ``True`` the URL will be https://demo-futures.kraken.com (default: ``False``) :type sandbox: bool, optional .. code-block:: python diff --git a/kraken/futures/user/__init__.py b/kraken/futures/user.py similarity index 99% rename from kraken/futures/user/__init__.py rename to kraken/futures/user.py index 8627e6c3..6a8dd9e2 100644 --- a/kraken/futures/user/__init__.py +++ b/kraken/futures/user.py @@ -10,7 +10,7 @@ import requests -from ...base_api import KrakenBaseFuturesAPI, defined +from ..base_api import KrakenBaseFuturesAPI, defined class User(KrakenBaseFuturesAPI): @@ -26,7 +26,7 @@ class User(KrakenBaseFuturesAPI): :type secret: str, optional :param url: Alternative URL to access the Futures Kraken API (default: https://futures.kraken.com) :type url: str, optional - :param sandbox: If set to ``True`` the URL will be https://demo-futures.kraken.com + :param sandbox: If set to ``True`` the URL will be https://demo-futures.kraken.com (default: ``False``) :type sandbox: bool, optional .. code-block:: python diff --git a/kraken/futures/ws_client/__init__.py b/kraken/futures/ws_client.py similarity index 98% rename from kraken/futures/ws_client/__init__.py rename to kraken/futures/ws_client.py index 8defb6a1..0af72e0f 100644 --- a/kraken/futures/ws_client/__init__.py +++ b/kraken/futures/ws_client.py @@ -4,7 +4,7 @@ # GitHub: https://github.com/btschwertfeger # -"""Provides the KrakenWsClientCl class to use the Kraken Futures websockets.""" +"""Provides the websocket client for Kraken Futures""" import base64 import hashlib @@ -13,9 +13,9 @@ from copy import deepcopy from typing import Any, Dict, List, Optional -from ...base_api import KrakenBaseFuturesAPI -from ...exceptions import KrakenException -from ...futures.websocket import ConnectFuturesWebsocket +from ..base_api import KrakenBaseFuturesAPI +from ..exceptions import KrakenException +from .websocket import ConnectFuturesWebsocket class KrakenFuturesWSClient(KrakenBaseFuturesAPI): diff --git a/kraken/spot/__init__.py b/kraken/spot/__init__.py index b0fe7f4f..7926e3be 100644 --- a/kraken/spot/__init__.py +++ b/kraken/spot/__init__.py @@ -8,7 +8,8 @@ # pylint: disable=unused-import from kraken.spot.funding import Funding from kraken.spot.market import Market +from kraken.spot.orderbook import OrderbookClient from kraken.spot.staking import Staking from kraken.spot.trade import Trade from kraken.spot.user import User -from kraken.spot.websocket import KrakenSpotWSClient +from kraken.spot.ws_client import KrakenSpotWSClient diff --git a/kraken/spot/funding/__init__.py b/kraken/spot/funding.py similarity index 99% rename from kraken/spot/funding/__init__.py rename to kraken/spot/funding.py index 6afed638..9f1c8039 100644 --- a/kraken/spot/funding/__init__.py +++ b/kraken/spot/funding.py @@ -8,7 +8,7 @@ from typing import List, Optional, Union -from ...base_api import KrakenBaseSpotAPI, defined +from ..base_api import KrakenBaseSpotAPI, defined class Funding(KrakenBaseSpotAPI): diff --git a/kraken/spot/market/__init__.py b/kraken/spot/market.py similarity index 98% rename from kraken/spot/market/__init__.py rename to kraken/spot/market.py index 45e6cbd1..838aa9c6 100644 --- a/kraken/spot/market/__init__.py +++ b/kraken/spot/market.py @@ -4,12 +4,12 @@ # GitHub: https://github.com/btschwertfeger # -"""Module that implements the Kraken Spot market client""" +"""Module that implements the Kraken Spot Market client""" from functools import lru_cache from typing import List, Optional, Union -from ...base_api import KrakenBaseSpotAPI, defined, ensure_string +from ..base_api import KrakenBaseSpotAPI, defined, ensure_string class Market(KrakenBaseSpotAPI): @@ -304,7 +304,7 @@ def get_order_book(self: "Market", pair: str, count: Optional[int] = 100) -> dic .. code-block:: python :linenos: - :caption: Spot Market: Get the order book + :caption: Spot Market: Get the orderbook >>> from kraken.spot import Market >>> Market().get_order_book(pair="XBTUSD", count=2) diff --git a/kraken/spot/orderbook.py b/kraken/spot/orderbook.py new file mode 100644 index 00000000..db54ca2d --- /dev/null +++ b/kraken/spot/orderbook.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (C) 2023 Benjamin Thomas Schwertfeger +# GitHub: https://github.com/btschwertfeger +# + +"""Module that implements the Kraken Spot Orderbook client""" + +import logging +from asyncio import sleep as asyncio_sleep +from binascii import crc32 +from collections import OrderedDict +from inspect import iscoroutinefunction +from typing import Callable, Dict, List, Optional, Union + +from .ws_client import KrakenSpotWSClient + + +class OrderbookClient: + """ + The orderbook client can be used for instantiation and maintaining + one or multiple orderbooks for Spot trading on the Kraken cryptocurrency + exchange. It connects to the websocket feed(s) and receives the book + updates, calculates the checksum and will publish the changes to the + :func:`OrderbookClient.on_book_update` function or to the specified + callback function. + + The :func:`OrderbookClient.get` function can be used to access a specific + book of this client. + + The client will resubscribe to the book feed(s) if any errors occur and + publish the changes to the mentioned function(s). + + This class has a fixed book depth. Available depths are: {10, 25, 50, 100} + + - https://support.kraken.com/hc/en-us/articles/360027821131-WebSocket-API-v1-How-to-maintain-a-valid-order-book + + - https://docs.kraken.com/websockets/#book-checksum + + .. code-block:: python + :linenos: + :caption: Example: Create and maintain a Spot orderbook as custom class + + from typing import Any, Dict, List, Tuple + from kraken.spot import OrderbookClient + import asyncio + + class OrderBook(OrderbookClient): + async def on_book_update(self: "OrderBook", pair: str, message: list) -> None: + '''This function must be overloaded to get the recent updates.''' + book: Dict[str, Any] = self.get(pair=pair) + bid: List[Tuple[str, str]] = list(book["bid"].items()) + ask: List[Tuple[str, str]] = list(book["ask"].items()) + + print("Bid Volume\t\t Ask Volume") + for level in range(self.depth): + print( + f"{bid[level][0]} ({bid[level][1]}) \t {ask[level][0]} ({ask[level][1]})" + ) + + async def main() -> None: + orderbook: OrderBook = OrderBook(depth=10) + await orderbook.add_book( + pairs=["XBT/USD"] # we can also subscribe to more currency pairs + ) + + while not orderbook.exception_occur: + await asyncio.sleep(10) + + if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass + + + .. code-block:: python + :linenos: + :caption: Example: Create and maintain a Spot orderbook using a callback + + from typing import Any, Dict, List, Tuple + from kraken.spot import OrderbookClient + import asyncio + + async def my_callback(self: "OrderBook", pair: str, message: list) -> None: + '''This function do not need to be async.''' + print(message) + + async def main() -> None: + orderbook: OrderBook = OrderBook(depth=100, callback=my_callback) + await orderbook.add_book( + pairs=["XBT/USD"] # we can also subscribe to more currency pairs + ) + + while not orderbook.exception_occur: + await asyncio.sleep(10) + + if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass + """ + + LOG: logging.Logger = logging.getLogger(__name__) + + def __init__( + self: "OrderbookClient", + depth: int = 10, + callback: Optional[Callable] = None, + ) -> None: + super().__init__() + self.__book: Dict[str, dict] = {} + self.__depth: int = depth + self.__callback: Optional[Callable] = callback + + self.ws_client: KrakenSpotWSClient = KrakenSpotWSClient( + callback=self.on_message + ) + + async def on_message(self: "OrderbookClient", msg: Union[list, dict]) -> None: + """ + The on_message function is implemented in the KrakenSpotWSClient + class and used as callback to receive all messages sent by the + Kraken API. + + *This function should not be overloaded - this would break this client!* + """ + if "errorMessage" in msg: + self.LOG.warning(msg) + + if "event" in msg and isinstance(msg, dict): + # ignore heartbeat / ping - pong messages / any event message + # ignore errors since they are handled by the parent class + # just handle the removal of an orderbook + if ( + msg["event"] == "subscriptionStatus" + and "status" in msg + and "pair" in msg + and msg["status"] == "unsubscribed" + and msg["pair"] in self.__book + ): + del self.__book[msg["pair"]] + return + + if not isinstance(msg, list): + # The orderbook feed only sends messages with type list, + # so we can ignore anything else. + return + + pair: str = msg[-1] + if pair not in self.__book: + self.__book[pair] = { + "bid": {}, + "ask": {}, + "valid": True, + } + + if "as" in msg[1]: + # This will be triggered initially when the + # first message comes in that provides the initial snapshot + # of the current orderbook. + self.__update_book(pair=pair, side="ask", snapshot=msg[1]["as"]) + self.__update_book(pair=pair, side="bid", snapshot=msg[1]["bs"]) + else: + checksum: Optional[str] = None + # This is executed every time a new update comes in. + for data in msg[1 : len(msg) - 2]: + if "a" in data: + self.__update_book(pair=pair, side="ask", snapshot=data["a"]) + elif "b" in data: + self.__update_book(pair=pair, side="bid", snapshot=data["b"]) + if "c" in data: + checksum = data["c"] + + self.__validate_checksum(pair=pair, checksum=checksum) + + if not self.__book[pair]["valid"]: + await self.on_book_update( + pair=pair, + message=[ + { + "error": f"Checksum mismatch - resubscribe to the orderbook {pair}" + } + ], + ) + # if the orderbook's checksum is invalid, we need re-add the orderbook + await self.remove_book(pairs=[pair]) + + await asyncio_sleep(3) + await self.add_book(pairs=[pair]) + else: + await self.on_book_update(pair=pair, message=msg) + + async def on_book_update(self: "OrderbookClient", pair: str, message: list) -> None: + """ + This function will be called every time the orderbook gets updated. + It needs to be overloaded if no callback function was defined + during the instantiation of this class. + + :param pair: The currency pair of the orderbook that has + been updated. + :type pair: str + """ + + if self.__callback: + if iscoroutinefunction(self.__callback): + await self.__callback(pair=pair, message=message) + else: + self.__callback(pair=pair, message=message) + else: + logging.info(message) + + async def add_book(self: "OrderbookClient", pairs: List[str]) -> None: + """ + Add an orderbook to this client. The feed will be subscribed + and updates will be published to the :func:`on_book_update` function. + + :param pairs: The pair(s) to subscribe to + :type pairs: List[str] + :param depth: The book depth + :type depth: int + """ + await self.ws_client.subscribe( + subscription={"name": "book", "depth": self.__depth}, pair=pairs + ) + + async def remove_book(self: "OrderbookClient", pairs: List[str]) -> None: + """ + Unsubscribe from a subscribed orderbook. + + :param pairs: The pair(s) to unsubscribe from + :type pairs: List[str] + :param depth: The book depth + :type depth: int + """ + await self.ws_client.unsubscribe( + subscription={"name": "book", "depth": self.__depth}, pair=pairs + ) + + @property + def depth(self: "OrderbookClient") -> int: + """ + Return the fixed depth of this orderbook client. + """ + return self.__depth + + @property + def exception_occur(self: "OrderbookClient") -> bool: + """ + Can be used to determine if any critical error occurred within the + websocket connection. If so, the function will return ``True`` + and the client instance is most likely not useable anymore. So this + is the switch lets the user know, when to delete the current one and + create a new one. + + :return: ``True`` if any critical error occurred else ``False`` + :rtype: bool + """ + return self.ws_client.exception_occur + + def get(self: "OrderbookClient", pair: str) -> Optional[dict]: + """ + Returns the orderbook for a specific ``pair``. + + :param pair: The pair to get the orderbook from + :type pair: str + :return: The orderbook of that ``pair``. + :rtype: dict + """ + return self.__book.get(pair) + + def __update_book( + self: "OrderbookClient", pair: str, side: str, snapshot: list + ) -> None: + """ + This functions updates the local orderbook based on the + information provided in ``data`` and assigns/update the + asks and bids in book. + + The ``data`` here looks like: + [ + ['25026.00000', '2.77183035', '1684658128.013525'], + ['25028.50000', '0.04725650', '1684658121.180535'], + ['25030.20000', '0.29527502', '1684658128.018182'], + ['25030.40000', '2.77134976', '1684658131.751539'], + ['25032.20000', '0.13978808', '1684658131.751577'] + ] + ... where the first value is the ask or bid price, the second + represents the volume and the last one is the time stamp. + + :param side: The side to assign the data to, + either ``ask`` or ``bid`` + :type side: str + :param data: The data that needs to be assigned. + :type data: list + """ + for entry in snapshot: + price: str = entry[0] + volume: str = entry[1] + + if float(volume) > 0.0: + # Price level exist or is new + self.__book[pair][side][price] = volume + else: + # Price level moved out of range + self.__book[pair][side].pop(price) + + if side == "ask": + self.__book[pair]["ask"] = OrderedDict( + sorted(self.__book[pair]["ask"].items(), key=self.get_first)[ + : self.__depth + ] + ) + + elif side == "bid": + self.__book[pair]["bid"] = OrderedDict( + sorted( + self.__book[pair]["bid"].items(), + key=self.get_first, + reverse=True, + )[: self.__depth] + ) + + def __validate_checksum(self: "OrderbookClient", pair: str, checksum: str) -> None: + """ + Function that validates the checksum of the orderbook as described here + https://docs.kraken.com/websockets/#book-checksum. + + :param pair: The pair that's orderbook checksum should be validated. + :type pair: str + :param checksum: The checksum sent by the Kraken API + :type checksum: str + """ + book: dict = self.__book[pair] + + # sort ask (desc) and bid (asc) + ask: List[tuple] = sorted(book["ask"].items(), key=self.get_first) + bid: List[tuple] = sorted( + book["bid"].items(), + key=self.get_first, + reverse=True, + ) + + local_checksum: str = "" + for price_level, volume in ask[:10]: + local_checksum += price_level.replace(".", "").lstrip("0") + volume.replace( + ".", "" + ).lstrip("0") + + for price_level, volume in bid[:10]: + local_checksum += price_level.replace(".", "").lstrip("0") + volume.replace( + ".", "" + ).lstrip("0") + + self.__book[pair]["valid"] = checksum == str(crc32(local_checksum.encode())) + # assert self.__book[pair]["valid"] + + @staticmethod + def get_first(values: tuple) -> float: + """ + This function is used as callback for the ``sorted`` method + to sort a tuple/list by its first value and while ensuring + that the values are floats and comparable. + + :param values: A tuple of string values + :type values: tuple + :return: The first value of ``values`` as float. + :rtype: float + """ + return float(values[0]) diff --git a/kraken/spot/staking/__init__.py b/kraken/spot/staking.py similarity index 98% rename from kraken/spot/staking/__init__.py rename to kraken/spot/staking.py index 834648ab..e652c321 100644 --- a/kraken/spot/staking/__init__.py +++ b/kraken/spot/staking.py @@ -4,11 +4,11 @@ # GitHub: https://github.com/btschwertfeger # -"""Module that implements the Kraken Spot Stakung client""" +"""Module that implements the Kraken Spot Staking client""" from typing import List, Optional, Union -from ...base_api import KrakenBaseSpotAPI, defined +from ..base_api import KrakenBaseSpotAPI, defined class Staking(KrakenBaseSpotAPI): diff --git a/kraken/spot/trade/__init__.py b/kraken/spot/trade.py similarity index 96% rename from kraken/spot/trade/__init__.py rename to kraken/spot/trade.py index 36a11631..385906dd 100644 --- a/kraken/spot/trade/__init__.py +++ b/kraken/spot/trade.py @@ -11,8 +11,8 @@ from math import floor from typing import List, Optional, Union -from ...base_api import KrakenBaseSpotAPI, defined, ensure_string -from ...spot import Market +from ..base_api import KrakenBaseSpotAPI, defined, ensure_string +from .market import Market class Trade(KrakenBaseSpotAPI): @@ -133,7 +133,7 @@ def create_order( :param timeinforce: how long the order remains in the orderbook, one of: ``GTC``, ``IOC``, ``GTD`` (see the referenced Kraken documentation for more information) :type timeinforce: str, optional - :param displayvol: Define how much of the volume is visible in the order book (iceberg) + :param displayvol: Define how much of the volume is visible in the orderbook (iceberg) :type displayvol: str | int | float, optional :param starttim: Unix timestamp or seconds defining the start time (default: ``"0"``) :type starttim: str, optional @@ -682,6 +682,38 @@ def truncate( :raises ValueError: If no valid ``amount_type`` was passed. :return: A string representation of the amount. :rtype: str + + .. code-block:: python + :linenos: + :caption: Spot Trade: Truncate + + >>> print(trade.truncate( + ... amount=0.123456789, + ... amount_type="volume", + ... pair="XBTUSD" + ... )) + 0.12345678 + + >>> print(trade.truncate( + ... amount=21123.12849829993, + ... amount_type="price", + ... pair="XBTUSD") + ... )) + 21123.1 + + >>> print(trade.truncate( + ... amount=0.1, + ... amount_type="volume", + ... pair="XBTUSD" + ... )) + 0.10000000 + + >>> print(trade.truncate( + ... amount=21123, + ... amount_type="price", + ... pair="XBTUSD" + ... )) + 21123.0 """ if amount_type not in ("price", "volume"): raise ValueError("Amount type must be 'volume' or 'price'!") diff --git a/kraken/spot/user/__init__.py b/kraken/spot/user.py similarity index 99% rename from kraken/spot/user/__init__.py rename to kraken/spot/user.py index f9768f9f..d9163960 100644 --- a/kraken/spot/user/__init__.py +++ b/kraken/spot/user.py @@ -8,7 +8,7 @@ from decimal import Decimal from typing import List, Optional, Union -from ...base_api import KrakenBaseSpotAPI, defined, ensure_string +from ..base_api import KrakenBaseSpotAPI, defined, ensure_string class User(KrakenBaseSpotAPI): @@ -767,7 +767,7 @@ def get_ledgers( def get_trade_volume( self: "User", pair: Optional[Union[str, List[str]]] = None, - fee_info: Optional[bool] = True, + fee_info: bool = True, ) -> dict: """ Get the 30-day user specific trading volume in USD. diff --git a/kraken/spot/websocket/__init__.py b/kraken/spot/websocket/__init__.py index c0147f23..dd58b0b3 100644 --- a/kraken/spot/websocket/__init__.py +++ b/kraken/spot/websocket/__init__.py @@ -15,12 +15,14 @@ from copy import deepcopy from random import random from time import time -from typing import Any, Callable, List, Optional, Union +from typing import TYPE_CHECKING, Any, List, Optional, Union import websockets -from ...exceptions import KrakenException -from ...spot.ws_client import SpotWsClientCl +from kraken.exceptions import KrakenException + +if TYPE_CHECKING: + from kraken.spot import KrakenSpotWSClient class ConnectSpotWebsocket: @@ -39,7 +41,8 @@ class ConnectSpotWebsocket: :type private: bool, optional """ - MAX_RECONNECT_NUM: int = 10 + MAX_RECONNECT_NUM: int = 7 + LOG: logging.Logger = logging.getLogger(__name__) def __init__( self: "ConnectSpotWebsocket", @@ -61,7 +64,7 @@ def __init__( self.__socket: Optional[Any] = None self.__subscriptions: List[dict] = [] - asyncio.ensure_future(self.__run_forever(), loop=asyncio.get_running_loop()) + self.task: asyncio.Task = asyncio.create_task(self.__run_forever()) @property def subscriptions(self: "ConnectSpotWebsocket") -> list: @@ -79,12 +82,12 @@ async def __run(self: "ConnectSpotWebsocket", event: asyncio.Event) -> None: self.__ws_conn_details = ( None if not self.__is_auth else self.__client.get_ws_token() ) - logging.debug(f"Websocket token: {self.__ws_conn_details}") + self.LOG.debug(f"Websocket token: {self.__ws_conn_details}") async with websockets.connect( # pylint: disable=no-member f"wss://{self.__ws_endpoint}", ping_interval=30 ) as socket: - logging.info("Websocket connected!") + self.LOG.info("Websocket connected!") self.__socket = socket if not event.is_set(): @@ -100,14 +103,14 @@ async def __run(self: "ConnectSpotWebsocket", event: asyncio.Event) -> None: except asyncio.TimeoutError: # important await self.send_ping() except asyncio.CancelledError: - logging.exception("asyncio.CancelledError") + self.LOG.exception("asyncio.CancelledError") keep_alive = False await self.__callback({"error": "asyncio.CancelledError"}) else: try: msg: dict = json.loads(_msg) except ValueError: - logging.warning(_msg) + self.LOG.warning(_msg) else: if "event" in msg: if msg["event"] == "subscriptionStatus" and "status" in msg: @@ -119,12 +122,20 @@ async def __run(self: "ConnectSpotWebsocket", event: asyncio.Event) -> None: elif msg["status"] == "unsubscribed": self.__remove_subscription(msg) elif msg["status"] == "error": - logging.warning(msg) + self.LOG.warning(msg) except AttributeError: pass + await self.__callback(msg) async def __run_forever(self: "ConnectSpotWebsocket") -> None: + """ + This function ensures the reconnects. + + todo: This is stupid. There must be a better way for passing + the raised exception to the client class - not + through this ``exception_occur`` flag + """ try: while True: await self.__reconnect() @@ -133,35 +144,38 @@ async def __run_forever(self: "ConnectSpotWebsocket") -> None: {"error": "kraken.exceptions.KrakenException.MaxReconnectError"} ) except Exception as exc: - logging.error(f"{exc}: {traceback.format_exc()}") + traceback_: str = traceback.format_exc() + logging.error(f"{exc}: {traceback_}") + await self.__callback({"error": traceback_}) finally: + await self.__callback({"error": "KrakenSpotWSClient: exception_occur"}) self.__client.exception_occur = True async def __reconnect(self: "ConnectSpotWebsocket") -> None: - logging.info("Websocket start connect/reconnect") + self.LOG.info("Websocket start connect/reconnect") self.__reconnect_num += 1 if self.__reconnect_num >= self.MAX_RECONNECT_NUM: + self.LOG.error( + "The KrakenSpotWebsocketClient encountered to many reconnects!" + ) raise KrakenException.MaxReconnectError() reconnect_wait: float = self.__get_reconnect_wait(self.__reconnect_num) - logging.debug( - f"asyncio sleep reconnect_wait={reconnect_wait} s reconnect_num={self.__reconnect_num}" + self.LOG.debug( + "asyncio sleep reconnect_wait={reconnect_wait} s reconnect_num={self.__reconnect_num}" ) await asyncio.sleep(reconnect_wait) - logging.debug("asyncio sleep done") - event: asyncio.Event = asyncio.Event() - tasks: dict = { - asyncio.ensure_future( - self.__recover_subscriptions(event) - ): self.__recover_subscriptions, - asyncio.ensure_future(self.__run(event)): self.__run, - } + event: asyncio.Event = asyncio.Event() + tasks: List[asyncio.Task] = [ + asyncio.create_task(self.__recover_subscriptions(event)), + asyncio.create_task(self.__run(event)), + ] - while set(tasks.keys()): + while True: finished, pending = await asyncio.wait( - tasks.keys(), return_when=asyncio.FIRST_EXCEPTION + tasks, return_when=asyncio.FIRST_EXCEPTION ) exception_occur: bool = False for task in finished: @@ -169,23 +183,22 @@ async def __reconnect(self: "ConnectSpotWebsocket") -> None: exception_occur = True traceback.print_stack() message: str = f"{task} got an exception {task.exception()}\n {task.get_stack()}" - logging.warning(message) + self.LOG.warning(message) for process in pending: - logging.warning(f"pending {process}") + self.LOG.warning(f"pending {process}") try: process.cancel() except asyncio.CancelledError: - logging.exception("asyncio.CancelledError") - logging.warning("Cancel OK") + self.LOG.exception("asyncio.CancelledError") await self.__callback({"error": message}) if exception_occur: break - logging.warning("reconnect over") + self.LOG.warning("reconnect over") async def __recover_subscriptions( self: "ConnectSpotWebsocket", event: asyncio.Event ) -> None: - logging.info( + self.LOG.info( f'Recover {"auth" if self.__is_auth else "public"} subscriptions {self.__subscriptions} waiting.' ) await event.wait() @@ -201,9 +214,9 @@ async def __recover_subscriptions( cpy["subscription"]["token"] = self.__ws_conn_details["token"] private = True await self.send_message(cpy, private=private) - logging.info(f"{sub} OK") + self.LOG.info(f"{sub} OK") - logging.info( + self.LOG.info( f'Recovering {"auth" if self.__is_auth else "public"} subscriptions {self.__subscriptions} done.' ) @@ -292,344 +305,10 @@ def __build_subscription(self: "ConnectSpotWebsocket", msg: dict) -> dict: ): # private endpoint sub["subscription"] = {"name": msg["subscription"]["name"]} else: - logging.warning( + self.LOG.warning( "Feed not implemented. Please contact the python-kraken-sdk package author." ) return sub def __get_reconnect_wait(self, attempts: int) -> Union[float, Any]: return round(random() * min(60 * 3, (2**attempts) - 1) + 1) - - -class KrakenSpotWSClient(SpotWsClientCl): - """ - Class to access public and (optional) - private/authenticated websocket connection. - - - https://docs.kraken.com/websockets/#overview - - This class holds up to two websocket connections, one private - and one public. - - When accessing private endpoints that need authentication make sure, - that the ``Access WebSockets API`` API key permission is set in the user's - account. - - :param key: API Key for the Kraken Spot API (default: ``""``) - :type key: str, optional - :param secret: Secret API Key for the Kraken Spot API (default: ``""``) - :type secret: str, optional - :param url: Set a specific/custom url to access the Kraken API - :type url: str, optional - :param beta: Use the beta websocket channels (maybe not supported anymore, default: ``False``) - :type beta: bool - - .. code-block:: python - :linenos: - :caption: HowTo: Create a Bot and integrate the python-kraken-sdk Spot Websocket Client - - import asyncio - from kraken.spot import KrakenSpotWSClient - - async def main() -> None: - class Bot(KrakenSpotWSClient): - - async def on_message(self, event: dict) -> None: - print(event) - - bot = Bot() # unauthenticated - auth_bot = Bot( # authenticated - key='kraken-api-key', - secret='kraken-secret-key' - ) - - # subscribe to the desired feeds: - await bot.subscribe( - subscription={"name": ticker}, - pair=["XBTUSD", "DOT/EUR"] - ) - # from now on the on_message function receives the ticker feed - - while True: - await asyncio.sleep(6) - - if __name__ == '__main__': - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - asyncio.run(main()) - except KeyboardInterrupt: - loop.close() - - .. code-block:: python - :linenos: - :caption: HowTo: Use the websocket client as context manager - - import asyncio - from kraken.spot import KrakenSpotWSClient - - async def on_message(msg): - print(msg) - - async def main() -> None: - async with KrakenSpotWSClient( - key="api-key", - secret="secret-key", - callback=on_message - ) as session: - await session.subscribe( - subscription={"name": "ticker"}, - pair=["XBT/USD"] - ) - - while True: - await asyncio.sleep(6) - - - if __name__ == "__main__": - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - asyncio.run(main()) - except KeyboardInterrupt: - pass - finally: - loop.close() - """ - - PROD_ENV_URL: str = "ws.kraken.com" - AUTH_PROD_ENV_URL: str = "ws-auth.kraken.com" - BETA_ENV_URL: str = "beta-ws.kraken.com" - AUTH_BETA_ENV_URL: str = "beta-ws-auth.kraken.com" - - def __init__( - self: "KrakenSpotWSClient", - key: str = "", - secret: str = "", - url: str = "", - callback: Optional[Callable] = None, - beta: bool = False, - ): - super().__init__(key=key, secret=secret, url=url, sandbox=beta) - self.__callback: Any = callback - self.__is_auth: bool = bool(key and secret) - self.exception_occur: bool = False - self._pub_conn: ConnectSpotWebsocket = ConnectSpotWebsocket( - client=self, - endpoint=self.PROD_ENV_URL if not beta else self.BETA_ENV_URL, - is_auth=False, - callback=self.on_message, - ) - - self._priv_conn: Optional[ConnectSpotWebsocket] = ( - ConnectSpotWebsocket( - client=self, - endpoint=self.AUTH_PROD_ENV_URL if not beta else self.AUTH_BETA_ENV_URL, - is_auth=True, - callback=self.on_message, - ) - if self.__is_auth - else None - ) - - async def on_message(self: "KrakenSpotWSClient", msg: dict) -> None: - """ - Calls the defined callback function (if defined) - or overload this function. - - Can be overloaded as described in :class:`kraken.spot.KrakenSpotWSClient` - - :param msg: The message received sent by Kraken via the websocket connection - :type msg: dict - """ - if self.__callback is not None: - await self.__callback(msg) - else: - logging.warning("Received event but no callback is defined.") - print(msg) - - async def subscribe( - self: "KrakenSpotWSClient", subscription: dict, pair: List[str] = None - ) -> None: - """ - Subscribe to a channel - - Success or failures are sent over the websocket connection and can be - received via the on_message callback function. - - When accessing private endpoints and subscription feeds that need authentication - make sure, that the ``Access WebSockets API`` API key permission is set - in the users Kraken account. - - - https://docs.kraken.com/websockets-beta/#message-subscribe - - :param subscription: The subscription message - :type subscription: dict - :param pair: The pair to subscribe to - :type pair: List[str] | None, optional - - Initialize your client as described in :class:`kraken.spot.KrakenSpotWSClient` to - run the following example: - - .. code-block:: python - :linenos: - :caption: Spot Websocket: Subscribe to a websocket feed - - >>> await bot.subscribe( - ... subscription={"name": ticker}, - ... pair=["XBTUSD", "DOT/EUR"] - ... ) - """ - - if "name" not in subscription: - raise AttributeError('Subscription requires a "name" key."') - private: bool = bool(subscription["name"] in self.private_sub_names) - - payload: dict = {"event": "subscribe", "subscription": subscription} - if pair is not None: - if not isinstance(pair, list): - raise ValueError( - 'Parameter pair must be type of List[str] (e.g. pair=["XBTUSD"])' - ) - payload["pair"] = pair - - if private: # private == without pair - if not self.__is_auth: - raise ValueError( - "Cannot subscribe to private feeds without valid credentials!" - ) - if pair is not None: - raise ValueError( - "Cannot subscribe to private endpoint with specific pair!" - ) - await self._priv_conn.send_message(payload, private=True) - - elif pair is not None: # public with pair - for symbol in pair: - sub = deepcopy(payload) - sub["pair"] = [symbol] - await self._pub_conn.send_message(sub, private=False) - - else: - await self._pub_conn.send_message(payload, private=False) - - async def unsubscribe( - self: "KrakenSpotWSClient", subscription: dict, pair: Optional[List[str]] = None - ) -> None: - """ - Unsubscribe from a topic - - Success or failures are sent over the websocket connection and can be - received via the on_message callback function. - - When accessing private endpoints and subscription feeds that need authentication - make sure, that the ``Access WebSockets API`` API key permission is set - in the users Kraken account. - - - https://docs.kraken.com/websockets/#message-unsubscribe - - :param subscription: The subscription to unsubscribe from - :type subscription: dict - :param pair: The pair or list of pairs to unsubscribe - :type pair: List[str], optional - - Initialize your client as described in :class:`kraken.spot.KrakenSpotWSClient` to - run the following example: - - .. code-block:: python - :linenos: - :caption: Spot Websocket: Unsubscribe from a websocket feed - - >>> await bot.unsubscribe( - ... subscription={"name": ticker}, - ... pair=["XBTUSD", "DOT/EUR"] - ... ) - """ - if "name" not in subscription: - raise AttributeError('Subscription requires a "name" key."') - private: bool = bool(subscription["name"] in self.private_sub_names) - - payload: dict = {"event": "unsubscribe", "subscription": subscription} - if pair is not None: - if not isinstance(pair, list): - raise ValueError( - 'Parameter pair must be type of List[str] (e.g. pair=["XBTUSD"])' - ) - payload["pair"] = pair - - if private: # private == without pair - if not self.__is_auth: - raise ValueError( - "Cannot unsubscribe from private feeds without valid credentials!" - ) - if pair is not None: - raise ValueError( - "Cannot unsubscribe from private endpoint with specific pair!" - ) - await self._priv_conn.send_message(payload, private=True) - - elif pair is not None: # public with pair - for symbol in pair: - sub = deepcopy(payload) - sub["pair"] = [symbol] - await self._pub_conn.send_message(sub, private=False) - - else: - await self._pub_conn.send_message(payload, private=False) - - @property - def private_sub_names(self: "KrakenSpotWSClient") -> List[str]: - """ - Returns the private subscription names - - :return: List of private subscription names (``ownTrades``, ``openOrders``) - :rtype: List[str] - """ - return ["ownTrades", "openOrders"] - - @property - def public_sub_names(self: "KrakenSpotWSClient") -> List[str]: - """ - Returns the public subscription names - - :return: List of public subscription names (``ticker``, - ``spread``, ``book``, ``ohlc``, ``trade``, ``*``) - :rtype: List[str] - """ - return ["ticker", "spread", "book", "ohlc", "trade", "*"] - - @property - def active_public_subscriptions( - self: "KrakenSpotWSClient", - ) -> Union[List[dict], Any]: - """ - Returns the active public subscriptions - - :return: List of active public subscriptions - :rtype: Union[List[dict], Any] - :raises ConnectionError: If there is no public connection. - """ - if self._pub_conn is not None: - return self._pub_conn.subscriptions - raise ConnectionError("Public connection does not exist!") - - @property - def active_private_subscriptions( - self: "KrakenSpotWSClient", - ) -> Union[List[dict], Any]: - """ - Returns the active private subscriptions - - :return: List of active private subscriptions - :rtype: Union[List[dict], Any] - :raises ConnectionError: If there is no private connection - """ - if self._priv_conn is not None: - return self._priv_conn.subscriptions - raise ConnectionError("Private connection does not exist!") - - async def __aenter__(self: "KrakenSpotWSClient") -> "KrakenSpotWSClient": - return self - - async def __aexit__(self, *exc: tuple, **kwargs: dict) -> None: - pass diff --git a/kraken/spot/ws_client/__init__.py b/kraken/spot/ws_client.py similarity index 54% rename from kraken/spot/ws_client/__init__.py rename to kraken/spot/ws_client.py index 7584b370..a516cec4 100644 --- a/kraken/spot/ws_client/__init__.py +++ b/kraken/spot/ws_client.py @@ -4,51 +4,350 @@ # GitHub: https://github.com/btschwertfeger # -"""Module that implements the Spot Kraken Websocket client""" +"""This module provides the Spot websocket client. """ from __future__ import annotations import logging -from typing import TYPE_CHECKING, List, Optional, Union +from copy import deepcopy +from typing import Any, Callable, List, Optional, Union -from ...base_api import KrakenBaseSpotAPI, defined, ensure_string -from ..trade import Trade +from ..base_api import KrakenBaseSpotAPI, defined, ensure_string +from .trade import Trade +from .websocket import ConnectSpotWebsocket -if TYPE_CHECKING: - # to avaoid circular import for type checking - from ...spot.websocket import ConnectSpotWebsocket - -class SpotWsClientCl(KrakenBaseSpotAPI): +class KrakenSpotWSClient(KrakenBaseSpotAPI): """ - Class that implements the Spot Kraken Websocket client + Class to access public and (optional) + private/authenticated websocket connection. + + - https://docs.kraken.com/websockets/#overview + + This class holds up to two websocket connections, one private + and one public. + + When accessing private endpoints that need authentication make sure, + that the ``Access WebSockets API`` API key permission is set in the user's + account. - :param key: Spot API public key (default: ``""``) + :param key: API Key for the Kraken Spot API (default: ``""``) :type key: str, optional - :param secret: Spot API secret key (default: ``""``) + :param secret: Secret API Key for the Kraken Spot API (default: ``""``) :type secret: str, optional - :param url: url to access the Kraken API (default: "https://api.kraken.com") + :param url: Set a specific/custom url to access the Kraken API :type url: str, optional - :param sandbox: Use the sandbox (not supported for Spot trading so far, default: ``False``) - :type sandbox: bool, optional + :param beta: Use the beta websocket channels (maybe not supported anymore, default: ``False``) + :type beta: bool + + .. code-block:: python + :linenos: + :caption: HowTo: Create a Bot and integrate the python-kraken-sdk Spot Websocket Client + + import asyncio + from kraken.spot import KrakenSpotWSClient + + async def main() -> None: + class Bot(KrakenSpotWSClient): + + async def on_message(self, event: dict) -> None: + print(event) + + bot = Bot() # unauthenticated + auth_bot = Bot( # authenticated + key='kraken-api-key', + secret='kraken-secret-key' + ) + + # subscribe to the desired feeds: + await bot.subscribe( + subscription={"name": ticker}, + pair=["XBTUSD", "DOT/EUR"] + ) + # from now on the on_message function receives the ticker feed + + while not bot.exception_occur: + await asyncio.sleep(6) + + if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass + + .. code-block:: python + :linenos: + :caption: HowTo: Use the websocket client as context manager + + import asyncio + from kraken.spot import KrakenSpotWSClient + + async def on_message(msg): + print(msg) + + async def main() -> None: + async with KrakenSpotWSClient( + key="api-key", + secret="secret-key", + callback=on_message + ) as session: + await session.subscribe( + subscription={"name": "ticker"}, + pair=["XBT/USD"] + ) - This is just the class in which the Spot websocket methods are defined. It is derived - in :func:`kraken.spot.KrakenSpotWSClient`. + while not bot.exception_occur:: + await asyncio.sleep(6) + + + if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass """ + LOG: logging.Logger = logging.getLogger(__name__) + + PROD_ENV_URL: str = "ws.kraken.com" + AUTH_PROD_ENV_URL: str = "ws-auth.kraken.com" + BETA_ENV_URL: str = "beta-ws.kraken.com" + AUTH_BETA_ENV_URL: str = "beta-ws-auth.kraken.com" + def __init__( - self: "SpotWsClientCl", + self: "KrakenSpotWSClient", key: str = "", secret: str = "", url: str = "", - sandbox: bool = False, + callback: Optional[Callable] = None, + beta: bool = False, ): - super().__init__(key=key, secret=secret, url=url, sandbox=sandbox) + super().__init__(key=key, secret=secret, url=url, sandbox=beta) + self.__callback: Any = callback + self.__is_auth: bool = bool(key and secret) + self.exception_occur: bool = False + + self._pub_conn: ConnectSpotWebsocket = ConnectSpotWebsocket( + client=self, + endpoint=self.PROD_ENV_URL if not beta else self.BETA_ENV_URL, + is_auth=False, + callback=self.on_message, + ) + + self._priv_conn: Optional[ConnectSpotWebsocket] = ( + ConnectSpotWebsocket( + client=self, + endpoint=self.AUTH_PROD_ENV_URL if not beta else self.AUTH_BETA_ENV_URL, + is_auth=True, + callback=self.on_message, + ) + if self.__is_auth + else None + ) + + async def on_message(self: "KrakenSpotWSClient", msg: Union[dict, list]) -> None: + """ + Calls the defined callback function (if defined) + or overload this function. - self._pub_conn: Optional[ConnectSpotWebsocket] = None - self._priv_conn: Optional[ConnectSpotWebsocket] = None + Can be overloaded as described in :class:`kraken.spot.KrakenSpotWSClient` - def get_ws_token(self: "SpotWsClientCl") -> dict: + :param msg: The message received sent by Kraken via the websocket connection + :type msg: dict | list + """ + if self.__callback is not None: + await self.__callback(msg) + else: + self.LOG.warning("Received event but no callback is defined.") + print(msg) + + async def subscribe( + self: "KrakenSpotWSClient", subscription: dict, pair: List[str] = None + ) -> None: + """ + Subscribe to a channel + + Success or failures are sent over the websocket connection and can be + received via the on_message callback function. + + When accessing private endpoints and subscription feeds that need authentication + make sure, that the ``Access WebSockets API`` API key permission is set + in the users Kraken account. + + - https://docs.kraken.com/websockets/#message-subscribe + + :param subscription: The subscription message + :type subscription: dict + :param pair: The pair to subscribe to + :type pair: List[str] | None, optional + + Initialize your client as described in :class:`kraken.spot.KrakenSpotWSClient` to + run the following example: + + .. code-block:: python + :linenos: + :caption: Spot Websocket: Subscribe to a websocket feed + + >>> await bot.subscribe( + ... subscription={"name": ticker}, + ... pair=["XBTUSD", "DOT/EUR"] + ... ) + """ + + if "name" not in subscription: + raise AttributeError('Subscription requires a "name" key."') + private: bool = bool(subscription["name"] in self.private_sub_names) + + payload: dict = {"event": "subscribe", "subscription": subscription} + if pair is not None: + if not isinstance(pair, list): + raise ValueError( + 'Parameter pair must be type of List[str] (e.g. pair=["XBTUSD"])' + ) + payload["pair"] = pair + + if private: # private == without pair + if not self.__is_auth: + raise ValueError( + "Cannot subscribe to private feeds without valid credentials!" + ) + if pair is not None: + raise ValueError( + "Cannot subscribe to private endpoint with specific pair!" + ) + await self._priv_conn.send_message(payload, private=True) + + elif pair is not None: # public with pair + for symbol in pair: + sub = deepcopy(payload) + sub["pair"] = [symbol] + await self._pub_conn.send_message(sub, private=False) + + else: + await self._pub_conn.send_message(payload, private=False) + + async def unsubscribe( + self: "KrakenSpotWSClient", subscription: dict, pair: Optional[List[str]] = None + ) -> None: + """ + Unsubscribe from a topic + + Success or failures are sent over the websocket connection and can be + received via the on_message callback function. + + When accessing private endpoints and subscription feeds that need authentication + make sure, that the ``Access WebSockets API`` API key permission is set + in the users Kraken account. + + - https://docs.kraken.com/websockets/#message-unsubscribe + + :param subscription: The subscription to unsubscribe from + :type subscription: dict + :param pair: The pair or list of pairs to unsubscribe + :type pair: List[str], optional + + Initialize your client as described in :class:`kraken.spot.KrakenSpotWSClient` to + run the following example: + + .. code-block:: python + :linenos: + :caption: Spot Websocket: Unsubscribe from a websocket feed + + >>> await bot.unsubscribe( + ... subscription={"name": ticker}, + ... pair=["XBTUSD", "DOT/EUR"] + ... ) + """ + if "name" not in subscription: + raise AttributeError('Subscription requires a "name" key."') + private: bool = bool(subscription["name"] in self.private_sub_names) + + payload: dict = {"event": "unsubscribe", "subscription": subscription} + if pair is not None: + if not isinstance(pair, list): + raise ValueError( + 'Parameter pair must be type of List[str] (e.g. pair=["XBTUSD"])' + ) + payload["pair"] = pair + + if private: # private == without pair + if not self.__is_auth: + raise ValueError( + "Cannot unsubscribe from private feeds without valid credentials!" + ) + if pair is not None: + raise ValueError( + "Cannot unsubscribe from private endpoint with specific pair!" + ) + await self._priv_conn.send_message(payload, private=True) + + elif pair is not None: # public with pair + for symbol in pair: + sub = deepcopy(payload) + sub["pair"] = [symbol] + await self._pub_conn.send_message(sub, private=False) + + else: + await self._pub_conn.send_message(payload, private=False) + + @property + def private_sub_names(self: "KrakenSpotWSClient") -> List[str]: + """ + Returns the private subscription names + + :return: List of private subscription names (``ownTrades``, ``openOrders``) + :rtype: List[str] + """ + return ["ownTrades", "openOrders"] + + @property + def public_sub_names(self: "KrakenSpotWSClient") -> List[str]: + """ + Returns the public subscription names + + :return: List of public subscription names (``ticker``, + ``spread``, ``book``, ``ohlc``, ``trade``, ``*``) + :rtype: List[str] + """ + return ["ticker", "spread", "book", "ohlc", "trade", "*"] + + @property + def active_public_subscriptions( + self: "KrakenSpotWSClient", + ) -> Union[List[dict], Any]: + """ + Returns the active public subscriptions + + :return: List of active public subscriptions + :rtype: Union[List[dict], Any] + :raises ConnectionError: If there is no public connection. + """ + if self._pub_conn is not None: + return self._pub_conn.subscriptions + raise ConnectionError("Public connection does not exist!") + + @property + def active_private_subscriptions( + self: "KrakenSpotWSClient", + ) -> Union[List[dict], Any]: + """ + Returns the active private subscriptions + + :return: List of active private subscriptions + :rtype: Union[List[dict], Any] + :raises ConnectionError: If there is no private connection + """ + if self._priv_conn is not None: + return self._priv_conn.subscriptions + raise ConnectionError("Private connection does not exist!") + + async def __aenter__(self: "KrakenSpotWSClient") -> "KrakenSpotWSClient": + return self + + async def __aexit__(self, *exc: tuple, **kwargs: dict) -> None: + pass + + def get_ws_token(self: "KrakenSpotWSClient") -> dict: """ Get the authentication token to establish the authenticated websocket connection. @@ -64,7 +363,7 @@ def get_ws_token(self: "SpotWsClientCl") -> dict: @ensure_string("oflags") async def create_order( - self: "SpotWsClientCl", + self: "KrakenSpotWSClient", ordertype: str, side: str, pair: str, @@ -218,7 +517,7 @@ async def create_order( @ensure_string("oflags") async def edit_order( - self: "SpotWsClientCl", + self: "KrakenSpotWSClient", orderid: str, reqid: Optional[Union[str, int]] = None, pair: Optional[str] = None, @@ -312,8 +611,7 @@ async def edit_order( await self._priv_conn.send_message(msg=payload, private=True) - @ensure_string("txid") - async def cancel_order(self: "SpotWsClientCl", txid: Union[str, List[str]]) -> None: + async def cancel_order(self: "KrakenSpotWSClient", txid: List[str]) -> None: """ Cancel a specific order or a list of orders. @@ -321,8 +619,8 @@ async def cancel_order(self: "SpotWsClientCl", txid: Union[str, List[str]]) -> N - https://docs.kraken.com/websockets/#message-cancelOrder - :param txid: Transaction id or list of txids or comma delimited list as string - :type txid: str | List[str] + :param txid: A single or multiple transaction ids as list + :type txid: List[str] :raises ValueError: If the websocket is not connected or the connection is not authenticated :return: None @@ -333,7 +631,7 @@ async def cancel_order(self: "SpotWsClientCl", txid: Union[str, List[str]]) -> N :linenos: :caption: Spot Websocket: Cancel an order - >>> await auth_bot.cancel_order(txid="OBGFYP-XVQNL-P4GMWF") + >>> await auth_bot.cancel_order(txid=["OBGFYP-XVQNL-P4GMWF"]) """ if not self._priv_conn: logging.warning("Websocket not connected!") @@ -344,7 +642,7 @@ async def cancel_order(self: "SpotWsClientCl", txid: Union[str, List[str]]) -> N msg={"event": "cancelOrder", "txid": txid}, private=True ) - async def cancel_all_orders(self: "SpotWsClientCl") -> None: + async def cancel_all_orders(self: "KrakenSpotWSClient") -> None: """ Cancel all open Spot orders. @@ -372,7 +670,9 @@ async def cancel_all_orders(self: "SpotWsClientCl") -> None: raise ValueError("Cannot use cancel_all_orders on public websocket client!") await self._priv_conn.send_message(msg={"event": "cancelAll"}, private=True) - async def cancel_all_orders_after(self: "SpotWsClientCl", timeout: int = 0) -> None: + async def cancel_all_orders_after( + self: "KrakenSpotWSClient", timeout: int = 0 + ) -> None: """ Set a Death Man's Switch diff --git a/pyproject.toml b/pyproject.toml index c541fcdc..ceb795f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "python-kraken-sdk" dynamic = ["version"] authors = [ - { name="Benjamin Thomas Schwertfeger", email="contact@b-schwertfeger.de" }, + {name = "Benjamin Thomas Schwertfeger", email = "contact@b-schwertfeger.de"}, ] description = "Collection of REST and websocket clients to interact with the Kraken cryptocurrency exchange." readme = "README.md" @@ -81,7 +81,7 @@ testpaths = ["tests"] [tool.pytest.ini_options] markers = [ - "selection: Used to run a specific test by hand.", + "select: Used to run a specific test by hand.", "spot: mark a test that tests a Spot endpoint.", "spot_auth: mark a test that tests a authenticaed Spot endpoint.", "spot_trade: mark a test that tests a Spot Trade endpoint.", @@ -89,7 +89,8 @@ markers = [ "spot_market: mark a test that tests a Spot Market endpoint.", "spot_funding: mark a test that tests a Spot Funding endpoint.", "spot_staking: mark a test that tests a Spot Staking endpoint.", - "spot_websocket: mark a test that tests a Spot Websocket endpoint.", + "spot_websocket: mark a test that tests the Spot Websocket client.", + "spot_orderbook: mark a test that tests the Spot Orderbook client.", "futures: mark a test that tests a Futures endpoint.", "futures_auth: mark a test that tests a authenticated Futures endpoint.", "futures_market: mark a test that tests a Futures Market endpoint.", diff --git a/tests/futures/conftest.py b/tests/futures/conftest.py index 84a8ee44..cba66094 100644 --- a/tests/futures/conftest.py +++ b/tests/futures/conftest.py @@ -8,7 +8,19 @@ import pytest -from kraken.futures import Funding, Market, Trade, User +from kraken.futures import Funding, KrakenFuturesWSClient, Market, Trade, User + + +@pytest.fixture +def futures_api_key() -> str: + """Returns the Futures API key""" + return os.getenv("FUTURES_API_KEY") + + +@pytest.fixture +def futures_secret_key() -> str: + """Returns the Futures API secret key""" + return os.getenv("FUTURES_SECRET_KEY") @pytest.fixture diff --git a/tests/futures/helper.py b/tests/futures/helper.py index 51b34b60..aaa9e499 100644 --- a/tests/futures/helper.py +++ b/tests/futures/helper.py @@ -4,7 +4,12 @@ # GitHub: https://github.com/btschwertfeger # -from typing import Any +import logging +from asyncio import sleep +from time import time +from typing import Any, Union + +from kraken.futures import KrakenFuturesWSClient def is_success(value: Any) -> bool: @@ -19,3 +24,47 @@ def is_success(value: Any) -> bool: def is_not_error(value: Any) -> bool: """Returns true if result is not error""" return isinstance(value, dict) and "error" not in value.keys() + + +async def async_wait(seconds: float = 1.0) -> None: + """Function that realizes the wait for ``seconds``.""" + start: float = time() + while time() - seconds < start: + await sleep(0.2) + return + + +class FuturesWebsocketClientTestWrapper(KrakenFuturesWSClient): + """ + Class that creates an instance to test the KrakenFuturesWSClient. + + It writes the messages to the log and a file. The log is used + within the tests, the log file is for local debugging. + """ + + LOG: logging.Logger = logging.getLogger(__name__) + + def __init__( + self: "FuturesWebsocketClientTestWrapper", key: str = "", secret: str = "" + ) -> None: + super().__init__(key=key, secret=secret, callback=self.on_message) + self.LOG.setLevel(logging.INFO) + + async def on_message( + self: "FuturesWebsocketClientTestWrapper", msg: Union[list, dict] + ) -> None: + """ + This is the callback function that must be implemented + to handle custom websocket messages. + """ + self.LOG.info(msg) # the log is read within the tests + + log: str = "" + try: + with open("futures_ws.log", "r", encoding="utf-8") as logfile: + log = logfile.read() + except FileNotFoundError: + pass + + with open("futures_ws.log", "w", encoding="utf-8") as logfile: + logfile.write(f"{log}\n{msg}") diff --git a/tests/futures/test_futures_websocket.py b/tests/futures/test_futures_websocket.py index 10e005fd..c5e19089 100644 --- a/tests/futures/test_futures_websocket.py +++ b/tests/futures/test_futures_websocket.py @@ -9,267 +9,258 @@ from __future__ import annotations import asyncio -import os -import time -import unittest -from typing import Any, List, Union +from typing import Any, List import pytest -from kraken.futures import KrakenFuturesWSClient - - -class Bot(KrakenFuturesWSClient): - """Class to create a websocket bot""" - - async def on_message(self: "Bot", event: Union[list, dict]) -> None: - # The following comments are only for debugging. - # log = "" - # try: - # with open("futures_ws_log.log", "r", encoding="utf-8") as f: - # log = f.read() - # except FileNotFoundError: - # pass - - # with open("futures_ws_log.log", "w", encoding="utf-8") as f: - # f.write(f"{log}\n{event}") - pass - - -class WebsocketTests(unittest.TestCase): - def setUp(self: "WebsocketTests") -> None: - self.__key: str = os.getenv("FUTURES_API_KEY") - self.__secret: str = os.getenv("FUTURES_SECRET_KEY") - self.__full_ws_access: str = os.getenv("FULLACCESS") is not None - - def __create_loop(self: "WebsocketTests", coro: Any) -> None: - """Function that creates an event loop.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - asyncio.run(coro()) - loop.close() - - async def __wait(self: "WebsocketTests", seconds: float = 1.0) -> None: - """Function that realizes the wait for ``seconds``.""" - start: int = time.time() - while time.time() - seconds < start: - await asyncio.sleep(0.2) - return - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_create_public_bot(self: "WebsocketTests") -> None: - """ - Checks if the unauthenticated websocket client - can be instantiated. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - await self.__wait(1.5) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_auth - @pytest.mark.futures_websocket - def test_create_private_bot(self: "WebsocketTests") -> None: - """ - Checks if the authenticated websocket client - can be instantiated. - """ - - async def create_bot() -> None: - Bot(key=self.__key, secret=self.__secret) - await self.__wait(1.5) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_get_subscriptions(self: "WebsocketTests") -> None: - """ - Checks the ``get_subscriptions`` function. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - bot.get_active_subscriptions() - await self.__wait(1.5) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_get_available_public_subscriptions(self) -> None: - """ - Checks the ``get_available_public_subscriptions`` function. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - assert bot.get_available_public_subscription_feeds() == [ - "trade", - "book", - "ticker", - "ticker_lite", - "heartbeat", - ] - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_get_available_private_subscriptions(self: "WebsocketTests") -> None: - """ - Checks the ``get_available_private_subscriptions`` function. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - assert bot.get_available_private_subscription_feeds() == [ - "fills", - "open_positions", - "open_orders", - "open_orders_verbose", - "balances", - "deposits_withdrawals", - "account_balances_and_margins", - "account_log", - "notifications_auth", - ] - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_auth - @pytest.mark.futures_websocket - def test_get_auth_state(self: "WebsocketTests") -> None: - """ - Checks if the ``is_auth`` attribute is set correctly. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - assert not bot.is_auth - - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - assert auth_bot.is_auth - - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_subscribe_public(self: "WebsocketTests") -> None: - """ - Checks if the unauthenticated websocket client is able to subscribe - to public feeds. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - products = ["PI_XBTUSD", "PF_SOLUSD"] - - with pytest.raises(ValueError): # products must be List[str] - await bot.subscribe(feed="ticker", products="PI_XBTUSD") - - await bot.subscribe(feed="heartbeat") - await bot.subscribe(feed="ticker", products=products) - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_auth - @pytest.mark.futures_websocket - def test_subscribe_private(self: "WebsocketTests") -> None: - """ - Checks if the authenticated websocket client is able to subscribe - to private feeds. - """ - - async def create_bot() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - - with pytest.raises( - ValueError - ): # private subscriptions does not use products - await auth_bot.subscribe(feed="fills", products=["PI_XBTUSD"]) - - await auth_bot.subscribe(feed="open_orders") - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_unsubsribe_public(self: "WebsocketTests") -> None: - """ - Checks if the unauthenticated websocket client is able to unsubscribe - from public feeds. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - products: List[str] = ["PI_XBTUSD", "PF_SOLUSD"] - - with pytest.raises(ValueError): # products must be type List[str] - await bot.unsubscribe(feed="ticker", products="PI_XBTUSD") - - await bot.subscribe(feed="ticker", products=products) - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_auth - @pytest.mark.futures_websocket - def test_unsubscribe_private(self) -> None: - """ - Checks if the authenticated websocket client is able to unsubscribe - from private feeds. - """ - - async def create_bot() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - - with pytest.raises( - ValueError - ): # private un/-subscriptions does not accept a product - await auth_bot.unsubscribe(feed="open_orders", products=["PI_XBTUSD"]) - - await auth_bot.unsubscribe(feed="open_orders") - await self.__wait(2) - - self.__create_loop(coro=create_bot) - - @pytest.mark.futures - @pytest.mark.futures_websocket - def test_get_active_subscriptions(self: "WebsocketTests") -> None: - """ - Checks the ``get_active_subscriptions`` function. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - assert bot.get_active_subscriptions() == [] - await self.__wait(2) - await bot.subscribe(feed="ticker", products=["PI_XBTUSD"]) - await self.__wait(5) - assert len(bot.get_active_subscriptions()) == 1 - await bot.unsubscribe(feed="ticker", products=["PI_XBTUSD"]) - await self.__wait(5) - assert bot.get_active_subscriptions() == [] - - self.__create_loop(coro=create_bot) - - def tearDown(self: "WebsocketTests") -> None: - return super().tearDown() - - -if __name__ == "__main__": - unittest.main() +from .helper import FuturesWebsocketClientTestWrapper, async_wait + + +@pytest.mark.futures +@pytest.mark.futures_websocket +def test_create_public_bot(caplog: Any) -> None: + """ + Checks if the unauthenticated websocket client + can be instantiated. + """ + + async def instantiate_client() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper() + await async_wait(5) + + assert not client.is_auth + + asyncio.run(instantiate_client()) + + assert "{'event': 'info', 'version': 1}" in caplog.text + + +@pytest.mark.futures +@pytest.mark.futures_auth +@pytest.mark.futures_websocket +def test_create_private_bot( + futures_api_key: str, futures_secret_key: str, caplog: Any +) -> None: + """ + Checks if the authenticated websocket client + can be instantiated. + """ + + async def instantiate_client() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper( + key=futures_api_key, secret=futures_secret_key + ) + assert client.is_auth + await async_wait(5) + + asyncio.run(instantiate_client()) + + assert "{'event': 'info', 'version': 1}" in caplog.text + + +@pytest.mark.futures +@pytest.mark.futures_websocket +def test_get_available_public_subscriptions() -> None: + """ + Checks the ``get_available_public_subscription_feeds`` function. + """ + + expected: List[str] = [ + "trade", + "book", + "ticker", + "ticker_lite", + "heartbeat", + ] + assert all( + feed in expected + for feed in FuturesWebsocketClientTestWrapper.get_available_public_subscription_feeds() + ) + + +@pytest.mark.futures +@pytest.mark.futures_websocket +def test_get_available_private_subscriptions() -> None: + """ + Checks the ``get_available_private_subscription_feeds`` function. + """ + + expected: List[str] = [ + "fills", + "open_positions", + "open_orders", + "open_orders_verbose", + "balances", + "deposits_withdrawals", + "account_balances_and_margins", + "account_log", + "notifications_auth", + ] + assert all( + feed in expected + for feed in FuturesWebsocketClientTestWrapper.get_available_private_subscription_feeds() + ) + + +@pytest.mark.futures +@pytest.mark.futures_websocket +def test_subscribe_public(caplog: Any) -> None: + """ + Checks if the client is able to subscribe to a public feed. + """ + + async def check_subscription() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper() + await async_wait(2) + + with pytest.raises(ValueError): + # products must be List[str] + await client.subscribe(feed="ticker", products="PI_XBTUSD") # type: ignore[arg-type] + + await client.subscribe(feed="ticker", products=["PI_XBTUSD", "PF_SOLUSD"]) + await async_wait(seconds=2) + + subs: List[dict] = client.get_active_subscriptions() + assert isinstance(subs, list) + + expected_subscriptions: List[dict] = [ + {"event": "subscribe", "feed": "ticker", "product_ids": ["PI_XBTUSD"]}, + {"event": "subscribe", "feed": "ticker", "product_ids": ["PF_SOLUSD"]}, + ] + assert all(sub in subs for sub in expected_subscriptions) + + asyncio.run(check_subscription()) + + for expected in ( + "{'event': 'subscribed', 'feed': 'ticker', 'product_ids': ['PI_XBTUSD']}", + "{'event': 'subscribed', 'feed': 'ticker', 'product_ids': ['PF_SOLUSD']}", + ): + assert expected in caplog.text + + +@pytest.mark.futures +@pytest.mark.futures_auth +@pytest.mark.futures_websocket +def test_subscribe_private( + futures_api_key: str, futures_secret_key: str, caplog: Any +) -> None: + """ + Checks if the authenticated websocket client is able to subscribe + to private feeds. + """ + + async def submit_subscription() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper( + key=futures_api_key, secret=futures_secret_key + ) + + with pytest.raises(ValueError): + # private subscriptions does not use products + await client.subscribe(feed="fills", products=["PI_XBTUSD"]) + + await client.subscribe(feed="open_orders") + await async_wait(2) + + asyncio.run(submit_subscription()) + + for expected in ( + "{'event': 'subscribed', 'feed': 'open_orders'}", + "{'feed': 'open_orders_snapshot', 'account':", + ): + assert expected in caplog.text + + +@pytest.mark.futures +@pytest.mark.futures_websocket +def test_unsubscribe_public(caplog: Any) -> None: + """ + Checks if the unauthenticated websocket client is able to unsubscribe + from public feeds. + """ + + async def execute_unsubscribe() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper() + products: List[str] = ["PI_XBTUSD", "PF_SOLUSD"] + + await client.subscribe(feed="ticker", products=products) + await async_wait(seconds=2) + + with pytest.raises(ValueError): + # products must be type List[str] + await client.unsubscribe(feed="ticker", products="PI_XBTUSD") # type: ignore[arg-type] + + await client.unsubscribe(feed="ticker", products=products) + await async_wait(seconds=2) + + asyncio.run(execute_unsubscribe()) + + for expected in ( + "{'event': 'subscribed', 'feed': 'ticker', 'product_ids': ['PI_XBTUSD']}", + "{'event': 'subscribed', 'feed': 'ticker', 'product_ids': ['PF_SOLUSD']}", + "{'event': 'unsubscribed', 'feed': 'ticker', 'product_ids': ['PI_XBTUSD']}", + "{'event': 'unsubscribed', 'feed': 'ticker', 'product_ids': ['PF_SOLUSD']}", + ): + assert expected in caplog.text + + +@pytest.mark.futures +@pytest.mark.futures_auth +@pytest.mark.futures_websocket +def test_unsubscribe_private( + futures_api_key: str, futures_secret_key: str, caplog: Any +) -> None: + """ + Checks if the authenticated websocket client is able to unsubscribe + from private feeds. + """ + + async def execute_unsubscribe() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper( + key=futures_api_key, secret=futures_secret_key + ) + await client.subscribe(feed="open_orders") + + await async_wait(seconds=2) + with pytest.raises(ValueError): + # private un/-subscriptions does not accept a product + await client.unsubscribe(feed="open_orders", products=["PI_XBTUSD"]) + + await client.unsubscribe(feed="open_orders") + await async_wait(seconds=2) + + asyncio.run(execute_unsubscribe()) + + for expected in ( + "{'event': 'subscribed', 'feed': 'open_orders'}", + "{'event': 'unsubscribed', 'feed': 'open_orders'}", + ): + assert expected in caplog.text + + +@pytest.mark.futures +@pytest.mark.futures_websocket +def test_get_active_subscriptions(caplog: Any) -> None: + """ + Checks the ``get_active_subscriptions`` function. + """ + + async def check_subscriptions() -> None: + client: FuturesWebsocketClientTestWrapper = FuturesWebsocketClientTestWrapper() + assert client.get_active_subscriptions() == [] + await async_wait(seconds=1) + + await client.subscribe(feed="ticker", products=["PI_XBTUSD"]) + await async_wait(seconds=1) + assert len(client.get_active_subscriptions()) == 1 + + await client.unsubscribe(feed="ticker", products=["PI_XBTUSD"]) + await async_wait(seconds=1) + assert client.get_active_subscriptions() == [] + + asyncio.run(check_subscriptions()) + + for expected in ( + "{'event': 'subscribed', 'feed': 'ticker', 'product_ids': ['PI_XBTUSD']}", + "{'event': 'unsubscribed', 'feed': 'ticker', 'product_ids': ['PI_XBTUSD']}", + ): + assert expected in caplog.text diff --git a/tests/spot/conftest.py b/tests/spot/conftest.py index 29047168..a7a2af8a 100644 --- a/tests/spot/conftest.py +++ b/tests/spot/conftest.py @@ -13,6 +13,18 @@ from kraken.spot import Funding, Market, Staking, Trade, User +@pytest.fixture +def spot_api_key() -> str: + """Returns the Kraken Spot API Key for testing.""" + return os.getenv("SPOT_API_KEY") + + +@pytest.fixture +def spot_secret_key() -> str: + """Returns the Kraken Spot API secret for testing.""" + return os.getenv("SPOT_SECRET_KEY") + + @pytest.fixture def spot_auth_user() -> User: """ diff --git a/tests/spot/fixture/orderbook.json b/tests/spot/fixture/orderbook.json new file mode 100644 index 00000000..32acf6dc --- /dev/null +++ b/tests/spot/fixture/orderbook.json @@ -0,0 +1,1111 @@ +{ + "init": [ + 336, + { + "as": [ + ["27918.60000", "0.10013466", "1685349920.743439"], + ["27921.40000", "0.04476644", "1685349907.543572"], + ["27921.70000", "0.00200000", "1685349905.035112"], + ["27922.60000", "0.00744600", "1685349928.257340"], + ["27922.70000", "0.03909756", "1685349924.678410"], + ["27922.80000", "0.01570717", "1685349908.867956"], + ["27922.90000", "0.01933877", "1685349924.723802"], + ["27923.10000", "0.03165398", "1685349918.895395"], + ["27923.20000", "2.68594216", "1685349918.892241"], + ["27924.10000", "0.03123600", "1685349904.102605"] + ], + "bs": [ + ["27918.50000", "0.44201019", "1685349927.212868"], + ["27918.20000", "2.68641244", "1685349908.361819"], + ["27918.10000", "0.01920189", "1685349915.031326"], + ["27917.30000", "0.10000000", "1685349886.493802"], + ["27916.60000", "0.04794621", "1685349927.358956"], + ["27916.00000", "2.57867480", "1685349925.953576"], + ["27915.90000", "0.10736874", "1685349886.522716"], + ["27914.40000", "2.68678413", "1685349918.462280"], + ["27914.00000", "0.01734074", "1685349886.609849"], + ["27913.90000", "0.04475898", "1685349923.140809"] + ] + }, + "book-10", + "XBT/USD" + ], + "updates": [ + [ + 336, + { + "a": [["27918.60000", "0.19013466", "1685349935.063741"]], + "c": "3509641781" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.19313466", "1685349935.084055"]], + "c": "3537477593" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27922.90000", "0.00000000", "1685349935.119111"], + ["27925.20000", "0.40000000", "1685349920.087641", "r"] + ], + "c": "2130071711" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.70000", "0.05851057", "1685349935.175617"]], + "c": "266505086" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "0.08078351", "1685349935.175773"]], + "c": "3007200011" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.19013466", "1685349935.258430"]], + "c": "444693541" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.10013466", "1685349935.264022"]], + "c": "1651272542" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.60000", "0.00726150", "1685349937.726931"]], + "c": "1879094727" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.70000", "0.03909756", "1685349937.783489"]], + "c": "18116134" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "0.03123600", "1685349937.783517"]], + "c": "3184057939" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.30000", "0.04801270", "1685349937.838799"]], + "c": "863732242" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.90000", "0.01947342", "1685349937.839291"]], + "c": "252574142" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.30000", "0.00035818", "1685349938.879169"]], + "c": "4126495252" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27916.00000", "0.00000000", "1685349939.244993"], + ["27913.90000", "0.04475898", "1685349923.140809", "r"] + ], + "c": "2280320765" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27914.50000", "2.57867589", "1685349939.245219"]], + "c": "1349553540" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27923.20000", "0.00000000", "1685349941.056967"], + ["27924.30000", "0.04801270", "1685349937.838799", "r"] + ], + "c": "1669155769" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27923.10000", "0.00000000", "1685349941.061416"], + ["27925.10000", "0.03165380", "1685349941.060744", "r"] + ], + "c": "2351793982" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.50000", "2.68581386", "1685349941.062365"]], + "c": "942232279" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.40000", "0.03165380", "1685349941.065602"]], + "c": "592973027" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27922.70000", "0.03809756", "1685349941.107992"], + ["27924.00000", "0.00100000", "1685349941.108014"] + ], + "c": "1569770770" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27922.90000", "0.00000000", "1685349941.113404"], + ["27924.40000", "0.03165380", "1685349941.065602", "r"] + ], + "c": "75362674" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27924.30000", "0.00000000", "1685349941.113601"], + ["27924.50000", "2.68581386", "1685349941.062365", "r"] + ], + "c": "2024644163" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27924.00000", "0.00000000", "1685349941.143808"], + ["27925.20000", "0.40000000", "1685349920.087641", "r"], + ["27923.50000", "0.00100000", "1685349941.143865"] + ], + "c": "533928585" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.00000", "0.01985571", "1685349941.168295"]], + "c": "327178704" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.40000", "0.55873501", "1685349942.284089"]], + "c": "4257718114" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.40000", "0.52708121", "1685349942.287084"]], + "c": "3120263824" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.30000", "0.03165380", "1685349942.287193"]], + "c": "1608440427" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27924.00000", "0.00000000", "1685349942.339296"], + ["27924.40000", "0.52708121", "1685349942.287084", "r"] + ], + "c": "1351103071" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27921.40000", "0.00000000", "1685349942.716424"], + ["27924.50000", "2.68581386", "1685349941.062365", "r"] + ], + "c": "4202616783" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.00000", "0.00726150", "1685349942.798571"]], + "c": "2955916574" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27922.60000", "0.00000000", "1685349942.833003"], + ["27924.50000", "2.68581386", "1685349941.062365", "r"] + ], + "c": "146080236" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27918.30000", "0.00000000", "1685349943.977869"], + ["27913.70000", "0.10720000", "1685349819.386184", "r"] + ], + "c": "1905568309" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "0.05103859", "1685349944.033410"]], + "c": "2963133225" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27921.60000", "0.04476820", "1685349944.052239"]], + "c": "4043940463" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27914.60000", "0.04476123", "1685349944.054163"]], + "c": "4049734427" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27918.60000", "0.10000000", "1685349944.150868"], + ["27918.60000", "0.09658466", "1685349944.150921"] + ], + "c": "536261886" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "0.01980259", "1685349944.186646"]], + "c": "2546990307" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.40000", "0.55828821", "1685349944.187337"]], + "c": "908595798" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.30000", "1.25879052", "1685349944.371687"]], + "c": "3078024937" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.20000", "0.03165375", "1685349944.374851"]], + "c": "3152519925" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.30000", "1.22713672", "1685349944.374902"]], + "c": "3501356139" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27924.10000", "0.00000000", "1685349944.426402"], + ["27924.40000", "0.55828821", "1685349944.187337", "r"] + ], + "c": "3724026697" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27914.00000", "0.00000000", "1685349946.477477"], + ["27913.70000", "0.10720000", "1685349819.386184", "r"] + ], + "c": "1408051223" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.40000", "0.03120700", "1685349947.248502"]], + "c": "2900723716" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.20000", "0.87912321", "1685349947.609794"]], + "c": "1455903584" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "0.03165379", "1685349947.612975"]], + "c": "1533994848" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.20000", "0.84746946", "1685349947.613018"]], + "c": "2543540458" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27924.30000", "0.00000000", "1685349949.307709"], + ["27924.40000", "0.03120700", "1685349947.248502", "r"] + ], + "c": "3088672957" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.30000", "0.00035818", "1685349949.351016"]], + "c": "3032429653" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "1.21624354", "1685349949.479449"]], + "c": "111908971" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.10000", "1.18458975", "1685349949.482337"]], + "c": "2293745829" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.00000", "0.03891529", "1685349949.482600"]], + "c": "2292758720" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.60000", "0.00017005", "1685349950.085024"]], + "c": "944626187" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27923.70000", "0.01979147", "1685349950.313316"]], + "c": "1951234033" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18658466", "1685349950.390246"]], + "c": "4024192285" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18958466", "1685349950.390596"]], + "c": "183803256" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09958466", "1685349950.495287"]], + "c": "2439358356" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09658466", "1685349950.499112"]], + "c": "1951234033" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09958466", "1685349950.991985"]], + "c": "2439358356" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18958466", "1685349950.993260"]], + "c": "183803256" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27923.70000", "0.00000000", "1685349951.088341"], + ["27924.20000", "0.84746946", "1685349947.613018", "r"] + ], + "c": "2891405413" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18658466", "1685349951.094397"]], + "c": "2439701858" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09658466", "1685349951.097528"]], + "c": "944626187" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09958466", "1685349951.195964"]], + "c": "91252492" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18958466", "1685349951.208711"]], + "c": "2891405413" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18658466", "1685349951.295363"]], + "c": "2439701858" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09658466", "1685349951.297410"]], + "c": "944626187" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27921.40000", "0.40000000", "1685349951.524959"]], + "c": "579131225" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27924.00000", "0.00726150", "1685349951.529446"]], + "c": "2192271300" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27921.30000", "0.03165383", "1685349951.529648"]], + "c": "1616130935" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27923.00000", "2.68596609", "1685349951.556337"]], + "c": "507348957" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.31982866", "1685349951.580367"]], + "c": "4118745991" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.50000", "0.41035621", "1685349951.583400"]], + "c": "2135939390" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27921.30000", "0.00000000", "1685349951.583573"], + ["27924.00000", "0.00726150", "1685349951.529446", "r"] + ], + "c": "2891172944" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.30000", "0.03201201", "1685349951.583668"]], + "c": "1944594657" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.35148249", "1685349951.583839"]], + "c": "2781830632" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.30000", "0.00035818", "1685349951.587117"]], + "c": "2924490369" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27914.60000", "0.07641506", "1685349951.590587"]], + "c": "3777619536" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27923.50000", "0.00000000", "1685349951.597904"], + ["27924.10000", "1.18458975", "1685349949.482337", "r"], + ["27923.00000", "2.68696609", "1685349951.597932"] + ], + "c": "2153833982" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.44148249", "1685349951.600103"]], + "c": "3633153634" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.44448249", "1685349951.610218"]], + "c": "942516196" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27918.50000", "0.40935621", "1685349951.617964"], + ["27918.00000", "0.00100000", "1685349951.617994"] + ], + "c": "564648664" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27921.40000", "0.00000000", "1685349951.631957"], + ["27924.20000", "0.84746946", "1685349947.613018", "r"] + ], + "c": "1468280130" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27921.60000", "0.00000000", "1685349951.718028"], + ["27924.40000", "0.03120700", "1685349947.248502", "r"], + ["27921.30000", "0.04476820", "1685349951.718063"] + ], + "c": "1480551182" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27923.60000", "0.02800000", "1685349951.739233"]], + "c": "1126936459" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [ + ["27921.30000", "0.00000000", "1685349951.843317"], + ["27924.20000", "0.84746946", "1685349947.613018", "r"], + ["27921.60000", "0.04476820", "1685349951.843334"] + ], + "c": "4170504130" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.22123849", "1685349951.999472"]], + "c": "149054235" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.50000", "0.44101004", "1685349952.002489"]], + "c": "2565294110" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18958466", "1685349952.002713"]], + "c": "215422159" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27914.60000", "0.04476123", "1685349952.002737"]], + "c": "3551440014" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.90000", "0.03165383", "1685349952.003427"]], + "c": "1467306010" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.39638365", "1685349952.190714"]], + "c": "3848328756" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18958466", "1685349952.294948"]], + "c": "1467306010" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18658466", "1685349952.490845"]], + "c": "1893298897" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09658466", "1685349952.496934"]], + "c": "2316924997" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27918.00000", "0.00000000", "1685349952.533530"], + ["27914.40000", "2.68678413", "1685349918.462280", "r"], + ["27918.50000", "0.44201004", "1685349952.533549"] + ], + "c": "3277956956" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.09647879", "1685349952.761849"]], + "c": "862938467" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.50000", "0.61621004", "1685349954.319734"]], + "c": "506037520" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27918.30000", "0.00000000", "1685349954.347789"], + ["27913.70000", "0.10720000", "1685349819.386184", "r"] + ], + "c": "721063604" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27916.60000", "0.00000000", "1685349954.374522"], + ["27912.60000", "0.20000000", "1685349803.699369", "r"] + ], + "c": "1941338289" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.90000", "0.08874213", "1685349954.445982"]], + "c": "2032028182" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.70000", "0.05798267", "1685349954.502487"]], + "c": "1464738161" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27916.70000", "0.04997105", "1685349954.503109"]], + "c": "1037848036" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27914.60000", "0.04511946", "1685349954.515602"]], + "c": "2798664172" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18647879", "1685349954.593318"]], + "c": "2760469784" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27918.60000", "0.18947879", "1685349954.593669"]], + "c": "1578633683" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27918.50000", "0.44201004", "1685349955.575006"]], + "c": "3379480766" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [["27914.60000", "0.06428814", "1685349955.608266"]], + "c": "2416793890" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "a": [["27922.70000", "0.03809756", "1685349955.633289"]], + "c": "1953218399" + }, + "book-10", + "XBT/USD" + ], + [ + 336, + { + "b": [ + ["27916.70000", "0.00000000", "1685349955.633321"], + ["27912.60000", "0.20000000", "1685349803.699369", "r"] + ], + "c": "3521251616" + }, + "book-10", + "XBT/USD" + ] + ] +} diff --git a/tests/spot/helper.py b/tests/spot/helper.py index 0124b732..9c262692 100644 --- a/tests/spot/helper.py +++ b/tests/spot/helper.py @@ -4,9 +4,103 @@ # GitHub: https://github.com/btschwertfeger # -from typing import Any +import logging +import os +from asyncio import sleep +from time import time +from typing import Any, Union + +from kraken.spot import KrakenSpotWSClient, OrderbookClient + +FIXTURE_DIR: str = os.path.join(os.path.dirname(__file__), "fixture") def is_not_error(value: Any) -> bool: """Returns True if 'error' as key not in dict.""" return isinstance(value, dict) and "error" not in value + + +async def async_wait(seconds: float = 1.0) -> None: + """Function that waits for ``seconds`` - asynchron.""" + start: float = time() + while time() - seconds < start: + await sleep(0.2) + return + + +class SpotWebsocketClientTestWrapper(KrakenSpotWSClient): + """ + Class that creates an instance to test the KrakenSpotWSClient. + + It writes the messages to the log and a file. The log is used + within the tests, the log file is for local debugging. + """ + + LOG: logging.Logger = logging.getLogger(__name__) + + def __init__( + self: "SpotWebsocketClientTestWrapper", key: str = "", secret: str = "" + ) -> None: + super().__init__(key=key, secret=secret, callback=self.on_message) + self.LOG.setLevel(logging.INFO) + fh = logging.FileHandler("spot_ws.log", mode="a") + fh.setLevel(logging.INFO) + self.LOG.addHandler(fh) + + async def on_message( + self: "SpotWebsocketClientTestWrapper", msg: Union[list, dict] + ) -> None: + """ + This is the callback function that must be implemented + to handle custom websocket messages. + """ + self.LOG.info(msg) # the log is read within the tests + + +class OrderbookClientWrapper(OrderbookClient): + """ + This class is used for testing the Spot Orderbook client. + + It writes the messages to the log and a file. The log is used + within the tests, the log file is for local debugging. + """ + + LOG: logging.Logger = logging.getLogger(__name__) + + def __init__(self: "OrderbookClientWrapper") -> None: + super().__init__() + self.LOG.setLevel(logging.INFO) + + async def on_message( + self: "OrderbookClientWrapper", msg: Union[list, dict] + ) -> None: + self.ensure_log(msg) + await super().on_message(msg=msg) + + async def on_book_update( + self: "OrderbookClientWrapper", pair: str, message: list + ) -> None: + """ + This is the callback function that must be implemented + to handle custom websocket messages. + """ + self.ensure_log((pair, message)) + + @classmethod + def ensure_log(cls, content: Any) -> None: + """ + Ensures that the messages are logged. + Into a file for debugging and general to the log + to read the logs within the unit tests. + """ + cls.LOG.info(content) + + log: str = "" + try: + with open("spot_orderbook.log", "r", encoding="utf-8") as logfile: + log = logfile.read() + except FileNotFoundError: + pass + + with open("spot_orderbook.log", "w", encoding="utf-8") as logfile: + logfile.write(f"{log}\n{content}") diff --git a/tests/spot/test_spot_base_api.py b/tests/spot/test_spot_base_api.py index 087808a1..1730ff1f 100644 --- a/tests/spot/test_spot_base_api.py +++ b/tests/spot/test_spot_base_api.py @@ -10,6 +10,7 @@ from kraken.base_api import KrakenBaseSpotAPI from kraken.exceptions import KrakenException +from kraken.spot import Funding, Market, Staking, Trade, User from .helper import is_not_error @@ -38,16 +39,21 @@ def test_KrakenBaseSpotAPI_without_exception() -> None: @pytest.mark.spot @pytest.mark.spot_auth def test_spot_rest_contextmanager( - spot_market, spot_auth_funding, spot_auth_trade, spot_auth_user, spot_auth_staking + spot_market: Market, + spot_auth_funding: Funding, + spot_auth_trade: Trade, + spot_auth_user: User, + spot_auth_staking: Staking, ) -> None: """ Checks if the clients can be used as context manager. """ with spot_market as market: - assert is_not_error(market.get_assets()) + result = market.get_assets() + assert is_not_error(result), result with spot_auth_funding as funding: - isinstance(funding.get_deposit_methods(asset="XBT"), list) + assert isinstance(funding.get_deposit_methods(asset="XBT"), list) with spot_auth_user as user: assert is_not_error(user.get_account_balance()) diff --git a/tests/spot/test_spot_orderbook.py b/tests/spot/test_spot_orderbook.py new file mode 100644 index 00000000..e4e19be4 --- /dev/null +++ b/tests/spot/test_spot_orderbook.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (C) 2023 Benjamin Thomas Schwertfeger +# GitHub: https://github.com/btschwertfeger +# + +""" +Module that implements the unit tests regarding the Spot Orderbook client. +""" + +import asyncio +import json +import os +from collections import OrderedDict +from typing import Any, Optional +from unittest import mock + +import pytest + +from kraken.spot import OrderbookClient + +from .helper import FIXTURE_DIR, OrderbookClientWrapper, async_wait + + +@pytest.mark.spot +@pytest.mark.spot_websocket +@pytest.mark.spot_orderbook +def test_create_public_bot(caplog: Any) -> None: + """ + Checks if the websocket client can be instantiated. + """ + + async def create_bot() -> None: + orderbook: OrderbookClientWrapper = OrderbookClientWrapper() + await async_wait(seconds=4) + + assert orderbook.depth == 10 + + asyncio.run(create_bot()) + + for expected in ( + "'connectionID", + "'event': 'systemStatus', 'status': 'online'", + "'event': 'pong', 'reqid':", + ): + assert expected in caplog.text + assert "Kraken websockets at full capacity, try again later" not in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_websocket +@pytest.mark.spot_orderbook +def test_get_first() -> None: + """ + Checks the ``get_first`` method. + """ + + assert ( + float(10) + == OrderbookClientWrapper.get_first(("10", "5")) + == OrderbookClientWrapper.get_first((10, 5)) + ) + + +@mock.patch("kraken.spot.orderbook.KrakenSpotWSClient", return_value=None) +@pytest.mark.spot +@pytest.mark.spot_orderbook +def test_assing_msg_and_validate_checksum(mock_ws_client: mock.MagicMock) -> None: + """ + This function checks if the initial snapshot and the book updates are + assigned correctly so that the checksum calculation can validate the + assigned book updates and values. + """ + with open( + os.path.join(FIXTURE_DIR, "orderbook.json"), "r", encoding="utf-8" + ) as json_file: + orderbook: dict = json.load(json_file) + + async def assign() -> None: + client: OrderbookClient = OrderbookClient(depth=10) + + await client.on_message(msg=orderbook["init"]) + assert client.get(pair="XBT/USD")["valid"] + + for update in orderbook["updates"]: + await client.on_message(msg=update) + assert client.get(pair="XBT/USD")["valid"] + + asyncio.run(assign()) + + +@pytest.mark.spot +@pytest.mark.spot_websocket +@pytest.mark.spot_orderbook +def test_add_book(caplog: Any) -> None: + """ + Checks if the orderbook client is able to add a book by subscribing. + The logs are then checked for the expected results. + """ + + async def execute_add_book() -> None: + orderbook: OrderbookClientWrapper = OrderbookClientWrapper() + + await orderbook.add_book(pairs=["XBT/USD"]) + await async_wait(seconds=2) + + book: Optional[dict] = orderbook.get(pair="XBT/USD") + assert isinstance(book, dict) + + assert all(key in book for key in ("ask", "bid", "valid")), book + + assert isinstance(book["ask"], OrderedDict) + assert isinstance(book["bid"], OrderedDict) + + for ask, bid in zip(book["ask"], book["bid"]): + assert isinstance(ask, str) + assert isinstance(bid, str) + + asyncio.run(execute_add_book()) + + for expected in ( + "'channelName': 'book-10', 'event': 'subscriptionStatus', 'pair': 'XBT/USD', 'reqid':", + "'status': 'subscribed', 'subscription': {'depth': 10, 'name': 'book'}}", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_websocket +@pytest.mark.spot_orderbook +def test_remove_book(caplog: Any) -> None: + """ + Checks if the orderbook client is able to add a book by subscribing to a book + and unsubscribing right after + validating using the logs. + """ + + async def execute_remove_book() -> None: + orderbook: OrderbookClientWrapper = OrderbookClientWrapper() + + await orderbook.add_book(pairs=["XBT/USD"]) + await async_wait(seconds=2) + + await orderbook.remove_book(pairs=["XBT/USD"]) + await async_wait(seconds=2) + + asyncio.run(execute_remove_book()) + + for expected in ( + "'channelName': 'book-10', 'event': 'subscriptionStatus', 'pair': 'XBT/USD', 'reqid':", + "'status': 'subscribed', 'subscription': {'depth': 10, 'name': 'book'}}", + "'status': 'unsubscribed', 'subscription': {'depth': 10, 'name': 'book'}}", + ): + assert expected in caplog.text diff --git a/tests/spot/test_spot_websocket.py b/tests/spot/test_spot_websocket.py index fa9aa981..95c31811 100644 --- a/tests/spot/test_spot_websocket.py +++ b/tests/spot/test_spot_websocket.py @@ -8,456 +8,530 @@ NOTE: * Since there is no sandbox environment for the Spot trading API, some tests are adjusted, so that there is a `validate` switch to not risk funds. -* Also there is a KrakenPermissionDeniedError class which will be raised when - the websocket client receives a message about missing authentication. Since the - API keys have no trade permission, this will be excepted to exit the asyncio event loop. - A asyncio.CancelledError will be raised and excepted during this procedure. - -todo: Create fixtures for the custom exception and the Bot class. +* The custom SpotWebsocketClientTestWrapper class is used that wraps around the + websocket client. To validate the functions the responses are logged and finally + the logs are read out and its input is checked for the expected output. """ from __future__ import annotations -import asyncio -import os -import time -import unittest -from typing import Callable, Optional, Union +from asyncio import CancelledError +from asyncio import run as asyncio_run +from typing import Any, Dict, List import pytest -from kraken.spot import KrakenSpotWSClient +from .helper import SpotWebsocketClientTestWrapper, async_wait -class KrakenPermissionDeniedError(Exception): +@pytest.mark.spot +@pytest.mark.spot_websocket +def test_create_public_bot(caplog: Any) -> None: """ - This Error will cancel the ws connection by closing the event loop - asyncio.CancelledError will be raised. + Checks if the websocket client can be instantiated. """ - def __init__(self): - try: - pending = asyncio.all_tasks() - for task in pending: - task.cancel() - - loop = asyncio.get_event_loop() - loop.run(self.kill_pending_tasks(pending)) - except AttributeError: - # AttributeError: '_UnixSelectorEventLoop' object has no attribute 'run' - # when there is no event loop - pass - - @classmethod - async def kill_pending_tasks(cls, tasks) -> None: - await asyncio.gather(tasks, return_exceptions=True) - - -class Bot(KrakenSpotWSClient): - """Class to create a websocket bot""" - - async def on_message(self: "Bot", event: Union[list, dict]) -> None: - """ - This is the callback function that must be implemented - to handle custom websocket messages. - """ - # The following comments are only used for debugging while - # implementing tests. - # log = "" - # try: - # with open("spot_ws_log.log", "r", encoding="utf-8") as f: - # log = f.read() - # except FileNotFoundError: - # pass - - # with open("spot_ws_log.log", "w", encoding="utf-8") as f: - # f.write(log + "\n" + str(event)) - - if isinstance(event, dict) and "error" in event.keys(): - if "KrakenPermissionDeniedError" in event["error"]: - raise KrakenPermissionDeniedError() - - -class WebsocketTests(unittest.TestCase): - def setUp(self: "WebsocketTests") -> None: - self.__key: str = os.getenv("SPOT_API_KEY") - self.__secret: str = os.getenv("SPOT_SECRET_KEY") - self.__full_ws_access: str = os.getenv("FULLACCESS") == "True" - - def __create_loop(self: "WebsocketTests", coro: Callable) -> None: - """Function that creates an event loop.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - asyncio.run(coro()) - loop.close() - - async def __wait(self: "WebsocketTests", seconds: Optional[float] = 1.0) -> None: - """Function that realizes the wait for ``seconds``.""" - start: int = time.time() - while time.time() - seconds < start: - await asyncio.sleep(0.2) - return - - @pytest.mark.spot - @pytest.mark.spot_websocket - def test_create_public_bot(self: "WebsocketTests") -> None: - """ - Checks if the websocket client can be instantiated. - """ - - async def create_bot() -> None: - bot: Bot = Bot() - await self.__wait(seconds=2.5) - - self.__create_loop(coro=create_bot) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_create_private_bot(self: "WebsocketTests") -> None: - """ - Checks if the authenticated websocket client can be instantiated. - """ - - async def create_bot(): - if self.__full_ws_access: - Bot(key=self.__key, secret=self.__secret) - await self.__wait(seconds=2.5) - else: - # with pytest.raises(asyncio.CancelledError): - Bot(key=self.__key, secret=self.__secret) - await self.__wait(seconds=2.5) - - self.__create_loop(coro=create_bot) - - @pytest.mark.spot - @pytest.mark.spot_websocket - def test_access_public_bot_attributes(self: "WebsocketTests") -> None: - """ - Checks the ``access_public_bot_attributes`` function - works as expected. - """ - - async def checkit() -> None: - bot: Bot = Bot() - - assert bot.private_sub_names == ["ownTrades", "openOrders"] - assert bot.public_sub_names == [ - "ticker", - "spread", - "book", - "ohlc", - "trade", - "*", - ] - assert bot.active_public_subscriptions == [] - await self.__wait(seconds=1) - with pytest.raises(ConnectionError): - # cannot access private subscriptions on unauthenticated client - bot.active_private_subscriptions() - - await self.__wait(seconds=1.5) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_access_private_bot_attributes(self) -> None: - """ - Checks the ``access_private_bot_attributes`` function - works as expected. - """ - - async def checkit() -> None: - auth_bot: Bot - if self.__full_ws_access: - auth_bot = Bot(key=self.__key, secret=self.__secret) - assert auth_bot.active_private_subscriptions == [] - await self.__wait(seconds=2.5) - else: - # with pytest.raises(asyncio.CancelledError): - auth_bot = Bot(key=self.__key, secret=self.__secret) - assert auth_bot.active_private_subscriptions == [] - await self.__wait(seconds=2.5) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_websocket - def test_public_subscribe(self: "WebsocketTests") -> None: - """ - Function that checks if the websocket client - is able to subscribe to public feeds. - """ - - async def checkit() -> None: - bot: Bot = Bot() - subscription = {"name": "ticker"} - - with pytest.raises(AttributeError): - await bot.subscribe(subscription={}) - - with pytest.raises(ValueError): - await bot.subscribe(subscription=subscription, pair="XBT/USD") - - await bot.subscribe(subscription=subscription, pair=["XBT/EUR"]) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_private_subscribe(self: "WebsocketTests") -> None: - """ - Checks if the authenticated websocket client can subscribe to private feeds. - """ - - async def checkit() -> None: - subscription = {"name": "ownTrades"} - - bot: Bot = Bot() - with pytest.raises(ValueError): # unauthenticated - await bot.subscribe(subscription=subscription) - with pytest.raises(ValueError): # unauthenticated and pair and pair is list - await bot.subscribe(subscription=subscription, pair=["XBT/EUR"]) - - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - with pytest.raises(ValueError): # private conns does not accept pairs - await auth_bot.subscribe(subscription=subscription, pair=["XBT/EUR"]) - await self.__wait(seconds=1) - - if self.__full_ws_access: - await auth_bot.subscribe(subscription=subscription) - await self.__wait(seconds=2) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.subscribe(subscription=subscription) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_websocket - def test_public_unsubscribe(self: "WebsocketTests") -> None: - """ - Checks if the websocket client can unsubscribe from public feeds. - """ - - async def checkit() -> None: - bot: Bot = Bot() - - # since we have no subscriptions, this will work, but the response will inform us that there are no subscriptions - await bot.unsubscribe(subscription={"name": "ticker"}, pair=["XBT/USD"]) - await bot.unsubscribe( - subscription={"name": "ticker"}, pair=["DOT/USD", "ETH/USD"] - ) + async def create_bot() -> None: + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + await async_wait(seconds=5) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_websocket - def test_public_unsubscribe_failure(self: "WebsocketTests") -> None: - """ - Checks if the websocket client responses with failures - when the ``unsubscribe`` function receives invalid parameters. - """ - - async def checkit() -> None: - bot: Bot = Bot() - - with pytest.raises(AttributeError): - await bot.unsubscribe(subscription={}) - - with pytest.raises(ValueError): - await bot.unsubscribe(subscription={"name": "ticker"}, pair="XBT/USD") - - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_private_unsubscribe(self: "WebsocketTests") -> None: - """ - Checks if private subscriptions are available. - """ - - async def checkit() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - - if self.__full_ws_access: - await auth_bot.unsubscribe(subscription={"name": "ownTrades"}) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.unsubscribe(subscription={"name": "ownTrades"}) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_private_unsubscribe_failing(self: "WebsocketTests") -> None: - """ - Checks if the ``unsubscribe`` function fails when invalid - parameters are passed. - """ - - async def checkit() -> None: - bot: Bot = Bot() - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - - with pytest.raises(ValueError): # private feed on unauthenticated client - await bot.unsubscribe(subscription={"name": "ownTrades"}) - - with pytest.raises(ValueError): - await auth_bot.unsubscribe( # private subscriptions does not have a pair - subscription={"name": "ownTrades"}, pair=["XBTUSD"] - ) - - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_create_order(self: "WebsocketTests") -> None: - """ - Checks the ``create_order`` function by submitting a - new order - but in validate mode. - """ - - async def checkit() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - params: dict = dict( - ordertype="limit", - side="buy", - pair="XBT/USD", - volume="2", - price="1000", - price2="1200", - leverage="2", - oflags="viqc", - starttm="0", - expiretm="1000", - userref="12345678", - validate=True, - close_ordertype="limit", - close_price="1000", - close_price2="1200", - timeinforce="GTC", - ) - if self.__full_ws_access: - await auth_bot.create_order(**params) - await self.__wait(seconds=2) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.create_order(**params) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - def test_edit_order(self: "WebsocketTests") -> None: - """ - Checks the edit order function by editing an order in validate mode. - """ - - async def checkit() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - - params: dict = dict( - orderid="OHSAUDZ-ASJKGD-EPAFUIH", - reqid=1244, - pair="XBT/USD", - price="120", - price2="1300", - oflags="fok", - newuserref="833773", - validate=True, + asyncio_run(create_bot()) + + for expected in ( + "'connectionID", + "'event': 'systemStatus', 'status': 'online'", + "'event': 'pong', 'reqid':", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_create_private_bot( + spot_api_key: str, spot_secret_key: str, caplog: Any +) -> None: + """ + Checks if the authenticated websocket client can be instantiated. + """ + + async def create_bot() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + await async_wait(seconds=5) + + asyncio_run(create_bot()) + for expected in ( + "'connectionID", + "'event': 'systemStatus', 'status': 'online'", + "'event': 'pong', 'reqid':", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_websocket +def test_access_public_bot_attributes() -> None: + """ + Checks the ``access_public_bot_attributes`` function + works as expected. + """ + + async def check_access() -> None: + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + + assert client.private_sub_names == ["ownTrades", "openOrders"] + assert client.public_sub_names == [ + "ticker", + "spread", + "book", + "ohlc", + "trade", + "*", + ] + assert client.active_public_subscriptions == [] + await async_wait(seconds=1) + with pytest.raises(ConnectionError): + # cannot access private subscriptions on unauthenticated client + assert isinstance(client.active_private_subscriptions, list) + + await async_wait(seconds=1.5) + + asyncio_run(check_access()) + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_access_private_bot_attributes(spot_api_key: str, spot_secret_key: str) -> None: + """ + Checks the ``access_private_bot_attributes`` function + works as expected. + """ + + async def check_access() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + + assert auth_client.active_private_subscriptions == [] + await async_wait(seconds=2.5) + + asyncio_run(check_access()) + + +@pytest.mark.spot +@pytest.mark.spot_websocket +def test_public_subscribe(caplog: Any) -> None: + """ + Function that checks if the websocket client + is able to subscribe to public feeds. + """ + + async def test_subscription() -> None: + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + subscription: Dict[str, str] = {"name": "ticker"} + + with pytest.raises(AttributeError): + # Invalid subscription format + await client.subscribe(subscription={}) + + with pytest.raises(ValueError): + # Pair must be type List[str] + await client.subscribe(subscription=subscription, pair="XBT/USD") # type: ignore[arg-type] + + await client.subscribe(subscription=subscription, pair=["XBT/EUR"]) + await async_wait(seconds=2) + + asyncio_run(test_subscription()) + + for expected in ( + "'channelName': 'ticker', 'event': 'subscriptionStatus', 'pair': 'XBT/EUR', 'reqid':", + "'status': 'subscribed', 'subscription': {'name': 'ticker'}}", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_private_subscribe( + spot_api_key: str, spot_secret_key: str, caplog: Any +) -> None: + """ + Checks if the authenticated websocket client can subscribe to private feeds. + """ + + async def test_subscription() -> None: + subscription: Dict[str, str] = {"name": "ownTrades"} + + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + with pytest.raises(ValueError): + # unauthenticated + await client.subscribe(subscription=subscription) + + with pytest.raises(ValueError): + # same here also using a pair for coverage ... + await client.subscribe(subscription=subscription, pair=["XBT/EUR"]) + + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + with pytest.raises(ValueError): + # private conns does not accept pairs + await auth_client.subscribe(subscription=subscription, pair=["XBT/EUR"]) + await async_wait(seconds=1) + + await auth_client.subscribe(subscription=subscription) + await async_wait(seconds=2) + + asyncio_run(test_subscription()) + for expected in ( + "'status': 'subscribed', 'subscription': {'name': 'ownTrades'}}", + "{'channelName': 'ownTrades', 'event': 'subscriptionStatus', 'reqid':", + ): + assert expected in caplog.text + + +@pytest.mark.spot_websocket +@pytest.mark.spot +def test_public_unsubscribe(caplog: Any) -> None: + """ + Checks if the websocket client can unsubscribe from public feeds. + """ + + async def test_unsubscribe() -> None: + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + + subscription: Dict[str, str] = {"name": "ticker"} + pair: List[str] = ["XBT/USD"] + await client.subscribe(subscription=subscription, pair=pair) + await async_wait(seconds=3) + + await client.unsubscribe(subscription=subscription, pair=pair) + + await async_wait(seconds=2) + + asyncio_run(test_unsubscribe()) + + # todo: regex! + for expected in ( + "'channelName': 'ticker', 'event': 'subscriptionStatus', 'pair': 'XBT/USD', 'reqid':", + "'status': 'subscribed', 'subscription': {'name': 'ticker'}", + "'unsubscribed', 'subscription': {'name': 'ticker'}}", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_websocket +def test_public_unsubscribe_failure(caplog: Any) -> None: + """ + Checks if the websocket client responses with failures + when the ``unsubscribe`` function receives invalid parameters. + """ + + async def check_unsubscribe_fail() -> None: + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + + # We did not subscribed to this tickers but it will work, + # and the response will inform us that there are no subscriptions. + await client.unsubscribe( + subscription={"name": "ticker"}, pair=["DOT/USD", "ETH/USD"] + ) + + with pytest.raises(AttributeError): + # invalid subscription + await client.unsubscribe(subscription={}) + + with pytest.raises(ValueError): + # pair must be List[str] + await client.unsubscribe(subscription={"name": "ticker"}, pair="XBT/USD") # type: ignore[arg-type] + + await async_wait(seconds=2) + + asyncio_run(check_unsubscribe_fail()) + + # todo: regex! + for expected in ( + "{'errorMessage': 'Subscription Not Found', 'event': 'subscriptionStatus', 'pair': 'DOT/USD', 'reqid':", + "{'errorMessage': 'Subscription Not Found', 'event': 'subscriptionStatus', 'pair': 'ETH/USD', 'reqid':", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_private_unsubscribe( + spot_api_key: str, spot_secret_key: str, caplog: Any +) -> None: + """ + Checks if private subscriptions are available. + """ + + async def check_unsubscribe() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + + await auth_client.subscribe(subscription={"name": "ownTrades"}) + await async_wait(seconds=1) + + await auth_client.unsubscribe(subscription={"name": "ownTrades"}) + await async_wait(seconds=2) + # todo: check if subs are removed from known list + + asyncio_run(check_unsubscribe()) + + for expected in ( + "{'channelName': 'ownTrades', 'event': 'subscriptionStatus', 'reqid': ", + "'status': 'subscribed', 'subscription': {'name': 'ownTrades'}}", + "'status': 'unsubscribed', 'subscription': {'name': 'ownTrades'}}", + ): + assert expected in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_private_unsubscribe_failing( + spot_api_key: str, spot_secret_key: str, caplog: Any +) -> None: + """ + Checks if the ``unsubscribe`` function fails when invalid + parameters are passed. + """ + + async def check_unsubscribe_failing() -> None: + client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + + with pytest.raises(ValueError): + # private feed on unauthenticated client + await client.unsubscribe(subscription={"name": "ownTrades"}) + + with pytest.raises(ValueError): + # private subscriptions does not have a pair + await auth_client.unsubscribe( + subscription={"name": "ownTrades"}, pair=["XBTUSD"] ) - if self.__full_ws_access: - await auth_bot.edit_order(**params) - await self.__wait(seconds=2) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.edit_order(**params) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - @pytest.mark.skip("CI does not have trade/cancel permission") - def test_cancel_order(self: "WebsocketTests") -> None: - """ - Checks the ``cancel_order`` function by canceling some orders. - """ - - async def checkit() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - if self.__full_ws_access: - await auth_bot.cancel_order(txid="AOUEHF-ASLBD-A6B4A") - await self.__wait(seconds=2) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.cancel_order(txid="AOUEHF-ASLBD-A6B4A") - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - @pytest.mark.skip("CI does not have trade/cancel permission") - def test_cancel_all_orders(self: "WebsocketTests") -> None: - """ - Check the ``cancel_all_orders`` function by executing the function. - """ - - async def checkit() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - if self.__full_ws_access: - await auth_bot.cancel_all_orders() - await self.__wait(seconds=2) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.cancel_all_orders() - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - @pytest.mark.spot - @pytest.mark.spot_auth - @pytest.mark.spot_websocket - @pytest.mark.skip("CI does not have trade/cancel permission") - def test_cancel_all_orders_after(self: "Bot") -> None: - """ - Checking the ``cancel_all_orders_after`` function by - executing it. - """ - - async def checkit() -> None: - auth_bot: Bot = Bot(key=self.__key, secret=self.__secret) - if self.__full_ws_access: - await auth_bot.cancel_all_orders_after(0) - await self.__wait(seconds=2) - else: - # with pytest.raises(asyncio.CancelledError): - await auth_bot.cancel_all_orders_after(0) - await self.__wait(seconds=2) - - self.__create_loop(coro=checkit) - - def tearDown(self: "WebsocketTests") -> None: - return super().tearDown() + await async_wait(seconds=2) + + asyncio_run(check_unsubscribe_failing()) + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_create_order(spot_api_key: str, spot_secret_key: str, caplog: Any) -> None: + """ + Checks the ``create_order`` function by submitting a + new order - but in validate mode. + + The order submission will fail, because the testing API keys do not have + trade permission - but it is also checked that error messages + starting with "EGeneral:Invalid" are not included in the received + messages. This ensures that the Kraken API received the message and the only + problem is the permission. + + NOTE: This function is not disabled, since the function is executed in + validate mode. + """ + + async def execute_create_order() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + params: dict = dict( + ordertype="limit", + side="buy", + pair="XBT/USD", + volume="2", + price="1000", + price2="1200", + leverage="2", + oflags="viqc", + starttm="0", + expiretm="1000", + userref="12345678", + validate=True, + close_ordertype="limit", + close_price="1000", + close_price2="1200", + timeinforce="GTC", + ) + await auth_client.create_order(**params) + await async_wait(seconds=2) + + asyncio_run(execute_create_order()) + + assert ( + "{'errorMessage': 'EGeneral:Permission denied', 'event': 'addOrderStatus', 'reqid':" + in caplog.text + ) + assert "'errorMessage': 'EGeneral:Invalid" not in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_edit_order(spot_api_key: str, spot_secret_key: str, caplog: Any) -> None: + """ + Checks the edit order function by editing an order in validate mode. + + Same as with the trade endpoint - the response will include + a permission denied error - but it is also checked that no other + error includes the "invalid" string which means that the only problem + is the permission. + + NOTE: This function is not disabled, since the orderId does not + exist and would not cause any problems. + """ + + async def execute_edit_order() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + + params: dict = dict( + orderid="OHSAUDZ-ASJKGD-EPAFUIH", + reqid=1244, + pair="XBT/USD", + price="120", + price2="1300", + oflags="fok", + newuserref="833773", + validate=True, + ) + + await auth_client.edit_order(**params) + await async_wait(seconds=2) + + asyncio_run(execute_edit_order()) + + assert ( + "{'errorMessage': 'EGeneral:Permission denied', 'event': 'editOrderStatus', 'reqid':" + in caplog.text + ) + assert "'errorMessage': 'EGeneral:Invalid" not in caplog.text + + +# @pytest.mark.skip("CI does not have trade/cancel permission") +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_cancel_order(spot_api_key: str, spot_secret_key: str, caplog: Any) -> None: + """ + Checks the ``cancel_order`` function by canceling some orders. + + Same permission denied reason as for create and edit error. + + NOTE: This function is not disabled, since the txid does not + exist and would not cause any problems. + """ + + async def execute_cancel_order() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + await auth_client.cancel_order(txid=["AOUEHF-ASLBD-A6B4A"]) + await async_wait(seconds=2) + + asyncio_run(execute_cancel_order()) + + assert ( + "{'errorMessage': 'EGeneral:Permission denied', 'event': 'cancelOrderStatus', 'reqid':" + in caplog.text + ) + assert "'errorMessage': 'EGeneral:Invalid" not in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +@pytest.mark.skip("CI does not have trade/cancel permission") +def test_cancel_all_orders( + spot_api_key: str, spot_secret_key: str, caplog: Any +) -> None: + """ + Check the ``cancel_all_orders`` function by executing the function. + + Same permission denied reason as for create, edit and cancel error. + """ + + async def execute_cancel_all() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + await auth_client.cancel_all_orders() + await async_wait(seconds=2) + + asyncio_run(execute_cancel_all()) + + assert ( + "{'errorMessage': 'EGeneral:Permission denied', 'event': 'cancelAllStatus', 'reqid': " + in caplog.text + ) + assert "'errorMessage': 'EGeneral:Invalid" not in caplog.text + + +@pytest.mark.spot +@pytest.mark.spot_auth +@pytest.mark.spot_websocket +def test_cancel_all_orders_after( + spot_api_key: str, spot_secret_key: str, caplog: Any +) -> None: + """ + Checking the ``cancel_all_orders_after`` function by + executing it. + + NOTE: This function is not disabled, since the value 0 is + submitted which would reset the timer and would not cause + any problems. + """ + + async def execute_cancel_after() -> None: + auth_client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper( + key=spot_api_key, secret=spot_secret_key + ) + await auth_client.cancel_all_orders_after(0) + await async_wait(seconds=3) + + asyncio_run(execute_cancel_after()) + + assert ( + "{'errorMessage': 'EGeneral:Permission denied', 'event': 'cancelAllOrdersAfterStatus', 'reqid':" + in caplog.text + ) + assert "'errorMessage': 'EGeneral:Invalid" not in caplog.text in caplog.text + + +# todo: Create a test that kills the websocket connection +# to test the reconnect. +# from unittest import mock +# import json +# @pytest.mark.spot +# @pytest.mark.spot_websocket +# @pytest.mark.select +# @mock.patch( +# "kraken.spot.websocket.json.loads", +# ) +# def test_reconnect(mock_json_loads: mock.MagicMock, caplog: Any) -> None: +# mock_json_loads.side_effect = ( +# [json.dumps({"valid": "message"})] +# + [AttributeError("Test Error")] +# + [json.dumps({"valid": "message"})] * 10000 +# ) + +# async def check_reconnect() -> None: +# client: SpotWebsocketClientTestWrapper = SpotWebsocketClientTestWrapper() +# await async_wait(seconds=60) + +# asyncio_run(check_reconnect()) +# # with open("x.log", "w") as f: +# # f.write(caplog.text)