diff --git a/.github/.copilot-instructions.md b/.github/.copilot-instructions.md new file mode 100644 index 0000000..8dcbafd --- /dev/null +++ b/.github/.copilot-instructions.md @@ -0,0 +1,167 @@ +# Python Kraken SDK - Copilot Instructions + +## Project Overview + +This is the **python-kraken-sdk** - a high-performance REST and WebSocket API +client for Kraken Crypto Asset Exchange supporting both Spot and Futures +trading. The SDK prioritizes performance, maintainability, reliability, +modularization, and code reuse. + +## Core Principles + +### Code Quality Standards + +- **Performance First**: Optimize for speed and memory efficiency +- **Maintainability**: Write clean, readable, self-documenting code +- **Reliability**: Implement robust error handling and comprehensive testing +- **Modularization**: Favor composition over inheritance, create reusable + components +- **DRY Principle**: Eliminate code duplication through shared utilities and + base classes + +### Comment Philosophy + +- **Minimal Comments**: Code should be self-explanatory through clear naming and + structure +- **Only When Necessary**: Add comments only for non-obvious business logic, + complex algorithms, or API-specific quirks +- **No Obvious Comments**: Avoid stating what the code clearly does +- **Focus on Why**: When commenting, explain the reasoning, not the mechanics + +## Project Structure + +``` +src/kraken/ +├── __init__.py # Main package exports +├── cli.py # Command-line interface +├── base_api/ # Core base classes and utilities +│ └── __init__.py # SpotClient, FuturesClient, SpotAsyncClient, FuturesAsyncClient +├── exceptions/ # Custom exception classes +│ └── __init__.py # KrakenException hierarchy +├── utils/ # Shared utilities and helpers +├── spot/ # Spot trading API +│ ├── __init__.py # Spot client exports +│ ├── market.py # Market data client +│ ├── trade.py # Trading operations client +│ ├── user.py # User account client +│ ├── funding.py # Funding operations client +│ ├── earn.py # Earn/staking client +│ ├── orderbook.py # Order book client +│ ├── ws_client.py # WebSocket client implementation +│ └── websocket/ # WebSocket infrastructure +│ ├── __init__.py # SpotWSClientBase +│ └── connectors.py # Connection management +└── futures/ # Futures trading API + ├── __init__.py # Futures client exports + ├── market.py # Futures market data + ├── trade.py # Futures trading + ├── user.py # Futures user account + ├── funding.py # Futures funding + ├── ws_client.py # Futures WebSocket client + └── websocket/ # Futures WebSocket infrastructure +``` + +## Architecture Patterns + +### Client Hierarchy + +- **Base Classes**: `SpotClient`, `FuturesClient` (REST), `SpotAsyncClient`, + `FuturesAsyncClient` (async REST) +- **Specialized Clients**: Market, Trade, User, Funding, Earn (inherit from + base) +- **WebSocket Clients**: `SpotWSClient`, `FuturesWSClient` (extend async base + classes) + +### Key Design Patterns + +- **Composition over Inheritance**: Favor utility functions and mixins +- **Async/Await**: All WebSocket and async operations use proper async patterns +- **Context Managers**: Support `async with` for resource management +- **Type Hints**: Comprehensive typing with `typing` and `TYPE_CHECKING` +- **Error Handling**: Custom exception hierarchy with specific error types + +## Development Guidelines + +### Code Style + +- **Python 3.11+**: Use modern Python features and syntax +- **Type Annotations**: All functions must have complete type hints +- **Docstrings**: Use Google-style docstrings for public APIs +- **Naming**: Clear, descriptive names that eliminate need for comments + +### Performance Optimization + +- **Async Operations**: Use async/await for I/O operations +- **Connection Pooling**: Reuse HTTP connections where possible +- **Memory Efficiency**: Avoid unnecessary object creation in hot paths +- **Caching**: Use `@lru_cache` for expensive computations +- **Lazy Loading**: Initialize resources only when needed + +### Error Handling + +- **Custom Exceptions**: Use specific exception types from `kraken.exceptions` +- **Graceful Degradation**: Handle network failures and API errors robustly +- **Retry Logic**: Implement exponential backoff for transient failures +- **Logging**: Use structured logging with appropriate levels + +### Testing Standards + +- **Comprehensive Coverage**: Aim for high test coverage +- **Unit Tests**: Test individual components in isolation +- **Integration Tests**: Test API interactions (with mocking when needed) +- **Class-Based Organization**: Use pytest classes with shared fixtures and + constants +- **Helper Methods**: Create reusable assertion helpers to eliminate duplication + +### WebSocket Patterns + +- **Connection Management**: Proper connection lifecycle handling +- **Subscription Management**: Track active subscriptions +- **Message Routing**: Efficient message dispatch to handlers +- **Reconnection Logic**: Automatic reconnection with backoff + +## Code Generation Guidelines + +### When Creating New Features + +1. **Extend Existing Patterns**: Follow established client patterns +2. **Reuse Base Classes**: Inherit from appropriate base classes +3. **Share Common Logic**: Extract reusable components +4. **Maintain API Consistency**: Follow existing parameter and return patterns +5. **Add Comprehensive Tests**: Include unit and integration tests + +### When Refactoring + +1. **Preserve Public APIs**: Maintain backward compatibility +2. **Improve Performance**: Look for optimization opportunities +3. **Reduce Complexity**: Simplify complex methods through decomposition +4. **Enhance Type Safety**: Add or improve type annotations +5. **Update Tests**: Ensure tests reflect changes + +### Specific Preferences + +- **Constants**: Use UPPER_CASE for module-level constants +- **Private Methods**: Use single underscore prefix for internal methods +- **Property Methods**: Use `@property` for computed attributes +- **Context Managers**: Implement `__enter__`/`__exit__` or + `__aenter__`/`__aexit__` when managing resources + +## API Design Philosophy + +- **Pythonic Interface**: Feel natural to Python developers +- **Sensible Defaults**: Minimize required parameters +- **Flexibility**: Support both high-level convenience and low-level control +- **Performance**: Optimize for common use cases +- **Documentation**: Self-documenting through clear parameter names and types + +## Testing Philosophy + +- **Fast Feedback**: Tests should run quickly +- **Reliable**: Tests should not be flaky +- **Isolated**: Each test should be independent +- **Realistic**: Test real scenarios, not just edge cases +- **Maintainable**: Tests should be easy to understand and modify + +Remember: The goal is to create a professional, high-performance SDK that +developers love to use. Prioritize clarity, performance, and reliability in all +code contributions. diff --git a/pyproject.toml b/pyproject.toml index bdf8588..bfad0a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -291,6 +291,8 @@ task-tags = ["todo", "TODO", "fixme", "FIXME"] "S311", # pseudo-random-generator "SLF001", # private member access "TID252", # ban relative imports + "PLR0904", # too many public methods + "ANN401", # Use of typing.Any ] [tool.ruff.lint.flake8-copyright] diff --git a/tests/futures/conftest.py b/tests/futures/conftest.py index 226449d..cbe611b 100644 --- a/tests/futures/conftest.py +++ b/tests/futures/conftest.py @@ -18,19 +18,19 @@ FUTURES_EXTENDED_TIMEOUT: int = 30 -@pytest.fixture +@pytest.fixture(scope="session") def futures_api_key() -> str: """Returns the Futures API key""" return FUTURES_API_KEY -@pytest.fixture +@pytest.fixture(scope="session") def futures_secret_key() -> str: """Returns the Futures API secret key""" return FUTURES_SECRET_KEY -@pytest.fixture +@pytest.fixture(scope="session") def futures_market() -> Market: """ Fixture providing an unauthenticated Futures Market client @@ -40,7 +40,7 @@ def futures_market() -> Market: return market -@pytest.fixture +@pytest.fixture(scope="session") def futures_auth_market() -> Market: """ Fixture providing an authenticated Futures Market client. @@ -50,7 +50,7 @@ def futures_auth_market() -> Market: return market -@pytest.fixture +@pytest.fixture(scope="session") def futures_demo_market() -> Market: """ Fixture providing an authenticated Futures Market client that @@ -65,7 +65,7 @@ def futures_demo_market() -> Market: return market -@pytest.fixture +@pytest.fixture(scope="session") def futures_user() -> User: """ Fixture providing an unauthenticated Futures User client. @@ -75,7 +75,7 @@ def futures_user() -> User: return user -@pytest.fixture +@pytest.fixture(scope="session") def futures_auth_user() -> User: """ Fixture providing an authenticated Futures User client. @@ -85,7 +85,7 @@ def futures_auth_user() -> User: return user -@pytest.fixture +@pytest.fixture(scope="session") def futures_demo_user() -> User: """ Fixture providing an authenticated Futures User client that @@ -100,7 +100,7 @@ def futures_demo_user() -> User: return user -@pytest.fixture +@pytest.fixture(scope="session") def futures_trade() -> Trade: """ Fixture providing an unauthenticated Futures Trade client. @@ -110,7 +110,7 @@ def futures_trade() -> Trade: return trade -@pytest.fixture +@pytest.fixture(scope="session") def futures_auth_trade() -> Trade: """ Fixture providing an authenticated Futures Trade client. @@ -120,7 +120,7 @@ def futures_auth_trade() -> Trade: return trade -@pytest.fixture +@pytest.fixture(scope="session") def futures_demo_trade() -> Trade: """ Fixture providing an authenticated Futures Trade client that @@ -135,7 +135,7 @@ def futures_demo_trade() -> Trade: return trade -@pytest.fixture +@pytest.fixture(scope="session") def futures_funding() -> Funding: """ Fixture providing an unauthenticated Futures Funding client. @@ -145,7 +145,7 @@ def futures_funding() -> Funding: return funding -@pytest.fixture +@pytest.fixture(scope="session") def futures_auth_funding() -> Funding: """ Fixture providing an authenticated Futures Funding client. @@ -155,7 +155,7 @@ def futures_auth_funding() -> Funding: return funding -@pytest.fixture +@pytest.fixture(scope="session") def futures_demo_funding() -> Funding: """ Fixture providing an authenticated Futures Funding client that diff --git a/tests/futures/test_futures_base_api.py b/tests/futures/test_futures_base_api.py index f64b565..4f17daf 100644 --- a/tests/futures/test_futures_base_api.py +++ b/tests/futures/test_futures_base_api.py @@ -8,6 +8,7 @@ """Module that checks the general Futures Base API class.""" from asyncio import run +from typing import Self from unittest import IsolatedAsyncioTestCase import pytest @@ -21,51 +22,53 @@ @pytest.mark.futures -def test_KrakenFuturesBaseAPI_without_exception() -> None: - """ - Checks first if the expected error will be raised and than - creates a new KrakenFuturesBaseAPI instance that do not raise - the custom Kraken exceptions. This new instance than executes - the same request and the returned response gets evaluated. - """ - with pytest.raises(KrakenRequiredArgumentMissingError): - FuturesClient( - key="fake", - secret="fake", - ).request(method="POST", uri="/derivatives/api/v3/sendorder", auth=True) - - result: dict = ( - FuturesClient(key="fake", secret="fake", use_custom_exceptions=False) # type: ignore[union-attr] - .request(method="POST", uri="/derivatives/api/v3/sendorder", auth=True) - .json() - ) - - assert result.get("result") == "error" - assert result.get("error") == "requiredArgumentMissing" +class TestFuturesBaseAPI: + """Test class for Futures Base API functionality.""" + def test_KrakenFuturesBaseAPI_without_exception(self) -> None: + """ + Checks first if the expected error will be raised and then + creates a new KrakenFuturesBaseAPI instance that does not raise + the custom Kraken exceptions. This new instance then executes + the same request and the returned response gets evaluated. + """ + with pytest.raises(KrakenRequiredArgumentMissingError): + FuturesClient( + key="fake", + secret="fake", + ).request(method="POST", uri="/derivatives/api/v3/sendorder", auth=True) + + result: dict = ( + FuturesClient(key="fake", secret="fake", use_custom_exceptions=False) # type: ignore[union-attr] + .request(method="POST", uri="/derivatives/api/v3/sendorder", auth=True) + .json() + ) -@pytest.mark.futures -@pytest.mark.futures_auth -def test_futures_rest_contextmanager( - futures_market: Market, - futures_auth_funding: Funding, - futures_demo_trade: Trade, - futures_auth_user: User, -) -> None: - """ - Checks if the clients can be used as context manager. - """ - with futures_market as market: - assert isinstance(market.get_tick_types(), list) + assert result.get("result") == "error" + assert result.get("error") == "requiredArgumentMissing" + + @pytest.mark.futures_auth + def test_futures_rest_contextmanager( + self, + futures_market: Market, + futures_auth_funding: Funding, + futures_demo_trade: Trade, + futures_auth_user: User, + ) -> None: + """ + Checks if the clients can be used as context manager. + """ + with futures_market as market: + assert isinstance(market.get_tick_types(), list) - with futures_auth_funding as funding: - assert is_success(funding.get_historical_funding_rates(symbol="PF_SOLUSD")) + with futures_auth_funding as funding: + assert is_success(funding.get_historical_funding_rates(symbol="PF_SOLUSD")) - with futures_auth_user as user: - assert is_success(user.get_wallets()) + with futures_auth_user as user: + assert is_success(user.get_wallets()) - with futures_demo_trade as trade: - assert is_success(trade.get_fills()) + with futures_demo_trade as trade: + assert is_success(trade.get_fills()) # ============================================================================== @@ -73,64 +76,67 @@ def test_futures_rest_contextmanager( @pytest.mark.futures -def test_futures_async_rest_contextmanager() -> None: - """ - Checks if the clients can be used as context manager. - """ - - async def check() -> None: - async with FuturesAsyncClient() as client: - assert isinstance( - await client.request( - "GET", - "/api/charts/v1/spot/PI_XBTUSD/1h", - auth=False, - post_params={"from": "1668989233", "to": "1668999233"}, - ), - dict, - ) - - run(check()) +class TestFuturesBaseAPIAsync: + """Test class for Futures Base API async functionality.""" + def test_futures_async_rest_contextmanager(self: Self) -> None: + """ + Checks if the clients can be used as context manager. + """ -@pytest.mark.futures -@pytest.mark.futures_auth -def test_futures_rest_async_client_post( - futures_api_key: str, - futures_secret_key: str, -) -> None: - """ - Check the instantiation as well as a simple request using the async client. - """ - - async def check() -> None: - client = FuturesAsyncClient(futures_api_key, futures_secret_key) - try: - assert isinstance( - await client.request( - "POST", - "/derivatives/api/v3/orders/status", - post_params={ - "orderIds": [ - "bcaaefce-27a3-44b4-b13a-19df21e3f087", - "685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", - ], - }, - ), - dict, - ) - finally: - await client.close() - - run(check()) + async def check() -> None: + async with FuturesAsyncClient() as client: + assert isinstance( + await client.request( + "GET", + "/api/charts/v1/spot/PI_XBTUSD/1h", + auth=False, + post_params={"from": "1668989233", "to": "1668999233"}, + ), + dict, + ) + + run(check()) + + @pytest.mark.futures_auth + def test_futures_rest_async_client_post( + self: Self, + futures_api_key: str, + futures_secret_key: str, + ) -> None: + """ + Check the instantiation as well as a simple request using the async client. + """ + + async def check() -> None: + client = FuturesAsyncClient(futures_api_key, futures_secret_key) + try: + assert isinstance( + await client.request( + "POST", + "/derivatives/api/v3/orders/status", + post_params={ + "orderIds": [ + "bcaaefce-27a3-44b4-b13a-19df21e3f087", + "685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", + ], + }, + ), + dict, + ) + finally: + await client.close() + + run(check()) +@pytest.mark.futures +@pytest.mark.futures_market +@pytest.mark.futures_market class TestProxyPyEmbedded(TestCase, IsolatedAsyncioTestCase): - def get_proxy_str(self) -> str: + def get_proxy_str(self: Self) -> str: return f"http://127.0.0.1:{self.PROXY.flags.port}" - @pytest.mark.futures - @pytest.mark.futures_market def test_futures_rest_proxies(self) -> None: """ Checks if the clients can be used with a proxy. @@ -147,9 +153,7 @@ def test_futures_rest_proxies(self) -> None: ) @pytest.mark.asyncio - @pytest.mark.futures - @pytest.mark.futures_market - async def test_futures_rest_proxies_async(self) -> None: + async def test_futures_rest_proxies_async(self: Self) -> None: """ Checks if the async clients can be used with a proxy. """ diff --git a/tests/futures/test_futures_funding.py b/tests/futures/test_futures_funding.py index 681bb41..ac9c65a 100644 --- a/tests/futures/test_futures_funding.py +++ b/tests/futures/test_futures_funding.py @@ -7,76 +7,70 @@ """Module that implements the unit tests for the Futures funding client.""" +from typing import Self + import pytest from kraken.futures import Funding from .helper import is_success -# todo: Mocking? Or is this to dangerous? - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_funding -@pytest.mark.skip(reason="Too much data, read time out") -def test_get_historical_funding_rates(futures_demo_funding: Funding) -> None: - """ - Checks the ``get_historical_funding_rates`` function. - """ - assert is_success( - futures_demo_funding.get_historical_funding_rates(symbol="PF_XBTUSD"), - ) - @pytest.mark.futures @pytest.mark.futures_auth @pytest.mark.futures_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_initiate_wallet_transfer( - futures_demo_funding: Funding, -) -> None: - """ - Checks the ``initiate_wallet_transfer`` function - skipped since - a transfer in testing is not desired. - """ - # accounts must exist.. - # print(futures_demo_funding.initiate_wallet_transfer( - # amount=200, fromAccount='Futures Wallet', toAccount='Spot Wallet', unit='XBT' - # )) +class TestFuturesFunding: + def test_get_historical_funding_rates( + self: Self, + futures_demo_funding: Funding, + ) -> None: + """ + Checks the ``get_historical_funding_rates`` function. + """ + assert is_success( + futures_demo_funding.get_historical_funding_rates(symbol="PF_XBTUSD"), + ) + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_initiate_wallet_transfer( + self: Self, + futures_demo_funding: Funding, + ) -> None: + """ + Checks the ``initiate_wallet_transfer`` function - skipped since + a transfer in testing is not desired. + """ + # accounts must exist.. + # print(futures_demo_funding.initiate_wallet_transfer( + # amount=200, fromAccount='Futures Wallet', toAccount='Spot Wallet', unit='XBT' + # )) -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_initiate_subccount_transfer( - futures_demo_funding: Funding, -) -> None: - """ - Checks the ``initiate_subaccount_transfer`` function. - """ - # print(futures_demo_funding.initiate_subaccount_transfer( - # amount=200, - # fromAccount='The wallet (cash or margin account) from which funds should be debited', - # fromUser='The user account (this or a sub account) from which funds should be debited', - # toAccount='The wallet (cash or margin account) to which funds should be credited', - # toUser='The user account (this or a sub account) to which funds should be credited', - # unit='XBT', - # )) - + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_initiate_subccount_transfer( + self: Self, + futures_demo_funding: Funding, + ) -> None: + """ + Checks the ``initiate_subaccount_transfer`` function. + """ + # print(futures_demo_funding.initiate_subaccount_transfer( + # amount=200, + # fromAccount='The wallet (cash or margin account) from which funds should be debited', + # fromUser='The user account (this or a sub account) from which funds should be debited', + # toAccount='The wallet (cash or margin account) to which funds should be credited', + # toUser='The user account (this or a sub account) to which funds should be credited', + # unit='XBT', + # )) -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_initiate_withdrawal_to_spot_wallet( - futures_demo_funding: Funding, -) -> None: - """ - Checks the ``initiate_withdrawal_to_spot_wallet`` function. - """ - # print(futures_demo_funding.initiate_withdrawal_to_spot_wallet( - # amount=200, - # currency='XBT', - # )) + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_initiate_withdrawal_to_spot_wallet( + self: Self, + futures_demo_funding: Funding, + ) -> None: + """ + Checks the ``initiate_withdrawal_to_spot_wallet`` function. + """ + # print(futures_demo_funding.initiate_withdrawal_to_spot_wallet( + # amount=200, + # currency='XBT', + # )) diff --git a/tests/futures/test_futures_market.py b/tests/futures/test_futures_market.py index ad2648a..daee46e 100644 --- a/tests/futures/test_futures_market.py +++ b/tests/futures/test_futures_market.py @@ -7,6 +7,8 @@ """Module that implements the unit tests for the Futures market client.""" +from typing import Any, Self + import pytest from kraken.futures import Market @@ -16,277 +18,270 @@ @pytest.mark.futures @pytest.mark.futures_market -def test_get_ohlc(futures_market: Market) -> None: - """ - Checks the ``get_ohlc`` endpoint. - """ - assert isinstance( - futures_market.get_ohlc( - tick_type="trade", - symbol="PI_XBTUSD", - resolution="1m", - from_="1668989233", - to="1668999233", - ), - dict, - ) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_ohlc_failing_wrong_tick_type(futures_market: Market) -> None: - """ - Checks the ``get_ohlc`` function by passing an invalid tick type. - """ - with pytest.raises( - ValueError, - match=r"tick_type must be in \('spot', 'mark', 'trade'\)", - ): - futures_market.get_ohlc(symbol="XBTUSDT", resolution="240", tick_type="fail") - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_ohlc_failing_wrong_resolution(futures_market: Market) -> None: - """ - Checks the ``get_ohlc`` function by passing an invalid resolution. - """ - with pytest.raises( - ValueError, - match=r"resolution must be in \('1m', '5m', '15m', '30m', '1h', '4h', '12h', '1d', '1w'\)", - ): - futures_market.get_ohlc(symbol="XBTUSDT", resolution="1234", tick_type="trade") - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_tick_types(futures_market: Market) -> None: - assert isinstance(futures_market.get_tick_types(), list) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_tradeable_products(futures_market: Market) -> None: - """ - Checks the ``get_tradeable_products`` endpoint. - """ - assert isinstance(futures_market.get_tradeable_products(tick_type="mark"), list) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_resolutions(futures_market: Market) -> None: - """ - Checks the ``get_resolutions`` endpoint. - """ - assert isinstance( - futures_market.get_resolutions(tick_type="trade", tradeable="PI_XBTUSD"), - list, - ) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_fee_schedules(futures_market: Market) -> None: - """ - Checks the ``get_fee_schedules`` endpoint. - """ - assert is_success(futures_market.get_fee_schedules()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_market -def test_get_fee_schedules_vol(futures_auth_market: Market) -> None: - """ - Checks the ``get_fee_schedules_vol`` endpoint. - """ - assert is_success(futures_auth_market.get_fee_schedules_vol()) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_orderbook(futures_market: Market) -> None: - """ - Checks the ``get_orderbook`` endpoint. - """ - # assert type(market.get_orderbook()) == dict # raises 500-INTERNAL_SERVER_ERROR on Kraken, - # but symbol is optional as described in the API documentation (Dec, 2022) - assert is_success(futures_market.get_orderbook(symbol="PI_XBTUSD")) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_tickers(futures_market: Market) -> None: - """ - Checks the ``get_tickers`` endpoint. - """ - assert is_success(futures_market.get_tickers()) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_instruments(futures_market: Market) -> None: - """ - Checks the ``get_instruments`` endpoint. - """ - assert is_success(futures_market.get_instruments()) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_instruments_status(futures_market: Market) -> None: - """ - Checks the ``get_instruments_status`` endpoint. - """ - assert is_success(futures_market.get_instruments_status()) - assert is_success(futures_market.get_instruments_status(instrument="PI_XBTUSD")) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_trade_history(futures_market: Market) -> None: - """ - Checks the ``get_trade_history`` endpoint. - """ - assert is_success(futures_market.get_trade_history(symbol="PI_XBTUSD")) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_historical_funding_rates(futures_market: Market) -> None: - """ - Checks the ``get_historical_funding_rates`` endpoint. - """ - assert is_success(futures_market.get_historical_funding_rates(symbol="PI_XBTUSD")) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_market -def test_get_leverage_preference(futures_auth_market: Market) -> None: - """ - Checks the ``get_leverage_preference`` endpoint. - """ - assert is_not_error(futures_auth_market.get_leverage_preference()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_market -@pytest.mark.skip(reason="CI does not have trade permission") -def test_set_leverage_preference(futures_auth_market: Market) -> None: - """ - Checks the ``set_leverage_preference`` endpoint. - """ - old_leverage_preferences: dict = futures_auth_market.get_leverage_preference() - assert "result" in old_leverage_preferences - assert old_leverage_preferences["result"] == "success" - assert is_success( - futures_auth_market.set_leverage_preference(symbol="PF_XBTUSD", maxLeverage=2), - ) - - new_leverage_preferences: dict = futures_auth_market.get_leverage_preference() - assert "result" in new_leverage_preferences - assert new_leverage_preferences["result"] == "success" - assert "leveragePreferences" in new_leverage_preferences - assert {"symbol": "PF_XBTUSD", "maxLeverage": 2.0} in new_leverage_preferences[ - "leveragePreferences" - ] - - if "leveragePreferences" in old_leverage_preferences: - for setting in old_leverage_preferences["leveragePreferences"]: - if "symbol" in setting and setting["symbol"] == "PF_XBTUSD": - assert is_success( - futures_auth_market.set_leverage_preference(symbol="PF_XBTUSD"), - ) - break - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_market -def test_get_pnl_preference(futures_auth_market: Market) -> None: - """ - Checks the ``get_pnl_preference`` endpoint. - """ - assert is_not_error(futures_auth_market.get_pnl_preference()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_market -@pytest.mark.skip(reason="CI does not have trade permission") -def test_set_pnl_preference(futures_auth_market: Market) -> None: - """ - Checks the ``set_pnl_preference`` endpoint. - """ - old_pnl_preference: dict = futures_auth_market.get_pnl_preference() - assert "result" in old_pnl_preference - assert old_pnl_preference["result"] == "success" - assert is_success( - futures_auth_market.set_pnl_preference(symbol="PF_XBTUSD", pnlPreference="BTC"), - ) - - new_pnl_preference: dict = futures_auth_market.get_pnl_preference() - assert "result" in new_pnl_preference - assert new_pnl_preference["result"] == "success" - assert "preferences" in new_pnl_preference - assert {"symbol": "PF_XBTUSD", "pnlCurrency": "BTC"} in new_pnl_preference[ - "preferences" - ] - - if "preferences" in old_pnl_preference: - for setting in old_pnl_preference["preferences"]: - if "symbol" in setting and setting["symbol"] == "PF_XBTUSD": - assert is_success( - futures_auth_market.set_pnl_preference( - symbol="PF_XBTUSD", - pnlPreference=setting["pnlCurrency"], - ), - ) - break - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_public_execution_events(futures_market: Market) -> None: - """ - Checks the ``get_public_execution_events`` endpoint. - """ - assert is_not_error( - futures_market.get_public_execution_events( - tradeable="PF_SOLUSD", - since=1668989233, - before=1668999999, - ), - ) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_public_order_events(futures_market: Market) -> None: - """ - Checks the ``public_order_events`` endpoint. - """ - assert is_not_error( - futures_market.get_public_order_events( - tradeable="PF_SOLUSD", - since=1668989233, - sort="asc", - ), - ) - - -@pytest.mark.futures -@pytest.mark.futures_market -def test_get_public_mark_price_events(futures_market: Market) -> None: - """ - Checks the ``get_public_mark_price_events`` endpoint. - """ - assert is_not_error( - futures_market.get_public_mark_price_events( - tradeable="PF_SOLUSD", - since=1668989233, - ), - ) +class TestFuturesMarket: + """Test class for Futures Market client functionality.""" + + SYMBOL = "PI_XBTUSD" + TRADEABLE = "PF_SOLUSD" + SINCE = "1668989233" + BEFORE = "1668999999" + + def _assert_successful_response(self: Self, result: Any) -> None: + """Helper method to assert a successful response.""" + assert is_success(result) + + def _assert_not_error_response(self: Self, result: Any) -> None: + """Helper method to assert a response without errors.""" + assert is_not_error(result) + + def test_get_ohlc(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_ohlc`` endpoint. + """ + assert isinstance( + futures_market.get_ohlc( + tick_type="trade", + symbol=self.SYMBOL, + resolution="1m", + from_=self.SINCE, + to=self.BEFORE, + ), + dict, + ) + + def test_get_ohlc_failing_wrong_tick_type( + self: Self, + futures_market: Market, + ) -> None: + """ + Checks the ``get_ohlc`` function by passing an invalid tick type. + """ + with pytest.raises( + ValueError, + match=r"tick_type must be in \('spot', 'mark', 'trade'\)", + ): + futures_market.get_ohlc( + symbol=self.SYMBOL, + resolution="240", + tick_type="fail", + ) + + def test_get_ohlc_failing_wrong_resolution( + self: Self, + futures_market: Market, + ) -> None: + """ + Checks the ``get_ohlc`` function by passing an invalid resolution. + """ + with pytest.raises( + ValueError, + match=r"resolution must be in \('1m', '5m', '15m', '30m', '1h', '4h', '12h', '1d', '1w'\)", + ): + futures_market.get_ohlc( + symbol=self.SYMBOL, + resolution="1234", + tick_type="trade", + ) + + def test_get_tick_types(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_tick_types`` endpoint. + """ + assert isinstance(futures_market.get_tick_types(), list) + + def test_get_tradeable_products(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_tradeable_products`` endpoint. + """ + assert isinstance(futures_market.get_tradeable_products(tick_type="mark"), list) + + def test_get_resolutions(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_resolutions`` endpoint. + """ + assert isinstance( + futures_market.get_resolutions(tick_type="trade", tradeable=self.SYMBOL), + list, + ) + + def test_get_fee_schedules(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_fee_schedules`` endpoint. + """ + self._assert_successful_response(futures_market.get_fee_schedules()) + + @pytest.mark.futures + @pytest.mark.futures_auth + @pytest.mark.futures_market + def test_get_fee_schedules_vol(self: Self, futures_auth_market: Market) -> None: + """ + Checks the ``get_fee_schedules_vol`` endpoint. + """ + self._assert_successful_response(futures_auth_market.get_fee_schedules_vol()) + + def test_get_orderbook(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_orderbook`` endpoint. + """ + # assert type(market.get_orderbook()) == dict # raises 500-INTERNAL_SERVER_ERROR on Kraken, + # but symbol is optional as described in the API documentation (Dec, 2022) + self._assert_successful_response( + futures_market.get_orderbook(symbol=self.SYMBOL), + ) + + def test_get_tickers(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_tickers`` endpoint. + """ + self._assert_successful_response(futures_market.get_tickers()) + + def test_get_instruments(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_instruments`` endpoint. + """ + self._assert_successful_response(futures_market.get_instruments()) + + def test_get_instruments_status(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_instruments_status`` endpoint. + """ + self._assert_successful_response(futures_market.get_instruments_status()) + self._assert_successful_response( + futures_market.get_instruments_status(instrument=self.SYMBOL), + ) + + def test_get_trade_history(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_trade_history`` endpoint. + """ + self._assert_successful_response( + futures_market.get_trade_history(symbol=self.SYMBOL), + ) + + def test_get_historical_funding_rates(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_historical_funding_rates`` endpoint. + """ + self._assert_successful_response( + futures_market.get_historical_funding_rates(symbol=self.SYMBOL), + ) + + @pytest.mark.futures_auth + def test_get_leverage_preference(self: Self, futures_auth_market: Market) -> None: + """ + Checks the ``get_leverage_preference`` endpoint. + """ + self._assert_not_error_response(futures_auth_market.get_leverage_preference()) + + @pytest.mark.futures_auth + @pytest.mark.skip(reason="Tests do not have trade permission") + def test_set_leverage_preference(self: Self, futures_auth_market: Market) -> None: + """ + Checks the ``set_leverage_preference`` endpoint. + """ + old_preferences = futures_auth_market.get_leverage_preference() + assert "result" in old_preferences + assert old_preferences["result"] == "success" + try: + self._assert_successful_response( + futures_auth_market.set_leverage_preference( + symbol=self.SYMBOL, + maxLeverage=2, + ), + ) + + new_preferences = futures_auth_market.get_leverage_preference() + assert "result" in new_preferences + assert new_preferences["result"] == "success" + assert "leveragePreferences" in new_preferences + assert {"symbol": self.SYMBOL, "maxLeverage": 2.0} in new_preferences[ + "leveragePreferences" + ] + finally: + if "leveragePreferences" in old_preferences: + for setting in old_preferences["leveragePreferences"]: + if "symbol" in setting and setting["symbol"] == self.SYMBOL: + self._assert_successful_response( + futures_auth_market.set_leverage_preference( + symbol=self.SYMBOL, + ), + ) + break + + @pytest.mark.futures_auth + def test_get_pnl_preference(self: Self, futures_auth_market: Market) -> None: + """ + Checks the ``get_pnl_preference`` endpoint. + """ + self._assert_not_error_response(futures_auth_market.get_pnl_preference()) + + @pytest.mark.futures_auth + @pytest.mark.skip(reason="Tests do not have trade permission") + def test_set_pnl_preference(self: Self, futures_auth_market: Market) -> None: + """ + Checks the ``set_pnl_preference`` endpoint. + """ + old_preference = futures_auth_market.get_pnl_preference() + assert "result" in old_preference + assert old_preference["result"] == "success" + try: + self._assert_successful_response( + futures_auth_market.set_pnl_preference( + symbol=self.SYMBOL, + pnlPreference="BTC", + ), + ) + + new_preference = futures_auth_market.get_pnl_preference() + assert "result" in new_preference + assert new_preference["result"] == "success" + assert "preferences" in new_preference + assert {"symbol": self.SYMBOL, "pnlCurrency": "BTC"} in new_preference[ + "preferences" + ] + finally: + if "preferences" in old_preference: + for setting in old_preference["preferences"]: + if "symbol" in setting and setting["symbol"] == self.SYMBOL: + self._assert_successful_response( + futures_auth_market.set_pnl_preference( + symbol=self.SYMBOL, + pnlPreference=setting["pnlCurrency"], + ), + ) + break + + def test_get_public_execution_events(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_public_execution_events`` endpoint. + """ + self._assert_not_error_response( + futures_market.get_public_execution_events( + tradeable=self.TRADEABLE, + since=int(self.SINCE), + before=int(self.BEFORE), + ), + ) + + def test_get_public_order_events(self: Self, futures_market: Market) -> None: + """ + Checks the ``public_order_events`` endpoint. + """ + self._assert_not_error_response( + futures_market.get_public_order_events( + tradeable=self.TRADEABLE, + since=int(self.SINCE), + sort="asc", + ), + ) + + def test_get_public_mark_price_events(self: Self, futures_market: Market) -> None: + """ + Checks the ``get_public_mark_price_events`` endpoint. + """ + self._assert_not_error_response( + futures_market.get_public_mark_price_events( + tradeable=self.TRADEABLE, + since=int(self.SINCE), + ), + ) diff --git a/tests/futures/test_futures_trade.py b/tests/futures/test_futures_trade.py index 1589a91..f9d7fb9 100644 --- a/tests/futures/test_futures_trade.py +++ b/tests/futures/test_futures_trade.py @@ -10,6 +10,7 @@ from collections.abc import Generator from contextlib import suppress from time import sleep +from typing import Any, Self import pytest @@ -36,257 +37,250 @@ def _run_before_and_after_tests(futures_demo_trade: Trade) -> Generator: @pytest.mark.futures @pytest.mark.futures_auth @pytest.mark.futures_trade -def test_get_fills(futures_demo_trade: Trade) -> None: - """ - Checks the ``get_fills`` endpoint. - """ - assert is_success(futures_demo_trade.get_fills()) - assert is_success( - futures_demo_trade.get_fills(lastFillTime="2020-07-21T12:41:52.790Z"), - ) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_dead_mans_switch(futures_demo_trade: Trade) -> None: - """ - Checks the ``dead_mans_switch`` endpoint. - """ - assert is_success(futures_demo_trade.dead_mans_switch(timeout=60)) - assert is_success( - futures_demo_trade.dead_mans_switch(timeout=0), - ) # reset dead mans switch - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_get_orders_status(futures_demo_trade: Trade) -> None: - """ - Checks the ``get_orders_status`` endpoint. - """ - assert is_success( - futures_demo_trade.get_orders_status( - orderIds=[ - "bcaaefce-27a3-44b4-b13a-19df21e3f087", - "685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", - ], - ), - ) - - assert is_success( - futures_demo_trade.get_orders_status( - cliOrdIds=[ - "bcaaefce-27a3-44b4-b13a-19df21e3f087", - "685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", - ], - ), - ) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_create_order(futures_demo_trade: Trade) -> None: - """ - Checks the ``create_order`` endpoint. - """ - with suppress(KrakenInsufficientAvailableFundsError): - futures_demo_trade.create_order( - orderType="lmt", - size=10, - symbol="PI_XBTUSD", - side="buy", - limitPrice=1, - stopPrice=10, - reduceOnly=True, - processBefore="3033-11-08T19:56:35.441899Z", +class TestFuturesTrade: + """Test class for Futures Trade client functionality.""" + + SYMBOL = "PI_XBTUSD" + ORDER_TYPE_LIMIT = "lmt" + ORDER_TYPE_MARKET = "mkt" + ORDER_TYPE_TAKE_PROFIT = "take_profit" + SIDE_BUY = "buy" + SIDE_SELL = "sell" + INVALID_SIDE = "long" + SIZE = 10 + LIMIT_PRICE = 1 + STOP_PRICE = 10 + LIMIT_PRICE_HIGH = 12000 + STOP_PRICE_HIGH = 13000 + TRIGGER_SIGNAL_LAST = "last" + TRIGGER_SIGNAL_MARK = "mark" + TRIGGER_SIGNAL_INVALID = "fail" + REDUCE_ONLY = True + PROCESS_BEFORE = "3033-11-08T19:56:35.441899Z" + LAST_FILL_TIME = "2020-07-21T12:41:52.790Z" + TEST_ORDER_IDS = None + + def __init__(self) -> None: + self.TEST_ORDER_IDS = [ + "bcaaefce-27a3-44b4-b13a-19df21e3f087", + "685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", + ] + + def _assert_successful_response(self: Self, result: Any) -> None: + """Helper method to assert a successful response.""" + assert is_success(result) + + def test_get_fills(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``get_fills`` endpoint. + """ + self._assert_successful_response(futures_demo_trade.get_fills()) + self._assert_successful_response( + futures_demo_trade.get_fills(lastFillTime=self.LAST_FILL_TIME), ) - # FIXME: why are these commented out? - # with suppress(KrakenInsufficientAvailableFundsError): - # futures_demo_trade.create_order( - # orderType="take_profit", - # size=10, - # side="buy", - # symbol="PI_XBTUSD", - # limitPrice=12000, - # triggerSignal="last", - # stopPrice=13000, - # ) - - # try: - # # does not work, 400 response "invalid order type" - # # but it is documented here: https://docs.futures.kraken.com/#http-api-trading-v3-api-order-management-send-order - # # Kraken needs to fix this - # futures_demo_trade.create_order( - # orderType="trailing_stop", - # size=10, - # side="buy", - # symbol="PI_XBTUSD", - # limitPrice=12000, - # triggerSignal="mark", - # trailingStopDeviationUnit="PERCENT", - # trailingStopMaxDeviation=10, - # ) - # except KrakenException.KrakenException.KrakenInsufficientAvailableFundsError: - # pass - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_create_order_failing(futures_demo_trade: Trade) -> None: - """ - Checks ``create_order`` endpoint to fail when using invalid parameters. - """ - with pytest.raises( - ValueError, - match=r"Invalid side. One of \[\('buy', 'sell'\)\] is required!", - ): - futures_demo_trade.create_order( - orderType="mkt", - size=10, - symbol="PI_XBTUSD", - side="long", + def test_dead_mans_switch(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``dead_mans_switch`` endpoint. + """ + self._assert_successful_response( + futures_demo_trade.dead_mans_switch(timeout=60), ) - - with pytest.raises( - ValueError, - match=r"Trigger signal must be in \[\('mark', 'spot', 'last'\)\]!", - ): - futures_demo_trade.create_order( - orderType="take-profit", - size=10, - side="buy", - symbol="PI_XBTUSD", - limitPrice=12000, - triggerSignal="fail", - stopPrice=13000, + self._assert_successful_response( + futures_demo_trade.dead_mans_switch(timeout=0), + ) # reset dead mans switch + + def test_get_orders_status(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``get_orders_status`` endpoint. + """ + self._assert_successful_response( + futures_demo_trade.get_orders_status( + orderIds=self.TEST_ORDER_IDS, + ), ) - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_create_batch_order(futures_demo_trade: Trade) -> None: - """ - Checks the ``create_order_batch`` endpoint. - """ - with suppress(KrakenInsufficientAvailableFundsError): - assert is_success( - futures_demo_trade.create_batch_order( - batchorder_list=[ - { - "order": "send", - "order_tag": "1", - "orderType": "lmt", - "symbol": "PI_XBTUSD", - "side": "buy", - "size": 5, - "limitPrice": 1.00, - "cliOrdId": "my_another_client_id", - }, - { - "order": "send", - "order_tag": "2", - "orderType": "stp", - "symbol": "PI_XBTUSD", - "side": "buy", - "size": 1, - "limitPrice": 2.00, - "stopPrice": 3.00, - }, - { - "order": "send", - "order_tag": "3", - "orderType": "post", - "symbol": "PI_XBTUSD", - "side": "buy", - "size": 5, - "limitPrice": 1.00, - "reduceOnly": True, - }, - ], - processBefore="3033-11-08T19:56:35.441899Z", + self._assert_successful_response( + futures_demo_trade.get_orders_status( + cliOrdIds=self.TEST_ORDER_IDS, ), ) + def test_create_order(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``create_order`` endpoint. + """ + with suppress(KrakenInsufficientAvailableFundsError): + futures_demo_trade.create_order( + orderType=self.ORDER_TYPE_LIMIT, + size=self.SIZE, + symbol=self.SYMBOL, + side=self.SIDE_BUY, + limitPrice=self.LIMIT_PRICE, + stopPrice=self.STOP_PRICE, + reduceOnly=self.REDUCE_ONLY, + processBefore=self.PROCESS_BEFORE, + ) + + # FIXME: why are these commented out? + # with suppress(KrakenInsufficientAvailableFundsError): + # futures_demo_trade.create_order( + # orderType="take_profit", + # size=10, + # side="buy", + # symbol="PI_XBTUSD", + # limitPrice=12000, + # triggerSignal="last", + # stopPrice=13000, + # ) + + # try: + # # does not work, 400 response "invalid order type" + # # but it is documented here: https://docs.futures.kraken.com/#http-api-trading-v3-api-order-management-send-order + # # Kraken needs to fix this + # futures_demo_trade.create_order( + # orderType="trailing_stop", + # size=10, + # side="buy", + # symbol="PI_XBTUSD", + # limitPrice=12000, + # triggerSignal="mark", + # trailingStopDeviationUnit="PERCENT", + # trailingStopMaxDeviation=10, + # ) + # except KrakenException.KrakenException.KrakenInsufficientAvailableFundsError: + # pass + + def test_create_order_failing(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks ``create_order`` endpoint to fail when using invalid parameters. + """ + with pytest.raises( + ValueError, + match=r"Invalid side. One of \[\('buy', 'sell'\)\] is required!", + ): + futures_demo_trade.create_order( + orderType=self.ORDER_TYPE_MARKET, + size=self.SIZE, + symbol=self.SYMBOL, + side=self.INVALID_SIDE, + ) + + with pytest.raises( + ValueError, + match=r"Trigger signal must be in \[\('mark', 'spot', 'last'\)\]!", + ): + futures_demo_trade.create_order( + orderType=self.ORDER_TYPE_TAKE_PROFIT, + size=self.SIZE, + side=self.SIDE_BUY, + symbol=self.SYMBOL, + limitPrice=self.LIMIT_PRICE_HIGH, + triggerSignal=self.TRIGGER_SIGNAL_INVALID, + stopPrice=self.STOP_PRICE_HIGH, + ) + + def test_create_batch_order(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``create_order_batch`` endpoint. + """ + with suppress(KrakenInsufficientAvailableFundsError): + self._assert_successful_response( + futures_demo_trade.create_batch_order( + batchorder_list=[ + { + "order": "send", + "order_tag": "1", + "orderType": self.ORDER_TYPE_LIMIT, + "symbol": self.SYMBOL, + "side": self.SIDE_BUY, + "size": 5, + "limitPrice": self.LIMIT_PRICE, + "cliOrdId": "my_another_client_id", + }, + { + "order": "send", + "order_tag": "2", + "orderType": "stp", + "symbol": self.SYMBOL, + "side": self.SIDE_BUY, + "size": 1, + "limitPrice": 2.00, + "stopPrice": 3.00, + }, + { + "order": "send", + "order_tag": "3", + "orderType": "post", + "symbol": self.SYMBOL, + "side": self.SIDE_BUY, + "size": 5, + "limitPrice": self.LIMIT_PRICE, + "reduceOnly": self.REDUCE_ONLY, + }, + ], + processBefore=self.PROCESS_BEFORE, + ), + ) + + def test_edit_order(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``edit_order`` endpoint. + """ + self._assert_successful_response( + futures_demo_trade.edit_order( + orderId=self.TEST_ORDER_IDS[1], + limitPrice=3, + ), + ) -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_edit_order(futures_demo_trade: Trade) -> None: - """ - Checks the ``edit_order`` endpoint. - """ - assert is_success( - futures_demo_trade.edit_order( - orderId="685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", - limitPrice=3, - ), - ) - - assert is_success( - futures_demo_trade.edit_order( - cliOrdId="685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", - size=111.0, - stopPrice=1000, - processBefore="3033-11-08T19:56:35.441899Z", - ), - ) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_edit_order_failing(futures_demo_trade: Trade) -> None: - """ - Checks if the ``edit_order`` endpoint fails when using invalid parameters. - """ - with pytest.raises(ValueError, match=r"Either orderId or cliOrdId must be set!"): - futures_demo_trade.edit_order() - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_cancel_order(futures_demo_trade: Trade) -> None: - """ - Checks the ``cancel_order`` endpoint. - """ - assert is_success( - futures_demo_trade.cancel_order( - cliOrdId="my_another_client_id", - processBefore="3033-11-08T19:56:35.441899Z", - ), - ) - assert is_success( - futures_demo_trade.cancel_order( - order_id="685d5a1a-23eb-450c-bf17-1e4ab5c6fe8a", - ), - ) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_cancel_order_failing(futures_demo_trade: Trade) -> None: - """ - Checks if the ``cancel_order`` endpoint is failing when - passing invalid arguments. - """ - with pytest.raises(ValueError, match=r"Either order_id or cliOrdId must be set!"): - futures_demo_trade.cancel_order() + self._assert_successful_response( + futures_demo_trade.edit_order( + cliOrdId=self.TEST_ORDER_IDS[1], + size=111.0, + stopPrice=1000, + processBefore=self.PROCESS_BEFORE, + ), + ) + with pytest.raises( + ValueError, + match=r"Either orderId or cliOrdId must be set!", + ): + futures_demo_trade.edit_order() + + def test_cancel_order(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``cancel_order`` endpoint. + """ + self._assert_successful_response( + futures_demo_trade.cancel_order( + cliOrdId="my_another_client_id", + processBefore=self.PROCESS_BEFORE, + ), + ) + self._assert_successful_response( + futures_demo_trade.cancel_order( + order_id=self.TEST_ORDER_IDS[1], + ), + ) -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_trade -def test_cancel_all_orders(futures_demo_trade: Trade) -> None: - """ - Checks the ``cancel_all_orders`` endpoint. - """ - assert is_success(futures_demo_trade.cancel_all_orders(symbol="pi_xbtusd")) - assert is_success(futures_demo_trade.cancel_all_orders()) + def test_cancel_order_failing(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks if the ``cancel_order`` endpoint is failing when + passing invalid arguments. + """ + with pytest.raises( + ValueError, + match=r"Either order_id or cliOrdId must be set!", + ): + futures_demo_trade.cancel_order() + + def test_cancel_all_orders(self: Self, futures_demo_trade: Trade) -> None: + """ + Checks the ``cancel_all_orders`` endpoint. + """ + self._assert_successful_response( + futures_demo_trade.cancel_all_orders(symbol=self.SYMBOL.lower()), + ) + self._assert_successful_response(futures_demo_trade.cancel_all_orders()) diff --git a/tests/futures/test_futures_user.py b/tests/futures/test_futures_user.py index 7af5347..c08a2ea 100644 --- a/tests/futures/test_futures_user.py +++ b/tests/futures/test_futures_user.py @@ -10,7 +10,7 @@ import random import tempfile from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Self import pytest @@ -25,178 +25,149 @@ @pytest.mark.futures @pytest.mark.futures_auth @pytest.mark.futures_user -def test_get_wallets(futures_auth_user: User) -> None: - """ - Checks the ``get_wallets`` endpoint. - """ - assert is_success(futures_auth_user.get_wallets()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_subaccounts(futures_auth_user: User) -> None: - """ - Checks the ``get_subaccounts`` endpoint. - """ - assert is_success(futures_auth_user.get_subaccounts()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_unwindqueue(futures_auth_user: User) -> None: - """ - Checks the ``get_unwindqueue`` endpoint. - """ - assert is_success(futures_auth_user.get_unwind_queue()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_notifications(futures_auth_user: User) -> None: - """ - Checks the ``get_notifications`` endpoint. - """ - assert is_success(futures_auth_user.get_notifications()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_account_log(futures_auth_user: User) -> None: - """ - Checks the ``get_account_log`` endpoint. - """ - assert isinstance(futures_auth_user.get_account_log(), dict) - assert isinstance( - futures_auth_user.get_account_log(info="futures liquidation"), - dict, - ) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_account_log_csv(futures_auth_user: User) -> None: - """ - Checks the ``get_account_log_csv`` endpoint. - """ - response: requests.Response = futures_auth_user.get_account_log_csv() - assert response.status_code in {200, "200"} - - with tempfile.TemporaryDirectory() as tmp_dir: - file_path: Path = Path(tmp_dir) / f"account_log-{random.randint(0, 10000)}.csv" - - with file_path.open("wb") as file: - for chunk in response.iter_content(chunk_size=512): - if chunk: - file.write(chunk) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_execution_events(futures_auth_user: User) -> None: - """ - Checks the ``get_execution_events`` endpoint. - """ - result: dict = futures_auth_user.get_execution_events( - tradeable="PF_SOLUSD", - since=1668989233, - before=1668999999, - sort="asc", - ) - - assert isinstance(result, dict) - assert "elements" in result - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_order_events(futures_auth_user: User) -> None: - """ - Checks the ``get_order_events`` endpoint. - """ - result: dict = futures_auth_user.get_order_events( - tradeable="PF_SOLUSD", - since=1668989233, - before=1668999999, - sort="asc", - ) - assert isinstance(result, dict) - assert "elements" in result - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_open_orders(futures_auth_user: User) -> None: - """ - Checks the ``get_open_orders`` endpoint. - """ - assert is_success(futures_auth_user.get_open_orders()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_open_positions(futures_auth_user: User) -> None: - """ - Checks the ``get_open_positions`` endpoint. - """ - assert is_success(futures_auth_user.get_open_positions()) - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -def test_get_trigger_events(futures_auth_user: User) -> None: - """ - Checks the ``get_trigger_events`` endpoint. - """ - result = futures_auth_user.get_trigger_events( - tradeable="PF_SOLUSD", - since=1668989233, - before=1668999999, - sort="asc", - ) - assert isinstance(result, dict) - assert "elements" in result - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -@pytest.mark.skip("Subaccount actions are only available for institutional clients") -def test_check_trading_enabled_on_subaccount(futures_auth_user: User) -> None: - """ - Checks the ``check_trading_enabled_on_subaccount`` function. - - Until now, subaccounts are only available for institutional clients, so this - execution raises an error. This test will work correctly (hopefully) when - Kraken enables subaccounts for pro trader. - """ - assert futures_auth_user.check_trading_enabled_on_subaccount( - subaccountUid="778387bh61b-f990-4128-16a7-f819abc8", - ) == {"tradingEnabled": False} - - -@pytest.mark.futures -@pytest.mark.futures_auth -@pytest.mark.futures_user -@pytest.mark.skip("Subaccount actions are only available for institutional clients") -def test_set_trading_on_subaccount(futures_auth_user: User) -> None: - """ - Checks the ``set_trading_on_subaccount`` function. - - Until now, subaccounts are only available for institutional clients, so this - execution raises an error. This test will work correctly (hopefully) when - Kraken enables subaccounts for pro trader. - """ - assert futures_auth_user.set_trading_on_subaccount( - subaccountUid="778387bh61b-f990-4128-16a7-f819abc8", - trading_enabled=True, - ) == {"tradingEnabled": True} +class TestFuturesUser: + """Test class for Futures User client functionality.""" + + TRADEABLE = "PF_SOLUSD" + SINCE = 1668989233 + BEFORE = 1668999999 + SORT_ASC = "asc" + SUBACCOUNT_UID = "778387bh61b-f990-4128-16a7-f819abc8" + + def _assert_successful_response(self: Self, result: Any) -> None: + """Helper method to assert a successful response.""" + assert is_success(result) + + def _assert_elements_in_result(self: Self, result: dict) -> None: + """Helper method to assert 'elements' key is in result.""" + assert isinstance(result, dict) + assert "elements" in result + + def test_get_wallets(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_wallets`` endpoint. + """ + self._assert_successful_response(futures_auth_user.get_wallets()) + + def test_get_subaccounts(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_subaccounts`` endpoint. + """ + self._assert_successful_response(futures_auth_user.get_subaccounts()) + + def test_get_unwindqueue(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_unwindqueue`` endpoint. + """ + self._assert_successful_response(futures_auth_user.get_unwind_queue()) + + def test_get_notifications(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_notifications`` endpoint. + """ + self._assert_successful_response(futures_auth_user.get_notifications()) + + def test_get_account_log(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_account_log`` endpoint. + """ + assert isinstance(futures_auth_user.get_account_log(), dict) + assert isinstance( + futures_auth_user.get_account_log(info="futures liquidation"), + dict, + ) + + def test_get_account_log_csv(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_account_log_csv`` endpoint. + """ + response: requests.Response = futures_auth_user.get_account_log_csv() + assert response.status_code in {200, "200"} + + with tempfile.TemporaryDirectory() as tmp_dir: + file_path: Path = ( + Path(tmp_dir) / f"account_log-{random.randint(0, 10000)}.csv" + ) + + with file_path.open("wb") as file: + for chunk in response.iter_content(chunk_size=512): + if chunk: + file.write(chunk) + + def test_get_execution_events(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_execution_events`` endpoint. + """ + result: dict = futures_auth_user.get_execution_events( + tradeable=self.TRADEABLE, + since=self.SINCE, + before=self.BEFORE, + sort=self.SORT_ASC, + ) + self._assert_elements_in_result(result) + + def test_get_order_events(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_order_events`` endpoint. + """ + result: dict = futures_auth_user.get_order_events( + tradeable=self.TRADEABLE, + since=self.SINCE, + before=self.BEFORE, + sort=self.SORT_ASC, + ) + self._assert_elements_in_result(result) + + def test_get_open_orders(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_open_orders`` endpoint. + """ + self._assert_successful_response(futures_auth_user.get_open_orders()) + + def test_get_open_positions(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_open_positions`` endpoint. + """ + self._assert_successful_response(futures_auth_user.get_open_positions()) + + def test_get_trigger_events(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``get_trigger_events`` endpoint. + """ + result = futures_auth_user.get_trigger_events( + tradeable=self.TRADEABLE, + since=self.SINCE, + before=self.BEFORE, + sort=self.SORT_ASC, + ) + self._assert_elements_in_result(result) + + @pytest.mark.skip("Subaccount actions are only available for institutional clients") + def test_check_trading_enabled_on_subaccount( + self: Self, + futures_auth_user: User, + ) -> None: + """ + Checks the ``check_trading_enabled_on_subaccount`` function. + + Until now, subaccounts are only available for institutional clients, so this + execution raises an error. This test will work correctly (hopefully) when + Kraken enables subaccounts for pro trader. + """ + assert futures_auth_user.check_trading_enabled_on_subaccount( + subaccountUid=self.SUBACCOUNT_UID, + ) == {"tradingEnabled": False} + + @pytest.mark.skip("Subaccount actions are only available for institutional clients") + def test_set_trading_on_subaccount(self: Self, futures_auth_user: User) -> None: + """ + Checks the ``set_trading_on_subaccount`` function. + + Until now, subaccounts are only available for institutional clients, so this + execution raises an error. This test will work correctly (hopefully) when + Kraken enables subaccounts for pro trader. + """ + assert futures_auth_user.set_trading_on_subaccount( + subaccountUid=self.SUBACCOUNT_UID, + trading_enabled=True, + ) == {"tradingEnabled": True} diff --git a/tests/spot/conftest.py b/tests/spot/conftest.py index fa046d8..bd8e703 100644 --- a/tests/spot/conftest.py +++ b/tests/spot/conftest.py @@ -19,19 +19,19 @@ SPOT_SECRET_KEY: str = os.getenv("SPOT_SECRET_KEY") -@pytest.fixture +@pytest.fixture(scope="session") def spot_api_key() -> str: """Returns the Kraken Spot API Key for testing.""" return SPOT_API_KEY -@pytest.fixture +@pytest.fixture(scope="session") def spot_secret_key() -> str: """Returns the Kraken Spot API secret for testing.""" return SPOT_SECRET_KEY -@pytest.fixture +@pytest.fixture(scope="session") def spot_auth_user() -> User: """ Fixture providing an authenticated Spot user client. @@ -39,7 +39,7 @@ def spot_auth_user() -> User: return User(key=SPOT_API_KEY, secret=SPOT_SECRET_KEY) -@pytest.fixture +@pytest.fixture(scope="session") def spot_market() -> Market: """ Fixture providing an unauthenticated Spot market client. @@ -47,7 +47,7 @@ def spot_market() -> Market: return Market() -@pytest.fixture +@pytest.fixture(scope="session") def spot_auth_market() -> Market: """ Fixture providing an authenticated Spot market client. @@ -55,7 +55,7 @@ def spot_auth_market() -> Market: return Market(key=SPOT_API_KEY, secret=SPOT_SECRET_KEY) -@pytest.fixture +@pytest.fixture(scope="session") def spot_trade() -> Trade: """ Fixture providing an unauthenticated Spot trade client. @@ -63,7 +63,7 @@ def spot_trade() -> Trade: return Trade() -@pytest.fixture +@pytest.fixture(scope="session") def spot_auth_trade() -> Trade: """ Fixture providing an authenticated Spot trade client. @@ -71,7 +71,7 @@ def spot_auth_trade() -> Trade: return Trade(key=SPOT_API_KEY, secret=SPOT_SECRET_KEY) -@pytest.fixture +@pytest.fixture(scope="session") def spot_earn() -> Earn: """ Fixture providing an unauthenticated Spot earn client. @@ -79,7 +79,7 @@ def spot_earn() -> Earn: return Earn() -@pytest.fixture +@pytest.fixture(scope="session") def spot_auth_earn() -> Earn: """ Fixture providing an authenticated Spot earn client. @@ -87,7 +87,7 @@ def spot_auth_earn() -> Earn: raise ValueError("Do not use the authenticated Spot earn client for testing!") -@pytest.fixture +@pytest.fixture(scope="session") def spot_auth_funding() -> Funding: """ Fixture providing an authenticated Spot funding client. diff --git a/tests/spot/test_spot_base_api.py b/tests/spot/test_spot_base_api.py index b640afa..118b20c 100644 --- a/tests/spot/test_spot_base_api.py +++ b/tests/spot/test_spot_base_api.py @@ -26,191 +26,196 @@ if TYPE_CHECKING: from kraken.spot import Funding, Market, Trade, User +from typing import Self + from .helper import is_not_error @pytest.mark.spot -def test_KrakenSpotBaseAPI_without_exception() -> None: - """ - Checks first if the expected error will be raised and than creates a new - KrakenSpotBaseAPI instance that do not raise the custom Kraken exceptions. - This new instance than executes the same request and the returned response - gets evaluated. - """ - with pytest.raises(KrakenInvalidAPIKeyError): - SpotClient( - key="fake", - secret="fake", - ).request(method="POST", uri="/0/private/AddOrder", auth=True) - - assert SpotClient( - key="fake", - secret="fake", - use_custom_exceptions=False, - ).request(method="POST", uri="/0/private/AddOrder", auth=True).json() == { - "error": ["EAPI:Invalid key"], - } +class TestSpotBaseAPI: + """Test class for Spot Base API functionality.""" + TEST_PAIR_XBTUSD = "XBTUSD" + TEST_TXID = "OB6JJR-7NZ5P-N5SKCB" -@pytest.mark.spot -@pytest.mark.spot_auth -def test_spot_rest_contextmanager( - spot_market: Market, - spot_auth_funding: Funding, - spot_auth_trade: Trade, - spot_auth_user: User, -) -> None: - """ - Checks if the clients can be used as context manager. - """ - with spot_market as market: - result = market.get_assets() - assert is_not_error(result), result + def test_KrakenSpotBaseAPI_without_exception(self: Self) -> None: + """ + Checks first if the expected error will be raised and then creates a new + KrakenSpotBaseAPI instance that does not raise the custom Kraken exceptions. + This new instance then executes the same request and the returned response + gets evaluated. + """ + with pytest.raises(KrakenInvalidAPIKeyError): + SpotClient( + key="fake", + secret="fake", + ).request(method="POST", uri="/0/private/AddOrder", auth=True) - with spot_auth_funding as funding: - assert isinstance(funding.get_deposit_methods(asset="XBT"), list) + assert SpotClient( + key="fake", + secret="fake", + use_custom_exceptions=False, + ).request(method="POST", uri="/0/private/AddOrder", auth=True).json() == { + "error": ["EAPI:Invalid key"], + } + + @pytest.mark.spot_auth + def test_spot_rest_contextmanager( + self: Self, + spot_market: Market, + spot_auth_funding: Funding, + spot_auth_trade: Trade, + spot_auth_user: User, + ) -> None: + """ + Checks if the clients can be used as context manager. + """ + with spot_market as market: + result = market.get_assets() + assert is_not_error(result), result - with spot_auth_user as user: - assert is_not_error(user.get_account_balance()) + with spot_auth_funding as funding: + assert isinstance(funding.get_deposit_methods(asset="XBT"), list) - with spot_auth_trade as trade, pytest.raises(KrakenPermissionDeniedError): - trade.cancel_order(txid="OB6JJR-7NZ5P-N5SKCB") + with spot_auth_user as user: + assert is_not_error(user.get_account_balance()) + with spot_auth_trade as trade, pytest.raises(KrakenPermissionDeniedError): + trade.cancel_order(txid=self.TEST_TXID) -# ============================================================================== -# Spot async client + # ============================================================================== + # Spot async client + @pytest.mark.spot + def test_spot_rest_async_client_get(self: Self) -> None: + """ + Check the instantiation as well as a simple request using the async client. + """ -@pytest.mark.spot -def test_spot_rest_async_client_get() -> None: - """ - Check the instantiation as well as a simple request using the async client. - """ - - async def check() -> None: - client = SpotAsyncClient() - try: - assert is_not_error( - await client.request( - "GET", - "/0/public/OHLC", - params={"pair": "XBTUSD"}, - auth=False, - ), - ) - finally: - await client.close() - - run(check()) + async def check() -> None: + client = SpotAsyncClient() + try: + assert is_not_error( + await client.request( + "GET", + "/0/public/OHLC", + params={"pair": "XBTUSD"}, + auth=False, + ), + ) + finally: + await client.close() + + run(check()) + @pytest.mark.spot + def test_spot_async_rest_contextmanager( + self: Self, + spot_api_key: str, + spot_secret_key: str, + ) -> None: + """ + Checks if the clients can be used as context manager. + """ -@pytest.mark.spot -def test_spot_async_rest_contextmanager( - spot_api_key: str, - spot_secret_key: str, -) -> None: - """ - Checks if the clients can be used as context manager. - """ - - async def check() -> None: - async with SpotAsyncClient(spot_api_key, spot_secret_key) as client: - result = await client.request("GET", "/0/public/Time", auth=False) - assert is_not_error(result), result + async def check() -> None: + async with SpotAsyncClient(spot_api_key, spot_secret_key) as client: + result = await client.request("GET", "/0/public/Time", auth=False) + assert is_not_error(result), result - run(check()) + run(check()) + @pytest.mark.spot + @pytest.mark.spot_auth + @pytest.mark.parametrize("report", ["trades", "ledgers"]) + def test_spot_rest_async_client_post_report( + self: Self, + report: str, + spot_api_key: str, + spot_secret_key: str, + ) -> None: + """ + Check the authenticated async client using multiple request to retrieve a + the user-specific order report. + """ -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.parametrize("report", ["trades", "ledgers"]) -def test_spot_rest_async_client_post_report( - report: str, - spot_api_key: str, - spot_secret_key: str, -) -> None: - """ - Check the authenticated async client using multiple request to retrieve a - the user-specific order report. - """ - - async def check() -> None: - client = SpotAsyncClient(spot_api_key, spot_secret_key) - - try: - export_descr = f"{report}-export-{random.randint(0, 10000)}" - response = await client.request( - "POST", - "/0/private/AddExport", - params={ - "report": report, - "description": export_descr, - }, - ) - assert is_not_error(response) - assert "id" in response - sleep(2) - - status = await client.request( - "POST", - "/0/private/ExportStatus", - params={"report": report}, - ) - assert isinstance(status, list) - sleep(5) - - result = await client.request( - "POST", - "/0/private/RetrieveExport", - params={"id": response["id"]}, - timeout=30, - return_raw=True, - ) - - with tempfile.TemporaryDirectory() as tmp_dir: - file_path = Path(tmp_dir) / f"{export_descr}.zip" - - with file_path.open("wb") as file: - async for chunk in result.content.iter_chunked(1024): - file.write(chunk) - - status = await client.request( - "POST", - "/0/private/ExportStatus", - params={"report": report}, - ) - assert isinstance(status, list) - for response in status: - if response.get("delete"): - # ignore already deleted reports - continue + async def check() -> None: + client = SpotAsyncClient(spot_api_key, spot_secret_key) + + try: + export_descr = f"{report}-export-{random.randint(0, 10000)}" + response = await client.request( + "POST", + "/0/private/AddExport", + params={ + "report": report, + "description": export_descr, + }, + ) + assert is_not_error(response) assert "id" in response - with suppress(Exception): - assert isinstance( - await client.request( - "POST", - "/0/private/RemoveExport", - params={ - "id": response["id"], - "type": "delete", - }, - ), - dict, - ) sleep(2) - finally: - await client.close() - run(check()) + status = await client.request( + "POST", + "/0/private/ExportStatus", + params={"report": report}, + ) + assert isinstance(status, list) + sleep(5) + + result = await client.request( + "POST", + "/0/private/RetrieveExport", + params={"id": response["id"]}, + timeout=30, + return_raw=True, + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / f"{export_descr}.zip" + + with file_path.open("wb") as file: + async for chunk in result.content.iter_chunked(1024): + file.write(chunk) + + status = await client.request( + "POST", + "/0/private/ExportStatus", + params={"report": report}, + ) + assert isinstance(status, list) + for response in status: + if response.get("delete"): + # ignore already deleted reports + continue + assert "id" in response + with suppress(Exception): + assert isinstance( + await client.request( + "POST", + "/0/private/RemoveExport", + params={ + "id": response["id"], + "type": "delete", + }, + ), + dict, + ) + sleep(2) + finally: + await client.close() + + run(check()) class TestProxyPyEmbedded(TestCase, IsolatedAsyncioTestCase): - def get_proxy_str(self) -> str: + def get_proxy_str(self: Self) -> str: return f"http://127.0.0.1:{self.PROXY.flags.port}" @pytest.mark.spot @pytest.mark.spot_market - def test_spot_rest_proxies(self) -> None: + def test_spot_rest_proxies(self: Self) -> None: """ Checks if the clients can be used with a proxy. """ @@ -227,7 +232,7 @@ def test_spot_rest_proxies(self) -> None: @pytest.mark.spot @pytest.mark.spot_market @pytest.mark.asyncio - async def test_spot_rest_proxies_async(self) -> None: + async def test_spot_rest_proxies_async(self: Self) -> None: """ Checks if the async clients can be used with a proxy. """ diff --git a/tests/spot/test_spot_earn.py b/tests/spot/test_spot_earn.py index 891c345..4bd5c13 100644 --- a/tests/spot/test_spot_earn.py +++ b/tests/spot/test_spot_earn.py @@ -7,6 +7,8 @@ """Module that implements the unit tests for the Spot Earn client.""" +from typing import Any, Self + import pytest from kraken.spot import Earn @@ -17,90 +19,76 @@ @pytest.mark.spot @pytest.mark.spot_auth @pytest.mark.spot_earn -@pytest.mark.skip(reason="CI does not have earn permission") -def test_allocate_earn_funds(spot_auth_earn: Earn) -> None: - """ - Checks if the response of the ``allocate_earn_funds`` is of - type bool which mean that the request was successful. - """ - assert isinstance( - spot_auth_earn.allocate_earn_funds( - amount="1", - strategy_id="ESRFUO3-Q62XD-WIOIL7", - ), - bool, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_earn -@pytest.mark.skip(reason="CI does not have earn permission") -def test_deallocate_earn_funds(spot_auth_earn: Earn) -> None: - """ - Checks if the response of the ``deallocate_earn_funds`` is of - type bool which mean that the request was successful. - """ - assert isinstance( - spot_auth_earn.deallocate_earn_funds( - amount="1", - strategy_id="ESRFUO3-Q62XD-WIOIL7", - ), - bool, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_earn -@pytest.mark.skip(reason="CI does not have earn permission") -def test_get_allocation_status(spot_auth_earn: Earn) -> None: - """ - Checks if the response of the ``get_allocation_status`` does not contain a - named error which mean that the request was successful. - """ - assert is_not_error( - spot_auth_earn.get_allocation_status( - strategy_id="ESRFUO3-Q62XD-WIOIL7", - ), - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_earn -@pytest.mark.skip(reason="CI does not have earn permission") -def test_get_deallocation_status(spot_auth_earn: Earn) -> None: - """ - Checks if the response of the ``get_deallocation_status`` does not contain a - named error which mean that the request was successful. - """ - assert is_not_error( - spot_auth_earn.get_deallocation_status( - strategy_id="ESRFUO3-Q62XD-WIOIL7", - ), - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_earn -@pytest.mark.skip(reason="CI does not have earn permission") -def test_list_earn_strategies(spot_auth_earn: Earn) -> None: - """ - Checks if the response of the ``list_earn_strategies`` does not contain a - named error which mean that the request was successful. - """ - assert is_not_error(spot_auth_earn.list_earn_strategies(asset="DOT")) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_earn -@pytest.mark.skip(reason="CI does not have earn permission") -def test_list_earn_allocations(spot_auth_earn: Earn) -> None: - """ - Checks if the response of the ``list_earn_allocations`` does not contain a - named error which mean that the request was successful. - """ - assert is_not_error(spot_auth_earn.list_earn_allocations(asset="DOT")) +@pytest.mark.skip(reason="Tests do not have earn permission") +class TestSpotEarn: + """Test class for Spot Earn client functionality.""" + + TEST_AMOUNT = "1" + TEST_STRATEGY_ID = "ESRFUO3-Q62XD-WIOIL7" + TEST_ASSET = "DOT" + + def _assert_successful_operation(self: Self, result: Any) -> None: + """Helper method to assert successful operations for allocation/deallocation.""" + assert isinstance(result, bool) + + def _assert_successful_query(self: Self, result: Any) -> None: + """Helper method to assert successful query operations.""" + assert is_not_error(result) + + def test_allocate_earn_funds(self: Self, spot_auth_earn: Earn) -> None: + """ + Checks if the response of the ``allocate_earn_funds`` is of + type bool which mean that the request was successful. + """ + result = spot_auth_earn.allocate_earn_funds( + amount=self.TEST_AMOUNT, + strategy_id=self.TEST_STRATEGY_ID, + ) + self._assert_successful_operation(result) + + def test_deallocate_earn_funds(self: Self, spot_auth_earn: Earn) -> None: + """ + Checks if the response of the ``deallocate_earn_funds`` is of + type bool which mean that the request was successful. + """ + result = spot_auth_earn.deallocate_earn_funds( + amount=self.TEST_AMOUNT, + strategy_id=self.TEST_STRATEGY_ID, + ) + self._assert_successful_operation(result) + + def test_get_allocation_status(self: Self, spot_auth_earn: Earn) -> None: + """ + Checks if the response of the ``get_allocation_status`` does not contain a + named error which mean that the request was successful. + """ + result = spot_auth_earn.get_allocation_status( + strategy_id=self.TEST_STRATEGY_ID, + ) + self._assert_successful_query(result) + + def test_get_deallocation_status(self: Self, spot_auth_earn: Earn) -> None: + """ + Checks if the response of the ``get_deallocation_status`` does not contain a + named error which mean that the request was successful. + """ + result = spot_auth_earn.get_deallocation_status( + strategy_id=self.TEST_STRATEGY_ID, + ) + self._assert_successful_query(result) + + def test_list_earn_strategies(self: Self, spot_auth_earn: Earn) -> None: + """ + Checks if the response of the ``list_earn_strategies`` does not contain a + named error which mean that the request was successful. + """ + result = spot_auth_earn.list_earn_strategies(asset=self.TEST_ASSET) + self._assert_successful_query(result) + + def test_list_earn_allocations(self: Self, spot_auth_earn: Earn) -> None: + """ + Checks if the response of the ``list_earn_allocations`` does not contain a + named error which mean that the request was successful. + """ + result = spot_auth_earn.list_earn_allocations(asset=self.TEST_ASSET) + self._assert_successful_query(result) diff --git a/tests/spot/test_spot_funding.py b/tests/spot/test_spot_funding.py index c8d3626..809f73e 100644 --- a/tests/spot/test_spot_funding.py +++ b/tests/spot/test_spot_funding.py @@ -7,6 +7,8 @@ """Module that implements the unit tests for the Spot funding client.""" +from typing import Any, Self + import pytest from kraken.exceptions import KrakenInvalidArgumentsError, KrakenPermissionDeniedError @@ -14,193 +16,215 @@ from .helper import is_not_error -# todo: Mock skipped tests - or is this to dangerous? - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -def test_get_deposit_methods(spot_auth_funding: Funding) -> None: - """ - Checks if the response of the ``get_deposit_methods`` is of - type list which mean that the request was successful. - """ - assert isinstance(spot_auth_funding.get_deposit_methods(asset="XBT"), list) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -def test_get_deposit_address(spot_auth_funding: Funding) -> None: - """ - Checks the ``get_deposit_address`` function by performing a valid request - and validating that the response is of type list. - """ - assert isinstance( - spot_auth_funding.get_deposit_address(asset="XBT", method="Bitcoin", new=False), - list, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -def test_get_recent_deposits_status(spot_auth_funding: Funding) -> None: - """ - Checks the ``get_recent_deposit_status`` endpoint by executing multiple - request with different parameters and validating its return value. - """ - assert isinstance(spot_auth_funding.get_recent_deposits_status(), list) - assert isinstance(spot_auth_funding.get_recent_deposits_status(asset="XLM"), list) - assert isinstance( - spot_auth_funding.get_recent_deposits_status(method="Stellar XLM"), - list, - ) - assert isinstance( - spot_auth_funding.get_recent_deposits_status(asset="XLM", method="Stellar XLM"), - list, - ) - assert isinstance( - spot_auth_funding.get_recent_deposits_status( - asset="XLM", - method="Stellar XLM", - start=1688992722, - end=1688999722, - cursor=True, - ), - dict, - ) - @pytest.mark.spot @pytest.mark.spot_auth @pytest.mark.spot_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_withdraw_funds(spot_auth_funding: Funding) -> None: - """ - Checks the ``withdraw_funds`` endpoint by performing a withdraw. - - This test is disabled, because testing a withdraw cannot be done without - a real withdraw which is not what should be done here. Also the - API keys for testing are not allowed to withdraw or trade. - """ - with pytest.raises(KrakenPermissionDeniedError): - assert is_not_error( - spot_auth_funding.withdraw_funds( - asset="XLM", - key="enter-withdraw-key", - amount=10000000, - max_fee=20, +class TestSpotFunding: + """Test class for Spot Funding client functionality.""" + + TEST_ASSET_BTC = "XBT" + TEST_ASSET_XLM = "XLM" + TEST_ASSET_USD = "ZUSD" + TEST_METHOD_BITCOIN = "Bitcoin" + TEST_METHOD_STELLAR = "Stellar XLM" + TEST_METHOD_BANK_FRICK = "Bank Frick (SWIFT)" + TEST_WITHDRAW_KEY = "enter-withdraw-key" + TEST_AMOUNT_LARGE = 10000000 + TEST_AMOUNT_MEDIUM = 10000 + TEST_MAX_FEE = 20 + TEST_START_TIME = 1688992722 + TEST_END_TIME = 1688999722 + + def _assert_successful_list_response( + self: Self, + result: Any, + ) -> None: + """Helper method to assert successful list responses.""" + assert isinstance(result, list) + + def _assert_successful_dict_response( + self: Self, + result: Any, + ) -> None: + """Helper method to assert successful dict responses.""" + assert isinstance(result, dict) + + def _assert_not_error(self: Self, result: Any) -> None: + """Helper method to assert responses without errors.""" + assert is_not_error(result) + + def test_get_deposit_methods(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks if the response of the ``get_deposit_methods`` is of + type list which mean that the request was successful. + """ + result = spot_auth_funding.get_deposit_methods(asset=self.TEST_ASSET_BTC) + self._assert_successful_list_response(result) + + def test_get_deposit_address(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the ``get_deposit_address`` function by performing a valid request + and validating that the response is of type list. + """ + result = spot_auth_funding.get_deposit_address( + asset=self.TEST_ASSET_BTC, + method=self.TEST_METHOD_BITCOIN, + new=False, + ) + self._assert_successful_list_response(result) + + def test_get_recent_deposits_status(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the ``get_recent_deposit_status`` endpoint by executing multiple + request with different parameters and validating its return value. + """ + self._assert_successful_list_response( + spot_auth_funding.get_recent_deposits_status(), + ) + self._assert_successful_list_response( + spot_auth_funding.get_recent_deposits_status(asset=self.TEST_ASSET_XLM), + ) + self._assert_successful_list_response( + spot_auth_funding.get_recent_deposits_status( + method=self.TEST_METHOD_STELLAR, ), ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_get_withdrawal_info(spot_auth_funding: Funding) -> None: - """ - Checks the ``get_withdraw_info`` endpoint by requesting the data. - - This test is disabled, because the API keys for testing are not - allowed to withdraw or trade or even get withdraw information. - """ - with pytest.raises(KrakenPermissionDeniedError): - assert is_not_error( - spot_auth_funding.get_withdrawal_info( - asset="XLM", - amount=10000000, - key="enter-withdraw-key", + self._assert_successful_list_response( + spot_auth_funding.get_recent_deposits_status( + asset=self.TEST_ASSET_XLM, + method=self.TEST_METHOD_STELLAR, + ), + ) + self._assert_successful_dict_response( + spot_auth_funding.get_recent_deposits_status( + asset=self.TEST_ASSET_XLM, + method=self.TEST_METHOD_STELLAR, + start=self.TEST_START_TIME, + end=self.TEST_END_TIME, + cursor=True, ), ) + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_withdraw_funds(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the ``withdraw_funds`` endpoint by performing a withdraw. + + This test is disabled, because testing a withdraw cannot be done without + a real withdraw which is not what should be done here. Also the + API keys for testing are not allowed to withdraw or trade. + """ + with pytest.raises(KrakenPermissionDeniedError): + result = spot_auth_funding.withdraw_funds( + asset=self.TEST_ASSET_XLM, + key=self.TEST_WITHDRAW_KEY, + amount=self.TEST_AMOUNT_LARGE, + max_fee=self.TEST_MAX_FEE, + ) + self._assert_not_error(result) + + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_get_withdrawal_info(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the ``get_withdraw_info`` endpoint by requesting the data. + + This test is disabled, because the API keys for testing are not + allowed to withdraw or trade or even get withdraw information. + """ + with pytest.raises(KrakenPermissionDeniedError): + result = spot_auth_funding.get_withdrawal_info( + asset=self.TEST_ASSET_XLM, + amount=self.TEST_AMOUNT_LARGE, + key=self.TEST_WITHDRAW_KEY, + ) + self._assert_not_error(result) + + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_get_recent_withdraw_status(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the ``get_recent_withdraw_status`` endpoint using different arguments. + + This test is disabled, because testing a withdraw and receiving + withdrawal information cannot be done without a real withdraw which is not what + should be done here. Also the API keys for testing are not allowed to withdraw + or trade. + """ + self._assert_successful_list_response( + spot_auth_funding.get_recent_withdraw_status(), + ) + self._assert_successful_list_response( + spot_auth_funding.get_recent_withdraw_status(asset=self.TEST_ASSET_XLM), + ) + self._assert_successful_list_response( + spot_auth_funding.get_recent_withdraw_status( + method=self.TEST_METHOD_STELLAR, + ), + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_get_recent_withdraw_status(spot_auth_funding: Funding) -> None: - """ - Checks the ``get_recent_withdraw_status`` endpoint using different arguments. - - This test is disabled, because testing a withdraw and receiving - withdrawal information cannot be done without a real withdraw which is not what - should be done here. Also the API keys for testing are not allowed to withdraw - or trade. - """ - assert isinstance(spot_auth_funding.get_recent_withdraw_status(), list) - assert isinstance(spot_auth_funding.get_recent_withdraw_status(asset="XLM"), list) - assert isinstance( - spot_auth_funding.get_recent_withdraw_status(method="Stellar XLM"), - list, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_wallet_transfer(spot_auth_funding: Funding) -> None: - """ - Checks the ``get_recent_withdraw_status`` endpoint using different arguments. - (only works if futures wallet exists) - - This test is disabled, because testing a withdraw and receiving - withdrawal information cannot be done without a real withdraw which is not what - should be done here. Also the API keys for testing are not allowed to withdraw - or trade. - - This endpoint is broken, even the provided example on the kraken doc does not work. - """ - with pytest.raises(KrakenInvalidArgumentsError): - assert is_not_error( - spot_auth_funding.wallet_transfer( - asset="XLM", + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_wallet_transfer(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the ``get_recent_withdraw_status`` endpoint using different arguments. + (only works if futures wallet exists) + + This test is disabled, because testing a withdraw and receiving + withdrawal information cannot be done without a real withdraw which is not what + should be done here. Also the API keys for testing are not allowed to withdraw + or trade. + + This endpoint is broken, even the provided example on the kraken doc does not work. + """ + with pytest.raises(KrakenInvalidArgumentsError): + result = spot_auth_funding.wallet_transfer( + asset=self.TEST_ASSET_XLM, from_="Futures Wallet", to_="Spot Wallet", - amount=10000, - ), + amount=self.TEST_AMOUNT_MEDIUM, + ) + self._assert_not_error(result) + + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_withdraw_methods(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the withdraw_methods function for retrieving the correct data type + which is sufficient to validate the functionality. + """ + response = spot_auth_funding.withdraw_methods() + self._assert_successful_list_response(response) + + response = spot_auth_funding.withdraw_methods( + asset=self.TEST_ASSET_USD, + aclass="currency", ) + self._assert_successful_list_response(response) + response = spot_auth_funding.withdraw_methods( + asset=self.TEST_ASSET_BTC, + network=self.TEST_METHOD_BITCOIN, + ) + self._assert_successful_list_response(response) + + response = spot_auth_funding.withdraw_methods(aclass="forex") + self._assert_successful_list_response(response) + + @pytest.mark.skip(reason="Tests do not have withdraw permission") + def test_withdraw_addresses(self: Self, spot_auth_funding: Funding) -> None: + """ + Checks the withdraw_addresses function for retrieving the correct data type + which is sufficient to validate the functionality. + """ + response = spot_auth_funding.withdraw_addresses() + self._assert_successful_list_response(response) + + response = spot_auth_funding.withdraw_addresses( + asset=self.TEST_ASSET_USD, + method=self.TEST_METHOD_BANK_FRICK, + ) + self._assert_successful_list_response(response) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_withdraw_methods(spot_auth_funding: Funding) -> None: - """ - Checks the withdraw_methods function for retrieving the correct data type - which is sufficient to validate the functionality. - """ - response: list[dict] = spot_auth_funding.withdraw_methods() - assert isinstance(response, list) - response = spot_auth_funding.withdraw_methods( - asset="ZUSD", - aclass="currency", - ) - assert isinstance(response, list) - response = spot_auth_funding.withdraw_methods(asset="XBT", network="Bitcoin") - assert isinstance(response, list) - response = spot_auth_funding.withdraw_methods(aclass="forex") - assert isinstance(response, list) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_funding -@pytest.mark.skip(reason="CI does not have withdraw permission") -def test_withdraw_addresses(spot_auth_funding: Funding) -> None: - """ - Checks the withdraw_addresses function for retrieving the correct data type - which is sufficient to validate the functionality. - """ - response: list[dict] = spot_auth_funding.withdraw_addresses() - assert isinstance(response, list) - response = spot_auth_funding.withdraw_addresses( - asset="ZUSD", - method="Bank Frick (SWIFT)", - ) - assert isinstance(response, list) - response = spot_auth_funding.withdraw_addresses(asset="XLM", verified=True) - assert isinstance(response, list) + response = spot_auth_funding.withdraw_addresses( + asset=self.TEST_ASSET_XLM, + verified=True, + ) + self._assert_successful_list_response(response) diff --git a/tests/spot/test_spot_market.py b/tests/spot/test_spot_market.py index 08faac8..0095af5 100644 --- a/tests/spot/test_spot_market.py +++ b/tests/spot/test_spot_market.py @@ -8,6 +8,7 @@ """Module that implements the unit tests for the Spot market client.""" from time import sleep +from typing import Any, Self import pytest @@ -18,128 +19,152 @@ @pytest.mark.spot @pytest.mark.spot_market -def test_get_system_status(spot_market: Market) -> None: - """ - Checks the ``get_system_status`` endpoint by performing a - valid request and validating that the response does not - contain the error key. - """ - assert is_not_error(spot_market.get_system_status()) - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_assets(spot_market: Market) -> None: - """ - Checks the ``get_assets`` endpoint by performing multiple - requests with different parameters and - validating that the response does not contain the error key. - """ - for params in ( - {}, - {"assets": "USD"}, - {"assets": ["USD"]}, - {"assets": ["XBT,USD"]}, - {"assets": ["XBT", "USD"], "aclass": "currency"}, - ): - assert is_not_error(spot_market.get_assets(**params)) - sleep(3) - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_asset_pairs(spot_market: Market) -> None: - """ - Checks the ``get_asset_pairs`` endpoint by performing multiple - requests with different parameters and validating that the response - does not contain the error key. - """ - assert is_not_error(spot_market.get_asset_pairs()) - assert is_not_error(spot_market.get_asset_pairs(pair="BTCUSD")) - assert is_not_error(spot_market.get_asset_pairs(pair=["DOTEUR", "BTCUSD"])) - for i in ("info", "leverage", "fees", "margin"): - assert is_not_error(spot_market.get_asset_pairs(pair="DOTEUR", info=i)) - break # there is no reason for requesting more - but this loop is just for info - sleep(3) - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_ticker(spot_market: Market) -> None: - """ - Checks the ``get_ticker`` endpoint by performing multiple - requests with different parameters and validating that the response - does not contain the error key. - """ - assert is_not_error(spot_market.get_ticker()) - assert is_not_error(spot_market.get_ticker(pair="XBTUSD")) - assert is_not_error(spot_market.get_ticker(pair=["DOTUSD", "XBTUSD"])) - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_ohlc(spot_market: Market) -> None: - """ - Checks the ``get_ohlc`` endpoint by performing multiple - requests with different parameters and validating that the response - does not contain the error key. - """ - assert is_not_error(spot_market.get_ohlc(pair="XBTUSD")) - assert is_not_error( - spot_market.get_ohlc(pair="XBTUSD", interval=240, since="1616663618"), - ) # interval in [1 5 15 30 60 240 1440 10080 21600] - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_order_book(spot_market: Market) -> None: - """ - Checks the ``get_order_book`` endpoint by performing multiple - requests with different parameters and validating that the response - does not contain the error key. - """ - assert is_not_error(spot_market.get_order_book(pair="XBTUSD")) - assert is_not_error( - spot_market.get_order_book(pair="XBTUSD", count=2), - ) # count in [1...500] - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_recent_trades(spot_market: Market) -> None: - """ - Checks the ``get_recent_trades`` endpoint by performing multiple - requests with different parameters and validating that the response - does not contain the error key. - """ - assert is_not_error(spot_market.get_recent_trades(pair="XBTUSD")) - assert is_not_error( - spot_market.get_recent_trades(pair="XBTUSD", since="1616663618", count=2), - ) - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_get_recent_spreads(spot_market: Market) -> None: - """ - Checks the ``get_recent_spreads`` endpoint by performing multiple - requests with different parameters and validating that the response - does not contain the error key. - """ - assert is_not_error(spot_market.get_recent_spreads(pair="XBTUSD")) - assert is_not_error( - spot_market.get_recent_spreads(pair="XBTUSD", since="1616663618"), - ) - - -@pytest.mark.spot -@pytest.mark.spot_market -def test_extra_parameter(spot_market: Market) -> None: - """ - Checks if the extra parameter can be used to overwrite an existing one. - This also checks ensure_string for this parameter. - """ - - result: dict = spot_market.get_assets(assets="XBT", extra_params={"asset": "ETH"}) - assert "XBT" not in result - assert "XETH" in result +class TestSpotMarket: + """Test class for Spot Market client functionality.""" + + TEST_PAIR_BTCUSD = "XBTUSD" + TEST_PAIR_DOTUSD = "DOTUSD" + TEST_PAIR_DOTEUR = "DOTEUR" + TEST_ASSET_BTC = "XBT" + TEST_ASSET_ETH = "ETH" + TEST_ASSET_USD = "USD" + TEST_INTERVAL = 240 + TEST_SINCE = "1616663618" + TEST_COUNT = 2 + + def _assert_not_error(self: Self, result: Any) -> None: + """Helper method to assert responses without errors.""" + assert is_not_error(result) + + def test_get_system_status(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_system_status`` endpoint by performing a + valid request and validating that the response does not + contain the error key. + """ + self._assert_not_error(spot_market.get_system_status()) + + def test_get_assets(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_assets`` endpoint by performing multiple + requests with different parameters and + validating that the response does not contain the error key. + """ + for params in ( + {}, + {"assets": self.TEST_ASSET_USD}, + {"assets": [self.TEST_ASSET_USD]}, + {"assets": [f"{self.TEST_ASSET_BTC},{self.TEST_ASSET_USD}"]}, + { + "assets": [self.TEST_ASSET_BTC, self.TEST_ASSET_USD], + "aclass": "currency", + }, + ): + self._assert_not_error(spot_market.get_assets(**params)) + sleep(3) + + def test_get_asset_pairs(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_asset_pairs`` endpoint by performing multiple + requests with different parameters and validating that the response + does not contain the error key. + """ + self._assert_not_error(spot_market.get_asset_pairs()) + self._assert_not_error(spot_market.get_asset_pairs(pair=self.TEST_PAIR_BTCUSD)) + self._assert_not_error( + spot_market.get_asset_pairs( + pair=[self.TEST_PAIR_DOTEUR, self.TEST_PAIR_BTCUSD], + ), + ) + for i in ("info", "leverage", "fees", "margin"): + self._assert_not_error( + spot_market.get_asset_pairs(pair=self.TEST_PAIR_DOTEUR, info=i), + ) + break # there is not reason for requesting more, this is just for info + sleep(3) + + def test_get_ticker(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_ticker`` endpoint by performing multiple + requests with different parameters and validating that the response + does not contain the error key. + """ + self._assert_not_error(spot_market.get_ticker()) + self._assert_not_error(spot_market.get_ticker(pair=self.TEST_PAIR_BTCUSD)) + self._assert_not_error( + spot_market.get_ticker(pair=[self.TEST_PAIR_DOTUSD, self.TEST_PAIR_BTCUSD]), + ) + + def test_get_ohlc(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_ohlc`` endpoint by performing multiple + requests with different parameters and validating that the response + does not contain the error key. + """ + self._assert_not_error(spot_market.get_ohlc(pair=self.TEST_PAIR_BTCUSD)) + self._assert_not_error( + spot_market.get_ohlc( + pair=self.TEST_PAIR_BTCUSD, + interval=self.TEST_INTERVAL, + since=self.TEST_SINCE, + ), + ) + + def test_get_order_book(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_order_book`` endpoint by performing multiple + requests with different parameters and validating that the response + does not contain the error key. + """ + self._assert_not_error(spot_market.get_order_book(pair=self.TEST_PAIR_BTCUSD)) + self._assert_not_error( + spot_market.get_order_book( + pair=self.TEST_PAIR_BTCUSD, + count=self.TEST_COUNT, + ), + ) + + def test_get_recent_trades(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_recent_trades`` endpoint by performing multiple + requests with different parameters and validating that the response + does not contain the error key. + """ + self._assert_not_error( + spot_market.get_recent_trades(pair=self.TEST_PAIR_BTCUSD), + ) + self._assert_not_error( + spot_market.get_recent_trades( + pair=self.TEST_PAIR_BTCUSD, + since=self.TEST_SINCE, + count=self.TEST_COUNT, + ), + ) + + def test_get_recent_spreads(self: Self, spot_market: Market) -> None: + """ + Checks the ``get_recent_spreads`` endpoint by performing multiple + requests with different parameters and validating that the response + does not contain the error key. + """ + self._assert_not_error( + spot_market.get_recent_spreads(pair=self.TEST_PAIR_BTCUSD), + ) + self._assert_not_error( + spot_market.get_recent_spreads( + pair=self.TEST_PAIR_BTCUSD, + since=self.TEST_SINCE, + ), + ) + + def test_extra_parameter(self: Self, spot_market: Market) -> None: + """ + Checks if the extra parameter can be used to overwrite an existing one. + This also checks ensure_string for this parameter. + """ + result: dict = spot_market.get_assets( + assets=self.TEST_ASSET_BTC, + extra_params={"asset": self.TEST_ASSET_ETH}, + ) + assert self.TEST_ASSET_BTC not in result + assert f"X{self.TEST_ASSET_ETH}" in result diff --git a/tests/spot/test_spot_orderbook.py b/tests/spot/test_spot_orderbook.py index 59b3183..5a81feb 100644 --- a/tests/spot/test_spot_orderbook.py +++ b/tests/spot/test_spot_orderbook.py @@ -15,7 +15,7 @@ import json from asyncio import sleep as async_sleep from collections import OrderedDict -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self from unittest import mock import pytest @@ -31,162 +31,160 @@ @pytest.mark.spot @pytest.mark.spot_websocket @pytest.mark.spot_orderbook -def test_create_public_bot(caplog: pytest.LogCaptureFixture) -> None: - """ - Checks if the websocket client can be instantiated. - """ - - async def create_bot() -> None: - async with SpotOrderBookClientWrapper() as orderbook: - await async_sleep(10) - - assert orderbook.depth == 10 - - asyncio.run(create_bot()) - - for expected in ( - 'channel": "status"', - '"api_version": "v2"', - '"system": "online",', - '"type": "update"', - ): - assert expected 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) - == SpotOrderBookClientWrapper.get_first(("10", "5")) - == SpotOrderBookClientWrapper.get_first((10, 5)) +class TestSpotOrderBook: + """Test class for Spot Orderbook client functionality.""" + + TEST_PAIR_BTCUSD = "BTC/USD" + TEST_PAIR_DOTUSD = "DOT/USD" + TEST_PAIR_ETHUSD = "ETH/USD" + TEST_PAIR_MATICUSD = "MATIC/USD" + TEST_PAIR_BTCEUR = "BTC/EUR" + TEST_DEPTH = 10 + + def test_create_public_bot(self: Self, caplog: pytest.LogCaptureFixture) -> None: + """ + Checks if the websocket client can be instantiated. + """ + + async def create_bot() -> None: + async with SpotOrderBookClientWrapper() as orderbook: + await async_sleep(10) + assert orderbook.depth == self.TEST_DEPTH + + asyncio.run(create_bot()) + + for expected in ( + 'channel": "status"', + '"api_version": "v2"', + '"system": "online",', + '"type": "update"', + ): + assert expected in caplog.text + + def test_get_first(self) -> None: + """ + Checks the ``get_first`` method. + """ + assert ( + float(10) + == SpotOrderBookClientWrapper.get_first(("10", "5")) + == SpotOrderBookClientWrapper.get_first((10, 5)) + ) + + @mock.patch("kraken.spot.orderbook.SpotWSClient", return_value=None) + @mock.patch( + "kraken.spot.orderbook.SpotOrderBookClient.remove_book", + return_value=mock.AsyncMock(), ) - - -@pytest.mark.spot -@pytest.mark.spot_orderbook -@mock.patch("kraken.spot.orderbook.SpotWSClient", return_value=None) -@mock.patch( - "kraken.spot.orderbook.SpotOrderBookClient.remove_book", - return_value=mock.AsyncMock(), -) -@mock.patch( - "kraken.spot.orderbook.SpotOrderBookClient.add_book", - return_value=mock.AsyncMock(), -) -def test_passing_msg_and_validate_checksum( - mock_add_book: mock.MagicMock, # noqa: ARG001 - mock_remove_book: mock.MagicMock, # noqa: ARG001 - mock_ws_client: mock.MagicMock, # noqa: ARG001 -) -> 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. - """ - json_file_path: Path = FIXTURE_DIR / "orderbook-v2.json" - with json_file_path.open("r", encoding="utf-8") as json_file: - orderbook: dict = json.load(json_file) - - async def assign() -> None: - client: SpotOrderBookClient = SpotOrderBookClient(depth=10) - # await client.start() # not required here - - await client.on_message(message=orderbook["init"]) - assert client.get(pair="BTC/USD")["valid"] - - for update in orderbook["updates"]: - await client.on_message(message=update) - assert client.get(pair="BTC/USD")["valid"] - - bad_message: dict = { - "channel": "book", - "type": "update", - "data": [ - { - "symbol": "BTC/USD", - "bids": [{"price": 29430.3, "qty": 1.69289565}], - "asks": [], - "checksum": 2438868880, - "timestamp": "2023-07-30T15:30:49.008834Z", - }, - ], - } - await client.on_message(message=bad_message) - assert not client.get(pair="BTC/USD")["valid"] - - asyncio.run(assign()) - - -@pytest.mark.spot -@pytest.mark.spot_websocket -@pytest.mark.spot_orderbook -def test_add_book(caplog: pytest.LogCaptureFixture) -> 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: - async with SpotOrderBookClientWrapper() as orderbook: - - # having multiple pairs to test the cancellation queue error absence - await orderbook.add_book( - pairs=["BTC/USD", "DOT/USD", "ETH/USD", "MATIC/USD", "BTC/EUR"], - ) - await async_sleep(4) - - book: dict | None = orderbook.get(pair="BTC/USD") - assert isinstance(book, dict) - - assert all( - key in book - for key in ("ask", "bid", "valid", "price_decimals", "qty_decimals") - ), book - - assert isinstance(book["ask"], OrderedDict) - assert isinstance(book["bid"], OrderedDict) - - for ask, bid in zip(book["ask"], book["bid"], strict=True): - assert isinstance(ask, str) - assert isinstance(bid, str) - - asyncio.run(execute_add_book()) - - for expected in ( - '{"method": "subscribe", "result": {"channel": "book", "depth": 10, ' - '"snapshot": true, "symbol": "BTC/USD"}, "success": true, "time_in": ', - '{"channel": "book", "type": "snapshot", "data": ' - '[{"symbol": "BTC/USD", "bids": ', - ): - assert expected in caplog.text - - -@pytest.mark.spot -@pytest.mark.spot_websocket -@pytest.mark.spot_orderbook -def test_remove_book(caplog: pytest.LogCaptureFixture) -> 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: - async with SpotOrderBookClientWrapper() as orderbook: - await orderbook.add_book(pairs=["BTC/USD"]) - await async_sleep(2) - - await orderbook.remove_book(pairs=["BTC/USD"]) - await async_sleep(2) - - asyncio.run(execute_remove_book()) - - assert ( - '{"method": "unsubscribe", "result": {"channel": "book", "depth": 10, "symbol": "BTC/USD"}, "success": true, "time_in":' - in caplog.text + @mock.patch( + "kraken.spot.orderbook.SpotOrderBookClient.add_book", + return_value=mock.AsyncMock(), ) + def test_passing_msg_and_validate_checksum( + self: Self, + mock_add_book: mock.MagicMock, # noqa: ARG002 + mock_remove_book: mock.MagicMock, # noqa: ARG002 + mock_ws_client: mock.MagicMock, # noqa: ARG002 + ) -> 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. + """ + json_file_path: Path = FIXTURE_DIR / "orderbook-v2.json" + with json_file_path.open("r", encoding="utf-8") as json_file: + orderbook: dict = json.load(json_file) + + async def assign() -> None: + client: SpotOrderBookClient = SpotOrderBookClient(depth=self.TEST_DEPTH) + # starting the client is not necessary for this test + + await client.on_message(message=orderbook["init"]) + assert client.get(pair=self.TEST_PAIR_BTCUSD)["valid"] + + for update in orderbook["updates"]: + await client.on_message(message=update) + assert client.get(pair=self.TEST_PAIR_BTCUSD)["valid"] + + bad_message: dict = { + "channel": "book", + "type": "update", + "data": [ + { + "symbol": self.TEST_PAIR_BTCUSD, + "bids": [{"price": 29430.3, "qty": 1.69289565}], + "asks": [], + "checksum": 2438868880, + "timestamp": "2023-07-30T15:30:49.008834Z", + }, + ], + } + await client.on_message(message=bad_message) + assert not client.get(pair=self.TEST_PAIR_BTCUSD)["valid"] + + asyncio.run(assign()) + + def test_add_book(self: Self, caplog: pytest.LogCaptureFixture) -> 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: + async with SpotOrderBookClientWrapper() as orderbook: + await orderbook.add_book( + pairs=[ + self.TEST_PAIR_BTCUSD, + self.TEST_PAIR_DOTUSD, + self.TEST_PAIR_ETHUSD, + self.TEST_PAIR_MATICUSD, + self.TEST_PAIR_BTCEUR, + ], + ) + await async_sleep(4) + + book: dict | None = orderbook.get(pair=self.TEST_PAIR_BTCUSD) + assert isinstance(book, dict) + + assert all( + key in book + for key in ("ask", "bid", "valid", "price_decimals", "qty_decimals") + ), book + + assert isinstance(book["ask"], OrderedDict) + assert isinstance(book["bid"], OrderedDict) + + for ask, bid in zip(book["ask"], book["bid"], strict=True): + assert isinstance(ask, str) + assert isinstance(bid, str) + + asyncio.run(execute_add_book()) + + for expected in ( + '{"method": "subscribe", "result": {"channel": "book", "depth": 10, ' + '"snapshot": true, "symbol": "BTC/USD"}, "success": true, "time_in": ', + '{"channel": "book", "type": "snapshot", "data": ' + '[{"symbol": "BTC/USD", "bids": ', + ): + assert expected in caplog.text + + def test_remove_book(self: Self, caplog: pytest.LogCaptureFixture) -> 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: + async with SpotOrderBookClientWrapper() as orderbook: + await orderbook.add_book(pairs=[self.TEST_PAIR_BTCUSD]) + await async_sleep(2) + + await orderbook.remove_book(pairs=[self.TEST_PAIR_BTCUSD]) + await async_sleep(2) + + asyncio.run(execute_remove_book()) + + assert ( + '{"method": "unsubscribe", "result": {"channel": "book", "depth": 10, "symbol": "BTC/USD"}, "success": true, "time_in":' + in caplog.text + ) diff --git a/tests/spot/test_spot_trade.py b/tests/spot/test_spot_trade.py index 7166ed1..b7edb26 100644 --- a/tests/spot/test_spot_trade.py +++ b/tests/spot/test_spot_trade.py @@ -9,376 +9,356 @@ from datetime import UTC, datetime, timedelta from time import sleep +from typing import Self import pytest from kraken.exceptions import KrakenPermissionDeniedError from kraken.spot import Trade -# todo: Mock skipped tests - or is this to dangerous? - @pytest.mark.spot @pytest.mark.spot_auth @pytest.mark.spot_trade -def test_create_order(spot_auth_trade: Trade) -> None: - """ - This test checks the ``create_order`` function by performing calls to create - an order - but in validate mode - so that no real order is placed. The - KrakenPermissionDeniedError will be raised since the CI does not have trade - permission. - """ - with pytest.raises(KrakenPermissionDeniedError): - assert isinstance( +class TestSpotTrade: + """Test class for Spot Trade client functionality.""" + + TEST_PAIR_BTCEUR = "BTC/EUR" + TEST_PAIR_XBTUSD = "XBTUSD" + TEST_PAIR_DOTUSD = "DOTUSD" + TEST_TXID = "OHYO67-6LP66-HMQ437" + TEST_USERREF = "12345678" + + def test_create_order(self: Self, spot_auth_trade: Trade) -> None: + """ + This test checks the ``create_order`` function by performing calls to + create an order - but in validate mode - so that no real order is + placed. The KrakenPermissionDeniedError will be raised since the CI does + not have trade permission. + """ + with pytest.raises(KrakenPermissionDeniedError): + assert isinstance( + spot_auth_trade.create_order( + ordertype="limit", + side="buy", + volume=1.001, + oflags=["post"], + pair=self.TEST_PAIR_BTCEUR, + price=1.001, # this also checks the truncate option + timeinforce="GTC", + truncate=True, + validate=True, # important to just test this endpoint without risking money + ), + dict, + ) + + with pytest.raises(KrakenPermissionDeniedError): + assert isinstance( + spot_auth_trade.create_order( + ordertype="limit", + side="buy", + volume=10000000, + oflags=["post"], + pair=self.TEST_PAIR_BTCEUR, + price=100, + expiretm="0", + displayvol=1000, + validate=True, # important to just test this endpoint without risking money + ), + dict, + ) + + with pytest.raises(KrakenPermissionDeniedError): + assert isinstance( + spot_auth_trade.create_order( + ordertype="stop-loss", + side="sell", + volume="1000", + trigger="last", + pair=self.TEST_PAIR_XBTUSD, + price="100", + leverage="2", + reduce_only=True, + userref="12345", + close_ordertype="limit", + close_price="123", + close_price2="92", + validate=True, + ), + dict, + ) + + deadline = (datetime.now(UTC) + timedelta(seconds=20)).isoformat() + with pytest.raises( + KrakenPermissionDeniedError, + match=r"API key doesn't have permission to make this request.", + ): spot_auth_trade.create_order( - ordertype="limit", + ordertype="stop-loss-limit", + pair=self.TEST_PAIR_XBTUSD, side="buy", - volume=1.001, - oflags=["post"], - pair="BTC/EUR", - price=1.001, # this also checks the truncate option + volume=0.001, + price=25000, + price2=27000, + validate=True, + trigger="last", timeinforce="GTC", - truncate=True, - validate=True, # important to just test this endpoint without risking money - ), - dict, - ) - - with pytest.raises(KrakenPermissionDeniedError): - assert isinstance( + leverage=4, + deadline=deadline, + ) + + def test_failing_create_order(self: Self, spot_auth_trade: Trade) -> None: + """ + Test that checks if the ``create_order`` function raises a ValueError + because of missing or invalid parameters. + > stop-loss-limit (and take-profit-limit) require a second price ``price2``) + """ + with pytest.raises( + ValueError, + match=r"Ordertype stop-loss-limit requires a secondary price \(price2\)!", + ): spot_auth_trade.create_order( - ordertype="limit", + ordertype="stop-loss-limit", + pair=self.TEST_PAIR_XBTUSD, side="buy", - volume=10000000, - oflags=["post"], - pair="BTC/EUR", - price=100, - expiretm="0", - displayvol=1000, - validate=True, # important to just test this endpoint without risking money - ), - dict, - ) - - with pytest.raises(KrakenPermissionDeniedError): - assert isinstance( - spot_auth_trade.create_order( - ordertype="stop-loss", - side="sell", - volume="1000", - trigger="last", - pair="XBTUSD", - price="100", - leverage="2", - reduce_only=True, - userref="12345", - close_ordertype="limit", - close_price="123", - close_price2="92", + volume=0.001, + price=25000, + timeinforce="GTC", + leverage=4, validate=True, - ), - dict, - ) - - deadline = (datetime.now(UTC) + timedelta(seconds=20)).isoformat() - with pytest.raises( - KrakenPermissionDeniedError, - match=r"API key doesn't have permission to make this request.", - ): - spot_auth_trade.create_order( - ordertype="stop-loss-limit", - pair="XBTUSD", - side="buy", - volume=0.001, - price=25000, - price2=27000, - validate=True, - trigger="last", - timeinforce="GTC", - leverage=4, - deadline=deadline, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -def test_failing_create_order(spot_auth_trade: Trade) -> None: - """ - Test that checks if the ``create_order`` function raises a ValueError - because of missing or invalid parameters. - > stop-loss-limit (and take-profit-limit) require a second price ``price2``) - """ - with pytest.raises( - ValueError, - match=r"Ordertype stop-loss-limit requires a secondary price \(price2\)!", - ): - spot_auth_trade.create_order( - ordertype="stop-loss-limit", - pair="XBTUSD", - side="buy", - volume=0.001, - price=25000, - timeinforce="GTC", - leverage=4, - validate=True, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -def test_create_order_batch(spot_auth_trade: Trade) -> None: - """ - Checks the ``create_order_batch`` function by executing - a batch order in validate mode. (Permission denied, - since the CI does not have trade permissions) - """ - with pytest.raises(KrakenPermissionDeniedError): - spot_auth_trade.create_order_batch( - orders=[ - { - "close": { - "ordertype": "stop-loss-limit", - "price": 120, - "price2": 110, - }, - "ordertype": "limit", - "price": 140, - "price2": 130, - "timeinforce": "GTC", - "type": "buy", - "userref": 1680953421, - "volume": 1000, - }, - { - "ordertype": "limit", - "price": 150, - "timeinforce": "GTC", - "type": "sell", - "userref": 1680953421, - "volume": 123, - }, - ], - pair="BTC/USD", - validate=True, # important - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -def test_edit_order(spot_auth_trade: Trade) -> None: - """ - Test the ``edit_order`` function by editing an order. - - KrakenPermissionDeniedError: since CI does not have trade permissions. If - the request would be malformed, another exception could be observed. - """ - with pytest.raises(KrakenPermissionDeniedError): - spot_auth_trade.edit_order( - txid="OHYO67-6LP66-HMQ437", - userref="12345678", - volume=1.25, - pair="XBTUSD", - price=27500, - price2=26500, - cancel_response=False, - truncate=True, - oflags=["post"], - validate=True, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -def test_amend_order(spot_auth_trade: Trade) -> None: - """ - Test the ``amend_order`` function by editing an order. - - KrakenPermissionDeniedError: since CI does not have trade permissions. If - the request would be malformed, another exception could be observed. - """ - with pytest.raises(KrakenPermissionDeniedError): - spot_auth_trade.amend_order( - extra_params={ - "txid": "OVM3PT-56ACO-53SM2T", - "limit_price": "105636.9", - }, - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -def test_cancel_order(spot_auth_trade: Trade) -> None: - """ - Checks the ``cancel_order`` function by canceling an order. - - A KrakenPermissionDeniedError is expected since CI keys are - not allowed to trade/cancel/withdraw/stake. - """ - with pytest.raises(KrakenPermissionDeniedError): - spot_auth_trade.cancel_order(txid="OB6JJR-7NZ5P-N5SKCB") - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -@pytest.mark.skip(reason="CI does not have trade/cancel permission") -def test_cancel_all_orders(spot_auth_trade: Trade) -> None: - """ - Checks the ``cancel_all_orders`` endpoint by executing the function. - A KrakenPermissionDeniedError will be raised since the CI API keys - do not have cancel permission. - """ - with pytest.raises(KrakenPermissionDeniedError): - assert isinstance(spot_auth_trade.cancel_all_orders(), dict) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -@pytest.mark.skip(reason="CI does not have trade/cancel permission") -def test_cancel_all_orders_after_x(spot_auth_trade: Trade) -> None: - """ - Checks the ``cancel_all_orders_after_x`` function by validating its response - data type. - - THe KrakenPermissionDeniedError will be caught since the CI API keys are not - allowed to cancel orders. - """ - with pytest.raises(KrakenPermissionDeniedError): - assert isinstance(spot_auth_trade.cancel_all_orders_after_x(timeout=0), dict) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_trade -def test_cancel_order_batch(spot_auth_trade: Trade) -> None: - """ - Tests the ``cancel_order_batch`` function by cancelling dummy orders that - do not exist anymore. Error will be raised since the CI do not have trade - permissions. - """ - with pytest.raises(KrakenPermissionDeniedError): - assert isinstance( - spot_auth_trade.cancel_order_batch( + ) + + def test_create_order_batch(self: Self, spot_auth_trade: Trade) -> None: + """ + Checks the ``create_order_batch`` function by executing + a batch order in validate mode. (Permission denied, + since the CI does not have trade permissions) + """ + with pytest.raises(KrakenPermissionDeniedError): + spot_auth_trade.create_order_batch( orders=[ - "O2JLFP-VYFIW-35ZAAE", - "O523KJ-DO4M2-KAT243", - "OCDIAL-YC66C-DOF7HS", - "OVFPZ2-DA2GV-VBFVVI", + { + "close": { + "ordertype": "stop-loss-limit", + "price": 120, + "price2": 110, + }, + "ordertype": "limit", + "price": 140, + "price2": 130, + "timeinforce": "GTC", + "type": "buy", + "userref": 1680953421, + "volume": 1000, + }, + { + "ordertype": "limit", + "price": 150, + "timeinforce": "GTC", + "type": "sell", + "userref": 1680953421, + "volume": 123, + }, ], - ), - dict, - ) - - -@pytest.mark.spot -@pytest.mark.spot_trade -def test_truncate_price(spot_trade: Trade) -> None: - """ - Checks if the truncate function returns the expected results by checking - different inputs for price. - - NOTE: This test may break in the future since the lot_decimals, - pair_decimals, ordermin and costmin attributes could change. - """ - for price, expected in ( - (10000, "10000.0"), - (1000.1, "1000.1"), - (1000.01, "1000.0"), - (1000.001, "1000.0"), - ): - assert ( - spot_trade.truncate(amount=price, amount_type="price", pair="XBTUSD") - == expected - ) - sleep(3) - - for price, expected in ( - (2, "2.0000"), - (12.1, "12.1000"), - (13.105, "13.1050"), - (4.32595, "4.3259"), - ): - assert ( - spot_trade.truncate(amount=price, amount_type="price", pair="DOTUSD") - == expected - ) - sleep(3) - - -@pytest.mark.spot -@pytest.mark.spot_trade -def test_truncate_volume(spot_trade: Trade) -> None: - """ - Checks if the truncate function returns the expected results by checking - different inputs for volume. - - NOTE: This test may break in the future since the lot_decimals, - pair_decimals, ordermin and costmin attributes could change. - """ - for volume, expected in ( - (1, "1.00000000"), - (1.1, "1.10000000"), - (1.67, "1.67000000"), - (1.9328649837, "1.93286498"), - ): - assert ( - spot_trade.truncate(amount=volume, amount_type="volume", pair="XBTUSD") - == expected - ) - sleep(3) - - for volume, expected in ( - (2, "2.00000000"), - (12.158, "12.15800000"), - (13.1052093, "13.10520930"), - (4.32595342455, "4.32595342"), - ): - assert ( - spot_trade.truncate(amount=volume, amount_type="volume", pair="DOTUSD") - == expected - ) - sleep(3) - - -@pytest.mark.spot -@pytest.mark.spot_trade -def test_truncate_fail_price_costmin(spot_trade: Trade) -> None: - """ - Checks if the truncate function fails if the price is less than the costmin. - - NOTE: This test may break in the future since the lot_decimals, - pair_decimals, ordermin and costmin attributes could change. - """ - with pytest.raises(ValueError, match=r"Price is less than the costmin: 0.5!"): - spot_trade.truncate(amount=0.001, amount_type="price", pair="XBTUSD") - - -@pytest.mark.spot -@pytest.mark.spot_trade -def test_truncate_fail_volume_ordermin(spot_trade: Trade) -> None: - """ - Checks if the truncate function fails if the volume is less than the - ordermin. - - NOTE: This test may break in the future since the lot_decimals, - pair_decimals, ordermin and costmin attributes could change. - """ - with pytest.raises(ValueError, match=r"Volume is less than the ordermin: 0.00005!"): - spot_trade.truncate(amount=0.0000001, amount_type="volume", pair="XBTUSD") - - -@pytest.mark.spot -@pytest.mark.spot_trade -def test_truncate_fail_invalid_amount_type(spot_trade: Trade) -> None: - """ - Checks if the truncate function fails when no valid ``amount_type`` was - specified. - """ - with pytest.raises(ValueError, match=r"Amount type must be 'volume' or 'price'!"): - spot_trade.truncate(amount=1, amount_type="invalid", pair="XBTUSD") + pair=self.TEST_PAIR_BTCEUR, + validate=True, # important + ) + + def test_edit_order(self: Self, spot_auth_trade: Trade) -> None: + """ + Test the ``edit_order`` function by editing an order. + + KrakenPermissionDeniedError: since CI does not have trade permissions. If + the request would be malformed, another exception could be observed. + """ + with pytest.raises(KrakenPermissionDeniedError): + spot_auth_trade.edit_order( + txid=self.TEST_TXID, + userref=self.TEST_USERREF, + volume=1.25, + pair=self.TEST_PAIR_XBTUSD, + price=27500, + price2=26500, + cancel_response=False, + truncate=True, + oflags=["post"], + validate=True, + ) + + def test_amend_order(self: Self, spot_auth_trade: Trade) -> None: + """ + Test the ``amend_order`` function by editing an order. + + KrakenPermissionDeniedError: since CI does not have trade permissions. If + the request would be malformed, another exception could be observed. + """ + with pytest.raises(KrakenPermissionDeniedError): + spot_auth_trade.amend_order( + extra_params={ + "txid": "OVM3PT-56ACO-53SM2T", + "limit_price": "105636.9", + }, + ) + + def test_cancel_order(self: Self, spot_auth_trade: Trade) -> None: + """ + Checks the ``cancel_order`` function by canceling an order. + + A KrakenPermissionDeniedError is expected since CI keys are + not allowed to trade/cancel/withdraw/stake. + """ + with pytest.raises(KrakenPermissionDeniedError): + spot_auth_trade.cancel_order(txid="OB6JJR-7NZ5P-N5SKCB") + + @pytest.mark.skip(reason="Test do not have trade/cancel permission") + def test_cancel_all_orders(self: Self, spot_auth_trade: Trade) -> None: + """ + Checks the ``cancel_all_orders`` endpoint by executing the function. + A KrakenPermissionDeniedError will be raised since the CI API keys + do not have cancel permission. + """ + with pytest.raises(KrakenPermissionDeniedError): + assert isinstance(spot_auth_trade.cancel_all_orders(), dict) + + @pytest.mark.skip(reason="Test do not have trade/cancel permission") + def test_cancel_all_orders_after_x(self: Self, spot_auth_trade: Trade) -> None: + """ + Checks the ``cancel_all_orders_after_x`` function by validating its response + data type. + + THe KrakenPermissionDeniedError will be caught since the CI API keys are not + allowed to cancel orders. + """ + with pytest.raises(KrakenPermissionDeniedError): + assert isinstance( + spot_auth_trade.cancel_all_orders_after_x(timeout=0), + dict, + ) + + def test_cancel_order_batch(self: Self, spot_auth_trade: Trade) -> None: + """ + Tests the ``cancel_order_batch`` function by cancelling dummy orders that + do not exist anymore. Error will be raised since the CI do not have trade + permissions. + """ + with pytest.raises(KrakenPermissionDeniedError): + assert isinstance( + spot_auth_trade.cancel_order_batch( + orders=[ + "O2JLFP-VYFIW-35ZAAE", + "O523KJ-DO4M2-KAT243", + "OCDIAL-YC66C-DOF7HS", + "OVFPZ2-DA2GV-VBFVVI", + ], + ), + dict, + ) + + @pytest.mark.spot + @pytest.mark.spot_trade + def test_truncate_price(self: Self, spot_trade: Trade) -> None: + """ + Checks if the truncate function returns the expected results by checking + different inputs for price. + + NOTE: This test may break in the future since the lot_decimals, + pair_decimals, ordermin and costmin attributes could change. + """ + for price, expected in ( + (10000, "10000.0"), + (1000.1, "1000.1"), + (1000.01, "1000.0"), + (1000.001, "1000.0"), + ): + assert ( + spot_trade.truncate(amount=price, amount_type="price", pair="XBTUSD") + == expected + ) + sleep(3) + + for price, expected in ( + (2, "2.0000"), + (12.1, "12.1000"), + (13.105, "13.1050"), + (4.32595, "4.3259"), + ): + assert ( + spot_trade.truncate(amount=price, amount_type="price", pair="DOTUSD") + == expected + ) + sleep(3) + + @pytest.mark.spot + @pytest.mark.spot_trade + def test_truncate_volume(self: Self, spot_trade: Trade) -> None: + """ + Checks if the truncate function returns the expected results by checking + different inputs for volume. + + NOTE: This test may break in the future since the lot_decimals, + pair_decimals, ordermin and costmin attributes could change. + """ + for volume, expected in ( + (1, "1.00000000"), + (1.1, "1.10000000"), + (1.67, "1.67000000"), + (1.9328649837, "1.93286498"), + ): + assert ( + spot_trade.truncate(amount=volume, amount_type="volume", pair="XBTUSD") + == expected + ) + sleep(3) + + for volume, expected in ( + (2, "2.00000000"), + (12.158, "12.15800000"), + (13.1052093, "13.10520930"), + (4.32595342455, "4.32595342"), + ): + assert ( + spot_trade.truncate(amount=volume, amount_type="volume", pair="DOTUSD") + == expected + ) + sleep(3) + + @pytest.mark.spot + @pytest.mark.spot_trade + def test_truncate_fail_price_costmin(self: Self, spot_trade: Trade) -> None: + """ + Checks if the truncate function fails if the price is less than the costmin. + + NOTE: This test may break in the future since the lot_decimals, + pair_decimals, ordermin and costmin attributes could change. + """ + with pytest.raises(ValueError, match=r"Price is less than the costmin: 0.5!"): + spot_trade.truncate(amount=0.001, amount_type="price", pair="XBTUSD") + + @pytest.mark.spot + @pytest.mark.spot_trade + def test_truncate_fail_volume_ordermin(self: Self, spot_trade: Trade) -> None: + """ + Checks if the truncate function fails if the volume is less than the + ordermin. + + NOTE: This test may break in the future since the lot_decimals, + pair_decimals, ordermin and costmin attributes could change. + """ + with pytest.raises( + ValueError, + match=r"Volume is less than the ordermin: 0.00005!", + ): + spot_trade.truncate(amount=0.0000001, amount_type="volume", pair="XBTUSD") + + @pytest.mark.spot + @pytest.mark.spot_trade + def test_truncate_fail_invalid_amount_type(self: Self, spot_trade: Trade) -> None: + """ + Checks if the truncate function fails when no valid ``amount_type`` was + specified. + """ + with pytest.raises( + ValueError, + match=r"Amount type must be 'volume' or 'price'!", + ): + spot_trade.truncate(amount=1, amount_type="invalid", pair="XBTUSD") diff --git a/tests/spot/test_spot_user.py b/tests/spot/test_spot_user.py index 69261ad..ebf9afa 100644 --- a/tests/spot/test_spot_user.py +++ b/tests/spot/test_spot_user.py @@ -13,6 +13,7 @@ from datetime import datetime from pathlib import Path from time import sleep +from typing import Self from unittest import mock import pytest @@ -26,401 +27,351 @@ @pytest.mark.spot @pytest.mark.spot_auth @pytest.mark.spot_user -def test_get_account_balance(spot_auth_user: User) -> None: - """ - Checks the ``get_account_balance`` function by validating that - the response do not contain the error key. - """ - assert is_not_error(spot_auth_user.get_account_balance()) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_balances(spot_auth_user: User) -> None: - """ - Checks the ``get_balances`` function by validating that - the response do not contain the error key. - """ - result: dict = spot_auth_user.get_balances() - assert isinstance(result, dict) - assert is_not_error(result) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -@mock.patch.object( - User, - "get_balances", - return_value={ - "XXLM": {"balance": "0.00000000", "hold_trade": "0.00000000"}, - "ZEUR": {"balance": "500.0000", "hold_trade": "0.0000"}, - "XXBT": {"balance": "2.1031709100", "hold_trade": "0.1401000000"}, - "KFEE": {"balance": "7407.73", "hold_trade": "0.00"}, - }, -) -def test_get_balance( - mock_user: mock.MagicMock, # noqa: ARG001 - spot_auth_user: User, -) -> None: - """ - Checks the ``get_balances`` function by mocking the internal API call - (which is already covered by :func:`test_get_balances`) and checking the - return value. - """ - result: dict = spot_auth_user.get_balance(currency="XBT") - assert result == { - "currency": "XXBT", - "balance": 2.1031709100, - "available_balance": 1.96307091, - } - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_trade_balance(spot_auth_user: User) -> None: - """ - Checks the ``get_trade_balances`` function by validating that - the response do not contain the error key. - - (sleep since we don't want API rate limit error...) - """ - sleep(3) - assert is_not_error(spot_auth_user.get_trade_balance()) - assert is_not_error(spot_auth_user.get_trade_balance(asset="EUR")) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_open_orders(spot_auth_user: User) -> None: - """ - Checks the ``get_open_orders`` function by validating that - the response do not contain the error key. - """ - assert is_not_error(spot_auth_user.get_open_orders(trades=True)) - assert is_not_error(spot_auth_user.get_open_orders(trades=False, userref="1234567")) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_closed_orders(spot_auth_user: User) -> None: - """ - Checks the ``get_closed_orders`` function by validating that - the responses do not contain the error key. - """ - assert is_not_error(spot_auth_user.get_closed_orders()) - assert is_not_error(spot_auth_user.get_closed_orders(trades=True, userref="1234")) - assert is_not_error( - spot_auth_user.get_closed_orders(trades=True, start="1668431675.4778206"), - ) - assert is_not_error( - spot_auth_user.get_closed_orders( - trades=True, - start="1668431675.4778206", - end="1668455555.4778206", - ofs=2, - ), - ) - assert is_not_error( - spot_auth_user.get_closed_orders( - trades=True, - start="1668431675.4778206", - end="1668455555.4778206", - ofs=1, - closetime="open", - ), - ) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_trades_info(spot_auth_user: User) -> None: - """ - Checks the ``get_trades_info`` function by validating that - the responses do not contain the error key. - """ - for params, method in zip( - ( - {"txid": "OXBBSK-EUGDR-TDNIEQ"}, - {"txid": "OXBBSK-EUGDR-TDNIEQ", "trades": True}, - {"txid": "OQQYNL-FXCFA-FBFVD7"}, - {"txid": ["OE3B4A-NSIEQ-5L6HW3", "O23GOI-WZDVD-XWGC3R"]}, - ), - ( - spot_auth_user.get_trades_info, - spot_auth_user.get_trades_info, - spot_auth_user.get_trades_info, - spot_auth_user.get_trades_info, - ), - strict=True, - ): - try: - assert is_not_error(method(**params)) - except KrakenInvalidOrderError: - pass - finally: - sleep(2) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_orders_info(spot_auth_user: User) -> None: - """ - Checks the ``get_orders_info`` function by validating that - the responses do not contain the error key. - """ - for params, method in zip( - ( - {"txid": "OXBBSK-EUGDR-TDNIEQ"}, - {"txid": "OXBBSK-EUGDR-TDNIEQ", "trades": True}, - {"txid": "OQQYNL-FXCFA-FBFVD7", "consolidate_taker": True}, - {"txid": ["OE3B4A-NSIEQ-5L6HW3", "O23GOI-WZDVD-XWGC3R"]}, - ), - ( - spot_auth_user.get_orders_info, - spot_auth_user.get_orders_info, - spot_auth_user.get_orders_info, - spot_auth_user.get_orders_info, - ), - strict=True, - ): - try: - assert is_not_error(method(**params)) - except KrakenInvalidOrderError: - pass - finally: - sleep(2) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_order_amends(spot_auth_user: User) -> None: - """ - Checks the ``get_order_amends`` function by validating that the responses do - not contain the error key. - """ - - assert is_not_error(spot_auth_user.get_order_amends(order_id="OVM3PT-56ACO-53SM2T")) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_trades_history(spot_auth_user: User) -> None: - """ - Checks the ``get_trades_history`` function by validating that - the responses do not contain the error key. - """ - sleep(3) - assert is_not_error(spot_auth_user.get_trades_history(type_="all", trades=True)) - assert is_not_error( - spot_auth_user.get_trades_history( - type_="closed position", - start="1677717104", - end="1677817104", - ofs="1", - ), - ) - +class TestSpotUser: + """Test class for Spot User client functionality.""" + + TEST_TXID = "OXBBSK-EUGDR-TDNIEQ" + TEST_USERREF = "1234567" + TEST_ASSET_XBT = "XBT" + TEST_ASSET_EUR = "EUR" + TEST_ASSET_DOT = "DOT" + + def test_get_account_balance(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_account_balance`` function by validating that + the response do not contain the error key. + """ + assert is_not_error(spot_auth_user.get_account_balance()) + + def test_get_balances(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_balances`` function by validating that + the response do not contain the error key. + """ + result: dict = spot_auth_user.get_balances() + assert isinstance(result, dict) + assert is_not_error(result) + + def test_get_balance(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_balances`` function by mocking the internal API call + (which is already covered by :func:`test_get_balances`) and checking the + return value. + """ + with mock.patch.object( + User, + "get_balances", + return_value={ + "XXLM": {"balance": "0.00000000", "hold_trade": "0.00000000"}, + "ZEUR": {"balance": "500.0000", "hold_trade": "0.0000"}, + "XXBT": {"balance": "2.1031709100", "hold_trade": "0.1401000000"}, + "KFEE": {"balance": "7407.73", "hold_trade": "0.00"}, + }, + ): + result: dict = spot_auth_user.get_balance(currency="XBT") + assert result == { + "currency": "XXBT", + "balance": 2.1031709100, + "available_balance": 1.96307091, + } + + def test_get_trade_balance(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_trade_balances`` function by validating that + the response do not contain the error key. + + (sleep since we don't want API rate limit error...) + """ + sleep(3) + assert is_not_error(spot_auth_user.get_trade_balance()) + assert is_not_error(spot_auth_user.get_trade_balance(asset="EUR")) + + def test_get_open_orders(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_open_orders`` function by validating that + the response do not contain the error key. + """ + assert is_not_error(spot_auth_user.get_open_orders(trades=True)) + assert is_not_error( + spot_auth_user.get_open_orders(trades=False, userref="1234567"), + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_open_positions(spot_auth_user: User) -> None: - """ - Checks the ``get_open_positions`` function by validating that - the responses do not contain the error key. - """ - assert isinstance(spot_auth_user.get_open_positions(), list) - assert isinstance( - spot_auth_user.get_open_positions(txid="OQQYNL-FXCFA-FBFVD7"), - list, - ) - assert isinstance( - spot_auth_user.get_open_positions(txid="OQQYNL-FXCFA-FBFVD7", docalcs=True), - list, - ) + def test_get_closed_orders(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_closed_orders`` function by validating that + the responses do not contain the error key. + """ + assert is_not_error(spot_auth_user.get_closed_orders()) + assert is_not_error( + spot_auth_user.get_closed_orders(trades=True, userref="1234"), + ) + assert is_not_error( + spot_auth_user.get_closed_orders(trades=True, start="1668431675.4778206"), + ) + assert is_not_error( + spot_auth_user.get_closed_orders( + trades=True, + start="1668431675.4778206", + end="1668455555.4778206", + ofs=2, + ), + ) + assert is_not_error( + spot_auth_user.get_closed_orders( + trades=True, + start="1668431675.4778206", + end="1668455555.4778206", + ofs=1, + closetime="open", + ), + ) + def test_get_trades_info(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_trades_info`` function by validating that + the responses do not contain the error key. + """ + for params, method in zip( + ( + {"txid": "OXBBSK-EUGDR-TDNIEQ"}, + {"txid": "OXBBSK-EUGDR-TDNIEQ", "trades": True}, + {"txid": "OQQYNL-FXCFA-FBFVD7"}, + {"txid": ["OE3B4A-NSIEQ-5L6HW3", "O23GOI-WZDVD-XWGC3R"]}, + ), + ( + spot_auth_user.get_trades_info, + spot_auth_user.get_trades_info, + spot_auth_user.get_trades_info, + spot_auth_user.get_trades_info, + ), + strict=True, + ): + try: + assert is_not_error(method(**params)) + except KrakenInvalidOrderError: + pass + finally: + sleep(2) + + def test_get_orders_info(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_orders_info`` function by validating that + the responses do not contain the error key. + """ + for params, method in zip( + ( + {"txid": "OXBBSK-EUGDR-TDNIEQ"}, + {"txid": "OXBBSK-EUGDR-TDNIEQ", "trades": True}, + {"txid": "OQQYNL-FXCFA-FBFVD7", "consolidate_taker": True}, + {"txid": ["OE3B4A-NSIEQ-5L6HW3", "O23GOI-WZDVD-XWGC3R"]}, + ), + ( + spot_auth_user.get_orders_info, + spot_auth_user.get_orders_info, + spot_auth_user.get_orders_info, + spot_auth_user.get_orders_info, + ), + strict=True, + ): + try: + assert is_not_error(method(**params)) + except KrakenInvalidOrderError: + pass + finally: + sleep(2) + + def test_get_order_amends(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_order_amends`` function by validating that the responses do + not contain the error key. + """ + + assert is_not_error( + spot_auth_user.get_order_amends(order_id="OVM3PT-56ACO-53SM2T"), + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_ledgers_info(spot_auth_user: User) -> None: - """ - Checks the ``get_ledgers_info`` function by validating that - the responses do not contain the error key. - """ - assert is_not_error(spot_auth_user.get_ledgers_info()) - assert is_not_error(spot_auth_user.get_ledgers_info(type_="deposit")) - assert is_not_error( - spot_auth_user.get_ledgers_info( - asset="EUR", - start="1668431675.4778206", - end="1668455555.4778206", - ofs=2, - ), - ) - assert is_not_error( - spot_auth_user.get_ledgers_info( - asset=["EUR", "USD"], - ), - ) - assert is_not_error( - spot_auth_user.get_ledgers_info( - asset="EUR,USD", - ), - ) + def test_get_trades_history(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_trades_history`` function by validating that + the responses do not contain the error key. + """ + sleep(3) + assert is_not_error(spot_auth_user.get_trades_history(type_="all", trades=True)) + assert is_not_error( + spot_auth_user.get_trades_history( + type_="closed position", + start="1677717104", + end="1677817104", + ofs="1", + ), + ) + def test_get_open_positions(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_open_positions`` function by validating that + the responses do not contain the error key. + """ + assert isinstance(spot_auth_user.get_open_positions(), list) + assert isinstance( + spot_auth_user.get_open_positions(txid="OQQYNL-FXCFA-FBFVD7"), + list, + ) + assert isinstance( + spot_auth_user.get_open_positions(txid="OQQYNL-FXCFA-FBFVD7", docalcs=True), + list, + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_ledgers(spot_auth_user: User) -> None: - """ - Checks the ``get_ledgers`` function by validating that - the responses do not contain the error key. - """ - assert is_not_error(spot_auth_user.get_ledgers(id_="LNYQGU-SUR5U-UXTOWM")) - assert is_not_error( - spot_auth_user.get_ledgers( - id_=["LNYQGU-SUR5U-UXTOWM", "LTCMN2-5DZHX-6CPRC4"], - trades=True, - ), - ) + def test_get_ledgers_info(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_ledgers_info`` function by validating that + the responses do not contain the error key. + """ + assert is_not_error(spot_auth_user.get_ledgers_info()) + assert is_not_error(spot_auth_user.get_ledgers_info(type_="deposit")) + assert is_not_error( + spot_auth_user.get_ledgers_info( + asset="EUR", + start="1668431675.4778206", + end="1668455555.4778206", + ofs=2, + ), + ) + assert is_not_error( + spot_auth_user.get_ledgers_info( + asset=["EUR", "USD"], + ), + ) + assert is_not_error( + spot_auth_user.get_ledgers_info( + asset="EUR,USD", + ), + ) + def test_get_ledgers(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_ledgers`` function by validating that + the responses do not contain the error key. + """ + assert is_not_error(spot_auth_user.get_ledgers(id_="LNYQGU-SUR5U-UXTOWM")) + assert is_not_error( + spot_auth_user.get_ledgers( + id_=["LNYQGU-SUR5U-UXTOWM", "LTCMN2-5DZHX-6CPRC4"], + trades=True, + ), + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_get_trade_volume(spot_auth_user: User) -> None: - """ - Checks the ``get_trade_volume`` function by validating that - the responses do not contain the error key. - """ - assert is_not_error(spot_auth_user.get_trade_volume()) - assert is_not_error(spot_auth_user.get_trade_volume(pair="DOT/EUR", fee_info=False)) + def test_get_trade_volume(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``get_trade_volume`` function by validating that + the responses do not contain the error key. + """ + assert is_not_error(spot_auth_user.get_trade_volume()) + assert is_not_error( + spot_auth_user.get_trade_volume(pair="DOT/EUR", fee_info=False), + ) + @pytest.mark.parametrize("report", ["trades", "ledgers"]) + def test_request_save_export_report( + self: Self, + report: str, + spot_auth_user: User, + ) -> None: + """ + Checks the ``save_export_report`` function by requesting an + report and saving them. + """ + with pytest.raises( + ValueError, + match=r"`report` must be either \"trades\" or \"ledgers\"", + ): + spot_auth_user.request_export_report( + report="invalid", + description="this is an invalid report type", + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -@pytest.mark.parametrize("report", ["trades", "ledgers"]) -def test_request_save_export_report(report: str, spot_auth_user: User) -> None: - """ - Checks the ``save_export_report`` function by requesting an - report and saving them. - """ - with pytest.raises( - ValueError, - match=r"`report` must be either \"trades\" or \"ledgers\"", - ): - spot_auth_user.request_export_report( - report="invalid", - description="this is an invalid report type", + first_of_current_month = int(datetime.now().replace(day=1).timestamp()) + export_descr = f"{report}-export-{random.randint(0, 10000)}" + response = spot_auth_user.request_export_report( + report=report, + description=export_descr, + fields="all", + format_="CSV", + starttm=first_of_current_month, + endtm=first_of_current_month + 100 * 100, ) - - first_of_current_month = int(datetime.now().replace(day=1).timestamp()) - export_descr = f"{report}-export-{random.randint(0, 10000)}" - response = spot_auth_user.request_export_report( - report=report, - description=export_descr, - fields="all", - format_="CSV", - starttm=first_of_current_month, - endtm=first_of_current_month + 100 * 100, - ) - assert is_not_error(response) - assert "id" in response - sleep(2) - - status = spot_auth_user.get_export_report_status(report=report) - assert isinstance(status, list) - sleep(5) - - result = spot_auth_user.retrieve_export(id_=response["id"], timeout=30) - - with tempfile.TemporaryDirectory() as tmp_dir: - file_path: Path = Path(tmp_dir) / f"{export_descr}.zip" - - with file_path.open("wb") as file: - for chunk in result.iter_content(chunk_size=512): - if chunk: - file.write(chunk) - - status = spot_auth_user.get_export_report_status(report=report) - assert isinstance(status, list) - for response in status: - if response.get("delete"): - # ignore already deleted reports - continue + assert is_not_error(response) assert "id" in response - with suppress(Exception): - assert isinstance( - spot_auth_user.delete_export_report( - id_=response["id"], - type_="delete", - ), - dict, - ) sleep(2) + status = spot_auth_user.get_export_report_status(report=report) + assert isinstance(status, list) + sleep(5) + + result = spot_auth_user.retrieve_export(id_=response["id"], timeout=30) + + with tempfile.TemporaryDirectory() as tmp_dir: + file_path: Path = Path(tmp_dir) / f"{export_descr}.zip" + + with file_path.open("wb") as file: + for chunk in result.iter_content(chunk_size=512): + if chunk: + file.write(chunk) + + status = spot_auth_user.get_export_report_status(report=report) + assert isinstance(status, list) + for response in status: + if response.get("delete"): + # ignore already deleted reports + continue + assert "id" in response + with suppress(Exception): + assert isinstance( + spot_auth_user.delete_export_report( + id_=response["id"], + type_="delete", + ), + dict, + ) + sleep(2) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_export_report_status_invalid(spot_auth_user: User) -> None: - """ - Checks the ``export_report_status`` function by passing an invalid - report type. - """ - with pytest.raises( - ValueError, - match=r"report must be one of \"trades\", \"ledgers\"", - ): - spot_auth_user.get_export_report_status(report="invalid") - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -def test_create_subaccount_failing(spot_auth_user: User) -> None: - """ - Checks the ``create_subaccount`` function by creating one. - - Creating subaccounts is only available for institutional clients - (July 2023), so a KrakenException.KrakenPermissionDeniedError will be - raised. - - todo: test this using a valid account - """ - with pytest.raises(KrakenPermissionDeniedError): - spot_auth_user.create_subaccount(email="abc@welt.de", username="tomtucker") - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_user -@pytest.mark.skip("Subaccount actions are only available for institutional clients") -def test_account_transfer_failing(spot_auth_user: User) -> None: - """ - Checks the ``account_transfer`` function by creating one. - - Transferring funds between subaccounts is only available for institutional - clients (July 2023), so a KrakenException.KrakenPermissionDeniedError will - be raised. - - todo: test this using a valid account - """ - spot_auth_user.account_transfer( - asset="XBT", - amount=1.0, - from_="ABCD 1234 EFGH 5678", - to_="JKIL 9012 MNOP 3456", - ) + def test_export_report_status_invalid(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``export_report_status`` function by passing an invalid + report type. + """ + with pytest.raises( + ValueError, + match=r"report must be one of \"trades\", \"ledgers\"", + ): + spot_auth_user.get_export_report_status(report="invalid") + + def test_create_subaccount_failing(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``create_subaccount`` function by creating one. + + Creating subaccounts is only available for institutional clients + (July 2023), so a KrakenException.KrakenPermissionDeniedError will be + raised. + + todo: test this using a valid account + """ + with pytest.raises(KrakenPermissionDeniedError): + spot_auth_user.create_subaccount(email="abc@welt.de", username="tomtucker") + + @pytest.mark.skip("Subaccount actions are only available for institutional clients") + def test_account_transfer_failing(self: Self, spot_auth_user: User) -> None: + """ + Checks the ``account_transfer`` function by creating one. + + Transferring funds between subaccounts is only available for institutional + clients (July 2023), so a KrakenException.KrakenPermissionDeniedError will + be raised. + + todo: test this using a valid account + """ + spot_auth_user.account_transfer( + asset="XBT", + amount=1.0, + from_="ABCD 1234 EFGH 5678", + to_="JKIL 9012 MNOP 3456", + ) diff --git a/tests/spot/test_spot_websocket.py b/tests/spot/test_spot_websocket.py index ea89cd2..8eefc40 100644 --- a/tests/spot/test_spot_websocket.py +++ b/tests/spot/test_spot_websocket.py @@ -39,484 +39,461 @@ @pytest.mark.spot @pytest.mark.spot_websocket -def test_create_public_client(caplog: pytest.LogCaptureFixture) -> None: - """ - Checks if the websocket client can be instantiated. - """ +class TestSpotWebSocket: + """Test class for Kraken Spot WebSocket client.""" - async def create_client() -> None: - client = SpotWebsocketClientTestWrapper() - await client.start() - await async_sleep(5) - await client.close() + TEST_CHANNEL_TICKER = "ticker" + TEST_SYMBOL_BTCUSD = "BTC/USD" + TEST_REQ_ID = 123456789 - asyncio_run(create_client()) + def test_create_public_client(self, caplog: pytest.LogCaptureFixture) -> None: + """ + Checks if the websocket client can be instantiated. + """ - for expected in ( - 'channel": "status"', - '"api_version": "v2"', - '"system": "online",', - '"type": "update"', - ): - assert expected in caplog.text - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_create_public_client_as_context_manager( - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Checks if the websocket client can be instantiated as context manager. - """ - - async def create_client_as_context_manager() -> None: - async with SpotWebsocketClientTestWrapper(): + async def create_client() -> None: + client = SpotWebsocketClientTestWrapper() + await client.start() await async_sleep(5) - - asyncio_run(create_client_as_context_manager()) - - for expected in ( - 'channel": "status"', - '"api_version": "v2"', - '"system": "online",', - '"type": "update"', - ): - assert expected in caplog.text - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_access_public_client_attributes() -> None: - """ - Checks the ``access_public_client_attributes`` function - works as expected. - """ - - async def check_access() -> None: - async with SpotWebsocketClientTestWrapper() as client: - assert client.public_channel_names == [ - "book", - "instrument", - "ohlc", - "ticker", - "trade", - ] - assert client.active_public_subscriptions == [] - await async_sleep(1) - with pytest.raises(ConnectionError): - # can't access private subscriptions on unauthenticated client - assert isinstance(client.active_private_subscriptions, list) - - await async_sleep(1.5) - - asyncio_run(check_access()) - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_access_public_subscriptions_no_conn_failing() -> None: - """ - Checks if ``active_public_subscriptions`` fails, because there is no - public connection - """ - - async def check_access() -> None: - async with SpotWebsocketClientTestWrapper( - no_public=True, - ) as client: - with pytest.raises(ConnectionError): - assert isinstance(client.active_public_subscriptions, list) - - await async_sleep(1.5) - - asyncio_run(check_access()) - - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_websocket -def test_access_private_client_attributes( - spot_api_key: str, - spot_secret_key: str, -) -> None: - """ - Checks the ``access_private_client_attributes`` function - works as expected. - """ - - async def check_access() -> None: - async with SpotWebsocketClientTestWrapper( - key=spot_api_key, - secret=spot_secret_key, - ) as auth_client: - assert isinstance(auth_client.private_channel_names, list) - assert isinstance(auth_client.private_methods, list) - assert auth_client.active_private_subscriptions == [] - await async_sleep(2.5) - - asyncio_run(check_access()) - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_send_message_missing_method_failing() -> None: - """ - Checks if the send_message function fails when specific keys or values - are incorrect formatted or missing. - """ - - async def create_client() -> None: - async with SpotWebsocketClientTestWrapper() as client: - with pytest.raises(TypeError): # wrong message format - await client.send_message(message=[]) - with pytest.raises(TypeError): # method value not string - await client.send_message(message={"method": 1}) - with pytest.raises(TypeError): # missing params for '*subscribe' - await client.send_message(message={"method": "subscribe"}) - with pytest.raises(TypeError): # params not dict - await client.send_message(message={"method": "subscribe", "params": []}) - with pytest.raises(TypeError): # params missing channel key + await client.close() + + asyncio_run(create_client()) + + for expected in ( + 'channel": "status"', + '"api_version": "v2"', + '"system": "online",', + '"type": "update"', + ): + assert expected in caplog.text + + def test_create_public_client_as_context_manager( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """ + Checks if the websocket client can be instantiated as context manager. + """ + + async def create_client_as_context_manager() -> None: + async with SpotWebsocketClientTestWrapper(): + await async_sleep(5) + + asyncio_run(create_client_as_context_manager()) + + for expected in ( + 'channel": "status"', + '"api_version": "v2"', + '"system": "online",', + '"type": "update"', + ): + assert expected in caplog.text + + def test_access_public_client_attributes(self) -> None: + """ + Checks the ``access_public_client_attributes`` function + works as expected. + """ + + async def check_access() -> None: + async with SpotWebsocketClientTestWrapper() as client: + assert client.public_channel_names == [ + "book", + "instrument", + "ohlc", + "ticker", + "trade", + ] + assert client.active_public_subscriptions == [] + await async_sleep(1) + with pytest.raises(ConnectionError): + # can't access private subscriptions on unauthenticated client + assert isinstance(client.active_private_subscriptions, list) + + await async_sleep(1.5) + + asyncio_run(check_access()) + + def test_access_public_subscriptions_no_conn_failing(self) -> None: + """ + Checks if ``active_public_subscriptions`` fails, because there is no + public connection + """ + + async def check_access() -> None: + async with SpotWebsocketClientTestWrapper( + no_public=True, + ) as client: + with pytest.raises(ConnectionError): + assert isinstance(client.active_public_subscriptions, list) + + await async_sleep(1.5) + + asyncio_run(check_access()) + + @pytest.mark.spot_auth + def test_access_private_client_attributes( + self, + spot_api_key: str, + spot_secret_key: str, + ) -> None: + """ + Checks the ``access_private_client_attributes`` function + works as expected. + """ + + async def check_access() -> None: + async with SpotWebsocketClientTestWrapper( + key=spot_api_key, + secret=spot_secret_key, + ) as auth_client: + assert isinstance(auth_client.private_channel_names, list) + assert isinstance(auth_client.private_methods, list) + assert auth_client.active_private_subscriptions == [] + await async_sleep(2.5) + + asyncio_run(check_access()) + + def test_send_message_missing_method_failing(self) -> None: + """ + Checks if the send_message function fails when specific keys or values + are incorrect formatted or missing. + """ + + async def create_client() -> None: + async with SpotWebsocketClientTestWrapper() as client: + with pytest.raises(TypeError): # wrong message format + await client.send_message(message=[]) + with pytest.raises(TypeError): # method value not string + await client.send_message(message={"method": 1}) + with pytest.raises(TypeError): # missing params for '*subscribe' + await client.send_message(message={"method": "subscribe"}) + with pytest.raises(TypeError): # params not dict + await client.send_message( + message={"method": "subscribe", "params": []}, + ) + with pytest.raises(TypeError): # params missing channel key + await client.send_message( + message={"method": "subscribe", "params": {"test": 1}}, + ) + with pytest.raises(TypeError): # channel key must be str + await client.send_message( + message={"method": "subscribe", "params": {"channel": 1}}, + ) + await async_sleep(1) + + asyncio_run(create_client()) + + def test_send_message_raw(self, caplog: pytest.LogCaptureFixture) -> None: + """ + Checks if the send_message function fails when the socket is not available. + """ + + async def create_client() -> None: + async with SpotWebsocketClientTestWrapper() as client: await client.send_message( - message={"method": "subscribe", "params": {"test": 1}}, + message={"method": "ping", "req_id": 123456789}, + raw=True, ) - with pytest.raises(TypeError): # channel key must be str - await client.send_message( - message={"method": "subscribe", "params": {"channel": 1}}, - ) - await async_sleep(1) - - asyncio_run(create_client()) - + await async_sleep(1) -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_send_message_raw(caplog: pytest.LogCaptureFixture) -> None: - """ - Checks if the send_message function fails when the socket is not available. - """ - - async def create_client() -> None: - async with SpotWebsocketClientTestWrapper() as client: - await client.send_message( - message={"method": "ping", "req_id": 123456789}, - raw=True, - ) - await async_sleep(1) + asyncio_run(create_client()) - asyncio_run(create_client()) - - assert '{"method": "pong", "req_id": 123456789' in caplog.text - assert '"success": false' not in caplog.text - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_public_subscribe(caplog: pytest.LogCaptureFixture) -> None: - """ - Function that checks if the websocket client is able to subscribe to public - feeds. - """ - - async def test_subscription() -> None: - async with SpotWebsocketClientTestWrapper() as client: - await client.subscribe( - params={"channel": "ticker", "symbol": ["BTC/USD"]}, - req_id=12345678, - ) - await async_sleep(3) + assert '{"method": "pong", "req_id": 123456789' in caplog.text + assert '"success": false' not in caplog.text - asyncio_run(test_subscription()) + def test_public_subscribe(self, caplog: pytest.LogCaptureFixture) -> None: + """ + Function that checks if the websocket client is able to subscribe to public + feeds. + """ - assert ( - '{"method": "subscribe", "req_id": 12345678, "result": {"channel":' - ' "ticker", "event_trigger": "trades", "snapshot": true, "symbol":' - ' "BTC/USD"}, "success": true' in caplog.text - ) - assert '"success": false' not in caplog.text + async def test_subscription() -> None: + async with SpotWebsocketClientTestWrapper() as client: + await client.subscribe( + params={"channel": "ticker", "symbol": ["BTC/USD"]}, + req_id=12345678, + ) + await async_sleep(3) + asyncio_run(test_subscription()) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_websocket -def test_private_subscribe_failing_on_public_connection() -> None: - """ - Ensures that the public websocket connection can't subscribe to private - feeds. - """ - - async def test_subscription() -> None: - async with SpotWebsocketClientTestWrapper() as client: - with pytest.raises(KrakenAuthenticationError): - await client.subscribe( + assert ( + '{"method": "subscribe", "req_id": 12345678, "result": {"channel":' + ' "ticker", "event_trigger": "trades", "snapshot": true, "symbol":' + ' "BTC/USD"}, "success": true' in caplog.text + ) + assert '"success": false' not in caplog.text + + @pytest.mark.spot_auth + def test_private_subscribe_failing_on_public_connection(self) -> None: + """ + Ensures that the public websocket connection can't subscribe to private + feeds. + """ + + async def test_subscription() -> None: + async with SpotWebsocketClientTestWrapper() as client: + with pytest.raises(KrakenAuthenticationError): + await client.subscribe( + params={"channel": "executions"}, + req_id=123456789, + ) + + await async_sleep(2) + + asyncio_run(test_subscription()) + + @pytest.mark.spot_auth + def test_private_subscribe( + self, + spot_api_key: str, + spot_secret_key: str, + caplog: pytest.LogCaptureFixture, + ) -> None: + """ + Checks if the authenticated websocket client can subscribe to private feeds. + """ + + async def test_subscription() -> None: + async with SpotWebsocketClientTestWrapper( + key=spot_api_key, + secret=spot_secret_key, + no_public=True, + ) as auth_client: + await auth_client.subscribe( params={"channel": "executions"}, req_id=123456789, ) - await async_sleep(2) - - asyncio_run(test_subscription()) - + await async_sleep(2) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_websocket -def test_private_subscribe( - spot_api_key: str, - spot_secret_key: str, - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Checks if the authenticated websocket client can subscribe to private feeds. - """ - - async def test_subscription() -> None: - async with SpotWebsocketClientTestWrapper( - key=spot_api_key, - secret=spot_secret_key, - no_public=True, - ) as auth_client: - await auth_client.subscribe( - params={"channel": "executions"}, - req_id=123456789, - ) - - await async_sleep(2) - - asyncio_run(test_subscription()) - - assert re.search( - r'\{"method": "subscribe", "req_id": 123456789, "result": \{"channel": "executions".*"success": true', - caplog.text, - ) - assert '"success": false' not in caplog.text - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_public_unsubscribe(caplog: pytest.LogCaptureFixture) -> None: - """ - Checks if the websocket client can unsubscribe from public feeds. - """ - - async def test_unsubscribe() -> None: - async with SpotWebsocketClientTestWrapper() as client: - params: dict = {"channel": "ticker", "symbol": ["BTC/USD"]} - await client.subscribe(params=params, req_id=123456789) - await async_sleep(3) - - await client.unsubscribe(params=params, req_id=987654321) - await async_sleep(2) - - asyncio_run(test_unsubscribe()) - - for expected in ( - '{"method": "subscribe", "req_id": 123456789, "result": {"channel": "ticker", "event_trigger": "trades", "snapshot": true, "symbol": "BTC/USD"}, "success": true', - '{"channel": "ticker", "type": "snapshot", "data": [{"symbol": "BTC/USD", ', - '{"method": "unsubscribe", "req_id": 987654321, "result": {"channel": "ticker", "event_trigger": "trades", "symbol": "BTC/USD"}, "success": true', - ): - assert expected in caplog.text - assert '"success": false' not in caplog.text - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_public_unsubscribe_failure(caplog: pytest.LogCaptureFixture) -> None: - """ - Checks if the websocket client responses with failures - when the ``unsubscribe`` function receives invalid parameters. - """ - - async def check_unsubscribe_fail() -> None: - async with SpotWebsocketClientTestWrapper() as client: - # We did not subscribed to this ticker but it will work, - # and the response will inform us that there is no such subscription. - await client.unsubscribe( - params={"channel": "ticker", "symbol": ["BTC/USD"]}, - req_id=123456789, - ) - - await async_sleep(2) - - asyncio_run(check_unsubscribe_fail()) - - assert ( - '{"error": "Subscription Not Found", "method": "subscribe", "req_id": 123456789, "success": false, "symbol": "BTC/USD", "time_in": ' - in caplog.text - ) + asyncio_run(test_subscription()) + assert re.search( + r'\{"method": "subscribe", "req_id": 123456789, "result": \{"channel": "executions".*"success": true', + caplog.text, + ) + assert '"success": false' not in caplog.text + + def test_public_unsubscribe(self, caplog: pytest.LogCaptureFixture) -> None: + """ + Checks if the websocket client can unsubscribe from public feeds. + """ + + async def test_unsubscribe() -> None: + async with SpotWebsocketClientTestWrapper() as client: + params: dict = {"channel": "ticker", "symbol": ["BTC/USD"]} + await client.subscribe(params=params, req_id=123456789) + await async_sleep(3) + + await client.unsubscribe(params=params, req_id=987654321) + await async_sleep(2) + + asyncio_run(test_unsubscribe()) + + for expected in ( + '{"method": "subscribe", "req_id": 123456789, "result": {"channel": "ticker", "event_trigger": "trades", "snapshot": true, "symbol": "BTC/USD"}, "success": true', + '{"channel": "ticker", "type": "snapshot", "data": [{"symbol": "BTC/USD", ', + '{"method": "unsubscribe", "req_id": 987654321, "result": {"channel": "ticker", "event_trigger": "trades", "symbol": "BTC/USD"}, "success": true', + ): + assert expected in caplog.text + assert '"success": false' not in caplog.text + + def test_public_unsubscribe_failure(self, caplog: pytest.LogCaptureFixture) -> None: + """ + Checks if the websocket client responses with failures + when the ``unsubscribe`` function receives invalid parameters. + """ + + async def check_unsubscribe_fail() -> None: + async with SpotWebsocketClientTestWrapper() as client: + # We did not subscribed to this ticker but it will work, + # and the response will inform us that there is no such subscription. + await client.unsubscribe( + params={"channel": "ticker", "symbol": ["BTC/USD"]}, + req_id=123456789, + ) -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_websocket -def test_private_unsubscribe( - spot_api_key: str, - spot_secret_key: str, - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Checks if private unsubscriptions are available. - """ - - async def check_unsubscribe() -> None: - async with SpotWebsocketClientTestWrapper( - key=spot_api_key, - secret=spot_secret_key, - no_public=True, - ) as client: - await client.subscribe(params={"channel": "executions"}, req_id=123456789) - await async_sleep(2) - - await client.unsubscribe(params={"channel": "executions"}, req_id=987654321) - await async_sleep(2) - # todo: check if subs are removed from known list - Dec 2023: obsolete? - - asyncio_run(check_unsubscribe()) - - for expected in ( - '{"method": "subscribe", "req_id": 123456789, "result": {"channel": "executions"', - '{"method": "unsubscribe", "req_id": 987654321, "result": {"channel": "executions"}, "success": true', - ): - assert expected in caplog.text - assert '"success": false' not in caplog.text + await async_sleep(2) - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test___transform_subscription() -> None: - """ - Checks if the subscription transformation works properly by checking - the condition for multiple channels. This test may be trivial but in case - Kraken changes anything on that implementation, this will break and makes it - easier to track down the change. - """ - - incoming_subscription: dict - target_subscription: dict - for channel in ("book", "ticker", "ohlc", "trade"): - incoming_subscription = { - "method": "subscribe", - "result": { - "channel": channel, - "depth": 10, - "snapshot": True, - "symbol": "BTC/USD", - }, - "success": True, - "time_in": "2023-08-30T04:59:14.052226Z", - "time_out": "2023-08-30T04:59:14.052263Z", - } - - target_subscription = deepcopy(incoming_subscription) - target_subscription["result"]["symbol"] = ["BTC/USD"] + asyncio_run(check_unsubscribe_fail()) assert ( - ConnectSpotWebsocket._ConnectSpotWebsocket__transform_subscription( - ConnectSpotWebsocket, - subscription=incoming_subscription, - ) - == target_subscription + '{"error": "Subscription Not Found", "method": "subscribe", "req_id": 123456789, "success": false, "symbol": "BTC/USD", "time_in": ' + in caplog.text ) + @pytest.mark.spot_auth + def test_private_unsubscribe( + self, + spot_api_key: str, + spot_secret_key: str, + caplog: pytest.LogCaptureFixture, + ) -> None: + """ + Checks if private unsubscriptions are available. + """ + + async def check_unsubscribe() -> None: + async with SpotWebsocketClientTestWrapper( + key=spot_api_key, + secret=spot_secret_key, + no_public=True, + ) as client: + await client.subscribe( + params={"channel": "executions"}, + req_id=123456789, + ) + await async_sleep(2) -@pytest.mark.spot -@pytest.mark.spot_websocket -def test___transform_subscription_no_change() -> None: - """ - Similar to the test above -- but verifying that messages that don't need an - adjustment remain unchanged. - - This test must be extended in case Kraken decides to changes more - parameters. - """ - - incoming_subscription: dict - for channel in ("book", "ticker", "ohlc", "trade"): - incoming_subscription = { - "method": "subscribe", - "result": { - "channel": channel, - "depth": 10, - "snapshot": True, - "symbol": ["BTC/USD"], - }, - "success": True, - "time_in": "2023-08-30T04:59:14.052226Z", - "time_out": "2023-08-30T04:59:14.052263Z", - } - - assert ( - ConnectSpotWebsocket._ConnectSpotWebsocket__transform_subscription( - ConnectSpotWebsocket, - subscription=incoming_subscription, + await client.unsubscribe( + params={"channel": "executions"}, + req_id=987654321, + ) + await async_sleep(2) + # todo: check if subs are removed from known list - Dec 2023: obsolete? + + asyncio_run(check_unsubscribe()) + + for expected in ( + '{"method": "subscribe", "req_id": 123456789, "result": {"channel": "executions"', + '{"method": "unsubscribe", "req_id": 987654321, "result": {"channel": "executions"}, "success": true', + ): + assert expected in caplog.text + assert '"success": false' not in caplog.text + + def test___transform_subscription(self) -> None: + """ + Checks if the subscription transformation works properly by checking + the condition for multiple channels. This test may be trivial but in case + Kraken changes anything on that implementation, this will break and makes it + easier to track down the change. + """ + + incoming_subscription: dict + target_subscription: dict + for channel in ("book", "ticker", "ohlc", "trade"): + incoming_subscription = { + "method": "subscribe", + "result": { + "channel": channel, + "depth": 10, + "snapshot": True, + "symbol": "BTC/USD", + }, + "success": True, + "time_in": "2023-08-30T04:59:14.052226Z", + "time_out": "2023-08-30T04:59:14.052263Z", + } + + target_subscription = deepcopy(incoming_subscription) + target_subscription["result"]["symbol"] = ["BTC/USD"] + + assert ( + ConnectSpotWebsocket._ConnectSpotWebsocket__transform_subscription( + ConnectSpotWebsocket, + subscription=incoming_subscription, + ) + == target_subscription ) - == incoming_subscription - ) - -@pytest.mark.spot -@pytest.mark.spot_auth -@pytest.mark.spot_websocket -def test_reconnect( - spot_api_key: str, - spot_secret_key: str, - caplog: pytest.LogCaptureFixture, - mocker: MockerFixture, -) -> None: - """ - Checks if the reconnect works properly when forcing a closed connection. - """ - caplog.set_level(logging.INFO) - - async def check_reconnect() -> None: - async with SpotWebsocketClientTestWrapper( - key=spot_api_key, - secret=spot_secret_key, - ) as client: - await async_sleep(2) - - await client.subscribe(params={"channel": "ticker", "symbol": ["BTC/USD"]}) - await client.subscribe(params={"channel": "executions"}) - await async_sleep(2) - - for obj in (client._priv_conn, client._pub_conn): - mocker.patch.object( - obj, - "_ConnectSpotWebsocketBase__get_reconnect_wait", - return_value=2, + def test___transform_subscription_no_change(self) -> None: + """ + Similar to the test above -- but verifying that messages that don't need an + adjustment remain unchanged. + + This test must be extended in case Kraken decides to changes more + parameters. + """ + + incoming_subscription: dict + for channel in ("book", "ticker", "ohlc", "trade"): + incoming_subscription = { + "method": "subscribe", + "result": { + "channel": channel, + "depth": 10, + "snapshot": True, + "symbol": ["BTC/USD"], + }, + "success": True, + "time_in": "2023-08-30T04:59:14.052226Z", + "time_out": "2023-08-30T04:59:14.052263Z", + } + + assert ( + ConnectSpotWebsocket._ConnectSpotWebsocket__transform_subscription( + ConnectSpotWebsocket, + subscription=incoming_subscription, ) - await client._pub_conn.close_connection() - await client._priv_conn.close_connection() + == incoming_subscription + ) - await async_sleep(5) + @pytest.mark.spot_auth + def test_reconnect( + self, + spot_api_key: str, + spot_secret_key: str, + caplog: pytest.LogCaptureFixture, + mocker: MockerFixture, + ) -> None: + """ + Checks if the reconnect works properly when forcing a closed connection. + """ + caplog.set_level(logging.INFO) + + async def check_reconnect() -> None: + async with SpotWebsocketClientTestWrapper( + key=spot_api_key, + secret=spot_secret_key, + ) as client: + await async_sleep(2) - asyncio_run(check_reconnect()) - - for phrase in ( - "Recover public subscriptions []: waiting", - "Recover authenticated subscriptions []: waiting", - "Recover public subscriptions []: done", - "Recover authenticated subscriptions []: done", - "Websocket connection established!", - '{"channel": "status",', - '"data": [{', - '"api_version": "v2",', - '"connection_id": ', - '"system": "online",', - '{"method": "subscribe", "result": {"channel": "ticker", "event_trigger": "trades", "snapshot": true, "symbol": "BTC/USD"}, "success": true,', - '"channel": "ticker", "type": "snapshot", "data": [{"symbol": "BTC/USD", ', - "got an exception sent 1000 (OK); then received 1000 (OK)", - "Recover public subscriptions [{'channel': 'ticker', 'event_trigger': 'trades', 'snapshot': True, 'symbol': ['BTC/USD']}]: waiting", - "Recover public subscriptions [{'channel': 'ticker', 'event_trigger': 'trades', 'snapshot': True, 'symbol': ['BTC/USD']}]: done", - ): - assert phrase in caplog.text - - assert re.search( - r"Recover authenticated subscriptions .*'channel': 'executions'.* waiting", - caplog.text, - ) - assert re.search( - r"Recover authenticated subscriptions .*'channel': 'executions'.* done", - caplog.text, - ) - assert '"success": False' not in caplog.text + await client.subscribe( + params={"channel": "ticker", "symbol": ["BTC/USD"]}, + ) + await client.subscribe(params={"channel": "executions"}) + await async_sleep(2) + + for obj in (client._priv_conn, client._pub_conn): + mocker.patch.object( + obj, + "_ConnectSpotWebsocketBase__get_reconnect_wait", + return_value=2, + ) + await client._pub_conn.close_connection() + await client._priv_conn.close_connection() + + await async_sleep(5) + + asyncio_run(check_reconnect()) + + for phrase in ( + "Recover public subscriptions []: waiting", + "Recover authenticated subscriptions []: waiting", + "Recover public subscriptions []: done", + "Recover authenticated subscriptions []: done", + "Websocket connection established!", + '{"channel": "status",', + '"data": [{', + '"api_version": "v2",', + '"connection_id": ', + '"system": "online",', + '{"method": "subscribe", "result": {"channel": "ticker", "event_trigger": "trades", "snapshot": true, "symbol": "BTC/USD"}, "success": true,', + '"channel": "ticker", "type": "snapshot", "data": [{"symbol": "BTC/USD", ', + "got an exception sent 1000 (OK); then received 1000 (OK)", + "Recover public subscriptions [{'channel': 'ticker', 'event_trigger': 'trades', 'snapshot': True, 'symbol': ['BTC/USD']}]: waiting", + "Recover public subscriptions [{'channel': 'ticker', 'event_trigger': 'trades', 'snapshot': True, 'symbol': ['BTC/USD']}]: done", + ): + assert phrase in caplog.text + + assert re.search( + r"Recover authenticated subscriptions .*'channel': 'executions'.* waiting", + caplog.text, + ) + assert re.search( + r"Recover authenticated subscriptions .*'channel': 'executions'.* done", + caplog.text, + ) + assert '"success": False' not in caplog.text diff --git a/tests/spot/test_spot_websocket_internals.py b/tests/spot/test_spot_websocket_internals.py index 543844b..6faedaf 100644 --- a/tests/spot/test_spot_websocket_internals.py +++ b/tests/spot/test_spot_websocket_internals.py @@ -11,6 +11,7 @@ from asyncio import run as asyncio_run from asyncio import sleep as async_sleep +from typing import Self import pytest @@ -19,39 +20,40 @@ @pytest.mark.spot @pytest.mark.spot_websocket -def test_ws_base_client_context_manager() -> None: - """ - Checks that the KrakenSpotWSClientBase can be instantiated as context - manager. - """ - - async def check_it() -> None: - class TestClient(SpotWSClientBase): - async def on_message(self: TestClient, message: dict) -> None: - if message == {"error": "yes"}: - raise ValueError("Test Error") - - with TestClient(no_public=True) as client: - with pytest.raises(ValueError, match=r"Test Error"): - await client.on_message(message={"error": "yes"}) - await async_sleep(5) - - asyncio_run(check_it()) - - -@pytest.mark.spot -@pytest.mark.spot_websocket -def test_ws_base_client_on_message_no_callback( - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Checks that the KrakenSpotWSClientBase logs a message when no callback - was defined. - """ - - async def run() -> None: - client = SpotWSClientBase(no_public=True) - await client.on_message({"event": "testing"}) - - asyncio_run(run()) - assert "Received message but no callback is defined!" in caplog.text +class TestSpotWebSocketInternals: + """Test class for Spot WebSocket internals.""" + + def test_ws_base_client_context_manager(self: Self) -> None: + """ + Checks that the KrakenSpotWSClientBase can be instantiated as context + manager. + """ + + async def check_it() -> None: + class TestClient(SpotWSClientBase): + async def on_message(self: TestClient, message: dict) -> None: + if message == {"error": "yes"}: + raise ValueError("Test Error") + + with TestClient(no_public=True) as client: + with pytest.raises(ValueError, match=r"Test Error"): + await client.on_message(message={"error": "yes"}) + await async_sleep(5) + + asyncio_run(check_it()) + + def test_ws_base_client_on_message_no_callback( + self: Self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """ + Checks that the KrakenSpotWSClientBase logs a message when no callback + was defined. + """ + + async def run() -> None: + client = SpotWSClientBase(no_public=True) + await client.on_message({"event": "testing"}) + + asyncio_run(run()) + assert "Received message but no callback is defined!" in caplog.text