diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8c52bcc..db58019 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index 7a477af..f80b61c 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,9 @@ coverage.xml *,cover .hypothesis/ .python-version -test.py \ No newline at end of file +test.py +# Deribit Retail OpenAPI/AsyncAPI specs: generator input only, never committed. +# See scripts/generate_deribit.py (--spec-dir / DERIBIT_SPEC_DIR). +scripts/deribit_specs/ +*openapi*.json +*asyncapi*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c75152a..e30167c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2.0.0rc1] - 2026-SEP-30 + +Release candidate. Install with `pip install --pre coinbase-advanced-py`. + +From October 1, 2026, Global Derivatives run on a Deribit-powered gateway instead of INTX. See the [Global Derivatives overview](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/guides/derivatives/overview) for the migration details. + +### Added +- `coinbase.deribit` with `DeribitRetailClient` (REST) and `DeribitRetailWSClient` (WebSocket) for Global Derivatives. Both use your existing CDP API keys. See the README for usage. + +### Changed +- Requires Python 3.10 or newer. Python 3.8 and 3.9 users get 1.8.4. +- When an INTX perpetuals call on `RESTClient` fails, the SDK logs the Global Derivatives method to use instead. + ## [1.8.4] - 2026-JUN-19 ### Changed diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ab085ef --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,14 @@ +# setup.py reads these at build time, so they must ship in the sdist for a +# wheel to be buildable from the source distribution. +include requirements.txt +include test_requirements.txt +include lint_requirements.txt +include pinned_requirements.txt +include docs_requirements.txt +include README.md +include LICENSE.md +include CHANGELOG.md + +# Never ship generator inputs (Deribit specs) or internal scripts. +prune scripts +global-exclude *openapi*.json *asyncapi*.json diff --git a/README.md b/README.md index 94fe83d..702c9ec 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/license/apache-2-0/) [![Code Style](https://img.shields.io/badge/code_style-black-black)](https://black.readthedocs.io/en/stable/) -Welcome to the official Coinbase Advanced API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced API](https://docs.cdp.coinbase.com/advanced-trade/docs/welcome). -This SDK also supports easy connection to the [Coinbase Advanced Trade WebSocket API](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-overview). +Welcome to the official Coinbase Advanced API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced API](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/overview). +This SDK also supports easy connection to the [Coinbase Advanced Trade WebSocket API](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-overview), and to Global Derivatives through the [Deribit-powered gateway](#global-derivatives-clients). Coinbase Advanced Trade offers a comprehensive API for traders, providing access to real-time market data, order management, and execution. Elevate your trading strategies and develop sophisticated solutions using our powerful tools and features. @@ -20,7 +20,7 @@ pip3 install coinbase-advanced-py ___ ## Coinbase Developer Platform (CDP) API Keys -This SDK uses Cloud Developer Platform (CDP) API keys. To use this SDK, you will need to create a CDP API key and secret by following the instructions [here](https://docs.cdp.coinbase.com/advanced-trade/docs/getting-started). +This SDK uses Cloud Developer Platform (CDP) API keys. To use this SDK, you will need to create a CDP API key and secret by following the instructions [here](https://docs.cdp.coinbase.com/get-started/authentication/overview). Make sure to save your API key and secret in a safe place. You will not be able to retrieve your secret again. Ed25519 is the recommended key type. The SDK also supports ECDSA for existing keys. The key type is auto-detected and the correct JWT signing algorithm (`EdDSA` or `ES256`) is selected automatically. Accepted formats: @@ -132,15 +132,15 @@ portfolio = client.create_portfolio(name="TestPortfolio") ``` ### Rate Limit Response Headers -The Advanced API returns useful rate limit information in the response headers as detailed in our [documentation](https://docs.cdp.coinbase.com/advanced-trade/docs/rest-api-rate-limits#private-endpoints). By initializing the RESTClient with the `rate_limit_headers` field set to True, as shown below, these headers will be appended as fields to the API response body: +The Advanced API returns useful rate limit information in the response headers as detailed in our [documentation](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/rest-api#private-endpoints). By initializing the RESTClient with the `rate_limit_headers` field set to True, as shown below, these headers will be appended as fields to the API response body: ```python client = RESTClient(api_key=api_key, api_secret=api_secret, rate_limit_headers=True) ``` ___ ## WebSocket API Client -We offer a WebSocket API client that allows you to connect to the [Coinbase Advanced Trade WebSocket API](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-overview). -Refer to the [Advanced Trade WebSocket Channels](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-channels) page for detailed information on each offered channel. +We offer a WebSocket API client that allows you to connect to the [Coinbase Advanced Trade WebSocket API](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-overview). +Refer to the [Advanced Trade WebSocket Channels](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-channels) page for detailed information on each offered channel. In your code, import the WSClient class and instantiate it. The WSClient requires an API key and secret to be passed in as arguments. You can also use a key file or environment variables as described in the RESTClient instructions above. @@ -170,7 +170,7 @@ def on_open(): client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message, on_open=on_open) ``` ### WebSocket User API Client -We offer a WebSocket User API client that allows you to connect to the Coinbase Advanced Trade WebSocket [user channel](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-channels#user-channel) and [futures_balance_summary channel](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-channels#futures-balance-summary-channel). +We offer a WebSocket User API client that allows you to connect to the Coinbase Advanced Trade WebSocket [user channel](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-channels#user-channel) and [futures_balance_summary channel](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-channels#futures-balance-summary-channel). In your code, import the WSUserClient class instead of WSClient. @@ -313,7 +313,80 @@ In the example, note how we first checked `if ws_object.channel == "ticker"`. Since each channel's event field has a unique structure and set of fields, it's important to ensure that the fields we access are actually present in the object. For example, if we were to subscribe to the `user` channel and try to access a field that does not exist in it, such as the `tickers` field, we would be met with an error. -Therefore, we urge users to reference our [documentation](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-channels), which outlines the JSON object that each channel will return. +Therefore, we urge users to reference our [documentation](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-channels), which outlines the JSON object that each channel will return. + +___ +## Global Derivatives Clients +From October 1, 2026, Global Derivatives on Coinbase Advanced Trade run on a Deribit-powered gateway instead of INTX. The gateway uses JSON-RPC 2.0 over REST and WebSocket. The `coinbase.deribit` package has a client for each, and both use the same CDP API keys as the rest of the SDK. Spot and US Derivatives stay on `RESTClient` and `WSClient`. + +Each gateway method is a typed method on the client. `private/buy` becomes `private_buy`. Pass optional parameters by keyword. Typed methods return response objects, so read the payload from `.result`. + +### Global Derivatives REST Client +```python +from coinbase.deribit import DeribitRetailClient + +client = DeribitRetailClient(api_key=api_key, api_secret=api_secret) + +ticker = client.public_ticker(instrument_name="BTC_USDC-PERPETUAL") +print(ticker.result.mark_price) + +positions = client.private_get_positions(currency="USDC") + +order = client.private_buy( + instrument_name="BTC_USDC-PERPETUAL", + amount=0.0001, + type="limit", + price=60000, + post_only=True, + label="my-order-1", +) +print(order.result.order.order_id) +``` +Public methods work without keys: `DeribitRetailClient()`. To call a gateway method that has no typed wrapper, use `client.call("private/some_method", {...})`. + +### Global Derivatives WebSocket Client +The gateway has two WebSocket hosts, and `DeribitRetailWSClient` picks one based on whether you pass keys: + +- Without keys, it connects to the market data host. Subscribe to channels such as `ticker`, `book` and `trades` here. +- With keys, it connects to the trading host. Use it for `user.*` channels, WebSocket-only methods such as heartbeats and cancel-on-disconnect, and any other RPC. + +```python +from coinbase.deribit import DeribitRetailWSClient + +def on_message(data, channel): + print(channel, data) + +market_data = DeribitRetailWSClient(api_key=None, api_secret=None) +market_data.open() +market_data.subscribe(["ticker.BTC_USDC-PERPETUAL.100ms"], on_message) + +with DeribitRetailWSClient(api_key=api_key, api_secret=api_secret) as trading: + trading.subscribe(["user.orders.BTC_USDC-PERPETUAL.raw"], on_message) + trading.private_get_open_orders() +``` +Like the other clients, `DeribitRetailWSClient` reads `COINBASE_API_KEY` and `COINBASE_API_SECRET` from the environment, so pass `api_key=None, api_secret=None` for a market data client if those are set. + +Callbacks run on the client's reader thread, so keep them short and don't call RPC methods from inside them. The client re-authenticates before the token expires. If the connection drops, it reconnects and restores your subscriptions. If the gateway doesn't accept a channel, `subscribe` raises `DeribitSubscriptionError`. + +The SDK never resends an order after a connection drops. A call that was in flight raises `DeribitConnectionError`. Give each order a `label`, then check it with `private_get_order_state_by_label` before retrying. + +JSON-RPC errors raise `DeribitRPCError` or one of its subclasses: `DeribitRateLimitError`, `DeribitInsufficientFundsError`, `DeribitInvalidParamsError` or `DeribitMatchingQueueFullError`. Authentication failures raise `DeribitAuthError`. + +### Migrating from INTX Perpetuals +The INTX perpetuals methods on `RESTClient` still send requests. When the server rejects one, the SDK logs which Global Derivatives method to use instead, then raises the original `HTTPError`. + +| INTX method | Global Derivatives method | +|---|---| +| `get_perps_portfolio_summary` | `private_get_account_summary` | +| `get_perps_portfolio_balances` | `private_get_account_summaries` | +| `list_perps_positions` | `private_get_positions` | +| `get_perps_position` | `private_get_position` | +| `opt_in_or_out_multi_asset_collateral` | `private_change_margin_model` | +| `allocate_portfolio` | `private_submit_transfer_between_subaccounts` | +| `create_order` on a `-PERP-INTX` product | `private_buy` or `private_sell` | +| `close_position` on a `-PERP-INTX` product | `private_close_position` | + +Instrument names follow the gateway format, for example `BTC_USDC-PERPETUAL`. ___ ## Debugging the Clients @@ -328,7 +401,7 @@ ___ ## Authentication Authentication of CDP API Keys is handled automatically by the SDK when making a REST request or sending a WebSocket message. -However, if you wish to handle this yourself, you must create a JWT token and attach it to your request as detailed in the API docs [here](https://docs.cdp.coinbase.com/advanced-trade/docs/rest-api-auth#making-requests). Use the built in `jwt_generator` to create your JWT token. For example: +However, if you wish to handle this yourself, you must create a JWT token and attach it to your request as detailed in the API docs [here](https://docs.cdp.coinbase.com/get-started/authentication/jwt-authentication). Use the built in `jwt_generator` to create your JWT token. For example: ```python from coinbase import jwt_generator @@ -354,7 +427,7 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIV jwt = jwt_generator.build_ws_jwt(api_key, api_secret) ``` -Use this JWT to connect to the Websocket API by setting it in the "jwt" field of your subscription requests. See the docs [here](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-overview#sending-messages-with-cdp-keys) for more details. +Use this JWT to connect to the Websocket API by setting it in the "jwt" field of your subscription requests. See the docs [here](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-overview#sending-messages-with-cdp-keys) for more details. ___ ## Accessing public endpoints without authentication @@ -385,13 +458,13 @@ client = RESTClient() public_products = client.get_public_products() print(json.dumps(public_products.to_dict(), indent=2)) ``` -_Full list of all public REST endpoints [here](https://docs.cdp.coinbase.com/advanced-trade/docs/rest-api-overview#public-endpoints)_ +_Full list of all public REST endpoints [here](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/rest-api#public-endpoints)_ -_Rate limit details for REST endpoints [here](https://docs.cdp.coinbase.com/advanced-trade/docs/rest-api-rate-limits)_ +_Rate limit details for REST endpoints [here](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/rest-api)_ ### Websocket Client -In the Websocket client, here is an example subscribing to the [ticker](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-channels#ticker-channel) channel. +In the Websocket client, here is an example subscribing to the [ticker](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-channels#ticker-channel) channel. Unlike the REST client, Websocket channels handle both authenticated and unauthenticated requests. At the moment, most channels in the Websocket client are public and can be used without keys. @@ -413,9 +486,9 @@ client.ticker_unsubscribe(product_ids=["BTC-USD"]) client.close() ``` -_Full list of all public Websocket channels [here](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-channels)_ +_Full list of all public Websocket channels [here](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-channels)_ -_Rate limit details for Websocket channels [here](https://docs.cdp.coinbase.com/advanced-trade/docs/ws-rate-limits)_ +_Rate limit details for Websocket channels [here](https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/websocket/websocket-rate-limits)_ ___ diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 2294476..0cda2d1 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.8.4" +__version__ = "2.0.0rc1" diff --git a/coinbase/deribit/__init__.py b/coinbase/deribit/__init__.py new file mode 100644 index 0000000..4128b5c --- /dev/null +++ b/coinbase/deribit/__init__.py @@ -0,0 +1,27 @@ +from coinbase.deribit.auth import DeribitAuth +from coinbase.deribit.errors import ( + DeribitAuthError, + DeribitConnectionError, + DeribitInsufficientFundsError, + DeribitInvalidParamsError, + DeribitMatchingQueueFullError, + DeribitRateLimitError, + DeribitRPCError, + DeribitSubscriptionError, +) +from coinbase.deribit.rest_client import DeribitRetailClient +from coinbase.deribit.ws_client import DeribitRetailWSClient + +__all__ = [ + "DeribitAuth", + "DeribitRetailClient", + "DeribitRetailWSClient", + "DeribitAuthError", + "DeribitConnectionError", + "DeribitInsufficientFundsError", + "DeribitInvalidParamsError", + "DeribitMatchingQueueFullError", + "DeribitRPCError", + "DeribitRateLimitError", + "DeribitSubscriptionError", +] diff --git a/coinbase/deribit/_generated/README.md b/coinbase/deribit/_generated/README.md new file mode 100644 index 0000000..b59581d --- /dev/null +++ b/coinbase/deribit/_generated/README.md @@ -0,0 +1,12 @@ +# `coinbase/deribit/_generated/` + +Generated code. **Do not hand-edit** — regenerate from the Deribit Retail specs. + +Source specs (Coinbase Deribit Retail gateway, `drb.coinbase.com`): +- REST/RPC: `deribit_openapi.json` (OpenAPI 3.0) → request/response models + REST method stubs +- WebSocket: `deribit_asyncapi.json` (AsyncAPI 3.0) → subscription channel builders + notification payload models + +Models are emitted in the SDK's existing `BaseResponse` idiom (see `coinbase/rest/types/`), +not a stock generator's attrs/httpx output, so they stay consistent with the rest of +the package. The hand-authored runtime (`auth/`, `ws_client.py`, `errors.py`, rate limiting, +write-safety) lives outside this directory and imports from it. diff --git a/coinbase/deribit/_generated/__init__.py b/coinbase/deribit/_generated/__init__.py new file mode 100644 index 0000000..83840d5 --- /dev/null +++ b/coinbase/deribit/_generated/__init__.py @@ -0,0 +1 @@ +"""Generated Deribit client layer. Do not hand-edit.""" diff --git a/coinbase/deribit/_generated/channels.py b/coinbase/deribit/_generated/channels.py new file mode 100644 index 0000000..b4e6db0 --- /dev/null +++ b/coinbase/deribit/_generated/channels.py @@ -0,0 +1,176 @@ +"""GENERATED from the Deribit Retail AsyncAPI spec — do not edit.""" + +# Subscription-channel builders. Pass the result(s) to the WS client's +# subscribe(); the builder fills the instrument/currency/interval segments. + + +def announcements(): + return f"announcements" + + +def book_instrument_name_group_depth_interval(instrument_name, group, depth, interval): + return f"book.{instrument_name}.{group}.{depth}.{interval}" + + +def book_instrument_name_interval(instrument_name, interval): + return f"book.{instrument_name}.{interval}" + + +def chart_trades_instrument_name_resolution(instrument_name, resolution): + return f"chart.trades.{instrument_name}.{resolution}" + + +def deribit_price_index_index_name(index_name): + return f"deribit_price_index.{index_name}" + + +def deribit_price_ranking_index_name(index_name): + return f"deribit_price_ranking.{index_name}" + + +def deribit_price_statistics_index_name(index_name): + return f"deribit_price_statistics.{index_name}" + + +def deribit_volatility_index_index_name(index_name): + return f"deribit_volatility_index.{index_name}" + + +def estimated_expiration_price_index_name(index_name): + return f"estimated_expiration_price.{index_name}" + + +def incremental_ticker_instrument_name(instrument_name): + return f"incremental_ticker.{instrument_name}" + + +def instrument_creation_kind_currency(kind, currency): + return f"instrument.creation.{kind}.{currency}" + + +def instrument_state_kind_currency(kind, currency): + return f"instrument.state.{kind}.{currency}" + + +def markprice_options_index_name(index_name): + return f"markprice.options.{index_name}" + + +def perpetual_instrument_name_interval(instrument_name, interval): + return f"perpetual.{instrument_name}.{interval}" + + +def platform_state(): + return f"platform_state" + + +def platform_state_public_methods_state(): + return f"platform_state.public_methods_state" + + +def quote_instrument_name(instrument_name): + return f"quote.{instrument_name}" + + +def ticker_instrument_name_interval(instrument_name, interval): + return f"ticker.{instrument_name}.{interval}" + + +def trades_instrument_name_interval(instrument_name, interval): + return f"trades.{instrument_name}.{interval}" + + +def trades_kind_currency_interval(kind, currency, interval): + return f"trades.{kind}.{currency}.{interval}" + + +def user_access_log(): + return f"user.access_log" + + +def user_changes_instrument_name_interval(instrument_name, interval): + return f"user.changes.{instrument_name}.{interval}" + + +def user_changes_kind_currency_interval(kind, currency, interval): + return f"user.changes.{kind}.{currency}.{interval}" + + +def user_combo_trades_instrument_name_interval(instrument_name, interval): + return f"user.combo_trades.{instrument_name}.{interval}" + + +def user_combo_trades_kind_currency_interval(kind, currency, interval): + return f"user.combo_trades.{kind}.{currency}.{interval}" + + +def user_isolated_changes_instrument_name_interval(instrument_name, interval): + return f"user.isolated.changes.{instrument_name}.{interval}" + + +def user_isolated_changes_kind_currency_interval(kind, currency, interval): + return f"user.isolated.changes.{kind}.{currency}.{interval}" + + +def user_isolated_liquidation(): + return f"user.isolated.liquidation" + + +def user_isolated_orders_instrument_name_interval(instrument_name, interval): + return f"user.isolated.orders.{instrument_name}.{interval}" + + +def user_isolated_orders_kind_currency_interval(kind, currency, interval): + return f"user.isolated.orders.{kind}.{currency}.{interval}" + + +def user_isolated_portfolio_currency(currency): + return f"user.isolated.portfolio.{currency}" + + +def user_isolated_trades_instrument_name_interval(instrument_name, interval): + return f"user.isolated.trades.{instrument_name}.{interval}" + + +def user_isolated_trades_kind_currency_interval(kind, currency, interval): + return f"user.isolated.trades.{kind}.{currency}.{interval}" + + +def user_lock(): + return f"user.lock" + + +def user_orders_instrument_name_interval(instrument_name, interval): + return f"user.orders.{instrument_name}.{interval}" + + +def user_orders_instrument_name_raw(instrument_name): + return f"user.orders.{instrument_name}.raw" + + +def user_orders_kind_currency_interval(kind, currency, interval): + return f"user.orders.{kind}.{currency}.{interval}" + + +def user_orders_kind_currency_raw(kind, currency): + return f"user.orders.{kind}.{currency}.raw" + + +def user_portfolio_currency(currency): + return f"user.portfolio.{currency}" + + +def user_position_lock(): + return f"user.position_lock" + + +def user_trades_instrument_name_interval(instrument_name, interval): + return f"user.trades.{instrument_name}.{interval}" + + +def user_trades_kind_currency_interval(kind, currency, interval): + return f"user.trades.{kind}.{currency}.{interval}" + + +def user_trailing_orders_trigger_price_currency(currency): + return f"user.trailing_orders.trigger_price.{currency}" diff --git a/coinbase/deribit/_generated/models.py b/coinbase/deribit/_generated/models.py new file mode 100644 index 0000000..d129023 --- /dev/null +++ b/coinbase/deribit/_generated/models.py @@ -0,0 +1,1491 @@ +"""GENERATED from the Deribit Retail OpenAPI spec — do not edit.""" + +from coinbase.deribit._model import DeribitModel + + +# --- enums --- +class Advanced: + """advanced type: `"usd"` or `"implv"` (Only for options; field is omitted if not applicable).""" + + USD = "usd" + IMPLV = "implv" + VALUES = frozenset({"usd", "implv"}) + + +class BookState: + """The state of the order book. Represents the current lifecycle stage of the instrument.""" + + OPEN = "open" + SETTLEMENT = "settlement" + DELIVERED = "delivered" + INACTIVE = "inactive" + LOCKED = "locked" + HALTED = "halted" + ARCHIVIZED = "archivized" + VALUES = frozenset( + { + "open", + "settlement", + "delivered", + "inactive", + "locked", + "halted", + "archivized", + } + ) + + +class CancelReason: + """Enumerated reason behind cancel `"user_request"`, `"autoliquidation"`, `"cancel_on_disconnect"`, `"risk_mitigation"`, `"pme_risk_reduction"` (portfolio margining risk reduction), `"pme_account_locked"` (portfolio margining account locked per currency), `"position_locked"`, `"mmp_trigger"` (market maker protection), `"mmp_config_curtailment"` (market maker configured quantity decreased), `"edit_post_only_reject"` (cancelled on edit because of `reject_post_only` setting), `"oco_other_closed"` (the oco order linked to this order was closed), `"oto_primary_closed"` (the oto primary order that was going to trigger this order was cancelled), `"settlement"` (closed because of a settlement event, e.g. good-til-day orders are cancelled when an instrument enters the daily settlement). Note: orders cancelled because an instrument expired (delivery) currently do not include a `cancel_reason` field.""" + + USER_REQUEST = "user_request" + AUTOLIQUIDATION = "autoliquidation" + CANCEL_ON_DISCONNECT = "cancel_on_disconnect" + RISK_MITIGATION = "risk_mitigation" + PME_RISK_REDUCTION = "pme_risk_reduction" + PME_ACCOUNT_LOCKED = "pme_account_locked" + POSITION_LOCKED = "position_locked" + MMP_TRIGGER = "mmp_trigger" + MMP_CONFIG_CURTAILMENT = "mmp_config_curtailment" + EDIT_POST_ONLY_REJECT = "edit_post_only_reject" + OCO_OTHER_CLOSED = "oco_other_closed" + OTO_PRIMARY_CLOSED = "oto_primary_closed" + SETTLEMENT = "settlement" + VALUES = frozenset( + { + "user_request", + "autoliquidation", + "cancel_on_disconnect", + "risk_mitigation", + "pme_risk_reduction", + "pme_account_locked", + "position_locked", + "mmp_trigger", + "mmp_config_curtailment", + "edit_post_only_reject", + "oco_other_closed", + "oto_primary_closed", + "settlement", + } + ) + + +class CodScope: + """Informs if Cancel on Disconnect was checked for the current connection or the account""" + + CONNECTION = "connection" + ACCOUNT = "account" + VALUES = frozenset({"connection", "account"}) + + +class ComboState: + """Combo state: `"active"`, "`inactive`" """ + + ACTIVE = "active" + INACTIVE = "inactive" + VALUES = frozenset({"active", "inactive"}) + + +class Currency: + """Currency, i.e `"BTC"`, `"ETH"`, `"USDC"`""" + + BTC = "BTC" + ETH = "ETH" + USDC = "USDC" + USDT = "USDT" + EURR = "EURR" + VALUES = frozenset({"BTC", "ETH", "USDC", "USDT", "EURR"}) + + +class CurrencyWithAny: + """Currency name or `"any"` if don't care""" + + BTC = "BTC" + ETH = "ETH" + USDC = "USDC" + USDT = "USDT" + EURR = "EURR" + ANY = "any" + VALUES = frozenset({"BTC", "ETH", "USDC", "USDT", "EURR", "any"}) + + +class CurrencyWithAnyAndGrouped: + """Currency name or `"any"` if don't care or `"grouped"` if grouped by currencies""" + + BTC = "BTC" + ETH = "ETH" + USDC = "USDC" + SOL = "SOL" + USDT = "USDT" + EURR = "EURR" + XRP = "XRP" + STETH = "STETH" + USYC = "USYC" + PAXG = "PAXG" + BNB = "BNB" + USDE = "USDE" + ANY = "any" + GROUPED = "grouped" + VALUES = frozenset( + { + "BTC", + "ETH", + "USDC", + "SOL", + "USDT", + "EURR", + "XRP", + "STETH", + "USYC", + "PAXG", + "BNB", + "USDE", + "any", + "grouped", + } + ) + + +class Direction: + """Direction: `buy`, or `sell`""" + + BUY = "buy" + SELL = "sell" + VALUES = frozenset({"buy", "sell"}) + + +class FeeRole: + """Fee role of the user: `maker` or `taker`. Can be different from trade role of the user when iceberg order was involved in matching.""" + + MAKER = "maker" + TAKER = "taker" + VALUES = frozenset({"maker", "taker"}) + + +class IndexName: + """Index identifier, matches (base) cryptocurrency with quote currency""" + + BTC_USD = "btc_usd" + ETH_USD = "eth_usd" + ADA_USDC = "ada_usdc" + ALGO_USDC = "algo_usdc" + AVAX_USDC = "avax_usdc" + BCH_USDC = "bch_usdc" + BNB_USDC = "bnb_usdc" + BTC_USDC = "btc_usdc" + BTCDVOL_USDC = "btcdvol_usdc" + BUIDL_USDC = "buidl_usdc" + DOGE_USDC = "doge_usdc" + DOT_USDC = "dot_usdc" + EURR_USDC = "eurr_usdc" + ETH_USDC = "eth_usdc" + ETHDVOL_USDC = "ethdvol_usdc" + HYPE_USDC = "hype_usdc" + LINK_USDC = "link_usdc" + LTC_USDC = "ltc_usdc" + NEAR_USDC = "near_usdc" + PAXG_USDC = "paxg_usdc" + SHIB_USDC = "shib_usdc" + SOL_USDC = "sol_usdc" + STETH_USDC = "steth_usdc" + TON_USDC = "ton_usdc" + TRUMP_USDC = "trump_usdc" + TRX_USDC = "trx_usdc" + UNI_USDC = "uni_usdc" + USDE_USDC = "usde_usdc" + USYC_USDC = "usyc_usdc" + XRP_USDC = "xrp_usdc" + BTC_USDT = "btc_usdt" + ETH_USDT = "eth_usdt" + EURR_USDT = "eurr_usdt" + SOL_USDT = "sol_usdt" + STETH_USDT = "steth_usdt" + USDC_USDT = "usdc_usdt" + USDE_USDT = "usde_usdt" + BTC_EURR = "btc_eurr" + BTC_USDE = "btc_usde" + BTC_USYC = "btc_usyc" + ETH_BTC = "eth_btc" + ETH_EURR = "eth_eurr" + ETH_USDE = "eth_usde" + ETH_USYC = "eth_usyc" + STETH_ETH = "steth_eth" + PAXG_BTC = "paxg_btc" + DRBFIX_BTC_USDC = "drbfix-btc_usdc" + DRBFIX_ETH_USDC = "drbfix-eth_usdc" + VALUES = frozenset( + { + "btc_usd", + "eth_usd", + "ada_usdc", + "algo_usdc", + "avax_usdc", + "bch_usdc", + "bnb_usdc", + "btc_usdc", + "btcdvol_usdc", + "buidl_usdc", + "doge_usdc", + "dot_usdc", + "eurr_usdc", + "eth_usdc", + "ethdvol_usdc", + "hype_usdc", + "link_usdc", + "ltc_usdc", + "near_usdc", + "paxg_usdc", + "shib_usdc", + "sol_usdc", + "steth_usdc", + "ton_usdc", + "trump_usdc", + "trx_usdc", + "uni_usdc", + "usde_usdc", + "usyc_usdc", + "xrp_usdc", + "btc_usdt", + "eth_usdt", + "eurr_usdt", + "sol_usdt", + "steth_usdt", + "usdc_usdt", + "usde_usdt", + "btc_eurr", + "btc_usde", + "btc_usyc", + "eth_btc", + "eth_eurr", + "eth_usde", + "eth_usyc", + "steth_eth", + "paxg_btc", + "drbfix-btc_usdc", + "drbfix-eth_usdc", + } + ) + + +class Kind: + """Instrument kind: `"future"`, `"option"`, `"spot"`, `"future_combo"`, `"option_combo"`""" + + FUTURE = "future" + OPTION = "option" + SPOT = "spot" + FUTURE_COMBO = "future_combo" + OPTION_COMBO = "option_combo" + VALUES = frozenset({"future", "option", "spot", "future_combo", "option_combo"}) + + +class KindFutureOrOptionWithAny: + """Instrument kind: `"future"`, `"option"` or `"any"` for all""" + + FUTURE = "future" + OPTION = "option" + ANY = "any" + VALUES = frozenset({"future", "option", "any"}) + + +class KindWithComboAll: + """Instrument kind: `"future"`, `"option"`, `"spot"`, `"future_combo"`, `"option_combo"`, `"combo"` for any combo or `"any"` for all""" + + FUTURE = "future" + OPTION = "option" + SPOT = "spot" + FUTURE_COMBO = "future_combo" + OPTION_COMBO = "option_combo" + COMBO = "combo" + ANY = "any" + VALUES = frozenset( + {"future", "option", "spot", "future_combo", "option_combo", "combo", "any"} + ) + + +class KindWithoutSpot: + """Instrument kind: `"future"`, `"option"`, `"future_combo"`, `"option_combo"` (spot is excluded as spot trades are settled immediately and have no open positions)""" + + FUTURE = "future" + OPTION = "option" + FUTURE_COMBO = "future_combo" + OPTION_COMBO = "option_combo" + VALUES = frozenset({"future", "option", "future_combo", "option_combo"}) + + +class OrderState: + """Order state: `"open"`, `"filled"`, `"rejected"`, `"cancelled"`, `"untriggered"`""" + + OPEN = "open" + FILLED = "filled" + REJECTED = "rejected" + CANCELLED = "cancelled" + UNTRIGGERED = "untriggered" + TRIGGERED = "triggered" + VALUES = frozenset( + {"open", "filled", "rejected", "cancelled", "untriggered", "triggered"} + ) + + +class OrderStateInUserTrade: + """Order state: `"open"`, `"filled"`, `"rejected"`, `"cancelled"`, `"untriggered"` or `"archive"` (if order was archived)""" + + OPEN = "open" + FILLED = "filled" + REJECTED = "rejected" + CANCELLED = "cancelled" + UNTRIGGERED = "untriggered" + ARCHIVE = "archive" + VALUES = frozenset( + {"open", "filled", "rejected", "cancelled", "untriggered", "archive"} + ) + + +class OrderType: + """Order type: `"limit"`, `"market"`, `"stop_limit"`, `"stop_market"`, `"take_limit"`, `"take_market"`, `"trailing_stop"`""" + + MARKET = "market" + LIMIT = "limit" + STOP_MARKET = "stop_market" + STOP_LIMIT = "stop_limit" + TAKE_MARKET = "take_market" + TAKE_LIMIT = "take_limit" + TRAILING_STOP = "trailing_stop" + VALUES = frozenset( + { + "market", + "limit", + "stop_market", + "stop_limit", + "take_market", + "take_limit", + "trailing_stop", + } + ) + + +class OrderType2: + """Order type: `"all"`, `"limit"`, `"trigger_all"`, `"stop_all"`, `"stop_limit"`, `"stop_market"`, `"take_all"`, `"take_limit"`, `"take_market"`, `"trailing_all"`, `"trailing_stop"`""" + + ALL = "all" + LIMIT = "limit" + TRIGGER_ALL = "trigger_all" + STOP_ALL = "stop_all" + STOP_LIMIT = "stop_limit" + STOP_MARKET = "stop_market" + TAKE_ALL = "take_all" + TAKE_LIMIT = "take_limit" + TAKE_MARKET = "take_market" + TRAILING_ALL = "trailing_all" + TRAILING_STOP = "trailing_stop" + VALUES = frozenset( + { + "all", + "limit", + "trigger_all", + "stop_all", + "stop_limit", + "stop_market", + "take_all", + "take_limit", + "take_market", + "trailing_all", + "trailing_stop", + } + ) + + +class OriginalOrderType: + """Original API order type when an order is represented internally as a limit order. For example, Starbase market orders use `"limit"` as `order_type` with `"market"` in this optional field.""" + + MARKET = "market" + MARKET_LIMIT = "market_limit" + VALUES = frozenset({"market", "market_limit"}) + + +class PmeCurrency: + """The currency for which the Extended Risk Matrix will be calculated. Use `CROSS` for Cross Collateral simulation.""" + + BTC = "BTC" + ETH = "ETH" + USDC = "USDC" + USDT = "USDT" + CROSS = "CROSS" + VALUES = frozenset({"BTC", "ETH", "USDC", "USDT", "CROSS"}) + + +class PositionDirection: + """Direction: `buy`, `sell` or `zero`""" + + BUY = "buy" + SELL = "sell" + ZERO = "zero" + VALUES = frozenset({"buy", "sell", "zero"}) + + +class Role: + """Trade role of the user: `maker` or `taker`""" + + MAKER = "maker" + TAKER = "taker" + VALUES = frozenset({"maker", "taker"}) + + +class SettlementCurrencyWithAnyAndGrouped: + """Currency name or `"any"` if don't care or `"grouped"` if grouped by currencies""" + + BTC = "BTC" + ETH = "ETH" + USDC = "USDC" + USDT = "USDT" + ANY = "any" + GROUPED = "grouped" + VALUES = frozenset({"BTC", "ETH", "USDC", "USDT", "any", "grouped"}) + + +class SettlementType: + """The type of settlement event. `settlement`: daily settlement of futures and perpetual positions at 08:00 UTC, converting unrealized profit and loss into realized profit and loss (option positions do not settle). `delivery`: one-time expiration of a futures or options contract at 08:00 UTC, closing any remaining open position at the delivery price (does not apply to perpetual or spot instruments). `bankruptcy`.""" + + SETTLEMENT = "settlement" + DELIVERY = "delivery" + BANKRUPTCY = "bankruptcy" + VALUES = frozenset({"settlement", "delivery", "bankruptcy"}) + + +class SimpleOrderType: + """Order type: `"all"`, `"limit"`, `"stop"`, `"take"`, `"trailing_stop"`, `"trailing_take"`""" + + ALL = "all" + LIMIT = "limit" + TRIGGER_ALL = "trigger_all" + STOP = "stop" + TAKE = "take" + TRAILING_STOP = "trailing_stop" + VALUES = frozenset({"all", "limit", "trigger_all", "stop", "take", "trailing_stop"}) + + +class Sorting: + """Sorting""" + + ASC = "asc" + DESC = "desc" + DEFAULT = "default" + VALUES = frozenset({"asc", "desc", "default"}) + + +class TickDirection: + """Direction of the "tick" (`0` = Plus Tick, `1` = Zero-Plus Tick, `2` = Minus Tick, `3` = Zero-Minus Tick).""" + + VALUES = frozenset({}) + + +class TimeInForce: + """Order time in force: `"good_til_cancelled"`, `"good_til_day"`, `"fill_or_kill"` or `"immediate_or_cancel"`""" + + GOOD_TIL_CANCELLED = "good_til_cancelled" + GOOD_TIL_DAY = "good_til_day" + FILL_OR_KILL = "fill_or_kill" + IMMEDIATE_OR_CANCEL = "immediate_or_cancel" + VALUES = frozenset( + {"good_til_cancelled", "good_til_day", "fill_or_kill", "immediate_or_cancel"} + ) + + +class TransferDirection: + """Transfer direction""" + + PAYMENT = "payment" + INCOME = "income" + VALUES = frozenset({"payment", "income"}) + + +class TransferType: + """Type of transfer: `user` - sent to user, `subaccount` - sent to subaccount""" + + USER = "user" + SUBACCOUNT = "subaccount" + VALUES = frozenset({"user", "subaccount"}) + + +class Trigger: + """Trigger type (only for trigger orders). Allowed values: `"index_price"`, `"mark_price"`, `"last_price"`.""" + + INDEX_PRICE = "index_price" + MARK_PRICE = "mark_price" + LAST_PRICE = "last_price" + VALUES = frozenset({"index_price", "mark_price", "last_price"}) + + +class TriggerFillCondition: + """

The fill condition of the linked order (Only for linked order types), default: `first_hit`.

""" + + FIRST_HIT = "first_hit" + COMPLETE_FILL = "complete_fill" + INCREMENTAL = "incremental" + VALUES = frozenset({"first_hit", "complete_fill", "incremental"}) + + +class WalletCurrency: + """Currency, i.e `"BTC"`, `"ETH"`, `"USDC"`""" + + BTC = "BTC" + ETH = "ETH" + STETH = "STETH" + ETHW = "ETHW" + USDC = "USDC" + USDT = "USDT" + EURR = "EURR" + SOL = "SOL" + XRP = "XRP" + USYC = "USYC" + PAXG = "PAXG" + BNB = "BNB" + USDE = "USDE" + VALUES = frozenset( + { + "BTC", + "ETH", + "STETH", + "ETHW", + "USDC", + "USDT", + "EURR", + "SOL", + "XRP", + "USYC", + "PAXG", + "BNB", + "USDE", + } + ) + + +# --- models --- +class AccessLog(DeribitModel): + """AccessLog""" + + +class ApiLimits(DeribitModel): + """Account API rate limits.""" + + +class BlockTrade(DeribitModel): + """BlockTrade""" + + +class BookSummary(DeribitModel): + """BookSummary""" + + +class Combo(DeribitModel): + """Combo""" + + +class ComboLeg(DeribitModel): + """ComboLeg""" + + +class CurrencyPortfolio(DeribitModel): + """CurrencyPortfolio""" + + +class CurrencyWithApr(DeribitModel): + """CurrencyWithApr""" + + +class DeltaTotalMap(DeribitModel): + """Map of position delta sums by price index (e.g. `btc_usd`), covering both futures and options positions.""" + + +class ErrorMessageResponse(DeribitModel): + """ErrorMessageResponse""" + + +class Expirations(DeribitModel): + """A map where each key is valid currency (e.g. btc, eth, usdc), and the value is a list of expirations or a map where each key is a valid kind (future or options) and value is a list of expirations from every instrument""" + + +class Getunsubscribe200response(DeribitModel): + """Getunsubscribe200response""" + + +class Greeks(DeribitModel): + """Only for options. Greeks are risk measures that describe how the option's price changes with respect to various factors.""" + + +class Instrument(DeribitModel): + """Instrument""" + + +class KeyNumberPair(DeribitModel): + """KeyNumberPair""" + + +class OkResponse(DeribitModel): + """OkResponse""" + + +class Order(DeribitModel): + """Order""" + + +class OrderIdInitialMarginPair(DeribitModel): + """OrderIdInitialMarginPair""" + + +class Portfolio(DeribitModel): + """Portfolio""" + + +class Position(DeribitModel): + """Position""" + + +class PositionWithOpenOrdersMargin(DeribitModel): + """PositionWithOpenOrdersMargin""" + + +class PrivateAccountResponse(DeribitModel): + """PrivateAccountResponse""" + + +class PrivateAccountResponseResult(DeribitModel): + """PrivateAccountResponseResult""" + + +class PrivateAccountResponseResultFees(DeribitModel): + """Fee structure for all currency pairs and instrument types related to the currency (available when parameter `extended` = `true` and user has any discounts). Keys are index names (e.g., "btc_usd"), values are objects with instrument types as keys (option, perpetual, future).""" + + +class PrivateAccountResponseResultOptionsGammaMap(DeribitModel): + """Map of options' gammas per index""" + + +class PrivateAccountResponseResultOptionsThetaMap(DeribitModel): + """Map of options' thetas per index""" + + +class PrivateAccountResponseResultOptionsVegaMap(DeribitModel): + """Map of options' vegas per index""" + + +class PrivateAccountResponseResultTradingProductsDetails(DeribitModel): + """Which trading products are enabled or can be overwritten for the account""" + + +class PrivateAccountSummariesResponse(DeribitModel): + """PrivateAccountSummariesResponse""" + + +class PrivateAccountSummariesResponseResult(DeribitModel): + """PrivateAccountSummariesResponseResult""" + + +class PrivateAccountSummariesResponseResultIsolatedAccountSummaries(DeribitModel): + """PrivateAccountSummariesResponseResultIsolatedAccountSummaries""" + + +class PrivateAccountSummariesResponseResultSummaries(DeribitModel): + """PrivateAccountSummariesResponseResultSummaries""" + + +class PrivateAccountSummariesResponseResultSummariesFees(DeribitModel): + """Fee structure for all currency pairs and instrument types related to the currency (available when parameter `extended` = `true` and user has any discounts). Keys are index names (e.g., "btc_usd"), values are objects with instrument types as keys (option, perpetual, future).""" + + +class PrivateAccountSummariesResponseResultSummariesOptionsGammaMap(DeribitModel): + """Map of options' gammas per index""" + + +class PrivateAccountSummariesResponseResultSummariesOptionsThetaMap(DeribitModel): + """Map of options' thetas per index""" + + +class PrivateAccountSummariesResponseResultSummariesOptionsVegaMap(DeribitModel): + """Map of options' vegas per index""" + + +class PrivateAccountSummariesResponseResultSummariesTradingProductsDetails( + DeribitModel +): + """Which trading products are enabled or can be overwritten for the account""" + + +class PrivateAccountSummariesResponseResultTradingProductsDetails(DeribitModel): + """Which trading products are enabled or can be overwritten for the account""" + + +class PrivateBuyAndSellResponse(DeribitModel): + """PrivateBuyAndSellResponse""" + + +class PrivateBuyAndSellResponseResult(DeribitModel): + """PrivateBuyAndSellResponseResult""" + + +class PrivateCancelAllResponse(DeribitModel): + """PrivateCancelAllResponse""" + + +class PrivateCancelResponse(DeribitModel): + """PrivateCancelResponse""" + + +class PrivateChangeMarginModelResponse(DeribitModel): + """PrivateChangeMarginModelResponse""" + + +class PrivateChangeMarginModelResponseResult(DeribitModel): + """PrivateChangeMarginModelResponseResult""" + + +class PrivateChangeMarginModelResponseResultNewState(DeribitModel): + """Represents portfolio state after change""" + + +class PrivateChangeMarginModelResponseResultOldState(DeribitModel): + """Represents portfolio state before change""" + + +class PrivateCreateComboResponse(DeribitModel): + """PrivateCreateComboResponse""" + + +class PrivateEditResponse(DeribitModel): + """PrivateEditResponse""" + + +class PrivateEditResponseResult(DeribitModel): + """PrivateEditResponseResult""" + + +class PrivateGetAccessLogResponse(DeribitModel): + """PrivateGetAccessLogResponse""" + + +class PrivateGetBrokerTradeRequestsResponse(DeribitModel): + """PrivateGetBrokerTradeRequestsResponse""" + + +class PrivateGetBrokerTradeRequestsResponseResult(DeribitModel): + """PrivateGetBrokerTradeRequestsResponseResult""" + + +class PrivateGetBrokerTradeRequestsResponseResultMaker(DeribitModel): + """PrivateGetBrokerTradeRequestsResponseResultMaker""" + + +class PrivateGetBrokerTradeRequestsResponseResultTaker(DeribitModel): + """PrivateGetBrokerTradeRequestsResponseResultTaker""" + + +class PrivateGetBrokerTradeRequestsResponseResultTrades(DeribitModel): + """PrivateGetBrokerTradeRequestsResponseResultTrades""" + + +class PrivateGetBrokerTradesResponse(DeribitModel): + """PrivateGetBrokerTradesResponse""" + + +class PrivateGetBrokerTradesResponseResult(DeribitModel): + """PrivateGetBrokerTradesResponseResult""" + + +class PrivateGetBrokerTradesResponseResultHistory(DeribitModel): + """PrivateGetBrokerTradesResponseResultHistory""" + + +class PrivateGetBrokerTradesResponseResultHistoryMaker(DeribitModel): + """PrivateGetBrokerTradesResponseResultHistoryMaker""" + + +class PrivateGetBrokerTradesResponseResultHistoryTaker(DeribitModel): + """PrivateGetBrokerTradesResponseResultHistoryTaker""" + + +class PrivateGetCancelOnDisconnectResponse(DeribitModel): + """PrivateGetCancelOnDisconnectResponse""" + + +class PrivateGetCancelOnDisconnectResponseResult(DeribitModel): + """PrivateGetCancelOnDisconnectResponseResult""" + + +class PrivateGetLegPricesResponse(DeribitModel): + """PrivateGetLegPricesResponse""" + + +class PrivateGetLegPricesResponseResult(DeribitModel): + """PrivateGetLegPricesResponseResult""" + + +class PrivateGetLeverageResponse(DeribitModel): + """PrivateGetLeverageResponse""" + + +class PrivateGetMarginsResponse(DeribitModel): + """PrivateGetMarginsResponse""" + + +class PrivateGetMarginsResponseResult(DeribitModel): + """PrivateGetMarginsResponseResult""" + + +class PrivateGetMaxOrderSizeResponse(DeribitModel): + """PrivateGetMaxOrderSizeResponse""" + + +class PrivateGetMaxOrderSizeResponseResult(DeribitModel): + """PrivateGetMaxOrderSizeResponseResult""" + + +class PrivateGetMaxOrderSizeResponseResultMaxTradableAmount(DeribitModel): + """PrivateGetMaxOrderSizeResponseResultMaxTradableAmount""" + + +class PrivateGetMaxOrderSizeResponseResultMaxTradableAmountReduceOnly(DeribitModel): + """PrivateGetMaxOrderSizeResponseResultMaxTradableAmountReduceOnly""" + + +class PrivateGetOpenOrdersResponse(DeribitModel): + """PrivateGetOpenOrdersResponse""" + + +class PrivateGetOrderHistoryResponse(DeribitModel): + """PrivateGetOrderHistoryResponse""" + + +class PrivateGetOrderMarginByIdsResponse(DeribitModel): + """PrivateGetOrderMarginByIdsResponse""" + + +class PrivateGetOrderStateByLabelResponse(DeribitModel): + """PrivateGetOrderStateByLabelResponse""" + + +class PrivateGetOrderStateResponse(DeribitModel): + """PrivateGetOrderStateResponse""" + + +class PrivateGetPositionResponse(DeribitModel): + """PrivateGetPositionResponse""" + + +class PrivateGetPositionsResponse(DeribitModel): + """PrivateGetPositionsResponse""" + + +class PrivateGetRiskProfileResponse(DeribitModel): + """PrivateGetRiskProfileResponse""" + + +class PrivateGetRiskProfileResponseResult(DeribitModel): + """PrivateGetRiskProfileResponseResult""" + + +class PrivateGetSubaccountsDetailsResponse(DeribitModel): + """PrivateGetSubaccountsDetailsResponse""" + + +class PrivateGetSubaccountsDetailsResponseResult(DeribitModel): + """PrivateGetSubaccountsDetailsResponseResult""" + + +class PrivateGetSubaccountsResponse(DeribitModel): + """PrivateGetSubaccountsResponse""" + + +class PrivateGetSubaccountsResponseResult(DeribitModel): + """PrivateGetSubaccountsResponseResult""" + + +class PrivateGetTradingLimitsResponse(DeribitModel): + """PrivateGetTradingLimitsResponse""" + + +class PrivateGetTradingLimitsResponseResult(DeribitModel): + """PrivateGetTradingLimitsResponseResult""" + + +class PrivateGetTransactionLogResponse(DeribitModel): + """PrivateGetTransactionLogResponse""" + + +class PrivateGetTransactionLogResponseResult(DeribitModel): + """PrivateGetTransactionLogResponseResult""" + + +class PrivateGetTriggerOrderHistoryResponse(DeribitModel): + """PrivateGetTriggerOrderHistoryResponse""" + + +class PrivateGetTriggerOrderHistoryResponseResult(DeribitModel): + """PrivateGetTriggerOrderHistoryResponseResult""" + + +class PrivateGetUserLocksResponse(DeribitModel): + """PrivateGetUserLocksResponse""" + + +class PrivateGetUserLocksResponseResult(DeribitModel): + """PrivateGetUserLocksResponseResult""" + + +class PrivateGetUserTradesByOrderResponse(DeribitModel): + """PrivateGetUserTradesByOrderResponse""" + + +class PrivateGetUserTradesHistoryResponse(DeribitModel): + """PrivateGetUserTradesHistoryResponse""" + + +class PrivateGetUserTradesHistoryResponseResult(DeribitModel): + """PrivateGetUserTradesHistoryResponseResult""" + + +class PrivatePmeParamsResponse(DeribitModel): + """PrivatePmeParamsResponse""" + + +class PrivatePmeParamsResponseResult(DeribitModel): + """Portfolio Margin order restrictions, or `enabled: false` when Portfolio Margin is disabled.""" + + +class PrivatePmeSimulateResponse(DeribitModel): + """PrivatePmeSimulateResponse""" + + +class PrivatePmeSimulateResponseResult(DeribitModel): + """Simulation details""" + + +class PrivateSetLeverageResponse(DeribitModel): + """PrivateSetLeverageResponse""" + + +class PrivateSettlementResponse(DeribitModel): + """PrivateSettlementResponse""" + + +class PrivateSettlementResponseResult(DeribitModel): + """PrivateSettlementResponseResult""" + + +class PrivateSimulatePortfolioResponse(DeribitModel): + """PrivateSimulatePortfolioResponse""" + + +class PrivateSimulatePortfolioResponseResult(DeribitModel): + """Portfolio margin simulation result""" + + +class PrivateSimulatePortfolioResponseResultDeltaTotalMap(DeribitModel): + """Map of total deltas per index""" + + +class PrivateSimulatePortfolioResponseResultOptionsGammaMap(DeribitModel): + """Map of options' gammas per index""" + + +class PrivateSimulatePortfolioResponseResultOptionsThetaMap(DeribitModel): + """Map of options' thetas per index""" + + +class PrivateSimulatePortfolioResponseResultOptionsVegaMap(DeribitModel): + """Map of options' vegas per index""" + + +class PrivateSubmitTransferResponse(DeribitModel): + """PrivateSubmitTransferResponse""" + + +class PrivateSubscribeResponse(DeribitModel): + """PrivateSubscribeResponse""" + + +class PublicAuthResponse(DeribitModel): + """PublicAuthResponse""" + + +class PublicAuthResponseResult(DeribitModel): + """PublicAuthResponseResult""" + + +class PublicGetAnnouncementsResponse(DeribitModel): + """PublicGetAnnouncementsResponse""" + + +class PublicGetAnnouncementsResponseResult(DeribitModel): + """PublicGetAnnouncementsResponseResult""" + + +class PublicGetBookSummaryResponse(DeribitModel): + """PublicGetBookSummaryResponse""" + + +class PublicGetComboDetailsResponse(DeribitModel): + """PublicGetComboDetailsResponse""" + + +class PublicGetComboIdsResponse(DeribitModel): + """PublicGetComboIdsResponse""" + + +class PublicGetCombosResponse(DeribitModel): + """PublicGetCombosResponse""" + + +class PublicGetContractSizeResponse(DeribitModel): + """PublicGetContractSizeResponse""" + + +class PublicGetContractSizeResponseResult(DeribitModel): + """PublicGetContractSizeResponseResult""" + + +class PublicGetCurrenciesResponse(DeribitModel): + """PublicGetCurrenciesResponse""" + + +class PublicGetDeliveryPricesResponse(DeribitModel): + """PublicGetDeliveryPricesResponse""" + + +class PublicGetDeliveryPricesResponseResult(DeribitModel): + """PublicGetDeliveryPricesResponseResult""" + + +class PublicGetDeliveryPricesResponseResultData(DeribitModel): + """PublicGetDeliveryPricesResponseResultData""" + + +class PublicGetExpirationsResponse(DeribitModel): + """PublicGetExpirationsResponse""" + + +class PublicGetFundingChartDataResponse(DeribitModel): + """PublicGetFundingChartDataResponse""" + + +class PublicGetFundingChartDataResponseResult(DeribitModel): + """PublicGetFundingChartDataResponseResult""" + + +class PublicGetFundingChartDataResponseResultData(DeribitModel): + """PublicGetFundingChartDataResponseResultData""" + + +class PublicGetFundingRateHistoryResponse(DeribitModel): + """PublicGetFundingRateHistoryResponse""" + + +class PublicGetFundingRateHistoryResponseResult(DeribitModel): + """PublicGetFundingRateHistoryResponseResult""" + + +class PublicGetFundingRateValueResponse(DeribitModel): + """PublicGetFundingRateValueResponse""" + + +class PublicGetHistoricalVolatilityResponse(DeribitModel): + """PublicGetHistoricalVolatilityResponse""" + + +class PublicGetHistoricalVolatilityResponseResult(DeribitModel): + """PublicGetHistoricalVolatilityResponseResult""" + + +class PublicGetIndexChartDataResponse(DeribitModel): + """PublicGetIndexChartDataResponse""" + + +class PublicGetIndexPriceNamesResponse(DeribitModel): + """PublicGetIndexPriceNamesResponse""" + + +class PublicGetIndexPriceNamesResponseResult(DeribitModel): + """PublicGetIndexPriceNamesResponseResult""" + + +class PublicGetIndexPriceResponse(DeribitModel): + """PublicGetIndexPriceResponse""" + + +class PublicGetIndexPriceResponseResult(DeribitModel): + """PublicGetIndexPriceResponseResult""" + + +class PublicGetInstrumentResponse(DeribitModel): + """PublicGetInstrumentResponse""" + + +class PublicGetInstrumentsResponse(DeribitModel): + """PublicGetInstrumentsResponse""" + + +class PublicGetMarkPriceHistoryResponse(DeribitModel): + """PublicGetMarkPriceHistoryResponse""" + + +class PublicGetOrderBookResponse(DeribitModel): + """PublicGetOrderBookResponse""" + + +class PublicGetTimeResponse(DeribitModel): + """PublicGetTimeResponse""" + + +class PublicGetTradesVolumesResponse(DeribitModel): + """PublicGetTradesVolumesResponse""" + + +class PublicGetTradingviewChartDataResponse(DeribitModel): + """PublicGetTradingviewChartDataResponse""" + + +class PublicGetTradingviewChartDataResponseResult(DeribitModel): + """PublicGetTradingviewChartDataResponseResult""" + + +class PublicGetVolatilityIndexDataResponse(DeribitModel): + """PublicGetVolatilityIndexDataResponse""" + + +class PublicGetVolatilityIndexDataResponseResult(DeribitModel): + """Volatility index candles.""" + + +class PublicSettlementResponse(DeribitModel): + """PublicSettlementResponse""" + + +class PublicSettlementResponseResult(DeribitModel): + """PublicSettlementResponseResult""" + + +class PublicStatusResponse(DeribitModel): + """PublicStatusResponse""" + + +class PublicStatusResponseResult(DeribitModel): + """PublicStatusResponseResult""" + + +class PublicTestResponse(DeribitModel): + """PublicTestResponse""" + + +class PublicTestResponseResult(DeribitModel): + """PublicTestResponseResult""" + + +class PublicTickerResponse(DeribitModel): + """PublicTickerResponse""" + + +class PublicTickersByCurrencyResponse(DeribitModel): + """PublicTickersByCurrencyResponse""" + + +class PublicTrade(DeribitModel): + """PublicTrade""" + + +class PublicTradesHistoryResponse(DeribitModel): + """PublicTradesHistoryResponse""" + + +class PublicTradesHistoryResponseResult(DeribitModel): + """PublicTradesHistoryResponseResult""" + + +class Settlement(DeribitModel): + """Settlement""" + + +class Stats(DeribitModel): + """Stats""" + + +class TickSizeStep(DeribitModel): + """TickSizeStep""" + + +class TickerNotification(DeribitModel): + """TickerNotification""" + + +class TickerNotificationWithBidsAndAsks(DeribitModel): + """TickerNotificationWithBidsAndAsks""" + + +class TradesVolumes(DeribitModel): + """TradesVolumes""" + + +class TransactionLog(DeribitModel): + """TransactionLog""" + + +class TransactionLogInfo(DeribitModel): + """Additional information regarding transaction. Strongly dependent on the log entry type""" + + +class TransferItem(DeribitModel): + """TransferItem""" + + +class TriggerOrderHistoryRecord(DeribitModel): + """TriggerOrderHistoryRecord""" + + +class UserTrade(DeribitModel): + """UserTrade""" + + +class UserTradeTradeAllocations(DeribitModel): + """UserTradeTradeAllocations""" + + +class UserTradeTradeAllocationsClientInfo(DeribitModel): + """Optional client allocation info for brokers.""" + + +# --- nested-field wiring (forward refs resolved here) --- +BlockTrade._NESTED = {"trades": (UserTrade, True)} +Combo._NESTED = {"legs": (ComboLeg, True)} +CurrencyWithApr._NESTED = {"withdrawal_priorities": (KeyNumberPair, True)} +Instrument._NESTED = {"tick_size_steps": (TickSizeStep, False)} +Portfolio._NESTED = {"btc(example)": (CurrencyPortfolio, False)} +PrivateAccountResponse._NESTED = {"result": (PrivateAccountResponseResult, False)} +PrivateAccountResponseResult._NESTED = { + "delta_total_map": (DeltaTotalMap, False), + "fees": (PrivateAccountResponseResultFees, False), + "limits": (ApiLimits, False), + "options_gamma_map": (PrivateAccountResponseResultOptionsGammaMap, False), + "options_theta_map": (PrivateAccountResponseResultOptionsThetaMap, False), + "options_vega_map": (PrivateAccountResponseResultOptionsVegaMap, False), + "trading_products_details": ( + PrivateAccountResponseResultTradingProductsDetails, + False, + ), +} +PrivateAccountSummariesResponse._NESTED = { + "result": (PrivateAccountSummariesResponseResult, False) +} +PrivateAccountSummariesResponseResult._NESTED = { + "isolated_account_summaries": ( + PrivateAccountSummariesResponseResultIsolatedAccountSummaries, + True, + ), + "summaries": (PrivateAccountSummariesResponseResultSummaries, True), + "trading_products_details": ( + PrivateAccountSummariesResponseResultTradingProductsDetails, + False, + ), +} +PrivateAccountSummariesResponseResultSummaries._NESTED = { + "delta_total_map": (DeltaTotalMap, False), + "fees": (PrivateAccountSummariesResponseResultSummariesFees, False), + "limits": (ApiLimits, False), + "options_gamma_map": ( + PrivateAccountSummariesResponseResultSummariesOptionsGammaMap, + False, + ), + "options_theta_map": ( + PrivateAccountSummariesResponseResultSummariesOptionsThetaMap, + False, + ), + "options_vega_map": ( + PrivateAccountSummariesResponseResultSummariesOptionsVegaMap, + False, + ), + "trading_products_details": ( + PrivateAccountSummariesResponseResultSummariesTradingProductsDetails, + False, + ), +} +PrivateBuyAndSellResponse._NESTED = {"result": (PrivateBuyAndSellResponseResult, False)} +PrivateBuyAndSellResponseResult._NESTED = { + "order": (Order, False), + "trades": (UserTrade, True), +} +PrivateCancelResponse._NESTED = {"result": (Order, False)} +PrivateChangeMarginModelResponse._NESTED = { + "result": (PrivateChangeMarginModelResponseResult, True) +} +PrivateChangeMarginModelResponseResult._NESTED = { + "new_state": (PrivateChangeMarginModelResponseResultNewState, False), + "old_state": (PrivateChangeMarginModelResponseResultOldState, False), +} +PrivateCreateComboResponse._NESTED = {"result": (Combo, False)} +PrivateEditResponse._NESTED = {"result": (PrivateEditResponseResult, False)} +PrivateEditResponseResult._NESTED = { + "order": (Order, False), + "trades": (UserTrade, True), +} +PrivateGetAccessLogResponse._NESTED = {"result": (AccessLog, True)} +PrivateGetBrokerTradeRequestsResponse._NESTED = { + "result": (PrivateGetBrokerTradeRequestsResponseResult, True) +} +PrivateGetBrokerTradeRequestsResponseResult._NESTED = { + "maker": (PrivateGetBrokerTradeRequestsResponseResultMaker, False), + "taker": (PrivateGetBrokerTradeRequestsResponseResultTaker, False), + "trades": (PrivateGetBrokerTradeRequestsResponseResultTrades, True), +} +PrivateGetBrokerTradesResponse._NESTED = { + "result": (PrivateGetBrokerTradesResponseResult, False) +} +PrivateGetBrokerTradesResponseResult._NESTED = { + "history": (PrivateGetBrokerTradesResponseResultHistory, True) +} +PrivateGetBrokerTradesResponseResultHistory._NESTED = { + "maker": (PrivateGetBrokerTradesResponseResultHistoryMaker, False), + "taker": (PrivateGetBrokerTradesResponseResultHistoryTaker, False), + "trades": (BlockTrade, True), +} +PrivateGetCancelOnDisconnectResponse._NESTED = { + "result": (PrivateGetCancelOnDisconnectResponseResult, False) +} +PrivateGetLegPricesResponse._NESTED = { + "result": (PrivateGetLegPricesResponseResult, False) +} +PrivateGetMarginsResponse._NESTED = {"result": (PrivateGetMarginsResponseResult, False)} +PrivateGetMaxOrderSizeResponse._NESTED = { + "result": (PrivateGetMaxOrderSizeResponseResult, False) +} +PrivateGetMaxOrderSizeResponseResult._NESTED = { + "max_tradable_amount": ( + PrivateGetMaxOrderSizeResponseResultMaxTradableAmount, + False, + ), + "max_tradable_amount_reduce_only": ( + PrivateGetMaxOrderSizeResponseResultMaxTradableAmountReduceOnly, + False, + ), +} +PrivateGetOpenOrdersResponse._NESTED = {"result": (Order, True)} +PrivateGetOrderHistoryResponse._NESTED = {"result": (Order, True)} +PrivateGetOrderMarginByIdsResponse._NESTED = { + "result": (OrderIdInitialMarginPair, True) +} +PrivateGetOrderStateResponse._NESTED = {"result": (Order, False)} +PrivateGetPositionResponse._NESTED = {"result": (PositionWithOpenOrdersMargin, False)} +PrivateGetPositionsResponse._NESTED = {"result": (PositionWithOpenOrdersMargin, True)} +PrivateGetRiskProfileResponse._NESTED = { + "result": (PrivateGetRiskProfileResponseResult, False) +} +PrivateGetSubaccountsDetailsResponse._NESTED = { + "result": (PrivateGetSubaccountsDetailsResponseResult, True) +} +PrivateGetSubaccountsDetailsResponseResult._NESTED = { + "open_orders": (Order, True), + "positions": (Position, True), +} +PrivateGetSubaccountsResponse._NESTED = { + "result": (PrivateGetSubaccountsResponseResult, True) +} +PrivateGetSubaccountsResponseResult._NESTED = {"portfolio": (Portfolio, False)} +PrivateGetTradingLimitsResponse._NESTED = { + "result": (PrivateGetTradingLimitsResponseResult, False) +} +PrivateGetTransactionLogResponse._NESTED = { + "result": (PrivateGetTransactionLogResponseResult, False) +} +PrivateGetTransactionLogResponseResult._NESTED = {"logs": (TransactionLog, True)} +PrivateGetTriggerOrderHistoryResponse._NESTED = { + "result": (PrivateGetTriggerOrderHistoryResponseResult, False) +} +PrivateGetTriggerOrderHistoryResponseResult._NESTED = { + "entries": (TriggerOrderHistoryRecord, True) +} +PrivateGetUserLocksResponse._NESTED = { + "result": (PrivateGetUserLocksResponseResult, True) +} +PrivateGetUserTradesByOrderResponse._NESTED = {"result": (UserTrade, True)} +PrivateGetUserTradesHistoryResponse._NESTED = { + "result": (PrivateGetUserTradesHistoryResponseResult, False) +} +PrivateGetUserTradesHistoryResponseResult._NESTED = {"trades": (UserTrade, True)} +PrivatePmeParamsResponse._NESTED = {"result": (PrivatePmeParamsResponseResult, False)} +PrivatePmeSimulateResponse._NESTED = { + "result": (PrivatePmeSimulateResponseResult, False) +} +PrivateSettlementResponse._NESTED = {"result": (PrivateSettlementResponseResult, False)} +PrivateSettlementResponseResult._NESTED = {"settlements": (Settlement, True)} +PrivateSimulatePortfolioResponse._NESTED = { + "result": (PrivateSimulatePortfolioResponseResult, False) +} +PrivateSimulatePortfolioResponseResult._NESTED = { + "delta_total_map": (PrivateSimulatePortfolioResponseResultDeltaTotalMap, False), + "options_gamma_map": (PrivateSimulatePortfolioResponseResultOptionsGammaMap, False), + "options_theta_map": (PrivateSimulatePortfolioResponseResultOptionsThetaMap, False), + "options_vega_map": (PrivateSimulatePortfolioResponseResultOptionsVegaMap, False), +} +PrivateSubmitTransferResponse._NESTED = {"result": (TransferItem, False)} +PublicAuthResponse._NESTED = {"result": (PublicAuthResponseResult, False)} +PublicGetAnnouncementsResponse._NESTED = { + "result": (PublicGetAnnouncementsResponseResult, True) +} +PublicGetBookSummaryResponse._NESTED = {"result": (BookSummary, True)} +PublicGetComboDetailsResponse._NESTED = {"result": (Combo, False)} +PublicGetCombosResponse._NESTED = {"result": (Combo, True)} +PublicGetContractSizeResponse._NESTED = { + "result": (PublicGetContractSizeResponseResult, False) +} +PublicGetCurrenciesResponse._NESTED = {"result": (CurrencyWithApr, True)} +PublicGetDeliveryPricesResponse._NESTED = { + "result": (PublicGetDeliveryPricesResponseResult, False) +} +PublicGetDeliveryPricesResponseResult._NESTED = { + "data": (PublicGetDeliveryPricesResponseResultData, True) +} +PublicGetExpirationsResponse._NESTED = {"result": (Expirations, True)} +PublicGetFundingChartDataResponse._NESTED = { + "result": (PublicGetFundingChartDataResponseResult, False) +} +PublicGetFundingChartDataResponseResult._NESTED = { + "data": (PublicGetFundingChartDataResponseResultData, True) +} +PublicGetFundingRateHistoryResponse._NESTED = { + "result": (PublicGetFundingRateHistoryResponseResult, True) +} +PublicGetHistoricalVolatilityResponse._NESTED = { + "result": (PublicGetHistoricalVolatilityResponseResult, True) +} +PublicGetIndexPriceNamesResponse._NESTED = { + "result": (PublicGetIndexPriceNamesResponseResult, True) +} +PublicGetIndexPriceResponse._NESTED = { + "result": (PublicGetIndexPriceResponseResult, False) +} +PublicGetInstrumentResponse._NESTED = {"result": (Instrument, False)} +PublicGetInstrumentsResponse._NESTED = {"result": (Instrument, True)} +PublicGetOrderBookResponse._NESTED = { + "result": (TickerNotificationWithBidsAndAsks, False) +} +PublicGetTradesVolumesResponse._NESTED = {"result": (TradesVolumes, True)} +PublicGetTradingviewChartDataResponse._NESTED = { + "result": (PublicGetTradingviewChartDataResponseResult, False) +} +PublicGetVolatilityIndexDataResponse._NESTED = { + "result": (PublicGetVolatilityIndexDataResponseResult, False) +} +PublicSettlementResponse._NESTED = {"result": (PublicSettlementResponseResult, False)} +PublicSettlementResponseResult._NESTED = {"settlements": (Settlement, True)} +PublicStatusResponse._NESTED = {"result": (PublicStatusResponseResult, False)} +PublicTestResponse._NESTED = {"result": (PublicTestResponseResult, False)} +PublicTickerResponse._NESTED = {"result": (TickerNotification, False)} +PublicTradesHistoryResponse._NESTED = { + "result": (PublicTradesHistoryResponseResult, False) +} +PublicTradesHistoryResponseResult._NESTED = {"trades": (PublicTrade, True)} +TickerNotification._NESTED = {"greeks": (Greeks, False), "stats": (Stats, False)} +TickerNotificationWithBidsAndAsks._NESTED = { + "greeks": (Greeks, False), + "stats": (Stats, False), +} +TransactionLog._NESTED = {"info": (TransactionLogInfo, False)} +UserTrade._NESTED = {"trade_allocations": (UserTradeTradeAllocations, True)} +UserTradeTradeAllocations._NESTED = { + "client_info": (UserTradeTradeAllocationsClientInfo, False) +} diff --git a/coinbase/deribit/_generated/rest_methods.py b/coinbase/deribit/_generated/rest_methods.py new file mode 100644 index 0000000..4d11f13 --- /dev/null +++ b/coinbase/deribit/_generated/rest_methods.py @@ -0,0 +1,1412 @@ +"""GENERATED from the Deribit Retail OpenAPI spec — do not edit.""" + +from coinbase.deribit._generated import models + + +class GeneratedRESTMixin: + """RPC methods served on both REST and WebSocket. Mixed into both + clients, which implement ``_rpc(method, params, model_cls)``.""" + + def private_buy( + self, + instrument_name, + *, + amount=None, + contracts=None, + type=None, + label=None, + price=None, + time_in_force=None, + display_amount=None, + post_only=None, + reject_post_only=None, + reduce_only=None, + trigger_price=None, + trigger_offset=None, + trigger=None, + advanced=None, + mmp=None, + valid_until=None, + linked_order_type=None, + trigger_fill_condition=None, + otoco_config=None, + isolated=None, + allocated_margin=None + ): + """private/buy""" + params = { + "instrument_name": instrument_name, + "amount": amount, + "contracts": contracts, + "type": type, + "label": label, + "price": price, + "time_in_force": time_in_force, + "display_amount": display_amount, + "post_only": post_only, + "reject_post_only": reject_post_only, + "reduce_only": reduce_only, + "trigger_price": trigger_price, + "trigger_offset": trigger_offset, + "trigger": trigger, + "advanced": advanced, + "mmp": mmp, + "valid_until": valid_until, + "linked_order_type": linked_order_type, + "trigger_fill_condition": trigger_fill_condition, + "otoco_config": otoco_config, + "isolated": isolated, + "allocated_margin": allocated_margin, + } + return self._rpc("private/buy", params, models.PrivateBuyAndSellResponse) + + def private_cancel(self, order_id, *, isolated=None): + """private/cancel""" + params = {"order_id": order_id, "isolated": isolated} + return self._rpc("private/cancel", params, models.PrivateCancelResponse) + + def private_cancel_all( + self, *, detailed=None, freeze_quotes=None, include_isolated=None + ): + """private/cancel_all""" + params = { + "detailed": detailed, + "freeze_quotes": freeze_quotes, + "include_isolated": include_isolated, + } + return self._rpc("private/cancel_all", params, models.PrivateCancelAllResponse) + + def private_cancel_all_by_currency( + self, + currency, + *, + kind=None, + type=None, + detailed=None, + freeze_quotes=None, + include_isolated=None + ): + """private/cancel_all_by_currency""" + params = { + "currency": currency, + "kind": kind, + "type": type, + "detailed": detailed, + "freeze_quotes": freeze_quotes, + "include_isolated": include_isolated, + } + return self._rpc( + "private/cancel_all_by_currency", params, models.PrivateCancelAllResponse + ) + + def private_cancel_all_by_currency_pair( + self, currency_pair, *, kind=None, type=None, detailed=None, freeze_quotes=None + ): + """private/cancel_all_by_currency_pair""" + params = { + "currency_pair": currency_pair, + "kind": kind, + "type": type, + "detailed": detailed, + "freeze_quotes": freeze_quotes, + } + return self._rpc( + "private/cancel_all_by_currency_pair", + params, + models.PrivateCancelAllResponse, + ) + + def private_cancel_all_by_instrument( + self, + instrument_name, + *, + type=None, + detailed=None, + include_combos=None, + freeze_quotes=None, + include_isolated=None + ): + """private/cancel_all_by_instrument""" + params = { + "instrument_name": instrument_name, + "type": type, + "detailed": detailed, + "include_combos": include_combos, + "freeze_quotes": freeze_quotes, + "include_isolated": include_isolated, + } + return self._rpc( + "private/cancel_all_by_instrument", params, models.PrivateCancelAllResponse + ) + + def private_cancel_all_by_kind_or_type( + self, currency, *, kind=None, type=None, detailed=None, freeze_quotes=None + ): + """private/cancel_all_by_kind_or_type""" + params = { + "currency": currency, + "kind": kind, + "type": type, + "detailed": detailed, + "freeze_quotes": freeze_quotes, + } + return self._rpc( + "private/cancel_all_by_kind_or_type", + params, + models.PrivateCancelAllResponse, + ) + + def private_cancel_by_label(self, label, *, currency=None, include_isolated=None): + """private/cancel_by_label""" + params = { + "label": label, + "currency": currency, + "include_isolated": include_isolated, + } + return self._rpc( + "private/cancel_by_label", params, models.PrivateCancelAllResponse + ) + + def private_change_margin_model(self, margin_model, *, user_id=None, dry_run=None): + """private/change_margin_model""" + params = {"margin_model": margin_model, "user_id": user_id, "dry_run": dry_run} + return self._rpc( + "private/change_margin_model", + params, + models.PrivateChangeMarginModelResponse, + ) + + def private_close_position( + self, instrument_name, type, *, price=None, isolated=None + ): + """private/close_position""" + params = { + "instrument_name": instrument_name, + "type": type, + "price": price, + "isolated": isolated, + } + return self._rpc( + "private/close_position", params, models.PrivateBuyAndSellResponse + ) + + def private_create_combo(self, trades): + """private/create_combo""" + params = {"trades": trades} + return self._rpc( + "private/create_combo", params, models.PrivateCreateComboResponse + ) + + def private_edit( + self, + order_id, + *, + amount=None, + contracts=None, + price=None, + post_only=None, + reduce_only=None, + reject_post_only=None, + advanced=None, + trigger_price=None, + trigger_offset=None, + mmp=None, + valid_until=None, + display_amount=None, + isolated=None, + allocated_margin=None + ): + """private/edit""" + params = { + "order_id": order_id, + "amount": amount, + "contracts": contracts, + "price": price, + "post_only": post_only, + "reduce_only": reduce_only, + "reject_post_only": reject_post_only, + "advanced": advanced, + "trigger_price": trigger_price, + "trigger_offset": trigger_offset, + "mmp": mmp, + "valid_until": valid_until, + "display_amount": display_amount, + "isolated": isolated, + "allocated_margin": allocated_margin, + } + return self._rpc("private/edit", params, models.PrivateEditResponse) + + def private_edit_by_label( + self, + instrument_name, + *, + label=None, + amount=None, + contracts=None, + price=None, + post_only=None, + reduce_only=None, + reject_post_only=None, + advanced=None, + trigger_price=None, + mmp=None, + valid_until=None, + isolated=None, + allocated_margin=None + ): + """private/edit_by_label""" + params = { + "instrument_name": instrument_name, + "label": label, + "amount": amount, + "contracts": contracts, + "price": price, + "post_only": post_only, + "reduce_only": reduce_only, + "reject_post_only": reject_post_only, + "advanced": advanced, + "trigger_price": trigger_price, + "mmp": mmp, + "valid_until": valid_until, + "isolated": isolated, + "allocated_margin": allocated_margin, + } + return self._rpc("private/edit_by_label", params, models.PrivateEditResponse) + + def private_get_account_summaries( + self, *, subaccount_id=None, extended=None, include_isolated=None + ): + """private/get_account_summaries""" + params = { + "subaccount_id": subaccount_id, + "extended": extended, + "include_isolated": include_isolated, + } + return self._rpc( + "private/get_account_summaries", + params, + models.PrivateAccountSummariesResponse, + ) + + def private_get_account_summary( + self, currency, *, subaccount_id=None, extended=None + ): + """private/get_account_summary""" + params = { + "currency": currency, + "subaccount_id": subaccount_id, + "extended": extended, + } + return self._rpc( + "private/get_account_summary", params, models.PrivateAccountResponse + ) + + def private_get_broker_trade_requests(self): + """private/get_broker_trade_requests""" + params = {} + return self._rpc( + "private/get_broker_trade_requests", + params, + models.PrivateGetBrokerTradeRequestsResponse, + ) + + def private_get_broker_trades( + self, *, currency=None, count=None, start_id=None, end_id=None + ): + """private/get_broker_trades""" + params = { + "currency": currency, + "count": count, + "start_id": start_id, + "end_id": end_id, + } + return self._rpc( + "private/get_broker_trades", params, models.PrivateGetBrokerTradesResponse + ) + + def private_get_leg_prices(self, legs, price): + """private/get_leg_prices""" + params = {"legs": legs, "price": price} + return self._rpc( + "private/get_leg_prices", params, models.PrivateGetLegPricesResponse + ) + + def private_get_leverage( + self, *, instrument_name=None, subaccount_id=None, isolated=None + ): + """private/get_leverage""" + params = { + "instrument_name": instrument_name, + "subaccount_id": subaccount_id, + "isolated": isolated, + } + return self._rpc( + "private/get_leverage", params, models.PrivateGetLeverageResponse + ) + + def private_get_margins(self, instrument_name, amount, price, *, isolated=None): + """private/get_margins""" + params = { + "instrument_name": instrument_name, + "amount": amount, + "price": price, + "isolated": isolated, + } + return self._rpc( + "private/get_margins", params, models.PrivateGetMarginsResponse + ) + + def private_get_max_order_size( + self, + instrument_name, + *, + subaccount_id=None, + isolated=None, + leverage=None, + taker_fee_rate=None, + price=None + ): + """private/get_max_order_size""" + params = { + "instrument_name": instrument_name, + "subaccount_id": subaccount_id, + "isolated": isolated, + "leverage": leverage, + "taker_fee_rate": taker_fee_rate, + "price": price, + } + return self._rpc( + "private/get_max_order_size", params, models.PrivateGetMaxOrderSizeResponse + ) + + def private_get_open_orders( + self, *, kind=None, type=None, include_isolated=None, subaccount_id=None + ): + """private/get_open_orders""" + params = { + "kind": kind, + "type": type, + "include_isolated": include_isolated, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_open_orders", params, models.PrivateGetOpenOrdersResponse + ) + + def private_get_open_orders_by_currency(self, currency, *, kind=None, type=None): + """private/get_open_orders_by_currency""" + params = {"currency": currency, "kind": kind, "type": type} + return self._rpc( + "private/get_open_orders_by_currency", + params, + models.PrivateGetOpenOrdersResponse, + ) + + def private_get_open_orders_by_instrument(self, instrument_name, *, type=None): + """private/get_open_orders_by_instrument""" + params = {"instrument_name": instrument_name, "type": type} + return self._rpc( + "private/get_open_orders_by_instrument", + params, + models.PrivateGetOpenOrdersResponse, + ) + + def private_get_open_orders_by_label(self, currency, label): + """private/get_open_orders_by_label""" + params = {"currency": currency, "label": label} + return self._rpc( + "private/get_open_orders_by_label", + params, + models.PrivateGetOpenOrdersResponse, + ) + + def private_get_order_history_by_currency( + self, + currency, + *, + kind=None, + count=None, + offset=None, + include_old=None, + include_unfilled=None, + with_continuation=None, + continuation=None, + historical=None, + subaccount_id=None + ): + """private/get_order_history_by_currency""" + params = { + "currency": currency, + "kind": kind, + "count": count, + "offset": offset, + "include_old": include_old, + "include_unfilled": include_unfilled, + "with_continuation": with_continuation, + "continuation": continuation, + "historical": historical, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_order_history_by_currency", + params, + models.PrivateGetOrderHistoryResponse, + ) + + def private_get_order_history_by_instrument( + self, + instrument_name, + *, + count=None, + offset=None, + include_old=None, + include_unfilled=None, + with_continuation=None, + continuation=None, + historical=None + ): + """private/get_order_history_by_instrument""" + params = { + "instrument_name": instrument_name, + "count": count, + "offset": offset, + "include_old": include_old, + "include_unfilled": include_unfilled, + "with_continuation": with_continuation, + "continuation": continuation, + "historical": historical, + } + return self._rpc( + "private/get_order_history_by_instrument", + params, + models.PrivateGetOrderHistoryResponse, + ) + + def private_get_order_margin_by_ids(self, ids, *, isolated=None): + """private/get_order_margin_by_ids""" + params = {"ids": ids, "isolated": isolated} + return self._rpc( + "private/get_order_margin_by_ids", + params, + models.PrivateGetOrderMarginByIdsResponse, + ) + + def private_get_order_state(self, order_id, *, isolated=None): + """private/get_order_state""" + params = {"order_id": order_id, "isolated": isolated} + return self._rpc( + "private/get_order_state", params, models.PrivateGetOrderStateResponse + ) + + def private_get_order_state_by_label( + self, currency, label, *, include_isolated=None + ): + """private/get_order_state_by_label""" + params = { + "currency": currency, + "label": label, + "include_isolated": include_isolated, + } + return self._rpc( + "private/get_order_state_by_label", + params, + models.PrivateGetOrderStateByLabelResponse, + ) + + def private_get_pme_params(self, currency): + """private/get_pme_params""" + params = {"currency": currency} + return self._rpc( + "private/get_pme_params", params, models.PrivatePmeParamsResponse + ) + + def private_get_position(self, instrument_name, *, include_isolated=None): + """private/get_position""" + params = { + "instrument_name": instrument_name, + "include_isolated": include_isolated, + } + return self._rpc( + "private/get_position", params, models.PrivateGetPositionResponse + ) + + def private_get_positions( + self, *, currency=None, kind=None, subaccount_id=None, include_isolated=None + ): + """private/get_positions""" + params = { + "currency": currency, + "kind": kind, + "subaccount_id": subaccount_id, + "include_isolated": include_isolated, + } + return self._rpc( + "private/get_positions", params, models.PrivateGetPositionsResponse + ) + + def private_get_risk_profile(self): + """private/get_risk_profile""" + params = {} + return self._rpc( + "private/get_risk_profile", params, models.PrivateGetRiskProfileResponse + ) + + def private_get_settlement_history_by_currency( + self, + currency, + *, + type=None, + count=None, + continuation=None, + search_start_timestamp=None, + subaccount_id=None + ): + """private/get_settlement_history_by_currency""" + params = { + "currency": currency, + "type": type, + "count": count, + "continuation": continuation, + "search_start_timestamp": search_start_timestamp, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_settlement_history_by_currency", + params, + models.PrivateSettlementResponse, + ) + + def private_get_settlement_history_by_instrument( + self, + instrument_name, + *, + type=None, + count=None, + continuation=None, + search_start_timestamp=None + ): + """private/get_settlement_history_by_instrument""" + params = { + "instrument_name": instrument_name, + "type": type, + "count": count, + "continuation": continuation, + "search_start_timestamp": search_start_timestamp, + } + return self._rpc( + "private/get_settlement_history_by_instrument", + params, + models.PrivateSettlementResponse, + ) + + def private_get_subaccounts(self, *, with_portfolio=None, include_isolated=None): + """private/get_subaccounts""" + params = { + "with_portfolio": with_portfolio, + "include_isolated": include_isolated, + } + return self._rpc( + "private/get_subaccounts", params, models.PrivateGetSubaccountsResponse + ) + + def private_get_subaccounts_details( + self, currency, *, with_open_orders=None, include_isolated=None + ): + """private/get_subaccounts_details""" + params = { + "currency": currency, + "with_open_orders": with_open_orders, + "include_isolated": include_isolated, + } + return self._rpc( + "private/get_subaccounts_details", + params, + models.PrivateGetSubaccountsDetailsResponse, + ) + + def private_get_trading_limits(self, currency): + """private/get_trading_limits""" + params = {"currency": currency} + return self._rpc( + "private/get_trading_limits", params, models.PrivateGetTradingLimitsResponse + ) + + def private_get_transaction_log( + self, + currency, + start_timestamp, + end_timestamp, + *, + query=None, + count=None, + subaccount_id=None, + continuation=None + ): + """private/get_transaction_log""" + params = { + "currency": currency, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "query": query, + "count": count, + "subaccount_id": subaccount_id, + "continuation": continuation, + } + return self._rpc( + "private/get_transaction_log", + params, + models.PrivateGetTransactionLogResponse, + ) + + def private_get_trigger_order_history( + self, currency, *, instrument_name=None, count=None, continuation=None + ): + """private/get_trigger_order_history""" + params = { + "currency": currency, + "instrument_name": instrument_name, + "count": count, + "continuation": continuation, + } + return self._rpc( + "private/get_trigger_order_history", + params, + models.PrivateGetTriggerOrderHistoryResponse, + ) + + def private_get_user_locks(self): + """private/get_user_locks""" + params = {} + return self._rpc( + "private/get_user_locks", params, models.PrivateGetUserLocksResponse + ) + + def private_get_user_trades_by_currency( + self, + currency, + *, + kind=None, + start_id=None, + end_id=None, + count=None, + start_timestamp=None, + end_timestamp=None, + sorting=None, + historical=None, + subaccount_id=None + ): + """private/get_user_trades_by_currency""" + params = { + "currency": currency, + "kind": kind, + "start_id": start_id, + "end_id": end_id, + "count": count, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "sorting": sorting, + "historical": historical, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_user_trades_by_currency", + params, + models.PrivateGetUserTradesHistoryResponse, + ) + + def private_get_user_trades_by_currency_and_time( + self, + currency, + start_timestamp, + end_timestamp, + *, + kind=None, + count=None, + sorting=None, + historical=None, + subaccount_id=None + ): + """private/get_user_trades_by_currency_and_time""" + params = { + "currency": currency, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "kind": kind, + "count": count, + "sorting": sorting, + "historical": historical, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_user_trades_by_currency_and_time", + params, + models.PrivateGetUserTradesHistoryResponse, + ) + + def private_get_user_trades_by_instrument( + self, + instrument_name, + *, + start_seq=None, + end_seq=None, + count=None, + start_timestamp=None, + end_timestamp=None, + historical=None, + sorting=None, + subaccount_id=None + ): + """private/get_user_trades_by_instrument""" + params = { + "instrument_name": instrument_name, + "start_seq": start_seq, + "end_seq": end_seq, + "count": count, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "historical": historical, + "sorting": sorting, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_user_trades_by_instrument", + params, + models.PrivateGetUserTradesHistoryResponse, + ) + + def private_get_user_trades_by_instrument_and_time( + self, + instrument_name, + start_timestamp, + end_timestamp, + *, + count=None, + sorting=None, + historical=None, + subaccount_id=None + ): + """private/get_user_trades_by_instrument_and_time""" + params = { + "instrument_name": instrument_name, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "count": count, + "sorting": sorting, + "historical": historical, + "subaccount_id": subaccount_id, + } + return self._rpc( + "private/get_user_trades_by_instrument_and_time", + params, + models.PrivateGetUserTradesHistoryResponse, + ) + + def private_get_user_trades_by_order( + self, + order_id, + *, + sorting=None, + historical=None, + subaccount_id=None, + isolated=None + ): + """private/get_user_trades_by_order""" + params = { + "order_id": order_id, + "sorting": sorting, + "historical": historical, + "subaccount_id": subaccount_id, + "isolated": isolated, + } + return self._rpc( + "private/get_user_trades_by_order", + params, + models.PrivateGetUserTradesByOrderResponse, + ) + + def private_pme_simulate( + self, currency, *, add_positions=None, simulated_positions=None + ): + """private/pme/simulate""" + params = { + "currency": currency, + "add_positions": add_positions, + "simulated_positions": simulated_positions, + } + return self._rpc( + "private/pme/simulate", params, models.PrivatePmeSimulateResponse + ) + + def private_sell( + self, + instrument_name, + *, + amount=None, + contracts=None, + type=None, + label=None, + price=None, + time_in_force=None, + display_amount=None, + post_only=None, + reject_post_only=None, + reduce_only=None, + trigger_price=None, + trigger_offset=None, + trigger=None, + advanced=None, + mmp=None, + valid_until=None, + linked_order_type=None, + trigger_fill_condition=None, + otoco_config=None, + isolated=None, + allocated_margin=None + ): + """private/sell""" + params = { + "instrument_name": instrument_name, + "amount": amount, + "contracts": contracts, + "type": type, + "label": label, + "price": price, + "time_in_force": time_in_force, + "display_amount": display_amount, + "post_only": post_only, + "reject_post_only": reject_post_only, + "reduce_only": reduce_only, + "trigger_price": trigger_price, + "trigger_offset": trigger_offset, + "trigger": trigger, + "advanced": advanced, + "mmp": mmp, + "valid_until": valid_until, + "linked_order_type": linked_order_type, + "trigger_fill_condition": trigger_fill_condition, + "otoco_config": otoco_config, + "isolated": isolated, + "allocated_margin": allocated_margin, + } + return self._rpc("private/sell", params, models.PrivateBuyAndSellResponse) + + def private_set_leverage( + self, instrument_name, leverage, *, subaccount_id=None, isolated=None + ): + """private/set_leverage""" + params = { + "instrument_name": instrument_name, + "leverage": leverage, + "subaccount_id": subaccount_id, + "isolated": isolated, + } + return self._rpc( + "private/set_leverage", params, models.PrivateSetLeverageResponse + ) + + def private_simulate_portfolio( + self, currency, *, add_positions=None, simulated_positions=None + ): + """private/simulate_portfolio""" + params = { + "currency": currency, + "add_positions": add_positions, + "simulated_positions": simulated_positions, + } + return self._rpc( + "private/simulate_portfolio", + params, + models.PrivateSimulatePortfolioResponse, + ) + + def private_submit_transfer_between_subaccounts( + self, currency, amount, destination, *, source=None, nonce=None + ): + """private/submit_transfer_between_subaccounts""" + params = { + "currency": currency, + "amount": amount, + "destination": destination, + "source": source, + "nonce": nonce, + } + return self._rpc( + "private/submit_transfer_between_subaccounts", + params, + models.PrivateSubmitTransferResponse, + ) + + def public_auth(self, grant_type, token): + """public/auth""" + params = {"grant_type": grant_type, "token": token} + return self._rpc("public/auth", params, models.PublicAuthResponse) + + def public_get_announcements(self, *, start_timestamp=None, count=None): + """public/get_announcements""" + params = {"start_timestamp": start_timestamp, "count": count} + return self._rpc( + "public/get_announcements", params, models.PublicGetAnnouncementsResponse + ) + + def public_get_book_summary_by_currency(self, currency, *, kind=None): + """public/get_book_summary_by_currency""" + params = {"currency": currency, "kind": kind} + return self._rpc( + "public/get_book_summary_by_currency", + params, + models.PublicGetBookSummaryResponse, + ) + + def public_get_book_summary_by_instrument(self, instrument_name): + """public/get_book_summary_by_instrument""" + params = {"instrument_name": instrument_name} + return self._rpc( + "public/get_book_summary_by_instrument", + params, + models.PublicGetBookSummaryResponse, + ) + + def public_get_combo_details(self, combo_id): + """public/get_combo_details""" + params = {"combo_id": combo_id} + return self._rpc( + "public/get_combo_details", params, models.PublicGetComboDetailsResponse + ) + + def public_get_combo_ids(self, currency, *, state=None): + """public/get_combo_ids""" + params = {"currency": currency, "state": state} + return self._rpc( + "public/get_combo_ids", params, models.PublicGetComboIdsResponse + ) + + def public_get_combos(self, currency): + """public/get_combos""" + params = {"currency": currency} + return self._rpc("public/get_combos", params, models.PublicGetCombosResponse) + + def public_get_contract_size(self, instrument_name): + """public/get_contract_size""" + params = {"instrument_name": instrument_name} + return self._rpc( + "public/get_contract_size", params, models.PublicGetContractSizeResponse + ) + + def public_get_currencies(self): + """public/get_currencies""" + params = {} + return self._rpc( + "public/get_currencies", params, models.PublicGetCurrenciesResponse + ) + + def public_get_delivery_prices(self, index_name, *, offset=None, count=None): + """public/get_delivery_prices""" + params = {"index_name": index_name, "offset": offset, "count": count} + return self._rpc( + "public/get_delivery_prices", params, models.PublicGetDeliveryPricesResponse + ) + + def public_get_expirations(self, currency, kind, *, currency_pair=None): + """public/get_expirations""" + params = {"currency": currency, "kind": kind, "currency_pair": currency_pair} + return self._rpc( + "public/get_expirations", params, models.PublicGetExpirationsResponse + ) + + def public_get_funding_chart_data(self, instrument_name, length): + """public/get_funding_chart_data""" + params = {"instrument_name": instrument_name, "length": length} + return self._rpc( + "public/get_funding_chart_data", + params, + models.PublicGetFundingChartDataResponse, + ) + + def public_get_funding_rate_history( + self, instrument_name, start_timestamp, end_timestamp + ): + """public/get_funding_rate_history""" + params = { + "instrument_name": instrument_name, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + } + return self._rpc( + "public/get_funding_rate_history", + params, + models.PublicGetFundingRateHistoryResponse, + ) + + def public_get_funding_rate_value( + self, instrument_name, start_timestamp, end_timestamp + ): + """public/get_funding_rate_value""" + params = { + "instrument_name": instrument_name, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + } + return self._rpc( + "public/get_funding_rate_value", + params, + models.PublicGetFundingRateValueResponse, + ) + + def public_get_historical_volatility(self, currency): + """public/get_historical_volatility""" + params = {"currency": currency} + return self._rpc( + "public/get_historical_volatility", + params, + models.PublicGetHistoricalVolatilityResponse, + ) + + def public_get_index_chart_data(self, index_name, range): + """public/get_index_chart_data""" + params = {"index_name": index_name, "range": range} + return self._rpc( + "public/get_index_chart_data", + params, + models.PublicGetIndexChartDataResponse, + ) + + def public_get_index_price(self, index_name): + """public/get_index_price""" + params = {"index_name": index_name} + return self._rpc( + "public/get_index_price", params, models.PublicGetIndexPriceResponse + ) + + def public_get_index_price_names(self, *, extended=None): + """public/get_index_price_names""" + params = {"extended": extended} + return self._rpc( + "public/get_index_price_names", + params, + models.PublicGetIndexPriceNamesResponse, + ) + + def public_get_instrument(self, instrument_name): + """public/get_instrument""" + params = {"instrument_name": instrument_name} + return self._rpc( + "public/get_instrument", params, models.PublicGetInstrumentResponse + ) + + def public_get_instruments(self, currency, *, kind=None, expired=None): + """public/get_instruments""" + params = {"currency": currency, "kind": kind, "expired": expired} + return self._rpc( + "public/get_instruments", params, models.PublicGetInstrumentsResponse + ) + + def public_get_last_settlements_by_currency( + self, + currency, + *, + type=None, + count=None, + continuation=None, + search_start_timestamp=None + ): + """public/get_last_settlements_by_currency""" + params = { + "currency": currency, + "type": type, + "count": count, + "continuation": continuation, + "search_start_timestamp": search_start_timestamp, + } + return self._rpc( + "public/get_last_settlements_by_currency", + params, + models.PublicSettlementResponse, + ) + + def public_get_last_settlements_by_instrument( + self, + instrument_name, + *, + type=None, + count=None, + continuation=None, + search_start_timestamp=None + ): + """public/get_last_settlements_by_instrument""" + params = { + "instrument_name": instrument_name, + "type": type, + "count": count, + "continuation": continuation, + "search_start_timestamp": search_start_timestamp, + } + return self._rpc( + "public/get_last_settlements_by_instrument", + params, + models.PublicSettlementResponse, + ) + + def public_get_last_trades_by_currency( + self, + currency, + *, + kind=None, + start_id=None, + end_id=None, + start_timestamp=None, + end_timestamp=None, + count=None, + sorting=None + ): + """public/get_last_trades_by_currency""" + params = { + "currency": currency, + "kind": kind, + "start_id": start_id, + "end_id": end_id, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "count": count, + "sorting": sorting, + } + return self._rpc( + "public/get_last_trades_by_currency", + params, + models.PublicTradesHistoryResponse, + ) + + def public_get_last_trades_by_currency_and_time( + self, + currency, + start_timestamp, + end_timestamp, + *, + kind=None, + count=None, + sorting=None + ): + """public/get_last_trades_by_currency_and_time""" + params = { + "currency": currency, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "kind": kind, + "count": count, + "sorting": sorting, + } + return self._rpc( + "public/get_last_trades_by_currency_and_time", + params, + models.PublicTradesHistoryResponse, + ) + + def public_get_last_trades_by_instrument( + self, + instrument_name, + *, + start_seq=None, + end_seq=None, + start_timestamp=None, + end_timestamp=None, + count=None, + sorting=None + ): + """public/get_last_trades_by_instrument""" + params = { + "instrument_name": instrument_name, + "start_seq": start_seq, + "end_seq": end_seq, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "count": count, + "sorting": sorting, + } + return self._rpc( + "public/get_last_trades_by_instrument", + params, + models.PublicTradesHistoryResponse, + ) + + def public_get_last_trades_by_instrument_and_time( + self, + instrument_name, + start_timestamp, + end_timestamp, + *, + count=None, + sorting=None + ): + """public/get_last_trades_by_instrument_and_time""" + params = { + "instrument_name": instrument_name, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "count": count, + "sorting": sorting, + } + return self._rpc( + "public/get_last_trades_by_instrument_and_time", + params, + models.PublicTradesHistoryResponse, + ) + + def public_get_mark_price_history( + self, instrument_name, start_timestamp, end_timestamp + ): + """public/get_mark_price_history""" + params = { + "instrument_name": instrument_name, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + } + return self._rpc( + "public/get_mark_price_history", + params, + models.PublicGetMarkPriceHistoryResponse, + ) + + def public_get_order_book(self, instrument_name, *, depth=None): + """public/get_order_book""" + params = {"instrument_name": instrument_name, "depth": depth} + return self._rpc( + "public/get_order_book", params, models.PublicGetOrderBookResponse + ) + + def public_get_order_book_by_instrument_id(self, instrument_id, *, depth=None): + """public/get_order_book_by_instrument_id""" + params = {"instrument_id": instrument_id, "depth": depth} + return self._rpc( + "public/get_order_book_by_instrument_id", + params, + models.PublicGetOrderBookResponse, + ) + + def public_get_supported_index_names(self, *, type=None): + """public/get_supported_index_names""" + params = {"type": type} + return self._rpc( + "public/get_supported_index_names", + params, + models.PublicGetIndexPriceNamesResponse, + ) + + def public_get_time(self): + """public/get_time""" + params = {} + return self._rpc("public/get_time", params, models.PublicGetTimeResponse) + + def public_get_trade_volumes(self, *, extended=None): + """public/get_trade_volumes""" + params = {"extended": extended} + return self._rpc( + "public/get_trade_volumes", params, models.PublicGetTradesVolumesResponse + ) + + def public_get_volatility_index_data( + self, currency, start_timestamp, end_timestamp, resolution + ): + """public/get_volatility_index_data""" + params = { + "currency": currency, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "resolution": resolution, + } + return self._rpc( + "public/get_volatility_index_data", + params, + models.PublicGetVolatilityIndexDataResponse, + ) + + def public_status(self): + """public/status""" + params = {} + return self._rpc("public/status", params, models.PublicStatusResponse) + + def public_test(self, *, expected_result=None): + """public/test""" + params = {"expected_result": expected_result} + return self._rpc("public/test", params, models.PublicTestResponse) + + def public_ticker(self, instrument_name): + """public/ticker""" + params = {"instrument_name": instrument_name} + return self._rpc("public/ticker", params, models.PublicTickerResponse) + + def public_tickers_by_currency(self, currency, *, kind=None, depth=None): + """public/tickers_by_currency""" + params = {"currency": currency, "kind": kind, "depth": depth} + return self._rpc( + "public/tickers_by_currency", params, models.PublicTickersByCurrencyResponse + ) + + +class GeneratedWSMixin: + """RPC methods the gateway serves only over WebSocket. Mixed into + DeribitRetailWSClient only.""" + + def private_disable_cancel_on_disconnect(self, *, scope=None): + """private/disable_cancel_on_disconnect""" + params = {"scope": scope} + return self._rpc( + "private/disable_cancel_on_disconnect", params, models.OkResponse + ) + + def private_enable_cancel_on_disconnect(self, *, scope=None): + """private/enable_cancel_on_disconnect""" + params = {"scope": scope} + return self._rpc( + "private/enable_cancel_on_disconnect", params, models.OkResponse + ) + + def private_get_access_log(self, *, offset=None, count=None): + """private/get_access_log""" + params = {"offset": offset, "count": count} + return self._rpc( + "private/get_access_log", params, models.PrivateGetAccessLogResponse + ) + + def private_get_cancel_on_disconnect(self, *, scope=None): + """private/get_cancel_on_disconnect""" + params = {"scope": scope} + return self._rpc( + "private/get_cancel_on_disconnect", + params, + models.PrivateGetCancelOnDisconnectResponse, + ) + + def private_logout(self, *, invalidate_token=None): + """private/logout""" + params = {"invalidate_token": invalidate_token} + return self._rpc("private/logout", params, None) + + def public_disable_heartbeat(self): + """public/disable_heartbeat""" + params = {} + return self._rpc("public/disable_heartbeat", params, models.OkResponse) + + def public_get_tradingview_chart_data( + self, instrument_name, start_timestamp, end_timestamp, resolution + ): + """public/get_tradingview_chart_data""" + params = { + "instrument_name": instrument_name, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "resolution": resolution, + } + return self._rpc( + "public/get_tradingview_chart_data", + params, + models.PublicGetTradingviewChartDataResponse, + ) + + def public_hello(self, client_name, client_version): + """public/hello""" + params = {"client_name": client_name, "client_version": client_version} + return self._rpc("public/hello", params, models.PublicTestResponse) + + def public_set_heartbeat(self, interval): + """public/set_heartbeat""" + params = {"interval": interval} + return self._rpc("public/set_heartbeat", params, models.OkResponse) diff --git a/coinbase/deribit/_model.py b/coinbase/deribit/_model.py new file mode 100644 index 0000000..98ab847 --- /dev/null +++ b/coinbase/deribit/_model.py @@ -0,0 +1,37 @@ +from typing import Dict, Tuple, Type + +from coinbase.rest.types.base_response import BaseResponse + + +class DeribitModel(BaseResponse): + """Base for generated Deribit response models. + + Same attribute-access + ``to_dict()`` contract as the spot SDK's BaseResponse, + extended with a generated ``_NESTED`` map so nested objects and lists of objects + deserialize into their own typed models instead of staying raw dicts. + + ``_NESTED`` maps a field name to ``(ModelClass, is_list)``. Subclasses are + emitted by ``scripts/generate_deribit.py`` — do not hand-edit them. + """ + + _NESTED: Dict[str, Tuple[Type["DeribitModel"], bool]] = {} + + def __init__(self, response: dict): + response = dict(response) if response else {} + for field, (model, _is_list) in self._NESTED.items(): + if field not in response or response[field] is None: + continue + value = response.pop(field) + # Follow the shape the gateway actually returned. The spec and the + # gateway sometimes disagree on list vs object; never drop data. + if isinstance(value, list): + setattr( + self, + field, + [model(item) if isinstance(item, dict) else item for item in value], + ) + elif isinstance(value, dict): + setattr(self, field, model(value)) + else: + setattr(self, field, value) + super().__init__(**response) diff --git a/coinbase/deribit/auth/__init__.py b/coinbase/deribit/auth/__init__.py new file mode 100644 index 0000000..ce617c6 --- /dev/null +++ b/coinbase/deribit/auth/__init__.py @@ -0,0 +1,4 @@ +from coinbase.deribit.auth.cdp_jwt import build_cdp_jwt +from coinbase.deribit.auth.token_cache import DeribitAuth, build_auth_params + +__all__ = ["DeribitAuth", "build_auth_params", "build_cdp_jwt"] diff --git a/coinbase/deribit/auth/cdp_jwt.py b/coinbase/deribit/auth/cdp_jwt.py new file mode 100644 index 0000000..d362672 --- /dev/null +++ b/coinbase/deribit/auth/cdp_jwt.py @@ -0,0 +1,17 @@ +from coinbase import jwt_generator + + +def build_cdp_jwt(api_key: str, api_secret: str) -> str: + """Sign a CDP JWT to present at the gateway's public/auth endpoint. + + Reuses the existing jwt_generator, so ECDSA (ES256) and Ed25519 keys are both + handled with the same auto-detection as the spot RESTClient. The token is the + credential for the ``coinbase_cdp`` grant; the gateway validates its signature + via Cloud API Keys and returns a short-lived Deribit access_token. + + Unlike the per-request REST JWT, no ``uri`` claim is set — public/auth presents + the key itself rather than authorizing a single scoped request. + + :meta private: + """ + return jwt_generator.build_jwt(api_key, api_secret) diff --git a/coinbase/deribit/auth/token_cache.py b/coinbase/deribit/auth/token_cache.py new file mode 100644 index 0000000..b62ac79 --- /dev/null +++ b/coinbase/deribit/auth/token_cache.py @@ -0,0 +1,119 @@ +import threading +import time +from typing import Optional + +import requests + +from coinbase.api_base import get_logger +from coinbase.constants import USER_AGENT +from coinbase.deribit.auth.cdp_jwt import build_cdp_jwt +from coinbase.deribit.constants import ( + DERIBIT_AUTH_METHOD, + DERIBIT_REST_API_PREFIX, + DERIBIT_REST_BASE_URL, + DERIBIT_REST_TIMEOUT, + DERIBIT_TOKEN_REFRESH_RATIO, + GRANT_TYPE_CDP, +) +from coinbase.deribit.errors import DeribitAuthError, redact_secrets + +logger = get_logger("coinbase.deribit.auth") + + +def build_auth_params(api_key: str, api_secret: str) -> dict: + """JSON-RPC ``params`` for a public/auth coinbase_cdp exchange. + + Shared by the HTTP token cache and the WS in-band re-auth, which sign a fresh + CDP JWT each time rather than reusing a cached token. + + :meta private: + """ + return {"grant_type": GRANT_TYPE_CDP, "token": build_cdp_jwt(api_key, api_secret)} + + +class DeribitAuth: + """Manages the HTTP access_token: exchanges a CDP JWT at public/auth and keeps + the returned token fresh. + + The CDP JWT is sent in the JSON-RPC body (not the query string) so the + credential never lands in a URL or access log. The returned access_token is + held in memory only — never persisted, never logged — and refreshed proactively + once it passes ``refresh_ratio`` of its lifetime. + """ + + def __init__( + self, + api_key: str, + api_secret: str, + base_url: str = DERIBIT_REST_BASE_URL, + api_prefix: str = DERIBIT_REST_API_PREFIX, + session: Optional[requests.Session] = None, + timeout: Optional[int] = DERIBIT_REST_TIMEOUT, + refresh_ratio: float = DERIBIT_TOKEN_REFRESH_RATIO, + ) -> None: + self._api_key = api_key + self._api_secret = api_secret + self._auth_url = f"https://{base_url}{api_prefix}/{DERIBIT_AUTH_METHOD}" + self._session = session or requests.Session() + self._timeout = timeout + self._refresh_ratio = refresh_ratio + + self._lock = threading.Lock() + self._access_token: Optional[str] = None + self._refresh_at = 0.0 # monotonic deadline; 0 forces a first exchange + + def get_access_token(self, force_refresh: bool = False) -> str: + """Return a valid access_token, exchanging or refreshing if needed.""" + with self._lock: + if force_refresh or self._needs_refresh(): + self._exchange() + return self._access_token + + def _needs_refresh(self) -> bool: + return self._access_token is None or time.monotonic() >= self._refresh_at + + def _exchange(self) -> None: + body = { + "jsonrpc": "2.0", + "method": DERIBIT_AUTH_METHOD, + "params": build_auth_params(self._api_key, self._api_secret), + } + try: + resp = self._session.post( + self._auth_url, + json=body, + headers={ + "User-Agent": USER_AGENT, + "Content-Type": "application/json", + }, + timeout=self._timeout, + ) + except requests.RequestException as e: + raise DeribitAuthError( + f"public/auth request failed: {redact_secrets(e)}" + ) from None + + try: + payload = resp.json() + except ValueError: + payload = None + err = payload.get("error") if isinstance(payload, dict) else None + if err: + raise DeribitAuthError( + f"public/auth rejected (HTTP {resp.status_code}): " + f"[{err.get('code')}] {redact_secrets(err.get('message'))}" + ) + if resp.status_code != 200: + raise DeribitAuthError(f"public/auth returned status {resp.status_code}") + if not isinstance(payload, dict): + raise DeribitAuthError("public/auth returned an unexpected response") + + result = payload.get("result") or {} + token = result.get("access_token") + expires_in = result.get("expires_in") + if not token or not expires_in: + raise DeribitAuthError("public/auth response missing access_token") + + self._access_token = token + self._refresh_at = time.monotonic() + expires_in * self._refresh_ratio + logger.debug("Deribit access_token refreshed (expires_in=%ss)", expires_in) diff --git a/coinbase/deribit/constants.py b/coinbase/deribit/constants.py new file mode 100644 index 0000000..6edbf52 --- /dev/null +++ b/coinbase/deribit/constants.py @@ -0,0 +1,51 @@ +# Deribit Retail gateway — derivatives surface. +# Spot stays on api.coinbase.com via the existing RESTClient; these point at the +# Coinbase-operated Deribit gateway (drb.coinbase.com), not Deribit directly. +DERIBIT_REST_BASE_URL = "drb.coinbase.com" +DERIBIT_REST_API_PREFIX = "/api/v2" +DERIBIT_WS_URL = "wss://drb.coinbase.com/ws/api/v2" +DERIBIT_STREAM_WS_URL = "wss://streams.drb.coinbase.com/ws/api/v2" + +# Named environments the clients accept via environment=. Only "production" exists +# today; the parameter stays so a sandbox can be added without an API change. +DERIBIT_ENVIRONMENTS = { + "production": { + "rest_base_url": DERIBIT_REST_BASE_URL, + "ws_url": DERIBIT_WS_URL, + "stream_ws_url": DERIBIT_STREAM_WS_URL, + }, +} + + +def resolve_environment(environment: str) -> dict: + try: + return DERIBIT_ENVIRONMENTS[environment] + except KeyError: + raise ValueError( + f"unknown environment {environment!r}; " + f"expected one of {sorted(DERIBIT_ENVIRONMENTS)}" + ) + + +def select_environment(environment: str) -> dict: + """Resolve an environment name to its gateway hosts. + + :meta private: + """ + return resolve_environment(environment) + + +# WebSocket +DERIBIT_WS_CALL_TIMEOUT = 30 # default seconds to block on a JSON-RPC reply +DERIBIT_REST_TIMEOUT = 30 # default seconds for REST and public/auth HTTP requests +DERIBIT_WS_OPEN_TIMEOUT = 10 +DERIBIT_WS_RETRY_MAX = 5 # reconnect attempts before giving up +DERIBIT_WS_RETRY_BASE = 1.0 # initial backoff seconds +DERIBIT_WS_RETRY_FACTOR = 2.0 +DERIBIT_WS_RETRY_CAP = 30.0 + +# Auth — CDP JWT exchanged once at public/auth for a short-lived access_token. +DERIBIT_AUTH_METHOD = "public/auth" +GRANT_TYPE_CDP = "coinbase_cdp" +# Re-auth proactively at this fraction of expires_in (15-min token -> ~12 min). +DERIBIT_TOKEN_REFRESH_RATIO = 0.8 diff --git a/coinbase/deribit/errors.py b/coinbase/deribit/errors.py new file mode 100644 index 0000000..27750c3 --- /dev/null +++ b/coinbase/deribit/errors.py @@ -0,0 +1,109 @@ +import re +from typing import Optional + +# Deribit JSON-RPC error codes we special-case. Everything else falls back to the +# base DeribitRPCError carrying the raw code + message. The SDK never retries an +# RPC error on the caller's behalf; retry policy belongs to the caller. +RATE_LIMIT_CODE = 10028 +MATCHING_QUEUE_FULL_CODE = 10047 +INSUFFICIENT_FUNDS_CODE = 10009 +INVALID_PARAMS_CODE = 11030 +JSONRPC_INVALID_PARAMS_CODE = -32602 # what the gateway returns for bad params + +# Codes the gateway returns when an access token is rejected before the request +# runs. The REST client refreshes the token and retries once on these. +TOKEN_REJECTED_CODES = frozenset({13004, 13009}) # invalid_credentials, unauthorized + +# JWT-shaped strings (three base64url segments). Gateway error text is copied into +# exceptions and logs; if it ever echoes a credential, this keeps it out. +_JWT_LIKE = re.compile(r"[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}") + + +def redact_secrets(text) -> str: + """Replace JWT-like substrings with ````. + + :meta private: + """ + return _JWT_LIKE.sub("", str(text)) + + +class DeribitRPCError(Exception): + """A JSON-RPC ``error`` object returned by the Deribit gateway.""" + + def __init__(self, code: int, message: str, data: Optional[dict] = None) -> None: + self.code = code + self.message = message + self.data = data + super().__init__(f"[{code}] {message}") + + +class DeribitRateLimitError(DeribitRPCError): + """Code 10028 — too many requests.""" + + def __init__(self, code: int, message: str, data: Optional[dict] = None) -> None: + super().__init__(code, message, data) + retry_after = None + if isinstance(data, dict): + # Deribit surfaces a cool-down hint here when present. + retry_after = data.get("retry_after") or data.get("retry_after_ms") + self.retry_after = retry_after + + +class DeribitMatchingQueueFullError(DeribitRPCError): + """Code 10047 — matching engine queue is full; safe to retry with backoff.""" + + +class DeribitInsufficientFundsError(DeribitRPCError): + """Code 10009 (and variants) — not enough funds.""" + + +class DeribitInvalidParamsError(DeribitRPCError): + """Code 11030 or JSON-RPC -32602 — invalid arguments.""" + + +_CODE_TO_CLASS = { + RATE_LIMIT_CODE: DeribitRateLimitError, + MATCHING_QUEUE_FULL_CODE: DeribitMatchingQueueFullError, + INSUFFICIENT_FUNDS_CODE: DeribitInsufficientFundsError, + INVALID_PARAMS_CODE: DeribitInvalidParamsError, + JSONRPC_INVALID_PARAMS_CODE: DeribitInvalidParamsError, +} + + +def rpc_error_from(error: dict) -> DeribitRPCError: + """Build the most specific DeribitRPCError subclass for a JSON-RPC error object. + + :meta private: + """ + code = error.get("code", 0) + message = error.get("message", "") + data = error.get("data") + cls = _CODE_TO_CLASS.get(code, DeribitRPCError) + return cls(code, message, data) + + +class DeribitAuthError(Exception): + """Auth pipeline failure (token exchange/refresh) — not a Deribit RPC error.""" + + +class DeribitConnectionError(Exception): + """WS disconnect while a call was pending. Writes are never auto-resent.""" + + +class DeribitSubscriptionError(Exception): + """The gateway did not subscribe some of the requested channels. + + The gateway answers a subscribe with the channels it accepted and silently + drops the rest. Market-data channels are only served on the streams host + (an unauthenticated :class:`DeribitRetailWSClient`); ``user.*`` channels only + on the trading host (an authenticated one). Accepted channels stay subscribed. + + - **rejected** - channels that were not subscribed. + - **accepted** - channels that were subscribed. + """ + + def __init__(self, rejected, accepted, hint: str = "") -> None: + self.rejected = list(rejected) + self.accepted = list(accepted) + message = f"gateway did not subscribe: {', '.join(self.rejected)}" + super().__init__(f"{message}. {hint}".strip() if hint else message) diff --git a/coinbase/deribit/rest_client.py b/coinbase/deribit/rest_client.py new file mode 100644 index 0000000..82861dc --- /dev/null +++ b/coinbase/deribit/rest_client.py @@ -0,0 +1,157 @@ +import logging +import os +from typing import Any, Optional, Type + +import requests + +from coinbase.api_base import APIBase, get_logger +from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY, USER_AGENT +from coinbase.deribit._generated.rest_methods import GeneratedRESTMixin +from coinbase.deribit._model import DeribitModel +from coinbase.deribit.auth.token_cache import DeribitAuth +from coinbase.deribit.constants import ( + DERIBIT_REST_API_PREFIX, + DERIBIT_REST_TIMEOUT, + select_environment, +) +from coinbase.deribit.errors import ( + TOKEN_REJECTED_CODES, + DeribitRPCError, + rpc_error_from, +) + +logger = get_logger("coinbase.deribit.RESTClient") + + +class DeribitRetailClient(APIBase, GeneratedRESTMixin): + """Synchronous REST client for the Deribit Retail gateway (drb.coinbase.com). + + A convenience surface over HTTP; ``DeribitRetailWSClient`` is the primary + transport. Every Deribit RPC method is a typed method on this class (mixed in + from the generated layer); each forwards to :meth:`_rpc`, which handles the + JSON-RPC envelope, bearer auth for private methods, and error mapping. + + Authenticates with the same CDP keys as the spot RESTClient (Ed25519 or ECDSA), + exchanging a CDP JWT at public/auth for a short-lived access_token that is + refreshed automatically. + """ + + def __init__( + self, + api_key: Optional[str] = os.getenv(API_ENV_KEY), + api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + key_file: Optional[Any] = None, + environment: str = "production", + base_url: Optional[str] = None, + api_prefix: str = DERIBIT_REST_API_PREFIX, + timeout: Optional[int] = DERIBIT_REST_TIMEOUT, + verbose: bool = False, + ) -> None: + # environment picks the gateway host; an explicit base_url overrides it. + if base_url is None: + base_url = select_environment(environment)["rest_base_url"] + super().__init__( + api_key=api_key, + api_secret=api_secret, + key_file=key_file, + base_url=base_url, + timeout=timeout, + verbose=verbose, + ) + if verbose: + logger.setLevel(logging.DEBUG) + + self._api_prefix = api_prefix + self._endpoint = f"https://{base_url}{api_prefix}" + self._session = requests.Session() + self._auth: Optional[DeribitAuth] = None + if self.is_authenticated: + self._auth = DeribitAuth( + self.api_key, + self.api_secret, + base_url=base_url, + api_prefix=api_prefix, + session=self._session, + timeout=timeout, + ) + + def _rpc( + self, + method: str, + params: dict, + model_cls: Optional[Type[DeribitModel]] = None, + ): + """Send one JSON-RPC call and return the parsed response. + + Returns ``model_cls(envelope)`` when the method has a typed response (access + the payload via ``.result``), otherwise the raw response dict. Raises a + :class:`DeribitRPCError` subclass on a JSON-RPC error. + + If the gateway rejects the access token of a private call (it did not run), + the token is refreshed and the call is retried once. + + :meta private: + """ + params = {k: v for k, v in params.items() if v is not None} + is_private = method.startswith("private/") + if is_private and self._auth is None: + raise DeribitRPCError( + 13009, + f"{method} is a private method but the client is unauthenticated. " + "Provide api_key/api_secret or key_file.", + ) + try: + payload = self._post(method, params, is_private, force_refresh=False) + except DeribitRPCError as e: + if not (is_private and e.code in TOKEN_REJECTED_CODES): + raise + logger.debug("Deribit %s: token rejected, refreshing and retrying", method) + payload = self._post(method, params, is_private, force_refresh=True) + + if model_cls is not None: + return model_cls(payload) + return payload + + def _post( + self, method: str, params: dict, is_private: bool, force_refresh: bool + ) -> dict: + headers = { + "User-Agent": USER_AGENT, + "Content-Type": "application/json", + } + if is_private: + # Bearer header (not params.access_token) so the token never lands in a + # logged request body. + token = self._auth.get_access_token(force_refresh=force_refresh) + headers["Authorization"] = f"Bearer {token}" + + url = f"{self._endpoint}/{method}" + body = {"jsonrpc": "2.0", "method": method, "params": params} + logger.debug("Deribit RPC %s", method) + + response = self._session.post( + url, json=body, headers=headers, timeout=self.timeout + ) + try: + payload = response.json() + except ValueError: + response.raise_for_status() + raise DeribitRPCError(response.status_code, response.text) + if not isinstance(payload, dict): + response.raise_for_status() + raise DeribitRPCError( + response.status_code, "unexpected non-object JSON-RPC response" + ) + + if payload.get("error"): + raise rpc_error_from(payload["error"]) + response.raise_for_status() + return payload + + def call(self, method: str, params: Optional[dict] = None): + """Escape hatch: invoke any Deribit method by name with a raw params dict. + + Returns the raw response dict. Use the typed methods for the documented + surface; this covers anything not yet wrapped. + """ + return self._rpc(method, params or {}, None) diff --git a/coinbase/deribit/ws_client.py b/coinbase/deribit/ws_client.py new file mode 100644 index 0000000..1dd007b --- /dev/null +++ b/coinbase/deribit/ws_client.py @@ -0,0 +1,629 @@ +import concurrent.futures +import itertools +import json +import logging +import os +import threading +import time +from concurrent.futures import Future +from typing import Any, Callable, List, Optional, Type + +from websockets.exceptions import ConnectionClosed +from websockets.sync.client import connect as ws_connect + +from coinbase.api_base import APIBase, get_logger +from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY +from coinbase.deribit._generated.rest_methods import ( + GeneratedRESTMixin, + GeneratedWSMixin, +) +from coinbase.deribit._model import DeribitModel +from coinbase.deribit.auth.token_cache import build_auth_params +from coinbase.deribit.constants import ( + DERIBIT_AUTH_METHOD, + DERIBIT_TOKEN_REFRESH_RATIO, + DERIBIT_WS_CALL_TIMEOUT, + DERIBIT_WS_OPEN_TIMEOUT, + DERIBIT_WS_RETRY_BASE, + DERIBIT_WS_RETRY_CAP, + DERIBIT_WS_RETRY_FACTOR, + DERIBIT_WS_RETRY_MAX, + GRANT_TYPE_CDP, + select_environment, +) +from coinbase.deribit.errors import ( + DeribitAuthError, + DeribitConnectionError, + DeribitRPCError, + DeribitSubscriptionError, + redact_secrets, + rpc_error_from, +) + +logger = get_logger("coinbase.deribit.WSClient") + + +class DeribitRetailWSClient(APIBase, GeneratedRESTMixin, GeneratedWSMixin): + """Synchronous, thread-backed WebSocket client for the Deribit Retail gateway. + + This is the primary transport. Every Deribit RPC method is available as a typed + method (mixed in from the generated layer) and runs over the socket; subscription + channels deliver to per-channel callbacks. + + Concurrency model: a single background reader thread reads frames and + demuxes them by JSON-RPC ``id`` onto the matching :class:`Future`; :meth:`call` + blocks on that future. Notifications (no ``id``) go to the subscription dispatcher, + which also answers heartbeat ``test_request`` messages. + + Write safety: an in-flight call is never auto-resent across a reconnect. On + disconnect every pending future fails with :class:`DeribitConnectionError` and the + caller decides whether to retry (reconcile by ``label`` first). Each connection + has a generation number, so a call registered before a disconnect is never sent + on the replacement socket. + + Subscription callbacks run on the reader thread. They must not call :meth:`call` + or any RPC method (the reply could never be read); hand that work to another + thread. Doing so raises ``RuntimeError`` immediately. + """ + + def __init__( + self, + api_key: Optional[str] = os.getenv(API_ENV_KEY), + api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + key_file: Optional[Any] = None, + environment: str = "production", + url: Optional[str] = None, + timeout: Optional[int] = DERIBIT_WS_CALL_TIMEOUT, + verbose: bool = False, + ) -> None: + # environment picks the gateway; an explicit url overrides it. + environment_config = None + if url is None: + environment_config = select_environment(environment) + url = environment_config["ws_url"] + super().__init__( + api_key=api_key, + api_secret=api_secret, + key_file=key_file, + base_url=url, + timeout=timeout, + verbose=verbose, + ) + if verbose: + logger.setLevel(logging.DEBUG) + + # Public, unauthenticated market-data sessions use the dedicated streams + # host. Authenticated RPC and user-channel sessions use the trading host. + if environment_config is not None and not self.is_authenticated: + url = environment_config["stream_ws_url"] + self.url = url + self._call_timeout = timeout or DERIBIT_WS_CALL_TIMEOUT + + self._ws = None + self._reader: Optional[threading.Thread] = None + self._running = False + + self._ids = itertools.count(1) + self._id_lock = threading.Lock() + self._send_lock = threading.Lock() + self._pending = {} # id -> Future + self._pending_lock = threading.Lock() + # Bumped (under _send_lock + _pending_lock) whenever pending calls are + # failed, so a call registered on an old connection can't be sent later. + self._gen = 0 + self._lifecycle_lock = threading.RLock() + + # channel -> list[callback(data, channel)] + self._subscriptions = {} + self._sub_lock = threading.Lock() + + self._reauth_timer: Optional[threading.Timer] = None + # Seconds private_logout waits for the gateway to close before closing + # locally. + self._logout_close_wait = 5.0 + + def open(self) -> "DeribitRetailWSClient": + """Connect, authenticate (if keys were provided), and start the reader. + + If connecting or authenticating fails, the socket is closed and the error + propagates; calling ``open()`` again retries from scratch. + """ + with self._lifecycle_lock: + if self._running: + return self + self._connect_and_auth() + self._running = True + self._reader = threading.Thread( + target=self._run, name="deribit-ws-reader", daemon=True + ) + self._reader.start() + return self + + def close(self) -> None: + """Stop the reader, close the socket, and fail any pending calls.""" + with self._lifecycle_lock: + self._running = False + self._cancel_reauth() + self._close_ws(self._ws) + self._fail_pending(DeribitConnectionError("client closed")) + reader = self._reader + self._join_reader(reader) + + def __enter__(self): + return self.open() + + def __exit__(self, *exc): + self.close() + + def private_logout(self, invalidate_token=None): + """Log out and tear down the session. + + The gateway forwards the logout to Deribit and closes the socket (1000 + "logout") with no JSON-RPC reply, so this can't go through :meth:`call` + (which would block for a reply that never arrives and then surface the + close as a :class:`DeribitConnectionError`). We stop the reader from + reconnecting, send the frame fire-and-forget so the backend still tears + down COD-safely, wait briefly for the server to close, then close the + socket locally either way. + + Raises :class:`DeribitConnectionError` if the logout frame could not be + sent (the local session is still torn down). + """ + with self._lifecycle_lock: + self._running = False + self._cancel_reauth() + reader = self._reader + params = { + k: v + for k, v in {"invalidate_token": invalidate_token}.items() + if v is not None + } + send_error = None + try: + self._send( + { + "jsonrpc": "2.0", + "id": self._next_id(), + "method": "private/logout", + "params": params, + } + ) + except Exception as e: + send_error = e + finally: + # Give the gateway a moment to close (1000 "logout"), then make sure + # the socket and reader are gone even if it never does. + self._join_reader(reader, self._logout_close_wait) + self._close_ws(self._ws) + self._join_reader(reader) + self._fail_pending(DeribitConnectionError("client logged out")) + if send_error is not None: + raise DeribitConnectionError( + f"logout was not sent: {send_error}" + ) from send_error + return {"result": "ok"} + + def call( + self, method: str, params: Optional[dict] = None, timeout: Optional[int] = None + ) -> dict: + """Send one JSON-RPC call and block for the reply. + + Returns the full response envelope dict (``id``/``jsonrpc``/``result``). + Raises a :class:`DeribitRPCError` subclass on a JSON-RPC error, + :class:`DeribitConnectionError` if the request was not sent or the socket + drops while waiting, and ``TimeoutError`` if no reply arrives in + ``timeout`` seconds. Must not be called from a subscription callback. + """ + if threading.current_thread() is self._reader: + raise RuntimeError( + f"{method} called on the WebSocket reader thread (e.g. from a " + "subscription callback). Its reply could never be read; run the " + "call from another thread." + ) + rid = self._next_id() + fut: Future = Future() + with self._pending_lock: + gen = self._gen + self._pending[rid] = fut + frame = { + "jsonrpc": "2.0", + "id": rid, + "method": method, + "params": {k: v for k, v in (params or {}).items() if v is not None}, + } + try: + self._send(frame, gen=gen) + except Exception as e: + with self._pending_lock: + self._pending.pop(rid, None) + raise DeribitConnectionError(f"{method} was not sent: {e}") from e + + wait = timeout if timeout is not None else self._call_timeout + try: + return fut.result(wait) + except concurrent.futures.TimeoutError: + # Before Python 3.11 this is not the builtin TimeoutError. + with self._pending_lock: + self._pending.pop(rid, None) + raise TimeoutError(f"no reply to {method} within {wait}s") from None + + def _rpc( + self, + method: str, + params: dict, + model_cls: Optional[Type[DeribitModel]] = None, + ): + """Bridge for the generated typed methods. :meta private:""" + payload = self.call(method, params) + if model_cls is not None: + return model_cls(payload) + return payload + + def _next_id(self) -> int: + with self._id_lock: + return next(self._ids) + + def _send(self, obj: dict, gen: Optional[int] = None) -> None: + data = json.dumps(obj) + with self._send_lock: + if gen is not None and gen != self._gen: + raise DeribitConnectionError( + "connection was reset before the request was sent" + ) + if self._ws is None: + raise DeribitConnectionError("websocket is not connected") + self._ws.send(data) + + @property + def _sub_scope(self) -> str: + # The trading host (authenticated) only allows private/(un)subscribe and + # serves user.* channels; the streams host (unauthenticated) only allows + # public/(un)subscribe and serves market data. + return "private" if self.is_authenticated else "public" + + def subscribe( + self, + channels: List[str], + callback: Optional[Callable[[Any, str], None]] = None, + ) -> dict: + """Subscribe to channels and (optionally) register a callback for them. + + ``callback(data, channel)`` runs on the reader thread for each notification. + Channels are remembered and re-subscribed automatically after a reconnect. + + An authenticated client serves ``user.*`` channels; market-data channels + (ticker, book, trades, ...) need an unauthenticated client, which connects + to the streams host. Raises :class:`DeribitSubscriptionError` listing any + channel the gateway did not subscribe; the others stay subscribed. + """ + with self._sub_lock: + for ch in channels: + self._subscriptions.setdefault(ch, []) + if callback is not None: + self._subscriptions[ch].append(callback) + scope = self._sub_scope + try: + response = self.call(f"{scope}/subscribe", {"channels": list(channels)}) + except Exception: + self._forget(channels) + raise + rejected = self._rejected(channels, response) + if rejected: + self._forget(rejected) + accepted = [c for c in channels if c not in rejected] + raise DeribitSubscriptionError( + rejected, accepted, self._subscription_hint() + ) + return {scope: response} + + def unsubscribe(self, channels: List[str]) -> dict: + """Unsubscribe from channels and drop every callback registered for them. + + Callbacks are dropped only after the gateway confirms, so a failed call + leaves the local registry matching the server. + """ + scope = self._sub_scope + response = self.call(f"{scope}/unsubscribe", {"channels": list(channels)}) + self._forget(channels) + return {scope: response} + + def unsubscribe_all(self) -> dict: + """Unsubscribe from every channel and drop every registered callback. + + Callbacks are dropped only after the gateway confirms. + """ + scope = self._sub_scope + response = self.call(f"{scope}/unsubscribe_all", {}) + with self._sub_lock: + self._subscriptions.clear() + return {scope: response} + + def _forget(self, channels) -> None: + with self._sub_lock: + for ch in channels: + self._subscriptions.pop(ch, None) + + @staticmethod + def _rejected(channels: List[str], response: dict) -> List[str]: + accepted = response.get("result") + if not isinstance(accepted, list): + return [] # unexpected shape: don't second-guess the gateway + accepted = set(accepted) + return [c for c in channels if c not in accepted] + + def _subscription_hint(self) -> str: + if self.is_authenticated: + return ( + "This client is authenticated (trading host), which serves user.* " + "channels. Subscribe to market data with an unauthenticated " + "DeribitRetailWSClient() (streams host)." + ) + return ( + "This client is unauthenticated (streams host), which serves market " + "data. user.* channels need a DeribitRetailWSClient with API keys." + ) + + def _run(self) -> None: + while self._running: + ws = self._ws # None if re-auth already dropped it; reconnect below + if ws is not None: + try: + for raw in ws: + if not self._running: + return + self._handle_frame(raw) + except (ConnectionClosed, OSError) as e: + logger.debug("Deribit WS read loop ended: %s", e) + except Exception: + logger.exception("Deribit WS reader failed; reconnecting") + if not self._running: + return + # Unexpected drop: fail in-flight calls (never resent) and reconnect. + self._fail_pending(DeribitConnectionError("websocket disconnected")) + self._close_ws(ws) + if not self._reconnect(): + if self._running: + logger.error( + "Deribit WS reconnect gave up after %d attempts", + DERIBIT_WS_RETRY_MAX, + ) + self._running = False + return + + def _handle_frame(self, raw) -> None: + """Parse and dispatch one frame; a bad frame is logged, never fatal.""" + for msg in self._parse_frame(raw): + try: + self._dispatch(msg) + except Exception: + logger.exception("Deribit WS failed to handle a frame") + + @staticmethod + def _parse_frame(raw) -> list: + try: + msg = json.loads(raw) + except (TypeError, ValueError): + logger.warning("Deribit WS ignored a non-JSON frame") + return [] + msgs = msg if isinstance(msg, list) else [msg] + return [m for m in msgs if isinstance(m, dict)] + + def _reconnect(self) -> bool: + delay = DERIBIT_WS_RETRY_BASE + for attempt in range(1, DERIBIT_WS_RETRY_MAX + 1): + if not self._running: + return False + time.sleep(min(delay, DERIBIT_WS_RETRY_CAP)) + delay *= DERIBIT_WS_RETRY_FACTOR + if not self._running: + return False + try: + self._connect_and_auth() + self._resubscribe() + if not self._running: + # close() ran while we were reconnecting; don't leak the socket. + self._close_ws(self._ws) + return False + logger.debug("Deribit WS reconnected on attempt %d", attempt) + return True + except Exception as e: + self._close_ws(self._ws) + logger.debug( + "Deribit WS reconnect attempt %d failed: %s", + attempt, + redact_secrets(e), + ) + return False + + def _resubscribe(self) -> None: + """Restore subscriptions on a fresh socket. + + Runs on the reader thread before it resumes reading, so it reads replies + directly (like the auth handshake) instead of blocking in :meth:`call`. + """ + with self._sub_lock: + channels = list(self._subscriptions) + if not channels: + return + response = self._handshake_call( + f"{self._sub_scope}/subscribe", {"channels": channels} + ) + rejected = self._rejected(channels, response) + if rejected: + self._forget(rejected) + logger.warning( + "Deribit WS: gateway did not restore subscriptions after reconnect: %s", + ", ".join(rejected), + ) + + def _dispatch(self, msg: dict) -> None: + rid = msg.get("id") + if rid is not None: + with self._pending_lock: + fut = self._pending.pop(rid, None) + if fut is None: + return # response to a fire-and-forget (e.g. our heartbeat test) + try: + if msg.get("error"): + fut.set_exception(rpc_error_from(msg["error"])) + else: + fut.set_result(msg) + except concurrent.futures.InvalidStateError: + pass # already failed by close()/disconnect + return + + method = msg.get("method") + if method == "subscription": + params = msg.get("params", {}) + channel = params.get("channel") + data = params.get("data") + with self._sub_lock: + callbacks = list(self._subscriptions.get(channel, [])) + for cb in callbacks: + try: + cb(data, channel) + except Exception: + logger.exception( + "Deribit subscription callback error on %s", channel + ) + elif method == "heartbeat": + # Server liveness check; reply with public/test to keep the session up. + if msg.get("params", {}).get("type") == "test_request": + try: + self._send( + { + "jsonrpc": "2.0", + "id": self._next_id(), + "method": "public/test", + "params": {}, + } + ) + except Exception as e: + logger.debug("heartbeat reply failed: %s", e) + + def _fail_pending(self, exc: Exception) -> None: + # Holding _send_lock while bumping the generation means no call registered + # before this point can still be sent afterwards. + with self._send_lock: + with self._pending_lock: + self._gen += 1 + pending = list(self._pending.values()) + self._pending.clear() + for fut in pending: + try: + fut.set_exception(exc) + except concurrent.futures.InvalidStateError: + pass + + def _close_ws(self, ws) -> None: + """Close ``ws`` and clear it if it is still the current socket.""" + if ws is None: + return + with self._send_lock: + if self._ws is ws: + self._ws = None + try: + ws.close() + except Exception: + pass + + def _join_reader( + self, reader: Optional[threading.Thread], timeout: float = 5.0 + ) -> None: + if ( + reader is not None + and reader.is_alive() + and reader is not threading.current_thread() + ): + reader.join(timeout=timeout) + + def _cancel_reauth(self) -> None: + if self._reauth_timer is not None: + self._reauth_timer.cancel() + + def _connect_and_auth(self) -> None: + self._cancel_reauth() + ws = ws_connect(self.url, open_timeout=DERIBIT_WS_OPEN_TIMEOUT) + with self._send_lock: + self._ws = ws + try: + if self.is_authenticated: + self._handshake_auth() + except Exception: + self._close_ws(ws) + raise + + def _handshake_call(self, method: str, params: dict) -> dict: + """Send one call and read its reply directly off the socket. + + Only valid while the reader loop is not consuming the socket: on open + (reader not started) or during reconnect (the reader is the caller). + Other frames that arrive first are dispatched normally. + """ + rid = self._next_id() + self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + deadline = time.monotonic() + DERIBIT_WS_OPEN_TIMEOUT + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for {method} response") + ws = self._ws + if ws is None: + raise DeribitConnectionError(f"socket closed during {method}") + for msg in self._parse_frame(ws.recv(timeout=remaining)): + if msg.get("id") == rid: + if msg.get("error"): + raise rpc_error_from(msg["error"]) + return msg + try: + self._dispatch(msg) + except Exception: + logger.exception("Deribit WS failed to handle a frame") + + def _handshake_auth(self) -> None: + """Authenticate on a fresh socket, before the reader loop owns it. + + Scheduled in-band re-auth goes through :meth:`call` instead. + """ + try: + msg = self._handshake_call( + DERIBIT_AUTH_METHOD, build_auth_params(self.api_key, self.api_secret) + ) + except DeribitRPCError as e: + raise DeribitAuthError( + f"public/auth rejected: [{e.code}] {redact_secrets(e.message)}" + ) from None + except TimeoutError as e: + raise DeribitAuthError(str(e)) from None + except (ConnectionClosed, OSError, DeribitConnectionError) as e: + raise DeribitAuthError(f"socket closed during auth: {e}") from e + self._schedule_reauth(msg.get("result", {}).get("expires_in")) + + def _schedule_reauth(self, expires_in: Optional[int]) -> None: + self._cancel_reauth() + if not expires_in: + return + delay = expires_in * DERIBIT_TOKEN_REFRESH_RATIO + self._reauth_timer = threading.Timer(delay, self._reauth) + self._reauth_timer.daemon = True + self._reauth_timer.start() + + def _reauth(self) -> None: + """In-band re-auth on the live socket (runs on the timer thread).""" + if not self._running: + return + ws = self._ws # only ever drop the socket this re-auth started on + try: + result = self.call( + DERIBIT_AUTH_METHOD, + build_auth_params(self.api_key, self.api_secret), + ) + self._schedule_reauth(result.get("result", {}).get("expires_in")) + except Exception as e: + if not self._running or ws is not self._ws: + return # closed, or already reconnected; this result is stale + logger.error( + "Deribit WS re-auth failed, forcing reconnect: %s", redact_secrets(e) + ) + # Drop the socket; the reader loop will reconnect + re-auth fresh. + self._close_ws(ws) diff --git a/coinbase/rest/_intx_cutover.py b/coinbase/rest/_intx_cutover.py new file mode 100644 index 0000000..d16adfb --- /dev/null +++ b/coinbase/rest/_intx_cutover.py @@ -0,0 +1,54 @@ +import logging +from contextlib import contextmanager +from typing import Iterator, Optional + +from requests.exceptions import HTTPError + +PERP_PRODUCT_SUFFIX = "-PERP-INTX" + +# Same logger (and handler) that rest_base configures for HTTP errors. +logger = logging.getLogger("coinbase.RESTClient") + + +def is_intx_perp_product(product_id) -> bool: + """ + :meta private: + """ + return isinstance(product_id, str) and product_id.upper().endswith( + PERP_PRODUCT_SUFFIX + ) + + +def intx_migration_hint_message(method: str, replacement: Optional[str]) -> str: + """ + :meta private: + """ + if replacement: + hint = ( + f"Use {replacement} on coinbase.deribit.DeribitRetailClient " + f"or DeribitRetailWSClient instead." + ) + else: + hint = "There is no Deribit Retail replacement for this call yet." + return ( + f"{method}() failed. From October 1, 2026, Global Derivatives run on the " + f"Deribit-powered gateway at drb.coinbase.com instead of INTX. {hint}" + ) + + +@contextmanager +def intx_migration_hint( + method: str, replacement: Optional[str] = None, enabled: bool = True +) -> Iterator[None]: + """ + Send the request unchanged. If the server rejects it, log a migration hint + and re-raise the original :class:`HTTPError`. + + :meta private: + """ + try: + yield + except HTTPError: + if enabled: + logger.error(intx_migration_hint_message(method, replacement)) + raise diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py index 34f046e..f984fba 100644 --- a/coinbase/rest/orders.py +++ b/coinbase/rest/orders.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional from coinbase.constants import API_PREFIX +from coinbase.rest._intx_cutover import intx_migration_hint, is_intx_perp_product from coinbase.rest.types.orders_types import ( CancelOrdersResponse, ClosePositionResponse, @@ -32,6 +33,9 @@ def create_order( leverage: Optional[str] = None, margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, + attached_order_configuration: Optional[Dict] = None, + prediction_metadata: Optional[Dict] = None, + equity_order_metadata: Optional[Dict] = None, **kwargs, ) -> CreateOrderResponse: """ @@ -65,9 +69,17 @@ def create_order( "leverage": leverage, "margin_type": margin_type, "retail_portfolio_id": retail_portfolio_id, + "attached_order_configuration": attached_order_configuration, + "prediction_metadata": prediction_metadata, + "equity_order_metadata": equity_order_metadata, } - return CreateOrderResponse(self.post(endpoint, data=data, **kwargs)) + with intx_migration_hint( + "create_order", + "private_buy or private_sell", + enabled=is_intx_perp_product(product_id), + ): + return CreateOrderResponse(self.post(endpoint, data=data, **kwargs)) # Market orders @@ -1420,7 +1432,13 @@ def trigger_bracket_order_gtd_sell( ) -def get_order(self, order_id: str, **kwargs) -> GetOrderResponse: +def get_order( + self, + order_id: str, + client_order_id: Optional[str] = None, + user_native_currency: Optional[str] = None, + **kwargs, +) -> GetOrderResponse: """ **Get Order** _____________ @@ -1439,8 +1457,12 @@ def get_order(self, order_id: str, **kwargs) -> GetOrderResponse: `_ """ endpoint = f"{API_PREFIX}/orders/historical/{order_id}" + params = { + "client_order_id": client_order_id, + "user_native_currency": user_native_currency, + } - return GetOrderResponse(self.get(endpoint, **kwargs)) + return GetOrderResponse(self.get(endpoint, params=params, **kwargs)) def list_orders( @@ -1461,6 +1483,8 @@ def list_orders( retail_portfolio_id: Optional[str] = None, time_in_forces: Optional[str] = None, sort_by: Optional[str] = None, + user_native_currency: Optional[str] = None, + use_simplified_total_value_calculation: Optional[bool] = None, **kwargs, ) -> ListOrdersResponse: """ @@ -1498,6 +1522,8 @@ def list_orders( "retail_portfolio_id": retail_portfolio_id, "time_in_forces": time_in_forces, "sort_by": sort_by, + "user_native_currency": user_native_currency, + "use_simplified_total_value_calculation": use_simplified_total_value_calculation, } return ListOrdersResponse(self.get(endpoint, params=params, **kwargs)) @@ -1514,6 +1540,10 @@ def get_fills( limit: Optional[int] = None, cursor: Optional[str] = None, sort_by: Optional[str] = None, + order_side: Optional[str] = None, + order_types: Optional[List[str]] = None, + product_types: Optional[List[str]] = None, + asset_filters: Optional[List[str]] = None, **kwargs, ) -> ListFillsResponse: """ @@ -1544,6 +1574,10 @@ def get_fills( "limit": limit, "cursor": cursor, "sort_by": sort_by, + "order_side": order_side, + "order_types": order_types, + "product_types": product_types, + "asset_filters": asset_filters, } return ListFillsResponse(self.get(endpoint, params=params, **kwargs)) @@ -1554,6 +1588,10 @@ def edit_order( order_id: str, size: Optional[str] = None, price: Optional[str] = None, + stop_price: Optional[str] = None, + average_entry_price: Optional[str] = None, + attached_order_configuration: Optional[Dict] = None, + cancel_attached_order: Optional[bool] = None, **kwargs, ) -> EditOrderResponse: """ @@ -1578,6 +1616,10 @@ def edit_order( "order_id": order_id, "size": size, "price": price, + "stop_price": stop_price, + "average_entry_price": average_entry_price, + "attached_order_configuration": attached_order_configuration, + "cancel_attached_order": cancel_attached_order, } return EditOrderResponse(self.post(endpoint, data=data, **kwargs)) @@ -1588,6 +1630,10 @@ def preview_edit_order( order_id: str, size: Optional[str] = None, price: Optional[str] = None, + stop_price: Optional[str] = None, + average_entry_price: Optional[str] = None, + attached_order_configuration: Optional[Dict] = None, + cancel_attached_order: Optional[bool] = None, **kwargs, ) -> EditOrderPreviewResponse: """ @@ -1612,6 +1658,10 @@ def preview_edit_order( "order_id": order_id, "size": size, "price": price, + "stop_price": stop_price, + "average_entry_price": average_entry_price, + "attached_order_configuration": attached_order_configuration, + "cancel_attached_order": cancel_attached_order, } return EditOrderPreviewResponse(self.post(endpoint, data=data, **kwargs)) @@ -1651,6 +1701,9 @@ def preview_order( leverage: Optional[str] = None, margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, + attached_order_configuration: Optional[Dict] = None, + prediction_metadata: Optional[Dict] = None, + equity_order_metadata: Optional[Dict] = None, **kwargs, ) -> PreviewOrderResponse: """ @@ -1679,9 +1732,15 @@ def preview_order( "leverage": leverage, "margin_type": margin_type, "retail_portfolio_id": retail_portfolio_id, + "attached_order_configuration": attached_order_configuration, + "prediction_metadata": prediction_metadata, + "equity_order_metadata": equity_order_metadata, } - return PreviewOrderResponse(self.post(endpoint, data=data, **kwargs)) + with intx_migration_hint( + "preview_order", None, enabled=is_intx_perp_product(product_id) + ): + return PreviewOrderResponse(self.post(endpoint, data=data, **kwargs)) # Preview market orders @@ -2921,4 +2980,9 @@ def close_position( data = {"client_order_id": client_order_id, "product_id": product_id, "size": size} - return ClosePositionResponse(self.post(endpoint, data=data, **kwargs)) + with intx_migration_hint( + "close_position", + "private_close_position", + enabled=is_intx_perp_product(product_id), + ): + return ClosePositionResponse(self.post(endpoint, data=data, **kwargs)) diff --git a/coinbase/rest/perpetuals.py b/coinbase/rest/perpetuals.py index 10428d7..4765385 100644 --- a/coinbase/rest/perpetuals.py +++ b/coinbase/rest/perpetuals.py @@ -1,4 +1,5 @@ from coinbase.constants import API_PREFIX +from coinbase.rest._intx_cutover import intx_migration_hint from coinbase.rest.types.perpetuals_types import ( AllocatePortfolioResponse, GetPerpetualsPortfolioSummaryResponse, @@ -26,10 +27,18 @@ def allocate_portfolio( __________ + **Global Derivatives:** From October 1, 2026, Global Derivatives run on the + Deribit-powered gateway instead of INTX. + The request is still sent. If the server rejects it, the SDK logs a migration + hint and re-raises the ``HTTPError``. Isolated margin lives in a managed subaccount: + use ``private_submit_transfer_between_subaccounts`` on + ``coinbase.deribit.DeribitRetailClient`` instead. + + __________ + **Read more on the official documentation:** `Allocate Portfolio `_ """ - endpoint = f"{API_PREFIX}/intx/allocate" data = { @@ -39,7 +48,10 @@ def allocate_portfolio( "currency": currency, } - return AllocatePortfolioResponse(self.post(endpoint, data=data, **kwargs)) + with intx_migration_hint( + "allocate_portfolio", "private_submit_transfer_between_subaccounts" + ): + return AllocatePortfolioResponse(self.post(endpoint, data=data, **kwargs)) def get_perps_portfolio_summary( @@ -59,12 +71,23 @@ def get_perps_portfolio_summary( __________ + **Global Derivatives:** From October 1, 2026, Global Derivatives run on the + Deribit-powered gateway instead of INTX. + The request is still sent. If the server rejects it, the SDK logs a migration + hint and re-raises the ``HTTPError``. Use ``private_get_account_summary`` on + ``coinbase.deribit.DeribitRetailClient`` instead. + + __________ + **Read more on the official documentation:** `Get Perpetuals Portfolio Summary `_ """ endpoint = f"{API_PREFIX}/intx/portfolio/{portfolio_uuid}" - return GetPerpetualsPortfolioSummaryResponse(self.get(endpoint, **kwargs)) + with intx_migration_hint( + "get_perps_portfolio_summary", "private_get_account_summary" + ): + return GetPerpetualsPortfolioSummaryResponse(self.get(endpoint, **kwargs)) def list_perps_positions( @@ -84,12 +107,21 @@ def list_perps_positions( __________ + **Global Derivatives:** From October 1, 2026, Global Derivatives run on the + Deribit-powered gateway instead of INTX. + The request is still sent. If the server rejects it, the SDK logs a migration + hint and re-raises the ``HTTPError``. Use ``private_get_positions`` on + ``coinbase.deribit.DeribitRetailClient`` instead. + + __________ + **Read more on the official documentation:** `List Perpetuals Positions `_ """ endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}" - return ListPerpetualsPositionsResponse(self.get(endpoint, **kwargs)) + with intx_migration_hint("list_perps_positions", "private_get_positions"): + return ListPerpetualsPositionsResponse(self.get(endpoint, **kwargs)) def get_perps_position( @@ -109,12 +141,21 @@ def get_perps_position( __________ + **Global Derivatives:** From October 1, 2026, Global Derivatives run on the + Deribit-powered gateway instead of INTX. + The request is still sent. If the server rejects it, the SDK logs a migration + hint and re-raises the ``HTTPError``. Use ``private_get_position`` on + ``coinbase.deribit.DeribitRetailClient`` instead. + + __________ + **Read more on the official documentation:** `Get Perpetuals Positions `_ """ endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}/{symbol}" - return GetPerpetualsPositionResponse(self.get(endpoint, **kwargs)) + with intx_migration_hint("get_perps_position", "private_get_position"): + return GetPerpetualsPositionResponse(self.get(endpoint, **kwargs)) def get_perps_portfolio_balances( @@ -134,12 +175,23 @@ def get_perps_portfolio_balances( __________ + **Global Derivatives:** From October 1, 2026, Global Derivatives run on the + Deribit-powered gateway instead of INTX. + The request is still sent. If the server rejects it, the SDK logs a migration + hint and re-raises the ``HTTPError``. Use ``private_get_account_summaries`` on + ``coinbase.deribit.DeribitRetailClient`` instead. + + __________ + **Read more on the official documentation:** `Get Portfolio Balances `_ """ endpoint = f"{API_PREFIX}/intx/balances/{portfolio_uuid}" - return GetPortfolioBalancesResponse(self.get(endpoint, **kwargs)) + with intx_migration_hint( + "get_perps_portfolio_balances", "private_get_account_summaries" + ): + return GetPortfolioBalancesResponse(self.get(endpoint, **kwargs)) def opt_in_or_out_multi_asset_collateral( @@ -159,10 +211,17 @@ def opt_in_or_out_multi_asset_collateral( __________ + **Global Derivatives:** From October 1, 2026, Global Derivatives run on the + Deribit-powered gateway instead of INTX. + The request is still sent. If the server rejects it, the SDK logs a migration + hint and re-raises the ``HTTPError``. Use ``private_change_margin_model`` on + ``coinbase.deribit.DeribitRetailClient`` instead. + + __________ + **Read more on the official documentation:** `Opt In or Out of Multi Asset Collateral `_ """ - endpoint = f"{API_PREFIX}/intx/multi_asset_collateral" data = { @@ -170,6 +229,9 @@ def opt_in_or_out_multi_asset_collateral( "multi_asset_collateral_enabled": multi_asset_collateral_enabled, } - return OptInOutMultiAssetCollateralResponse( - self.post(endpoint, data=data, **kwargs) - ) + with intx_migration_hint( + "opt_in_or_out_multi_asset_collateral", "private_change_margin_model" + ): + return OptInOutMultiAssetCollateralResponse( + self.post(endpoint, data=data, **kwargs) + ) diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py index d6be9bd..68d41b1 100644 --- a/coinbase/rest/products.py +++ b/coinbase/rest/products.py @@ -19,6 +19,9 @@ def get_products( expiring_contract_status: Optional[str] = None, get_tradability_status: Optional[bool] = False, get_all_products: Optional[bool] = False, + cursor: Optional[str] = None, + futures_underlying_type: Optional[str] = None, + user_country_code: Optional[str] = None, **kwargs, ) -> ListProductsResponse: """ @@ -49,6 +52,9 @@ def get_products( "expiring_contract_status": expiring_contract_status, "get_tradability_status": get_tradability_status, "get_all_products": get_all_products, + "cursor": cursor, + "futures_underlying_type": futures_underlying_type, + "user_country_code": user_country_code, } return ListProductsResponse(self.get(endpoint, params=params, **kwargs)) diff --git a/coinbase/rest/public.py b/coinbase/rest/public.py index 402eb00..6d8b623 100644 --- a/coinbase/rest/public.py +++ b/coinbase/rest/public.py @@ -84,6 +84,9 @@ def get_public_products( contract_expiry_type: Optional[str] = None, expiring_contract_status: Optional[str] = None, get_all_products: bool = False, + cursor: Optional[str] = None, + futures_underlying_type: Optional[str] = None, + user_country_code: Optional[str] = None, **kwargs, ) -> ListProductsResponse: """ @@ -119,6 +122,9 @@ def get_public_products( "contract_expiry_type": contract_expiry_type, "expiring_contract_status": expiring_contract_status, "get_all_products": get_all_products, + "cursor": cursor, + "futures_underlying_type": futures_underlying_type, + "user_country_code": user_country_code, } return ListProductsResponse( diff --git a/coinbase/websocket/__init__.py b/coinbase/websocket/__init__.py index 7787afd..e576d28 100644 --- a/coinbase/websocket/__init__.py +++ b/coinbase/websocket/__init__.py @@ -87,7 +87,7 @@ class WSUserClient(WSBase): - **api_key | Optional (str)** - The API key - **api_secret | Optional (str)** - The API key secret - **key_file | Optional (IO | str)** - Path to API key file or file-like object - - **base_url | (str)** - The websocket base url. Default set to "wss://advanced-trade-ws.coinbase.com" + - **base_url | (str)** - The websocket base url. Default set to "wss://advanced-trade-ws-user.coinbase.com" - **timeout | Optional (int)** - Set timeout in seconds for REST requests - **max_size | Optional (int)** - Max size in bytes for messages received. Default set to (10 * 1024 * 1024) - **on_message | Optional (Callable[[str], None])** - Function called when a message is received diff --git a/coinbase/websocket/channels.py b/coinbase/websocket/channels.py index 9b7b058..d3d1e21 100644 --- a/coinbase/websocket/channels.py +++ b/coinbase/websocket/channels.py @@ -27,7 +27,7 @@ def heartbeats(self) -> None: __________ **Read more on the official documentation:** `Heartbeats Channel - `_ + `_ """ self.subscribe([], [HEARTBEATS]) @@ -46,7 +46,7 @@ async def heartbeats_async(self) -> None: __________ **Read more on the official documentation:** `Heartbeats Channel - `_ + `_ """ await self.subscribe_async([], [HEARTBEATS]) @@ -65,7 +65,7 @@ def heartbeats_unsubscribe(self) -> None: __________ **Read more on the official documentation:** `Heartbeats Channel - `_ + `_ """ self.unsubscribe([], [HEARTBEATS]) @@ -86,7 +86,7 @@ async def heartbeats_unsubscribe_async( __________ **Read more on the official documentation:** `Heartbeats Channel - `_ + `_ """ await self.unsubscribe_async([], [HEARTBEATS]) @@ -105,7 +105,7 @@ def candles(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Candles Channel - `_ + `_ """ self.subscribe(product_ids, [CANDLES]) @@ -124,7 +124,7 @@ async def candles_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Candles Channel - `_ + `_ """ await self.subscribe_async(product_ids, [CANDLES]) @@ -143,7 +143,7 @@ def candles_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Candles Channel - `_ + `_ """ self.unsubscribe(product_ids, [CANDLES]) @@ -162,7 +162,7 @@ async def candles_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Candles Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [CANDLES]) @@ -181,7 +181,7 @@ def market_trades(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Market Trades Channel - `_ + `_ """ self.subscribe(product_ids, [MARKET_TRADES]) @@ -200,7 +200,7 @@ async def market_trades_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Market Trades Channel - `_ + `_ """ await self.subscribe_async(product_ids, [MARKET_TRADES]) @@ -219,7 +219,7 @@ def market_trades_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Market Trades Channel - `_ + `_ """ self.unsubscribe(product_ids, [MARKET_TRADES]) @@ -238,7 +238,7 @@ async def market_trades_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Market Trades Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [MARKET_TRADES]) @@ -257,7 +257,7 @@ def status(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Status Channel - `_ + `_ """ self.subscribe(product_ids, [STATUS]) @@ -276,7 +276,7 @@ async def status_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Status Channel - `_ + `_ """ await self.subscribe_async(product_ids, [STATUS]) @@ -295,7 +295,7 @@ def status_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Status Channel - `_ + `_ """ self.unsubscribe(product_ids, [STATUS]) @@ -314,7 +314,7 @@ async def status_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Status Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [STATUS]) @@ -333,7 +333,7 @@ def ticker(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Channel - `_ + `_ """ self.subscribe(product_ids, [TICKER]) @@ -352,7 +352,7 @@ async def ticker_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Channel - `_ + `_ """ await self.subscribe_async(product_ids, [TICKER]) @@ -371,7 +371,7 @@ def ticker_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Channel - `_ + `_ """ self.unsubscribe(product_ids, [TICKER]) @@ -390,7 +390,7 @@ async def ticker_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [TICKER]) @@ -409,7 +409,7 @@ def ticker_batch(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Batch Channel - `_ + `_ """ self.subscribe(product_ids, [TICKER_BATCH]) @@ -428,7 +428,7 @@ async def ticker_batch_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Batch Channel - `_ + `_ """ await self.subscribe_async(product_ids, [TICKER_BATCH]) @@ -447,7 +447,7 @@ def ticker_batch_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Batch Channel - `_ + `_ """ self.unsubscribe(product_ids, [TICKER_BATCH]) @@ -466,7 +466,7 @@ async def ticker_batch_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Ticker Batch Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [TICKER_BATCH]) @@ -485,7 +485,7 @@ def level2(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Level2 Channel - `_ + `_ """ self.subscribe(product_ids, [LEVEL2]) @@ -504,7 +504,7 @@ async def level2_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Level2 Channel - `_ + `_ """ await self.subscribe_async(product_ids, [LEVEL2]) @@ -523,7 +523,7 @@ def level2_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Level2 Channel - `_ + `_ """ self.unsubscribe(product_ids, [LEVEL2]) @@ -542,7 +542,7 @@ async def level2_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `Level2 Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [LEVEL2]) @@ -561,7 +561,7 @@ def user(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `User Channel - `_ + `_ """ self.subscribe(product_ids, [USER]) @@ -580,7 +580,7 @@ async def user_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `User Channel - `_ + `_ """ await self.subscribe_async(product_ids, [USER]) @@ -599,7 +599,7 @@ def user_unsubscribe(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `User Channel - `_ + `_ """ self.unsubscribe(product_ids, [USER]) @@ -618,7 +618,7 @@ async def user_unsubscribe_async(self, product_ids: List[str]) -> None: __________ **Read more on the official documentation:** `User Channel - `_ + `_ """ await self.unsubscribe_async(product_ids, [USER]) @@ -637,7 +637,7 @@ def futures_balance_summary(self) -> None: __________ **Read more on the official documentation:** `Futures Balance Summary Channel - `_ + `_ """ self.subscribe([], [FUTURES_BALANCE_SUMMARY]) @@ -656,7 +656,7 @@ async def futures_balance_summary_async(self) -> None: __________ **Read more on the official documentation:** `Futures Balance Summary Channel - `_ + `_ """ await self.subscribe_async([], [FUTURES_BALANCE_SUMMARY]) @@ -675,7 +675,7 @@ def futures_balance_summary_unsubscribe(self) -> None: __________ **Read more on the official documentation:** `Futures Balance Summary Channel - `_ + `_ """ self.unsubscribe([], [FUTURES_BALANCE_SUMMARY]) @@ -694,6 +694,6 @@ async def futures_balance_summary_unsubscribe_async(self) -> None: __________ **Read more on the official documentation:** `Futures Balance Summary Channel - `_ + `_ """ await self.unsubscribe_async([], [FUTURES_BALANCE_SUMMARY]) diff --git a/docs/coinbase.deribit.rst b/docs/coinbase.deribit.rst new file mode 100644 index 0000000..5114e95 --- /dev/null +++ b/docs/coinbase.deribit.rst @@ -0,0 +1,32 @@ +Global Derivatives Clients +========================== + +Clients for Global Derivatives on the Deribit-powered gateway. Both use JSON-RPC 2.0 and the same CDP API keys as the rest of the SDK. + +DeribitRetailClient (REST) +--------------------------- + +.. autoclass:: coinbase.deribit.DeribitRetailClient + :members: + :inherited-members: + +DeribitRetailWSClient (WebSocket) +--------------------------------- + +Without keys, the client connects to the market data host. With keys, it connects to the trading host for ``user.*`` channels and RPC. + +.. autoclass:: coinbase.deribit.DeribitRetailWSClient + :members: + :inherited-members: + +Errors +--------------------------- + +.. autoexception:: coinbase.deribit.DeribitRPCError +.. autoexception:: coinbase.deribit.DeribitRateLimitError +.. autoexception:: coinbase.deribit.DeribitInsufficientFundsError +.. autoexception:: coinbase.deribit.DeribitInvalidParamsError +.. autoexception:: coinbase.deribit.DeribitMatchingQueueFullError +.. autoexception:: coinbase.deribit.DeribitAuthError +.. autoexception:: coinbase.deribit.DeribitConnectionError +.. autoexception:: coinbase.deribit.DeribitSubscriptionError diff --git a/docs/coinbase.rest.rst b/docs/coinbase.rest.rst index 4510a81..087d543 100644 --- a/docs/coinbase.rest.rst +++ b/docs/coinbase.rest.rst @@ -128,6 +128,8 @@ Futures Perpetuals --------------------------- +From October 1, 2026, Global Derivatives run on the Deribit-powered gateway instead of INTX. These methods still send the request; when the server rejects it, the SDK logs a migration hint and re-raises the ``HTTPError``. Use ``coinbase.deribit.DeribitRetailClient`` or ``DeribitRetailWSClient`` for Global Derivatives. + .. autofunction:: coinbase.rest.RESTClient.allocate_portfolio .. autofunction:: coinbase.rest.RESTClient.get_perps_portfolio_summary .. autofunction:: coinbase.rest.RESTClient.list_perps_positions diff --git a/docs/index.rst b/docs/index.rst index a2cdf80..04b208f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,14 +19,14 @@ Getting Started :alt: Apache License 2.0 Welcome to the official Coinbase Advanced API Python SDK. This python project was created to allow coders to easily plug -into the `Coinbase Advanced API `_ +into the `Coinbase Advanced API `_ -- Docs: https://docs.cdp.coinbase.com/advanced-trade/docs/welcome/ +- Docs: https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/overview - Python SDK: https://github.com/coinbase/coinbase-advanced-py For detailed exercises on how to get started using the SDK look at our SDK Overview: -https://docs.cdp.coinbase.com/advanced-trade/docs/sdk-overview +https://docs.cdp.coinbase.com/coinbase-app/advanced-trade-apis/sdk ------------- @@ -37,4 +37,5 @@ https://docs.cdp.coinbase.com/advanced-trade/docs/sdk-overview coinbase.rest coinbase.websocket coinbase.websocket.user + coinbase.deribit jwt_generator \ No newline at end of file diff --git a/lint_requirements.txt b/lint_requirements.txt index 5d21eeb..7b92a9a 100644 --- a/lint_requirements.txt +++ b/lint_requirements.txt @@ -1,2 +1,2 @@ -black==24.3.0 -isort==5.12.0 \ No newline at end of file +black==26.5.1 +isort==5.12.0 diff --git a/pinned_requirements.txt b/pinned_requirements.txt index 2c0cd88..d10e816 100644 --- a/pinned_requirements.txt +++ b/pinned_requirements.txt @@ -1,5 +1,5 @@ -requests==2.31.0 -cryptography==42.0.4 -PyJWT==2.8.0 +requests==2.34.2 +cryptography==49.0.0 +PyJWT==2.13.0 websockets==12.0 -backoff==2.2.1 \ No newline at end of file +backoff==2.2.1 diff --git a/requirements.txt b/requirements.txt index 0f6585d..f4738e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -requests>=2.31.0 -cryptography>=42.0.4 -PyJWT>=2.8.0 +requests>=2.34.2 +cryptography>=49.0.0 +PyJWT>=2.13.0 websockets>=12.0,<14.0 backoff>=2.2.1 diff --git a/setup.py b/setup.py index 6e9c828..a2c1d78 100644 --- a/setup.py +++ b/setup.py @@ -40,10 +40,9 @@ "Intended Audience :: Developers", "Intended Audience :: Financial and Insurance Industry", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], - python_requires=">=3.8", + python_requires=">=3.10", ) diff --git a/tests/deribit/__init__.py b/tests/deribit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/deribit/test_errors.py b/tests/deribit/test_errors.py new file mode 100644 index 0000000..f003077 --- /dev/null +++ b/tests/deribit/test_errors.py @@ -0,0 +1,57 @@ +import unittest + +from coinbase.deribit.errors import ( + DeribitInsufficientFundsError, + DeribitInvalidParamsError, + DeribitMatchingQueueFullError, + DeribitRateLimitError, + DeribitRPCError, + rpc_error_from, +) + + +class DeribitErrorsTest(unittest.TestCase): + def test_rate_limit_mapping_with_retry_after(self): + e = rpc_error_from( + { + "code": 10028, + "message": "too_many_requests", + "data": {"retry_after": 1.5}, + } + ) + self.assertIsInstance(e, DeribitRateLimitError) + self.assertEqual(e.retry_after, 1.5) + + def test_matching_queue_full(self): + self.assertIsInstance( + rpc_error_from({"code": 10047, "message": "full"}), + DeribitMatchingQueueFullError, + ) + + def test_insufficient_funds(self): + self.assertIsInstance( + rpc_error_from({"code": 10009, "message": "nsf"}), + DeribitInsufficientFundsError, + ) + + def test_invalid_params(self): + self.assertIsInstance( + rpc_error_from({"code": 11030, "message": "bad"}), + DeribitInvalidParamsError, + ) + + def test_unknown_code_falls_back_to_base(self): + e = rpc_error_from({"code": 99999, "message": "unknown"}) + self.assertIs(type(e), DeribitRPCError) + self.assertEqual(e.code, 99999) + self.assertEqual(e.message, "unknown") + + def test_jsonrpc_invalid_params_maps_to_invalid_params_error(self): + from coinbase.deribit.errors import DeribitInvalidParamsError, rpc_error_from + + err = rpc_error_from({"code": -32602, "message": "Invalid params"}) + self.assertIsInstance(err, DeribitInvalidParamsError) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/deribit/test_generated.py b/tests/deribit/test_generated.py new file mode 100644 index 0000000..0abf086 --- /dev/null +++ b/tests/deribit/test_generated.py @@ -0,0 +1,124 @@ +import inspect +import unittest + +from coinbase.deribit._generated import channels, models +from coinbase.deribit._generated.rest_methods import GeneratedRESTMixin + + +class GeneratedLayerTest(unittest.TestCase): + def test_channel_builder_fills_segments(self): + self.assertEqual( + channels.ticker_instrument_name_interval("BTC-PERPETUAL", "100ms"), + "ticker.BTC-PERPETUAL.100ms", + ) + self.assertEqual( + channels.book_instrument_name_group_depth_interval( + "BTC-PERPETUAL", "none", "10", "100ms" + ), + "book.BTC-PERPETUAL.none.10.100ms", + ) + + def test_model_nests_objects_and_lists(self): + buy = models.PrivateBuyAndSellResponse( + { + "id": 1, + "jsonrpc": "2.0", + "result": { + "order": {"order_id": "E1", "direction": "buy"}, + "trades": [{"trade_id": "t1"}, {"trade_id": "t2"}], + }, + } + ) + self.assertEqual(type(buy.result).__name__, "PrivateBuyAndSellResponseResult") + self.assertEqual(buy.result.order.order_id, "E1") + self.assertEqual([t.trade_id for t in buy.result.trades], ["t1", "t2"]) + + def test_to_dict_round_trips(self): + buy = models.PrivateBuyAndSellResponse( + {"id": 1, "jsonrpc": "2.0", "result": {"order": {"order_id": "E1"}}} + ) + self.assertEqual(buy.to_dict()["result"]["order"]["order_id"], "E1") + + def test_optional_rpc_params_are_keyword_only(self): + params = inspect.signature(GeneratedRESTMixin.private_buy).parameters + self.assertEqual( + params["instrument_name"].kind, inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + for name in ("amount", "type", "price", "label"): + self.assertEqual(params[name].kind, inspect.Parameter.KEYWORD_ONLY, name) + + def test_rest_client_exposes_only_rest_served_methods(self): + from coinbase.deribit import DeribitRetailClient, DeribitRetailWSClient + + ws_only = [ + "public_hello", + "public_set_heartbeat", + "public_disable_heartbeat", + "private_enable_cancel_on_disconnect", + "private_get_cancel_on_disconnect", + "private_disable_cancel_on_disconnect", + "private_logout", + "public_get_tradingview_chart_data", + "private_get_access_log", + ] + raw_subscription = [ + f"{scope}_{op}" + for scope in ("public", "private") + for op in ("subscribe", "unsubscribe", "unsubscribe_all") + ] + for name in ws_only: + self.assertFalse(hasattr(DeribitRetailClient, name), name) + self.assertTrue(hasattr(DeribitRetailWSClient, name), name) + for name in raw_subscription: + self.assertFalse(hasattr(DeribitRetailClient, name), name) + self.assertFalse(hasattr(DeribitRetailWSClient, name), name) + for name in ("subscribe", "unsubscribe", "unsubscribe_all"): + self.assertTrue(hasattr(DeribitRetailWSClient, name), name) + + def test_block_trade_and_rfq_methods_are_not_generated(self): + from coinbase.deribit import DeribitRetailWSClient + + leftovers = [ + n for n in dir(DeribitRetailWSClient) if "block" in n or "rfq" in n + ] + self.assertEqual(leftovers, []) + + def test_generated_code_only_embeds_expected_identifiers(self): + # Guards against spec values breaking out of string literals in the + # committed generated code. + import re + from pathlib import Path + + import coinbase.deribit._generated as gen + + source = (Path(gen.__file__).parent / "rest_methods.py").read_text() + methods = re.findall(r'self\._rpc\(\s*"([^"]*)"', source) + self.assertTrue(methods) + for method in methods: + self.assertRegex(method, r"^(public|private)(/[a-z0-9_]+)+$") + for key in re.findall(r"params = \{([^}]*)\}", source): + for name in re.findall(r'"([^"]*)":', key): + self.assertRegex(name, r"^[A-Za-z_][A-Za-z0-9_]*$") + for name, fn in inspect.getmembers(channels, inspect.isfunction): + args = ["x"] * len(inspect.signature(fn).parameters) + self.assertRegex(fn(*args), r"^[A-Za-z0-9_.\-]+$", name) + + def test_model_keeps_data_when_gateway_shape_differs_from_spec(self): + # Spec says result is a list; the gateway returns an object keyed by currency. + raw = {"result": {"btc": {"future": ["PERPETUAL"]}}} + resp = models.PublicGetExpirationsResponse(raw) + self.assertEqual(resp.to_dict(), raw) + self.assertEqual(resp.result.btc, {"future": ["PERPETUAL"]}) + + def test_model_accepts_list_where_spec_says_object(self): + cls = type( + "Tmp", + (models.DeribitModel,), + {"_NESTED": {"item": (models.DeribitModel, False)}}, + ) + raw = {"item": [{"a": 1}, {"a": 2}]} + self.assertEqual(cls(raw).to_dict(), raw) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/deribit/test_rest_client.py b/tests/deribit/test_rest_client.py new file mode 100644 index 0000000..1fac7c0 --- /dev/null +++ b/tests/deribit/test_rest_client.py @@ -0,0 +1,178 @@ +import unittest + +from requests_mock import ANY, Mocker + +from coinbase.deribit.errors import DeribitRateLimitError, DeribitRPCError +from coinbase.deribit.rest_client import DeribitRetailClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + +BASE = "https://drb.coinbase.com/api/v2" + + +def _test_client(): + return DeribitRetailClient(TEST_API_KEY, TEST_API_SECRET) + + +class DeribitRESTClientTest(unittest.TestCase): + def setUp(self): + self.client = _test_client() + + def test_public_method_skips_auth(self): + with Mocker() as m: + m.post( + f"{BASE}/public/get_time", + json={"id": 1, "jsonrpc": "2.0", "result": 1700000000000}, + ) + result = self.client.public_get_time() + self.assertEqual(result["result"], 1700000000000) + # Only the one call; no public/auth exchange for a public method. + self.assertEqual(len(m.request_history), 1) + + def test_private_method_authenticates_and_wraps(self): + with Mocker() as m: + m.post( + f"{BASE}/public/auth", + json={"result": {"access_token": "TOK", "expires_in": 900}}, + ) + m.post( + f"{BASE}/private/buy", + json={ + "id": 2, + "jsonrpc": "2.0", + "result": {"order": {"order_id": "E1"}}, + }, + ) + buy = self.client.private_buy( + instrument_name="ETH-PERPETUAL", amount=1, type="market" + ) + self.assertEqual(buy.result.order.order_id, "E1") + buy_req = [r for r in m.request_history if r.path.endswith("/private/buy")][ + 0 + ] + self.assertEqual(buy_req.headers["Authorization"], "Bearer TOK") + + def test_rpc_error_maps_to_typed_exception(self): + with Mocker() as m: + m.post( + f"{BASE}/public/auth", + json={"result": {"access_token": "TOK", "expires_in": 900}}, + ) + m.post( + f"{BASE}/private/cancel", + json={ + "error": { + "code": 10028, + "message": "too_many_requests", + "data": {"retry_after": 2}, + } + }, + ) + with self.assertRaises(DeribitRateLimitError): + self.client.private_cancel(order_id="x") + + def test_production_is_the_default(self): + client = DeribitRetailClient(TEST_API_KEY, TEST_API_SECRET) + self.assertEqual(client._endpoint, "https://drb.coinbase.com/api/v2") + + def test_test_environment_is_not_available(self): + # The pre-launch test gateway was retired; only production exists. + with self.assertRaises(ValueError): + DeribitRetailClient(TEST_API_KEY, TEST_API_SECRET, environment="test") + + def test_explicit_base_url_overrides_environment(self): + client = DeribitRetailClient( + TEST_API_KEY, TEST_API_SECRET, base_url="custom.example.com" + ) + self.assertEqual(client._endpoint, "https://custom.example.com/api/v2") + + def test_unknown_environment_raises(self): + with self.assertRaises(ValueError): + DeribitRetailClient(TEST_API_KEY, TEST_API_SECRET, environment="bogus") + + def test_call_escape_hatch(self): + with Mocker() as m: + m.post( + f"{BASE}/public/get_status", + json={"id": 3, "jsonrpc": "2.0", "result": {"locked": False}}, + ) + result = self.client.call("public/get_status") + self.assertEqual(result["result"]["locked"], False) + + def test_default_timeout_is_finite(self): + self.assertEqual(self.client.timeout, 30) + self.assertEqual(self.client._auth._timeout, 30) + + def test_rejected_token_is_refreshed_and_retried_once(self): + with Mocker() as m: + m.post( + f"{BASE}/public/auth", + [ + {"json": {"result": {"access_token": "OLD", "expires_in": 900}}}, + {"json": {"result": {"access_token": "NEW", "expires_in": 900}}}, + ], + ) + m.post( + f"{BASE}/private/get_positions", + [ + {"json": {"error": {"code": 13009, "message": "unauthorized"}}}, + {"json": {"jsonrpc": "2.0", "result": []}}, + ], + ) + self.client.call("private/get_positions", {"currency": "USDC"}) + calls = [ + r.headers["Authorization"] + for r in m.request_history + if r.path.endswith("/private/get_positions") + ] + self.assertEqual(calls, ["Bearer OLD", "Bearer NEW"]) + + def test_non_token_error_is_not_retried(self): + with Mocker() as m: + m.post( + f"{BASE}/public/auth", + json={"result": {"access_token": "TOK", "expires_in": 900}}, + ) + m.post( + f"{BASE}/private/buy", + json={"error": {"code": 10009, "message": "not_enough_funds"}}, + ) + with self.assertRaises(Exception): + self.client.call("private/buy", {"instrument_name": "X"}) + buys = [r for r in m.request_history if r.path.endswith("/private/buy")] + self.assertEqual(len(buys), 1) + + def test_non_json_auth_response_raises_auth_error(self): + from coinbase.deribit.errors import DeribitAuthError + + with Mocker() as m: + m.post(f"{BASE}/public/auth", text="bad gateway") + with self.assertRaises(DeribitAuthError): + self.client.call("private/get_positions", {}) + + def test_auth_rejection_reports_gateway_error(self): + from coinbase.deribit.errors import DeribitAuthError + + with Mocker() as m: + m.post( + f"{BASE}/public/auth", + status_code=401, + json={"error": {"code": 13004, "message": "invalid_credentials"}}, + ) + with self.assertRaises(DeribitAuthError) as ctx: + self.client.call("private/get_positions", {}) + self.assertIn("13004", str(ctx.exception)) + self.assertIn("HTTP 401", str(ctx.exception)) + + def test_private_method_without_keys_raises_locally(self): + anon = DeribitRetailClient(api_key=None, api_secret=None) + with Mocker() as m: + m.post(ANY, json={"result": {}}) + with self.assertRaises(DeribitRPCError) as ctx: + anon.private_get_positions(currency="USDC") + self.assertEqual(m.call_count, 0) + self.assertEqual(ctx.exception.code, 13009) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/deribit/test_ws_client.py b/tests/deribit/test_ws_client.py new file mode 100644 index 0000000..4b31828 --- /dev/null +++ b/tests/deribit/test_ws_client.py @@ -0,0 +1,438 @@ +import json +import queue +import threading +import time +import unittest +from unittest import mock + +import coinbase.deribit.ws_client as wsmod +from coinbase.deribit.errors import DeribitConnectionError + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class FakeWS: + """In-process stand-in for a websockets sync connection.""" + + def __init__(self, auth_error=None, closes_on_logout=True): + self.q = queue.Queue() + self.sent = [] + self._closed = threading.Event() + self.auth_error = auth_error + self.closes_on_logout = closes_on_logout + + def send(self, data): + obj = json.loads(data) + self.sent.append(obj) + rid, method = obj.get("id"), obj["method"] + if method == "public/auth" and self.auth_error: + self.q.put( + json.dumps({"jsonrpc": "2.0", "id": rid, "error": self.auth_error}) + ) + elif method == "public/auth": + self._reply(rid, {"access_token": "TOK", "expires_in": 900}) + elif method == "private/subscribe": + # Like production: the trading host only subscribes user.* channels. + chans = obj["params"]["channels"] + self._reply(rid, [c for c in chans if c.startswith("user.")]) + elif method == "public/subscribe": + # Like production: the streams host only subscribes market data. + chans = obj["params"]["channels"] + self._reply(rid, [c for c in chans if not c.startswith("user.")]) + elif method == "private/buy": + self._reply(rid, {"order": {"order_id": "E1"}}) + elif method in ("public/test", "never_answers"): + pass + elif method == "private/logout": + if self.closes_on_logout: + self._closed.set() # gateway closes the socket with no reply + else: + self._reply(rid, {}) + + def _reply(self, rid, result): + self.q.put(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result})) + + def push(self, obj): + self.q.put(json.dumps(obj)) + + def recv(self, timeout=None): + try: + return self.q.get(timeout=timeout) + except queue.Empty: + raise TimeoutError() + + def __iter__(self): + return self + + def __next__(self): + while True: + try: + return self.q.get(timeout=0.05) + except queue.Empty: + if self._closed.is_set(): + raise OSError("closed") + + def close(self): + self._closed.set() + + @property + def closed(self): + return self._closed.is_set() + + +def wait_for(predicate, timeout=3.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.02) + return predicate() + + +class DeribitWSClientTest(unittest.TestCase): + def _client(self, fake): + self._patch = mock.patch.object(wsmod, "ws_connect", return_value=fake) + self._patch.start() + self.addCleanup(self._patch.stop) + c = wsmod.DeribitRetailWSClient(TEST_API_KEY, TEST_API_SECRET) + c.open() + self.addCleanup(c.close) + return c + + def test_authenticated_production_client_uses_trading_host(self): + client = wsmod.DeribitRetailWSClient(TEST_API_KEY, TEST_API_SECRET) + self.assertEqual(client.url, "wss://drb.coinbase.com/ws/api/v2") + + def test_unauthenticated_production_client_uses_streams_host(self): + client = wsmod.DeribitRetailWSClient(api_key=None, api_secret=None) + self.assertEqual(client.url, "wss://streams.drb.coinbase.com/ws/api/v2") + + def test_authenticates_on_open(self): + fake = FakeWS() + self._client(fake) + self.assertEqual(fake.sent[0]["method"], "public/auth") + self.assertEqual(fake.sent[0]["params"]["grant_type"], "coinbase_cdp") + + def test_typed_call_over_socket(self): + fake = FakeWS() + c = self._client(fake) + buy = c.private_buy(instrument_name="ETH-PERPETUAL", amount=1, type="market") + self.assertEqual(buy.result.order.order_id, "E1") + + def test_subscription_dispatch(self): + fake = FakeWS() + c = self._client(fake) + received = [] + c.subscribe( + ["user.orders.BTC-PERPETUAL.raw"], + lambda data, ch: received.append((ch, data)), + ) + fake.push( + { + "jsonrpc": "2.0", + "method": "subscription", + "params": { + "channel": "user.orders.BTC-PERPETUAL.raw", + "data": {"mark_price": 42000}, + }, + } + ) + deadline = time.monotonic() + 2 + while not received and time.monotonic() < deadline: + time.sleep(0.02) + self.assertEqual(received[0][0], "user.orders.BTC-PERPETUAL.raw") + self.assertEqual(received[0][1]["mark_price"], 42000) + + def test_heartbeat_test_request_is_answered(self): + fake = FakeWS() + c = self._client(fake) + fake.push( + { + "jsonrpc": "2.0", + "method": "heartbeat", + "params": {"type": "test_request"}, + } + ) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + if any(s["method"] == "public/test" for s in fake.sent): + break + time.sleep(0.02) + self.assertTrue(any(s["method"] == "public/test" for s in fake.sent)) + + def test_inflight_call_fails_loud_on_disconnect(self): + fake = FakeWS() + c = self._client(fake) + c._reconnect = lambda: False # don't loop reconnecting in the test + result = {} + + def do_call(): + try: + c.call("never_answers", {}, timeout=5) + except Exception as e: + result["err"] = type(e).__name__ + + t = threading.Thread(target=do_call) + t.start() + time.sleep(0.2) + fake.close() # simulate disconnect while the call is in flight + t.join(timeout=3) + self.assertEqual(result.get("err"), "DeribitConnectionError") + + def test_logout_returns_ok_and_does_not_reconnect(self): + fake = FakeWS() + c = self._client(fake) + reconnected = [] + c._reconnect = lambda: reconnected.append(True) or True + + result = c.private_logout() + + self.assertEqual(result, {"result": "ok"}) + self.assertTrue(any(s["method"] == "private/logout" for s in fake.sent)) + # The 1000 close is the expected end of the session, not a drop. + self.assertFalse(reconnected) + self.assertFalse(c._running) + + def _client_seq(self, fakes): + """Client whose successive ws_connect calls return the given fakes.""" + self._patch = mock.patch.object(wsmod, "ws_connect", side_effect=list(fakes)) + self._patch.start() + self.addCleanup(self._patch.stop) + for name, value in ( + ("DERIBIT_WS_RETRY_BASE", 0.01), + ("DERIBIT_WS_RETRY_CAP", 0.01), + ): + p = mock.patch.object(wsmod, name, value) + p.start() + self.addCleanup(p.stop) + c = wsmod.DeribitRetailWSClient(TEST_API_KEY, TEST_API_SECRET) + self.addCleanup(c.close) + return c + + def test_reconnect_restores_subscriptions(self): + first, second = FakeWS(), FakeWS() + c = self._client_seq([first, second]).open() + got = [] + c.subscribe(["user.orders.BTC-PERPETUAL.raw"], lambda d, ch: got.append(d)) + + first.close() # unexpected drop + + self.assertTrue( + wait_for( + lambda: any(s["method"] == "private/subscribe" for s in second.sent) + ), + "subscription was not restored on the new socket", + ) + self.assertEqual( + [s["method"] for s in second.sent], ["public/auth", "private/subscribe"] + ) + second.push( + { + "jsonrpc": "2.0", + "method": "subscription", + "params": {"channel": "user.orders.BTC-PERPETUAL.raw", "data": 42}, + } + ) + self.assertTrue(wait_for(lambda: got == [42])) + self.assertTrue(c._running) + # RPCs work on the new socket afterwards. + self.assertIn("order", c.call("private/buy", {})["result"]) + + def test_call_registered_before_disconnect_is_never_sent_later(self): + old, new = FakeWS(), FakeWS() + c = self._client_seq([old]).open() + self.addCleanup(old.close) # the test swaps it out; let the reader exit + real_send = c._send + + def disconnect_then_send(obj, gen=None): + # A disconnect + reconnect lands between registering and sending. + c._fail_pending(wsmod.DeribitConnectionError("websocket disconnected")) + with c._send_lock: + c._ws = new + return real_send(obj, gen=gen) + + c._send = disconnect_then_send + with self.assertRaises(wsmod.DeribitConnectionError) as ctx: + c.call("private/buy", {"instrument_name": "BTC-PERPETUAL"}) + self.assertIn("not sent", str(ctx.exception)) + self.assertEqual(new.sent, []) + self.assertFalse(any(s["method"] == "private/buy" for s in old.sent)) + + def test_bad_frames_do_not_kill_the_reader(self): + fake = FakeWS() + c = self._client(fake) + fake.q.put("not json") + fake.q.put(json.dumps(["batch", 1])) + fake.q.put(json.dumps({"id": "no-such-call", "result": 1})) + self.assertIn("order", c.call("private/buy", {}, timeout=2)["result"]) + self.assertTrue(c._reader.is_alive()) + + def test_failed_open_cleans_up_and_can_retry(self): + bad = FakeWS(auth_error={"code": 13004, "message": "invalid_credentials"}) + good = FakeWS() + c = self._client_seq([bad, good]) + with self.assertRaises(wsmod.DeribitAuthError): + c.open() + self.assertTrue(bad.closed) + self.assertFalse(c._running) + self.assertIsNone(c._ws) + c.open() + self.assertTrue(c._running) + self.assertIn("order", c.call("private/buy", {})["result"]) + + def test_auth_error_redacts_echoed_jwt(self): + jwt = "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJvcmdhbml6YXRpb25zIn0.c2lnbmF0dXJlLXNpZw" + bad = FakeWS(auth_error={"code": 13004, "message": f"bad token {jwt}"}) + c = self._client_seq([bad]) + with self.assertRaises(wsmod.DeribitAuthError) as ctx: + c.open() + self.assertNotIn(jwt, str(ctx.exception)) + self.assertIn("", str(ctx.exception)) + + def test_call_from_subscription_callback_raises_immediately(self): + fake = FakeWS() + c = self._client(fake) + errors = [] + + def cb(data, channel): + started = time.monotonic() + try: + c.call("private/buy", {}) + except RuntimeError as e: + errors.append((str(e), time.monotonic() - started)) + + c.subscribe(["user.orders.BTC-PERPETUAL.raw"], cb) + fake.push( + { + "jsonrpc": "2.0", + "method": "subscription", + "params": {"channel": "user.orders.BTC-PERPETUAL.raw", "data": 1}, + } + ) + self.assertTrue(wait_for(lambda: errors)) + self.assertIn("reader thread", errors[0][0]) + self.assertLess(errors[0][1], 1) + + def test_call_timeout_raises_builtin_timeout_error(self): + c = self._client(FakeWS()) + with self.assertRaises(TimeoutError): + c.call("never_answers", {}, timeout=0.1) + self.assertEqual(c._pending, {}) + + def test_logout_closes_socket_when_gateway_does_not(self): + fake = FakeWS(closes_on_logout=False) + c = self._client(fake) + c._logout_close_wait = 0.1 + self.assertEqual(c.private_logout(), {"result": "ok"}) + self.assertTrue(fake.closed) + self.assertIsNone(c._ws) + self.assertTrue(wait_for(lambda: not c._reader.is_alive())) + + def test_logout_raises_when_not_sent(self): + c = self._client(FakeWS()) + c.close() + with self.assertRaises(wsmod.DeribitConnectionError): + c.private_logout() + + def test_stale_reauth_failure_does_not_close_new_socket(self): + old, new = FakeWS(), FakeWS() + c = self._client_seq([old]).open() + self.addCleanup(old.close) # the test swaps it out; let the reader exit + + def failing_call(method, params=None, timeout=None): + with c._send_lock: + c._ws = new # a reconnect happened while re-auth was in flight + raise wsmod.DeribitConnectionError("websocket disconnected") + + c.call = failing_call + c._reauth() + self.assertFalse(new.closed) + + def test_failed_reauth_drops_current_socket(self): + fake = FakeWS() + c = self._client(fake) + c._reconnect = lambda: False + c.call = mock.Mock(side_effect=wsmod.DeribitConnectionError("boom")) + c._reauth() + self.assertTrue(fake.closed) + + def test_authenticated_client_uses_private_subscribe(self): + fake = FakeWS() + c = self._client(fake) + out = c.subscribe(["user.changes.BTC-PERPETUAL.raw"]) + self.assertEqual(list(out), ["private"]) + self.assertEqual(fake.sent[-1]["method"], "private/subscribe") + c.unsubscribe(["user.changes.BTC-PERPETUAL.raw"]) + self.assertEqual(fake.sent[-1]["method"], "private/unsubscribe") + + def test_dropped_channels_raise_and_are_forgotten(self): + fake = FakeWS() + c = self._client(fake) + with self.assertRaises(wsmod.DeribitSubscriptionError) as ctx: + c.subscribe( + ["user.orders.any.any.raw", "ticker.BTC-PERPETUAL.100ms"], + lambda d, ch: None, + ) + err = ctx.exception + self.assertEqual(err.rejected, ["ticker.BTC-PERPETUAL.100ms"]) + self.assertEqual(err.accepted, ["user.orders.any.any.raw"]) + self.assertIn("unauthenticated", str(err)) + self.assertEqual(list(c._subscriptions), ["user.orders.any.any.raw"]) + + def test_unauthenticated_client_uses_public_subscribe(self): + fake = FakeWS() + self._patch = mock.patch.object(wsmod, "ws_connect", return_value=fake) + self._patch.start() + self.addCleanup(self._patch.stop) + c = wsmod.DeribitRetailWSClient(api_key=None, api_secret=None).open() + self.addCleanup(c.close) + out = c.subscribe(["ticker.BTC-PERPETUAL.100ms"]) + self.assertEqual(list(out), ["public"]) + self.assertEqual(fake.sent[-1]["method"], "public/subscribe") + with self.assertRaises(wsmod.DeribitSubscriptionError) as ctx: + c.subscribe(["user.orders.any.any.raw"]) + self.assertIn("API keys", str(ctx.exception)) + c.unsubscribe(["ticker.BTC-PERPETUAL.100ms"]) + self.assertEqual(fake.sent[-1]["method"], "public/unsubscribe") + + def test_reconnect_drops_and_warns_on_unrestored_channel(self): + first, second = FakeWS(), FakeWS() + c = self._client_seq([first, second]).open() + c.subscribe(["user.orders.any.any.raw"]) + with c._sub_lock: # simulate a channel the gateway now refuses + c._subscriptions["ticker.BTC-PERPETUAL.100ms"] = [] + with self.assertLogs("coinbase.deribit.WSClient", level="WARNING") as logs: + first.close() + self.assertTrue( + wait_for( + lambda: any(s["method"] == "private/subscribe" for s in second.sent) + ) + ) + wait_for(lambda: "ticker.BTC-PERPETUAL.100ms" not in c._subscriptions) + self.assertTrue(any("did not restore" in line for line in logs.output)) + self.assertEqual(list(c._subscriptions), ["user.orders.any.any.raw"]) + self.assertTrue(c._running) + + def test_unsubscribe_all_clears_registry_and_uses_client_scope(self): + fake = FakeWS() + c = self._client(fake) + c.subscribe(["user.orders.any.any.raw"], lambda d, ch: None) + out = c.unsubscribe_all() + self.assertEqual(list(out), ["private"]) + self.assertEqual(fake.sent[-1]["method"], "private/unsubscribe_all") + self.assertEqual(c._subscriptions, {}) + + def test_failed_unsubscribe_keeps_callbacks(self): + fake = FakeWS() + c = self._client(fake) + c.subscribe(["user.orders.any.any.raw"], lambda d, ch: None) + c.call = mock.Mock(side_effect=wsmod.DeribitConnectionError("down")) + with self.assertRaises(wsmod.DeribitConnectionError): + c.unsubscribe(["user.orders.any.any.raw"]) + with self.assertRaises(wsmod.DeribitConnectionError): + c.unsubscribe_all() + self.assertIn("user.orders.any.any.raw", c._subscriptions) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/rest/test_intx_migration_hint.py b/tests/rest/test_intx_migration_hint.py new file mode 100644 index 0000000..72eac65 --- /dev/null +++ b/tests/rest/test_intx_migration_hint.py @@ -0,0 +1,140 @@ +import unittest +from unittest.mock import patch + +from requests.exceptions import HTTPError +from requests_mock import Mocker + +from coinbase.rest import RESTClient, _intx_cutover + +from ..constants import TEST_API_KEY, TEST_API_SECRET + +BASE = "https://api.coinbase.com/api/v3/brokerage" +GONE = {"error": "NOT_FOUND", "message": "endpoint unavailable"} +LOGGER = "coinbase.RESTClient" + + +class IntxMigrationHintTest(unittest.TestCase): + """INTX calls still go out; on failure the SDK logs a Deribit migration hint.""" + + def setUp(self): + self.client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + def assert_hint_on_failure(self, method, url, call, method_name, replacement): + with Mocker() as m: + m.request(method, url, status_code=404, json=GONE) + with self.assertLogs(LOGGER, level="ERROR") as logs: + with self.assertRaises(HTTPError) as ctx: + call() + self.assertEqual(m.call_count, 1) + self.assertIn("endpoint unavailable", str(ctx.exception)) + hints = [line for line in logs.output if "October 1, 2026" in line] + self.assertEqual(len(hints), 1) + self.assertIn(f"{method_name}() failed", hints[0]) + if replacement: + self.assertIn(replacement, hints[0]) + else: + self.assertIn("no Deribit Retail replacement", hints[0]) + + def test_allocate_portfolio(self): + self.assert_hint_on_failure( + "POST", + f"{BASE}/intx/allocate", + lambda: self.client.allocate_portfolio("u", "BTC-PERP-INTX", "1", "USD"), + "allocate_portfolio", + "private_submit_transfer_between_subaccounts", + ) + + def test_get_perps_portfolio_summary(self): + self.assert_hint_on_failure( + "GET", + f"{BASE}/intx/portfolio/u", + lambda: self.client.get_perps_portfolio_summary("u"), + "get_perps_portfolio_summary", + "private_get_account_summary", + ) + + def test_list_perps_positions(self): + self.assert_hint_on_failure( + "GET", + f"{BASE}/intx/positions/u", + lambda: self.client.list_perps_positions("u"), + "list_perps_positions", + "private_get_positions", + ) + + def test_get_perps_position(self): + self.assert_hint_on_failure( + "GET", + f"{BASE}/intx/positions/u/BTC-PERP-INTX", + lambda: self.client.get_perps_position("u", "BTC-PERP-INTX"), + "get_perps_position", + "private_get_position", + ) + + def test_get_perps_portfolio_balances(self): + self.assert_hint_on_failure( + "GET", + f"{BASE}/intx/balances/u", + lambda: self.client.get_perps_portfolio_balances("u"), + "get_perps_portfolio_balances", + "private_get_account_summaries", + ) + + def test_opt_in_or_out_multi_asset_collateral(self): + self.assert_hint_on_failure( + "POST", + f"{BASE}/intx/multi_asset_collateral", + lambda: self.client.opt_in_or_out_multi_asset_collateral("u", True), + "opt_in_or_out_multi_asset_collateral", + "private_change_margin_model", + ) + + def test_create_order_perp_product(self): + self.assert_hint_on_failure( + "POST", + f"{BASE}/orders", + lambda: self.client.market_order_buy( + "id", "btc-perp-intx", quote_size="10" + ), + "create_order", + "private_buy or private_sell", + ) + + def test_preview_order_perp_product(self): + self.assert_hint_on_failure( + "POST", + f"{BASE}/orders/preview", + lambda: self.client.preview_market_order_buy( + "BTC-PERP-INTX", quote_size="10" + ), + "preview_order", + None, + ) + + def test_close_position_perp_product(self): + self.assert_hint_on_failure( + "POST", + f"{BASE}/orders/close_position", + lambda: self.client.close_position("id", "BTC-PERP-INTX"), + "close_position", + "private_close_position", + ) + + def test_no_hint_for_spot_order_failure(self): + with Mocker() as m: + m.request("POST", f"{BASE}/orders", status_code=400, json=GONE) + with self.assertLogs(LOGGER, level="ERROR") as logs: + with self.assertRaises(HTTPError): + self.client.market_order_buy("id", "BTC-USD", quote_size="10") + self.assertFalse(any("October 1, 2026" in line for line in logs.output)) + + def test_no_hint_on_success(self): + with Mocker() as m: + m.request("GET", f"{BASE}/intx/positions/u", json={"positions": []}) + with patch.object(_intx_cutover.logger, "error") as log_error: + self.client.list_perps_positions("u") + log_error.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/rest/test_orders.py b/tests/rest/test_orders.py index e70afd0..2d8803b 100644 --- a/tests/rest/test_orders.py +++ b/tests/rest/test_orders.py @@ -1186,6 +1186,110 @@ def test_preview_edit_order(self): ) self.assertEqual(order.__dict__, expected_response) + def test_create_order_new_params(self): + # 1.8.5: attached_order_configuration / prediction_metadata / + # equity_order_metadata are accepted by the live order endpoint. + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json={"order_id": "1234"}, + ) + client.create_order( + "client_order_id_1", + "product_id_1", + "BUY", + {"market_market_ioc": {"quote_size": "1"}}, + attached_order_configuration={"a": "b"}, + prediction_metadata={"c": "d"}, + equity_order_metadata={"e": "f"}, + ) + + captured_json = m.request_history[0].json() + self.assertEqual(captured_json["attached_order_configuration"], {"a": "b"}) + self.assertEqual(captured_json["prediction_metadata"], {"c": "d"}) + self.assertEqual(captured_json["equity_order_metadata"], {"e": "f"}) + + def test_edit_order_new_params(self): + # 1.8.5: stop_price / average_entry_price / attached_order_configuration / + # cancel_attached_order are accepted by the live edit endpoint. + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/edit", + json={"order_id": "order_id_1"}, + ) + client.edit_order( + "order_id_1", + size="100", + price="50", + stop_price="45", + average_entry_price="48", + attached_order_configuration={"a": "b"}, + cancel_attached_order=True, + ) + + captured_json = m.request_history[0].json() + self.assertEqual( + captured_json, + { + "order_id": "order_id_1", + "size": "100", + "price": "50", + "stop_price": "45", + "average_entry_price": "48", + "attached_order_configuration": {"a": "b"}, + "cancel_attached_order": True, + }, + ) + + def test_get_fills_new_filters(self): + # 1.8.5: order_side / order_types / product_types / asset_filters. + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/orders/historical/fills", + json={"fills": []}, + ) + client.get_fills( + order_side="BUY", + order_types=["LIMIT"], + product_types=["SPOT"], + asset_filters=["USD"], + ) + + query = m.request_history[0].query + self.assertIn("order_side=buy", query) + self.assertIn("order_types=limit", query) + self.assertIn("product_types=spot", query) + self.assertIn("asset_filters=usd", query) + + def test_get_order_new_params(self): + # 1.8.5: client_order_id / user_native_currency query params. + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/orders/historical/order_id_1", + json={"order": {}}, + ) + client.get_order( + "order_id_1", + client_order_id="client_1", + user_native_currency="EUR", + ) + + query = m.request_history[0].query + self.assertIn("client_order_id=client_1", query) + self.assertIn("user_native_currency=eur", query) + def test_cancel_orders(self): client = RESTClient(TEST_API_KEY, TEST_API_SECRET) diff --git a/tests/rest/test_products.py b/tests/rest/test_products.py index eeb2dd7..e6bed48 100644 --- a/tests/rest/test_products.py +++ b/tests/rest/test_products.py @@ -29,6 +29,24 @@ def test_get_products(self): ) self.assertEqual(products.__dict__, expected_response) + def test_get_products_cursor_pagination(self): + # 1.8.5: cursor / futures_underlying_type / user_country_code. + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/products", + json={"products": []}, + ) + client.get_products( + limit=2, cursor="next_page_token", user_country_code="US" + ) + + query = m.request_history[0].query + self.assertIn("cursor=next_page_token", query) + self.assertIn("user_country_code=us", query) + def test_get_product(self): client = RESTClient(TEST_API_KEY, TEST_API_SECRET) diff --git a/tests/rest/test_public.py b/tests/rest/test_public.py index e2c91ad..3c94a5d 100644 --- a/tests/rest/test_public.py +++ b/tests/rest/test_public.py @@ -105,6 +105,20 @@ def test_get_public_products(self): ) self.assertEqual(products.__dict__, expected_response) + def test_get_public_products_cursor_pagination(self): + # 1.8.5: cursor / futures_underlying_type / user_country_code. + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/market/products", + json={"products": []}, + ) + client.get_public_products(limit=2, cursor="next_page_token") + + self.assertIn("cursor=next_page_token", m.request_history[0].query) + def test_get_public_product(self): client = RESTClient(TEST_API_KEY, TEST_API_SECRET)