diff --git a/spoon_toolkits/data_platforms/deribit/README.md b/spoon_toolkits/data_platforms/deribit/README.md new file mode 100644 index 0000000..a803947 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/README.md @@ -0,0 +1,456 @@ + +## SpoonOS quick start (for Spoon developers) + +This section is for SpoonOS developers who want to call Deribit via the +`spoon-toolkit` integration and MCP tools, instead of using the Python APIs +directly. + +### Environment configuration + +Deribit credentials and network selection are provided via environment +variables (or a `.env` file): + +```bash +# Required +DERIBIT_CLIENT_ID=your_client_id +DERIBIT_CLIENT_SECRET=your_client_secret + +# Network selection +DERIBIT_USE_TESTNET=true # "true" for testnet, "false" for mainnet +``` + +Recommended workflow: + +- Start with **testnet** (`DERIBIT_USE_TESTNET=true`) for development and + dry‑runs. +- Only switch to **mainnet** when you have: + - Verified market data, account, and trading tools on testnet. + - Implemented your own risk controls at the application / agent layer. + +All tools read configuration via `DeribitConfig` in `env.py`. + +--- + +## Integration with Spoon framework / MCP + +Deribit tools are designed to be exposed to Spoon agents through MCP servers +and through the `spoon_toolkits` package. + +At a high level: + +- The **Python tool classes** (for example `GetInstrumentsTool`, + `PlaceBuyOrderTool`) are the building blocks. +- In Spoon, these tools are typically: + - Registered via an MCP server module that imports and exposes all tools, + or + - Imported directly from `spoon_toolkits.data_platforms.deribit` in your + own custom MCP server. + +When integrating with Spoon: + +- Treat each Deribit tool as a **single RPC‑like operation**. +- Use Spoon’s tool configuration (YAML or Python) to: + - Wire in the Deribit MCP server. + - Ensure the necessary environment variables are present in the SpoonOS + runtime. + +> The integration pattern is intentionally similar to the existing +> `desearch` and `chainbase` toolkits: you configure environment variables, +> register the MCP server, and then call tools by name from Spoon agents. + +--- + +## Mapping: Deribit JSON‑RPC → Deribit tools + +The table below maps Deribit’s official JSON‑RPC endpoints (as documented on +`https://docs.deribit.com/`) to the corresponding tools in this toolkit. +This is useful when you read Deribit’s docs and want to know “which tool +should I call from Spoon”. + +| Deribit JSON‑RPC method | Toolkit tool | Notes | +|--------------------------------------------|--------------------------|----------------------------------------------------| +| `public/get_instruments` | `GetInstrumentsTool` | List instruments by `currency`, `kind`, etc. | +| `public/get_ticker` | `GetTickerTool` | Ticker, mark price, last price, best bid/ask. | +| `public/get_order_book` | `GetOrderBookTool` | Level‑2 order book for an instrument. | +| `public/get_last_trades_by_instrument` | `GetLastTradesTool` | Recent trades for an instrument. | +| `public/get_index_price` | `GetIndexPriceTool` | Index price for an index. | +| `private/get_account_summary` | `GetAccountSummaryTool` | Account balances, equity, margin, etc. | +| `private/get_positions` | `GetPositionsTool` | Open positions by `currency` / `kind`. | +| `private/get_open_orders_by_instrument` | `GetOpenOrdersTool` | Open orders for a specific instrument. | +| `private/get_order_history_by_instrument` | `GetOrderHistoryTool` | Order history for an instrument. | +| `private/get_trades_by_instrument` | `GetTradeHistoryTool` | Trade history for an instrument. | +| `private/buy` | `PlaceBuyOrderTool` | Place buy orders (spot / futures / options). | +| `private/sell` | `PlaceSellOrderTool` | Place sell orders (spot / futures / options). | +| `private/cancel` | `CancelOrderTool` | Cancel a single order by ID. | +| `private/cancel_all_by_currency` / `kind` | `CancelAllOrdersTool` | Cancel all open orders by `currency` / `kind`. | + +The tools may perform additional **spec‑based validation** (contract size, +tick size, etc.) before calling Deribit; see the error handling section +below for details. + +--- + +## Typical workflows for SpoonOS + +This section describes high‑level workflows that you can implement in Spoon +agents by calling the tools in sequence. It is intentionally +language‑agnostic (no Python code). + +### 1. Spot round‑trip (safe functional test) + +Goal: verify spot trading tools and contract‑size validation with minimal +risk. + +1. **Discover instruments** + - Call `GetInstrumentsTool(currency="ETH", kind="spot", expired=False)`. + - Prefer instruments containing `USDC` or `USDT` in `instrument_name`. +2. **Fetch current price** + - Call `GetTickerTool(instrument_name=...)`. + - Use `last_price` or `mark_price` as the reference. +3. **Place a deep, non‑filling limit order** + - Call `PlaceBuyOrderTool` or `PlaceSellOrderTool` with: + - `order_type="limit"` + - `post_only=True` + - `price` set far away from the current price (for example + 30–40% away). + - The toolkit will: + - Validate `amount` against `contract_size`. + - Validate `price` against `tick_size`. +4. **Verify and cancel** + - Call `GetOpenOrdersTool(instrument_name=...)` to confirm the order + appears. + - Cancel it using `CancelOrderTool(order_id=...)` or + `CancelAllOrdersTool(currency="ETH", kind="spot")`. + +This pattern is used by `test_spot_trading.py` and +`test_0.02eth_safe_trading.py`. + +### 2. Futures open + close (low‑risk directional test) + +Goal: ensure futures buy/sell tools and position reporting work correctly. + +1. **Check current positions** + - Call `GetPositionsTool(currency="ETH", kind="future")`. + - Decide whether it is safe to open a small test position + (for example, no large existing exposure). +2. **Fetch futures price** + - Call `GetTickerTool(instrument_name="ETH-PERPETUAL")`. +3. **Open a small position** + - Call `PlaceBuyOrderTool` with: + - `instrument_name="ETH-PERPETUAL"` + - `order_type="market"` (for functional test), or a deep `limit` for + non‑filling tests. + - `amount=1.0` (or a very small size, depending on your risk policy). +4. **Verify position** + - Call `GetPositionsTool` again to confirm a non‑zero size. +5. **Close the position** + - Call `PlaceSellOrderTool` with: + - `instrument_name="ETH-PERPETUAL"` + - `order_type="market"` + - `amount` equal to the current long size (do not oversell). +6. **Final checks** + - Ensure `GetPositionsTool` reports zero size. + - Optionally call `GetOpenOrdersTool` to confirm no open futures orders + remain. + +This pattern is implemented (with extra logging and funding tracking) in +`test_complete_trading_workflow.py` and `test_all_trading_types.py`. + +### 3. Options safe round‑trip (recommended pattern) + +Goal: test options functionality with a very small balance, minimizing risk +and avoiding tick‑size issues. + +This pattern is also described in `TRADING_EXPERIENCE.md` and implemented in +`test_options_safe_roundtrip.py`: + +1. **Select a cheap ETH option** + - Use `GetInstrumentsTool(currency="ETH", kind="option", expired=False)` + to list options. + - For each candidate instrument: + - Call `GetTickerTool(instrument_name=...)` to read `mark_price`. + - Compute `estimated_cost = mark_price × contract_size`. + - Filter to options where `estimated_cost` is: + - Far below the current account balance. + - Below a hard cap (for example `0.005 ETH`). + - Pick the **cheapest** option that passes those filters. +2. **Buy 1 contract using a market order** + - Call `PlaceBuyOrderTool` with: + - `order_type="market"` + - `amount=1.0` (1 contract, or 1×`contract_size`). + - Let the matching engine decide the actual fill price and handle + tick‑size / price‑band rules. +3. **Verify position** + - Call `GetPositionsTool(currency="ETH", kind="option")`. + - Find the instrument with `size > 0` (the option you just bought). +4. **Sell using reduce‑only market order** + - Call `PlaceSellOrderTool` with: + - `order_type="market"` + - `amount` equal to the current long size. + - `reduce_only=True` (ensure this only closes the position, never + opens a short). +5. **Final checks** + - Verify balances and positions using `GetAccountSummaryTool` and + `GetPositionsTool`. + - Check recent trades using `GetTradeHistoryTool`. + +This pattern is the safest way to validate options trading end‑to‑end on a +small account. + +--- + +## Error handling and validation + +Deribit tools perform two layers of validation: + +1. **Toolkit‑level validation (pre‑flight)** + - Required parameters: `instrument_name`, `amount`, and `price` + (for limit orders). + - Basic checks: values must be positive and non‑null. + - Spec‑based checks using `GetInstrumentsTool`: + - `amount` must be a multiple of `contract_size`. + - `price` must conform to `tick_size` for the instrument. + - If these checks fail, the tool: + - **Does not call the Deribit API**. + - Returns a structured error (typically a dict with an `"error"` field + and, where possible, suggested adjusted values). + +2. **Deribit API errors** + - Even after local validation, Deribit may return errors due to: + - Insufficient funds. + - Internal price bands / additional tick rules (especially for + options). + - Network or internal server issues. + +Spoon developers should treat these as **normal, expected conditions** and +handle them in their own logic. Some common cases: + +- **`must be a multiple of contract size`** + - Origin: Deribit or toolkit. + - Action: + - Read `contract_size` from the error (or from `GetInstrumentsTool`). + - Adjust `amount` to the suggested multiple. + - At the agent layer, you can either reduce trade size or explain the + required contract size to the end user. + +- **`must conform to tick size`** + - Origin: Deribit or toolkit. + - Cause: + - Price not aligned with `tick_size`, or not within internal price + bands. + - Action: + - Use the `tick_size` returned by the toolkit. + - Adjust prices by rounding to multiples of `tick_size`. + - For options, consider using market orders as described in the safe + options pattern instead of complex limit‑order logic inside the core + tool layer. + +- **`not_enough_funds`** + - Origin: Deribit API. + - Action: + - Re‑check account balance via `GetAccountSummaryTool`. + - Decide at the strategy / application layer whether to: + - Reduce position size. + - Skip the trade. + - Ask the user to deposit more funds. + +In general: + +- **Toolkit errors** (validation failures) are your signal that the request + is malformed at the spec level. +- **Deribit errors** are your signal that the trade is valid in shape, but + not executable under current account or market conditions. + +--- + +## Recommended examples and docs for Spoon developers + +When integrating into SpoonOS, you rarely want to run all examples; +instead, use a small curated subset: + +- **Core documentation** + - This file: high‑level overview, tools list, and Spoon integration + hints. + - `TRADING_EXPERIENCE.md`: detailed trading notes (contract sizes, + tick sizes, options quirks, safe patterns). + +- **Read‑only / smoke tests** + - `examples/test_public_api.py` + - `examples/test_authentication.py` + - `examples/test_market_data_tools.py` + - `examples/test_account_tools.py` + - `examples/test_mainnet_readonly.py` (mainnet read‑only, safe to run + with real funds). + +- **Spot / futures functional tests** + - `examples/test_spot_trading.py` – safe spot limit‑order test + (deep limit + cancel). + - `examples/test_0.02eth_safe_trading.py` – safe futures test for very + small balances. + - `examples/test_complete_trading_workflow.py` – end‑to‑end + spot + futures workflow. + - `examples/test_all_trading_types.py` – combined spot + futures + + options test, with full cleanup. + +- **Options functional tests** + - `examples/test_options_safe_roundtrip.py` – **recommended default** for + options; small‑risk buy+sell pattern. + - `examples/test_options_complete.py` – more detailed options workflow, + including logs and funding tracking. + - `examples/test_options_auto_trade.py`, + `examples/test_options_close_position.py`, + `examples/test_options_trading.py` – additional options examples and + diagnostics. + +These `examples/` are meant as reference workflows and regression tests, +not as a public API. In SpoonOS agents, you should always call the tool +classes (`GetInstrumentsTool`, `PlaceBuyOrderTool`, etc.) directly and +apply your own business logic, risk management, and UX layer on top. +# Deribit API Integration Toolkit + +Deribit integration for the `spoon_toolkits` project. +This module provides typed tools for the Deribit JSON‑RPC API +(market data, account, and trading), suitable for use both directly +and via MCP. + +> **Note:** This README is written for the upstream `spoon-toolkit` +> repository. Paths below assume the package layout\n> `spoon_toolkits/data_platforms/deribit/`. + +--- + +## Quick start + +### Install (editable) + +From the `spoon-toolkit` project root: + +```bash +pip install -e . +``` + +### Configuration + +Create a `.env` file that provides Deribit credentials and network: + +```bash +DERIBIT_CLIENT_ID=your_client_id +DERIBIT_CLIENT_SECRET=your_client_secret +DERIBIT_USE_TESTNET=true # \"true\" for testnet, \"false\" for mainnet +``` + +The module reads configuration via `DeribitConfig` in `env.py`. + +--- + +## Usage + +You can use the tools either through the top‑level `spoon_toolkits` +package or via the `deribit` submodule. + +### Option 1: Import from the main package (recommended) + +```python +from spoon_toolkits import ( + GetInstrumentsTool, + GetTickerTool, + PlaceBuyOrderTool, +) + +async def main(): + tool = GetInstrumentsTool() + result = await tool.execute(currency=\"BTC\", kind=\"future\") + print(result) +``` + +### Option 2: Import from the `deribit` submodule + +```python +from spoon_toolkits.data_platforms.deribit import ( + GetInstrumentsTool, + GetTickerTool, + PlaceBuyOrderTool, +) +``` + +### Option 3: Use via MCP + +```python +from spoon_toolkits.data_platforms.deribit import deribit_mcp + +# deribit_mcp is a FastMCP instance that exposes all Deribit tools +# over the MCP protocol. +``` + +--- + +## Tools overview + +### Market data tools (public API) + +- `GetInstrumentsTool` – list instruments +- `GetOrderBookTool` – order book +- `GetTickerTool` – ticker / mark price +- `GetLastTradesTool` – recent trades +- `GetIndexPriceTool` – index price +- `GetBookSummaryTool` – book summary + +### Account tools (private API) + +- `GetAccountSummaryTool` – account summary +- `GetPositionsTool` – open positions +- `GetOrderHistoryTool` – order history +- `GetTradeHistoryTool` – trade history + +### Trading tools (private API) + +- `PlaceBuyOrderTool` – place buy orders +- `PlaceSellOrderTool` – place sell orders +- `CancelOrderTool` – cancel a single order +- `CancelAllOrdersTool` – cancel all open orders for a currency/kind +- `GetOpenOrdersTool` – list open orders by instrument +- `EditOrderTool` – edit an existing order + +--- + +## Examples and testing + +The `examples/` directory contains end‑to‑end scripts demonstrating +how to use the tools safely with small balances: + +- Spot and futures: + - `test_spot_trading.py` + - `test_0.02eth_safe_trading.py` + - `test_complete_trading_workflow.py` + - `test_all_trading_types.py` +- Options: + - `test_options_safe_roundtrip.py` – picks a cheap ETH option and\n performs a buy + sell round trip (with detailed logging). + - `test_options_complete.py` – more detailed options workflow tests. + +For a higher‑level summary of lessons learned (contract sizes, tick sizes, +options quirks, and safe patterns), see: + +- `TRADING_EXPERIENCE.md` + +For API‑level smoke tests (market data, account, auth) see: + +- `examples/test_public_api.py` +- `examples/test_authentication.py` +- `examples/test_market_data_tools.py` +- `examples/test_account_tools.py` + +--- + +## Development notes + +- Core implementation lives under: + + ```text + spoon_toolkits/data_platforms/deribit/ + ``` + +- The Deribit JSON‑RPC client is in `jsonrpc_client.py` and is reused by + all tools. +- Tools are implemented in `market_data.py`, `account.py`, and `trading.py`. +- Higher‑level test workflows and debug scripts reside in `examples/` and + are **not** part of the public API surface. + diff --git a/spoon_toolkits/data_platforms/deribit/TRADING_EXPERIENCE.md b/spoon_toolkits/data_platforms/deribit/TRADING_EXPERIENCE.md new file mode 100644 index 0000000..65b77a0 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/TRADING_EXPERIENCE.md @@ -0,0 +1,206 @@ +# Deribit Trading Experience Summary (Spot / Futures / Options) + +This document summarizes the main pitfalls and best practices we discovered while integrating +Deribit into `spoon_toolkits` and running small real-money tests. It is written for developers +who want to use the `deribit` tools safely and predictably. + +## 1. Spot (Spot) Trading + +### 1.1 Typical error: `must be a multiple of contract size` + +**Symptom** (HTTP 400): +- `reason: "must be a multiple of contract size"` +- `param: "amount"` + +**Cause:** +- Some spot instruments (e.g. `ETH_USDC`) are exposed as contracts. +- The `amount` must be an integer multiple of the instrument `contract_size` + (for example `0.0001`). + +**Best practices:** +- Always call `GetInstrumentsTool` first and read `contract_size`. +- Use `Decimal` to check `amount % contract_size == 0` before sending the order. +- In the toolkit we validate this at the tool level and, on failure, return + a clear error with a suggested `amount` that conforms to `contract_size`. + +### 1.2 Safe testing strategy + +When testing with small balances: +- Use very small `amount` values (e.g. a few thousandths of an ETH) for spot buy/sell. +- For “logic only” tests, use **limit orders far away from the market price**, so that + the order does not actually fill. +- Only switch to market orders or near-touch limit orders when you genuinely want real fills. + +## 2. Futures / Perpetual Trading + +### 2.1 Contract size + +- Futures instruments also have a `contract_size` requirement. +- The `amount` must be a multiple of `contract_size`. +- The toolkit enforces this and gives a helpful error when the amount does not conform. + +### 2.2 Risk control + +Recommended for tests: +- Use small sizes and short-lived positions. +- Implement an automated “buy then immediately close” round trip as part of your + regression tests (we provide examples under `examples/`). + +## 3. Options Trading + +### 3.1 Typical issues we hit + +#### 3.1.1 `not_enough_funds` + +**Symptom** (HTTP 400): +- `code: 10009, message: "not_enough_funds"` + +**Cause:** +- Options require option premium (and possibly margin). +- Some options have relatively high per-contract cost. +- A small account (e.g. ~0.02 ETH) cannot afford certain options. + +**Practical lesson:** +- With limited balance, prefer options with **very small `mark_price`** + (close to zero). For example, we successfully traded + `ETH-19NOV25-2800-P` with `mark_price ≈ 0.0001 ETH`. + +#### 3.1.2 `must conform to tick size` + +**Symptom** (HTTP 400): +- `code: -32602` +- `data.reason: "must conform to tick size"` + +**What we did to investigate:** +- Used `public/get_instruments` to read `tick_size`. +- Used `Decimal` to construct `price = k * tick_size` values. +- As a cross-check, we wrote a raw JSON-RPC debug script that calls + `private/buy` directly with different prices. + +**What we observed:** +- For some options, even prices that are clean multiples of `tick_size` + still return `must conform to tick size`. +- This strongly suggests Deribit applies additional internal rules + (price bands, step sizes, min price, etc.) beyond the public `tick_size` field. + +**Conclusion:** +- In the toolkit we only do **spec-based validation** using `tick_size` and + do not try to guess all of Deribit’s internal rules. +- If you need to explore which prices are accepted at a given moment for a + specific option, do it via dedicated debug scripts in `examples/`, not in + the core tooling. + +### 3.2 A stable, low-risk options test pattern + +Given a small balance, we found a relatively stable pattern for testing +options functionality: + +1. **Automatically pick the cheapest ETH option** + - Use `GetInstrumentsTool(currency="ETH", kind="option", expired=False)`. + - For each instrument: + - Use `GetTickerTool` to get `mark_price`. + - Compute estimated per-contract cost: `est_cost = mark_price * contract_size`. + - Filter options where `est_cost` is far below the account balance and below + a hard cap (e.g. `0.005 ETH`), then pick the **cheapest one**. + +2. **Buy using a market order with small size (1 contract)** + - Call `PlaceBuyOrderTool` with: + - `order_type="market"` + - `amount=1.0` (or 1 × `contract_size`) + - Benefits: + - The matching engine chooses the actual fill price and handles tick-size + and price-band constraints. + - For extremely cheap options, the actual cost is tiny, so this is safe + for functional verification and regression tests. + - In our tests: + - Buying `ETH-19NOV25-2800-P` with `amount=1.0` via a market order + returned `order_state: "filled"`, `filled_amount: 1.0`, and + `average_price ≈ 0.0002`. + +3. **Close the position with a reduce-only market sell** + - First, verify positions with `GetPositionsTool(currency="ETH", kind="option")`: + - Find the option with `size > 0` (e.g. `ETH-19NOV25-2800-P size=1.0`). + - Then call `PlaceSellOrderTool` with: + - `order_type="market"` + - `amount = current long size` + - `reduce_only=True` (so it only closes the position, never opens a short) + - This lets you complete a buy+sell round trip without worrying about the + exact limit price. + +4. **Round-trip example script** + - The script `examples/test_options_safe_roundtrip.py` shows this pattern: + - Automatically pick a cheap ETH option. + - Buy 1 contract with a market order. + - Check positions and sell the same amount with a reduce-only market order. + - Print initial balance, final balance, PnL and recent trade history. + +## 4. Boundary Conditions and Toolkit Design Philosophy + +### 4.1 What the toolkit does + +In `spoon_toolkits.data_platforms.deribit` we stick to a clear separation of concerns: + +**The toolkit does:** +- Configuration / JSON-RPC client management / OAuth2 authentication. +- Basic parameter validation: required fields, `> 0` checks, etc. +- Spec-based validation using `get_instruments`: + - Ensure `amount` is a multiple of `contract_size`. + - Ensure `price` conforms to `tick_size` (for limit orders). +- When validation fails: + - Do **not** call the API. + - Return a structured error with a human-readable message and suggested values. + +**The toolkit does not:** +- Change trade direction for you (no auto buy/sell decisions). +- Implement account-level money management (e.g. auto downsize on low balance). +- Implement price-search strategies (e.g. automatically probing different + limit prices until Deribit accepts one). +- Decide when to close positions or convert all balances to ETH – those are + higher-level, strategy-specific concerns. + +### 4.2 Why this separation matters + +- Deribit’s internal behaviour (especially around price bands and ticks for + options) is not fully documented. +- Trying to “guess the rules” inside the core toolkit is dangerous: a future + API change could break assumptions and turn the toolkit itself into a bug. +- By keeping **all strategies and experiments** in `examples/` and limiting + the toolkit to spec-based validation, we get: + - A stable, predictable API surface for other code to build on. + - Freedom to iterate on test strategies and debugging scripts without + risking regressions in the main package. + +## 5. Recommended Usage Flow + +### 5.1 First-time setup / sanity checks + +1. Run `examples/test_public_api.py` and `test_authentication.py` to verify + network connectivity and authentication. +2. Run `test_market_data_tools.py` and `test_account_tools.py` to confirm + that market data and balances are accessible and correctly parsed. + +### 5.2 Spot / futures functional tests + +1. Use `test_0.02eth_safe_trading.py` or `test_spot_trading.py` to verify + amount / contract-size validation and the basic trading tools. +2. For full workflows, look at: + - `test_complete_trading_workflow.py` + - `test_all_trading_types.py` + +### 5.3 Options functional tests + +- Use `test_options_safe_roundtrip.py` as the go-to script: + - It will automatically select a cheap ETH option. + - Execute a full buy+sell round trip with small risk. + - Print balance changes and trade details. + +### 5.4 When you see parameter errors + +- First, look at the toolkit error message – it usually includes the + relevant `contract_size` / `tick_size` and suggested values. +- If the error comes from Deribit itself (e.g. `not_enough_funds` or + `must conform to tick size`): + - Re-check balance, price bands and instrument specs. + - When in doubt, use the debug-oriented scripts under `examples/` to + probe the behaviour of the specific instrument. + diff --git a/spoon_toolkits/data_platforms/deribit/__init__.py b/spoon_toolkits/data_platforms/deribit/__init__.py new file mode 100644 index 0000000..e45d6aa --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/__init__.py @@ -0,0 +1,222 @@ +"""Deribit API integration toolkit for SpoonAI""" + +from fastmcp import FastMCP + +# Initialize MCP server +mcp = FastMCP("Deribit Tools") + +# Import core components +from .jsonrpc_client import DeribitJsonRpcClient, DeribitJsonRpcError +from .auth import DeribitAuth, DeribitAuthError +from .base import DeribitBaseTool +from .env import DeribitConfig + +# Import tools +from .market_data import ( + GetInstrumentsTool, + GetOrderBookTool, + GetTickerTool, + GetLastTradesTool, + GetIndexPriceTool, + GetBookSummaryTool +) +from .account import ( + GetAccountSummaryTool, + GetPositionsTool, + GetOrderHistoryTool, + GetTradeHistoryTool +) +from .trading import ( + PlaceBuyOrderTool, + PlaceSellOrderTool, + CancelOrderTool, + CancelAllOrdersTool, + GetOpenOrdersTool, + EditOrderTool +) + +# Register tools with MCP server +@mcp.tool() +async def get_instruments(currency: str, kind: str = "any", expired: bool = False): + """Get list of available instruments on Deribit""" + tool = GetInstrumentsTool() + result = await tool.execute(currency=currency, kind=kind, expired=expired) + return result + +@mcp.tool() +async def get_order_book(instrument_name: str, depth: int = 20): + """Get order book for a specific instrument""" + tool = GetOrderBookTool() + result = await tool.execute(instrument_name=instrument_name, depth=depth) + return result + +@mcp.tool() +async def get_account_summary(currency: str, extended: bool = False): + """Get account summary (requires authentication)""" + tool = GetAccountSummaryTool() + result = await tool.execute(currency=currency, extended=extended) + return result + +@mcp.tool() +async def get_positions(currency: str, kind: str = "any"): + """Get current positions (requires authentication)""" + tool = GetPositionsTool() + result = await tool.execute(currency=currency, kind=kind) + return result + +@mcp.tool() +async def get_ticker(instrument_name: str): + """Get ticker data for a specific instrument""" + tool = GetTickerTool() + result = await tool.execute(instrument_name=instrument_name) + return result + +@mcp.tool() +async def get_last_trades(instrument_name: str, count: int = 10, include_old: bool = False, sorting: str = "desc"): + """Get last trades for a specific instrument""" + tool = GetLastTradesTool() + result = await tool.execute( + instrument_name=instrument_name, + count=count, + include_old=include_old, + sorting=sorting + ) + return result + +@mcp.tool() +async def get_index_price(index_name: str): + """Get index price for a currency""" + tool = GetIndexPriceTool() + result = await tool.execute(index_name=index_name) + return result + +@mcp.tool() +async def get_book_summary(currency: str, kind: str = "any"): + """Get book summary by currency""" + tool = GetBookSummaryTool() + result = await tool.execute(currency=currency, kind=kind) + return result + +@mcp.tool() +async def place_buy_order( + instrument_name: str, + amount: float, + price: float = None, + order_type: str = "limit", + time_in_force: str = "good_til_cancelled", + reduce_only: bool = False, + post_only: bool = False +): + """Place a buy order (requires authentication)""" + tool = PlaceBuyOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + price=price, + order_type=order_type, + time_in_force=time_in_force, + reduce_only=reduce_only, + post_only=post_only + ) + return result + +@mcp.tool() +async def place_sell_order( + instrument_name: str, + amount: float, + price: float = None, + order_type: str = "limit", + time_in_force: str = "good_til_cancelled", + reduce_only: bool = False, + post_only: bool = False +): + """Place a sell order (requires authentication)""" + tool = PlaceSellOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + price=price, + order_type=order_type, + time_in_force=time_in_force, + reduce_only=reduce_only, + post_only=post_only + ) + return result + +@mcp.tool() +async def cancel_order(order_id: str): + """Cancel an order by order ID (requires authentication)""" + tool = CancelOrderTool() + result = await tool.execute(order_id=order_id) + return result + +@mcp.tool() +async def cancel_all_orders(currency: str, kind: str = "any", type: str = "all"): + """Cancel all orders for a currency (requires authentication)""" + tool = CancelAllOrdersTool() + result = await tool.execute(currency=currency, kind=kind, type=type) + return result + +@mcp.tool() +async def get_open_orders(instrument_name: str): + """Get open orders for an instrument (requires authentication)""" + tool = GetOpenOrdersTool() + result = await tool.execute(instrument_name=instrument_name) + return result + +@mcp.tool() +async def edit_order(order_id: str, amount: float = None, price: float = None): + """Edit an existing order (requires authentication)""" + tool = EditOrderTool() + result = await tool.execute(order_id=order_id, amount=amount, price=price) + return result + +@mcp.tool() +async def get_order_history(instrument_name: str, count: int = 20, offset: int = 0): + """Get order history for an instrument (requires authentication)""" + tool = GetOrderHistoryTool() + result = await tool.execute( + instrument_name=instrument_name, + count=count, + offset=offset + ) + return result + +@mcp.tool() +async def get_trade_history(instrument_name: str, count: int = 20, offset: int = 0): + """Get trade history for an instrument (requires authentication)""" + tool = GetTradeHistoryTool() + result = await tool.execute( + instrument_name=instrument_name, + count=count, + offset=offset + ) + return result + +# Export +__all__ = [ + "mcp", + "DeribitJsonRpcClient", + "DeribitJsonRpcError", + "DeribitAuth", + "DeribitAuthError", + "DeribitBaseTool", + "DeribitConfig", + "GetInstrumentsTool", + "GetOrderBookTool", + "GetTickerTool", + "GetLastTradesTool", + "GetIndexPriceTool", + "GetBookSummaryTool", + "GetAccountSummaryTool", + "GetPositionsTool", + "GetOrderHistoryTool", + "GetTradeHistoryTool", + "PlaceBuyOrderTool", + "PlaceSellOrderTool", + "CancelOrderTool", + "CancelAllOrdersTool", + "GetOpenOrdersTool", + "EditOrderTool", +] + diff --git a/spoon_toolkits/data_platforms/deribit/account.py b/spoon_toolkits/data_platforms/deribit/account.py new file mode 100644 index 0000000..452fb5f --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/account.py @@ -0,0 +1,244 @@ +"""Account management tools for Deribit API (Private methods)""" + +import logging +from typing import Any, Dict, Optional +from pydantic import Field + +from .base import DeribitBaseTool, ToolResult + +logger = logging.getLogger(__name__) + + +class GetAccountSummaryTool(DeribitBaseTool): + """Get account summary including balance, equity, and margin information""" + + name: str = "deribit_get_account_summary" + description: str = ( + "Get account summary for a specific currency. " + "Returns balance, equity, available funds, margin, positions value, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": ["BTC", "ETH", "USDC"], + "description": "Currency code (BTC, ETH, or USDC)" + }, + "extended": { + "type": "boolean", + "default": False, + "description": "Include extended account information" + } + }, + "required": ["currency"] + } + + currency: Optional[str] = Field(default=None, description="Currency code") + extended: bool = Field(default=False, description="Extended information") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get account summary tool""" + try: + currency = kwargs.get("currency", self.currency) + extended = kwargs.get("extended", self.extended) + + if not currency: + return ToolResult(error="Parameter 'currency' is required") + + params = { + "currency": currency, + "extended": extended + } + + result = await self._call_private_method("private/get_account_summary", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetAccountSummaryTool: {e}") + return ToolResult(error=f"Failed to get account summary: {str(e)}") + + +class GetPositionsTool(DeribitBaseTool): + """Get current positions""" + + name: str = "deribit_get_positions" + description: str = ( + "Get current positions for a specific currency and kind. " + "Returns position details including size, entry price, mark price, PnL, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": ["BTC", "ETH", "USDC"], + "description": "Currency code (BTC, ETH, or USDC)" + }, + "kind": { + "type": "string", + "enum": ["future", "option", "any"], + "default": "any", + "description": "Position kind filter" + } + }, + "required": ["currency"] + } + + currency: Optional[str] = Field(default=None, description="Currency code") + kind: str = Field(default="any", description="Position kind") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get positions tool""" + try: + currency = kwargs.get("currency", self.currency) + kind = kwargs.get("kind", self.kind) + + if not currency: + return ToolResult(error="Parameter 'currency' is required") + + params = { + "currency": currency, + "kind": kind if kind != "any" else None + } + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + result = await self._call_private_method("private/get_positions", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetPositionsTool: {e}") + return ToolResult(error=f"Failed to get positions: {str(e)}") + + +class GetOrderHistoryTool(DeribitBaseTool): + """Get order history for an instrument""" + + name: str = "deribit_get_order_history" + description: str = ( + "Get order history for a specific instrument. " + "Returns historical orders including filled, cancelled, and rejected orders." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL')" + }, + "count": { + "type": "integer", + "default": 20, + "description": "Number of orders to return (1-1000)" + }, + "offset": { + "type": "integer", + "default": 0, + "description": "Offset for pagination" + } + }, + "required": ["instrument_name"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + count: int = Field(default=20, description="Number of orders") + offset: int = Field(default=0, description="Offset") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get order history tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + count = kwargs.get("count", self.count) + offset = kwargs.get("offset", self.offset) + + if not instrument_name: + return ToolResult(error="Parameter 'instrument_name' is required") + + # Validate count + if count < 1 or count > 1000: + return ToolResult(error="Parameter 'count' must be between 1 and 1000") + + params = { + "instrument_name": instrument_name, + "count": count, + "offset": offset + } + + result = await self._call_private_method("private/get_order_history_by_instrument", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetOrderHistoryTool: {e}") + return ToolResult(error=f"Failed to get order history: {str(e)}") + + +class GetTradeHistoryTool(DeribitBaseTool): + """Get trade history for an instrument""" + + name: str = "deribit_get_trade_history" + description: str = ( + "Get trade history (executed trades) for a specific instrument. " + "Returns executed trades with price, amount, direction, timestamp, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL')" + }, + "count": { + "type": "integer", + "default": 20, + "description": "Number of trades to return (1-1000)" + }, + "offset": { + "type": "integer", + "default": 0, + "description": "Offset for pagination" + } + }, + "required": ["instrument_name"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + count: int = Field(default=20, description="Number of trades") + offset: int = Field(default=0, description="Offset") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get trade history tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + count = kwargs.get("count", self.count) + offset = kwargs.get("offset", self.offset) + + if not instrument_name: + return ToolResult(error="Parameter 'instrument_name' is required") + + # Validate count + if count < 1 or count > 1000: + return ToolResult(error="Parameter 'count' must be between 1 and 1000") + + params = { + "instrument_name": instrument_name, + "count": count, + "offset": offset + } + + result = await self._call_private_method("private/get_user_trades_by_instrument", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetTradeHistoryTool: {e}") + return ToolResult(error=f"Failed to get trade history: {str(e)}") + diff --git a/spoon_toolkits/data_platforms/deribit/auth.py b/spoon_toolkits/data_platforms/deribit/auth.py new file mode 100644 index 0000000..88dd61c --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/auth.py @@ -0,0 +1,187 @@ +"""OAuth2 authentication for Deribit API""" + +import logging +import time +from typing import Dict, Optional +from .jsonrpc_client import DeribitJsonRpcClient, DeribitJsonRpcError +from .env import DeribitConfig + +logger = logging.getLogger(__name__) + + +class DeribitAuthError(Exception): + """Exception for Deribit authentication errors""" + pass + + +class DeribitAuth: + """OAuth2 authentication manager for Deribit API""" + + def __init__( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + jsonrpc_client: Optional[DeribitJsonRpcClient] = None + ): + """ + Initialize authentication manager + + Args: + client_id: Deribit client ID (defaults to config) + client_secret: Deribit client secret (defaults to config) + jsonrpc_client: JSON-RPC client instance (creates new if not provided) + """ + if client_id and client_secret: + self.client_id = client_id + self.client_secret = client_secret + else: + # Lazy credential loading - only get when needed + self.client_id = None + self.client_secret = None + + self.jsonrpc_client = jsonrpc_client or DeribitJsonRpcClient() + self.access_token: Optional[str] = None + self.refresh_token: Optional[str] = None + self.token_expires_at: Optional[float] = None + self.scope: Optional[str] = None + + def _ensure_credentials(self): + """Ensure credentials are loaded""" + if not self.client_id or not self.client_secret: + self.client_id, self.client_secret = DeribitConfig.get_credentials() + + async def authenticate( + self, + grant_type: str = "client_credentials", + scope: Optional[str] = None + ) -> Dict[str, any]: + """ + Authenticate and get access token + + Args: + grant_type: OAuth2 grant type (default: "client_credentials") + scope: Access scope (e.g., "account:read trade:read_write") + + Returns: + Authentication response with access_token, refresh_token, etc. + + Raises: + DeribitAuthError: If authentication fails + """ + # Load credentials if not already loaded + self._ensure_credentials() + + try: + params = { + "grant_type": grant_type, + "client_id": self.client_id, + "client_secret": self.client_secret + } + + if scope: + params["scope"] = scope + + logger.info("Authenticating with Deribit API...") + result = await self.jsonrpc_client.call("public/auth", params) + + # Store tokens + self.access_token = result.get("access_token") + self.refresh_token = result.get("refresh_token") + self.scope = result.get("scope") + + # Calculate expiration time (expires_in is in seconds) + expires_in = result.get("expires_in", 3600) + self.token_expires_at = time.time() + expires_in - 60 # 1 minute buffer + + # Set token in JSON-RPC client + if self.access_token: + self.jsonrpc_client.set_access_token(self.access_token) + + logger.info("Authentication successful") + return result + + except DeribitJsonRpcError as e: + error_msg = f"Authentication failed: {str(e)}" + logger.error(error_msg) + raise DeribitAuthError(error_msg) from e + except Exception as e: + error_msg = f"Unexpected authentication error: {str(e)}" + logger.error(error_msg) + raise DeribitAuthError(error_msg) from e + + async def refresh_access_token(self) -> Dict[str, any]: + """ + Refresh access token using refresh_token + + Returns: + New authentication response + + Raises: + DeribitAuthError: If refresh fails + """ + if not self.refresh_token: + # If no refresh token, re-authenticate + return await self.authenticate() + + try: + params = { + "grant_type": "refresh_token", + "refresh_token": self.refresh_token + } + + logger.info("Refreshing access token...") + result = await self.jsonrpc_client.call("public/auth", params) + + # Update tokens + self.access_token = result.get("access_token") + self.refresh_token = result.get("refresh_token", self.refresh_token) + + # Calculate expiration time + expires_in = result.get("expires_in", 3600) + self.token_expires_at = time.time() + expires_in - 60 + + # Update token in JSON-RPC client + if self.access_token: + self.jsonrpc_client.set_access_token(self.access_token) + + logger.info("Token refreshed successfully") + return result + + except DeribitJsonRpcError as e: + error_msg = f"Token refresh failed: {str(e)}" + logger.error(error_msg) + # Try to re-authenticate + logger.info("Attempting to re-authenticate...") + return await self.authenticate() + except Exception as e: + error_msg = f"Unexpected refresh error: {str(e)}" + logger.error(error_msg) + raise DeribitAuthError(error_msg) from e + + def is_token_valid(self) -> bool: + """Check if current access token is valid (not expired)""" + if not self.access_token or not self.token_expires_at: + return False + return time.time() < self.token_expires_at + + async def ensure_authenticated(self): + """Ensure we have a valid access token, refresh if needed""" + if not self.is_token_valid(): + if self.refresh_token: + await self.refresh_access_token() + else: + await self.authenticate() + + def get_access_token(self) -> Optional[str]: + """Get current access token""" + return self.access_token + + def logout(self): + """Logout and clear tokens""" + self.access_token = None + self.refresh_token = None + self.token_expires_at = None + self.scope = None + self.jsonrpc_client.clear_access_token() + logger.info("Logged out") + diff --git a/spoon_toolkits/data_platforms/deribit/base.py b/spoon_toolkits/data_platforms/deribit/base.py new file mode 100644 index 0000000..f56a4a3 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/base.py @@ -0,0 +1,136 @@ +"""Base tool class for Deribit tools""" + +import logging +from typing import Any, Dict, Optional +from pydantic import Field + +try: + from spoon_ai.tools.base import BaseTool, ToolResult +except ImportError: + # Fallback for development/testing + from typing import TypedDict, Dict, Any + from abc import ABC, abstractmethod + from pydantic import BaseModel, Field + + class ToolResult(TypedDict, total=False): + output: Optional[Any] + error: Optional[str] + + class BaseTool(ABC, BaseModel): + """Base tool class (fallback when spoon_ai is not available)""" + name: str = Field(description="The name of the tool") + description: str = Field(description="A description of the tool") + parameters: dict = Field(default_factory=dict, description="The parameters of the tool") + + model_config = {"arbitrary_types_allowed": True} + + @abstractmethod + async def execute(self, **kwargs) -> ToolResult: + """Execute the tool""" + raise NotImplementedError("Subclasses must implement execute method") + +from .jsonrpc_client import DeribitJsonRpcClient +from .auth import DeribitAuth + +logger = logging.getLogger(__name__) + + +class DeribitBaseTool(BaseTool): + """Base class for all Deribit tools""" + + # Internal fields (not part of Pydantic model) + _jsonrpc_client: Optional[DeribitJsonRpcClient] = None + _auth: Optional[DeribitAuth] = None + _client_initialized: bool = False + + def __init__(self, **kwargs): + """Initialize Deribit base tool""" + super().__init__(**kwargs) + + # Lazy initialization - will be created on first use + self._client_initialized = False + + @property + def jsonrpc_client(self) -> Optional[DeribitJsonRpcClient]: + """Get JSON-RPC client (lazy initialization)""" + return self._jsonrpc_client + + @property + def auth(self) -> Optional[DeribitAuth]: + """Get auth manager (lazy initialization)""" + return self._auth + + def _ensure_client(self): + """Ensure JSON-RPC client and auth are initialized""" + if not self._client_initialized: + self._jsonrpc_client = DeribitJsonRpcClient() + self._auth = DeribitAuth(jsonrpc_client=self._jsonrpc_client) + self._client_initialized = True + + async def _ensure_authenticated(self): + """Ensure we have a valid authentication token""" + self._ensure_client() + if self._auth: + await self._auth.ensure_authenticated() + # Update client with latest token + if self._auth.get_access_token(): + self._jsonrpc_client.set_access_token(self._auth.get_access_token()) + + async def _call_public_method( + self, + method: str, + params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Call a public API method (no authentication required) + + Args: + method: JSON-RPC method name + params: Method parameters + + Returns: + API response result + """ + self._ensure_client() + return await self._jsonrpc_client.call(method, params) + + async def _call_private_method( + self, + method: str, + params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Call a private API method (authentication required) + + Args: + method: JSON-RPC method name + params: Method parameters + + Returns: + API response result + """ + await self._ensure_authenticated() + return await self._jsonrpc_client.call(method, params) + + async def execute(self, **kwargs) -> ToolResult: + """ + Execute the tool (to be implemented by subclasses) + + Args: + **kwargs: Tool-specific parameters + + Returns: + ToolResult with output or error + """ + raise NotImplementedError("Subclasses must implement execute method") + + async def __aenter__(self): + """Async context manager entry""" + self._ensure_client() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit""" + if self._jsonrpc_client: + await self._jsonrpc_client.close() + diff --git a/spoon_toolkits/data_platforms/deribit/cache.py b/spoon_toolkits/data_platforms/deribit/cache.py new file mode 100644 index 0000000..c6e5822 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/cache.py @@ -0,0 +1,79 @@ +"""Cache decorator for Deribit API responses""" + +import functools +import time +from typing import Any, Callable, Dict, Optional +from .env import DeribitConfig + +# Simple in-memory cache (can be replaced with Redis, etc. in the future) +_cache: Dict[str, tuple[Any, float]] = {} + + +def time_cache(ttl: Optional[int] = None): + """ + Decorator to cache function results with time-based expiration + + Args: + ttl: Time to live in seconds (defaults to config) + + Example: + @time_cache(ttl=300) + async def get_instruments(currency: str): + ... + """ + ttl = ttl or DeribitConfig.CACHE_TTL + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + # Generate cache key from function name and arguments + cache_key = f"{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}" + + # Check cache + if cache_key in _cache: + result, expires_at = _cache[cache_key] + if time.time() < expires_at: + return result + + # Call function and cache result + result = await func(*args, **kwargs) + _cache[cache_key] = (result, time.time() + ttl) + + return result + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): + # Generate cache key + cache_key = f"{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}" + + # Check cache + if cache_key in _cache: + result, expires_at = _cache[cache_key] + if time.time() < expires_at: + return result + + # Call function and cache result + result = func(*args, **kwargs) + _cache[cache_key] = (result, time.time() + ttl) + + return result + + # Return appropriate wrapper based on function type + if hasattr(func, '__code__') and 'async' in str(func.__code__.co_flags): + return async_wrapper + else: + return sync_wrapper + + return decorator + + +def clear_cache(): + """Clear all cached data""" + global _cache + _cache.clear() + + +def get_cache_size() -> int: + """Get number of cached items""" + return len(_cache) + diff --git a/spoon_toolkits/data_platforms/deribit/env.py b/spoon_toolkits/data_platforms/deribit/env.py new file mode 100644 index 0000000..0bed8a2 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/env.py @@ -0,0 +1,95 @@ +"""Environment variable configuration for Deribit API""" + +import os +from pathlib import Path +from typing import Optional +from dotenv import load_dotenv + +# Try to load .env file from multiple locations +# 1. Current working directory (where user runs the code) - highest priority +# 2. Examples directory (where examples are located) +# 3. Project root directory (DeribitMcp/spoon-toolkit/) +# 4. Parent project root (DeribitMcp/) +examples_dir = Path(__file__).parent / "examples" +project_root = Path(__file__).parent.parent.parent.parent # spoon-toolkit/ +parent_root = project_root.parent # DeribitMcp/ + +# Load .env from current directory first, then examples, then project roots +load_dotenv() # Current working directory (highest priority) +if examples_dir.exists(): + load_dotenv(examples_dir / ".env", override=False) # examples/.env +if project_root.exists(): + load_dotenv(project_root / ".env", override=False) # spoon-toolkit/.env +if parent_root.exists(): + load_dotenv(parent_root / ".env", override=False) # DeribitMcp/.env + + +class DeribitConfig: + """Deribit API configuration from environment variables""" + + # Required credentials + CLIENT_ID: Optional[str] = os.getenv("DERIBIT_CLIENT_ID") + CLIENT_SECRET: Optional[str] = os.getenv("DERIBIT_CLIENT_SECRET") + + # Environment settings + USE_TESTNET: bool = os.getenv("DERIBIT_USE_TESTNET", "false").lower() == "true" + + # API URLs + API_URL: str = os.getenv( + "DERIBIT_API_URL", + "https://test.deribit.com/api/v2" if USE_TESTNET else "https://www.deribit.com/api/v2" + ) + + TESTNET_API_URL: str = os.getenv( + "DERIBIT_TESTNET_API_URL", + "https://test.deribit.com/api/v2" + ) + + WS_URL: str = os.getenv( + "DERIBIT_WS_URL", + "wss://test.deribit.com/ws/api/v2" if USE_TESTNET else "wss://www.deribit.com/ws/api/v2" + ) + + TESTNET_WS_URL: str = os.getenv( + "DERIBIT_TESTNET_WS_URL", + "wss://test.deribit.com/ws/api/v2" + ) + + # Connection settings + TIMEOUT: int = int(os.getenv("DERIBIT_TIMEOUT", "30")) + RETRY_COUNT: int = int(os.getenv("DERIBIT_RETRY_COUNT", "3")) + CACHE_TTL: int = int(os.getenv("DERIBIT_CACHE_TTL", "300")) + + # Logging + LOG_LEVEL: str = os.getenv("DERIBIT_LOG_LEVEL", "INFO") + + # Rate limiting + RATE_LIMIT: int = int(os.getenv("DERIBIT_RATE_LIMIT", "0")) + + @classmethod + def get_api_url(cls) -> str: + """Get the appropriate API URL based on testnet setting""" + return cls.TESTNET_API_URL if cls.USE_TESTNET else cls.API_URL + + @classmethod + def get_ws_url(cls) -> str: + """Get the appropriate WebSocket URL based on testnet setting""" + return cls.TESTNET_WS_URL if cls.USE_TESTNET else cls.WS_URL + + @classmethod + def validate_credentials(cls) -> bool: + """Validate that required credentials are set""" + if not cls.CLIENT_ID or not cls.CLIENT_SECRET: + return False + return True + + @classmethod + def get_credentials(cls) -> tuple[str, str]: + """Get client credentials""" + if not cls.validate_credentials(): + raise ValueError( + "Deribit API credentials not configured. " + "Please set DERIBIT_CLIENT_ID and DERIBIT_CLIENT_SECRET environment variables." + ) + return cls.CLIENT_ID, cls.CLIENT_SECRET + diff --git a/spoon_toolkits/data_platforms/deribit/examples/.env.example b/spoon_toolkits/data_platforms/deribit/examples/.env.example new file mode 100644 index 0000000..6429be6 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/.env.example @@ -0,0 +1,48 @@ +# Deribit API Integration Configuration +# Copy this file to .env and replace with your actual values + +# ============================================ +# Required: Deribit API Credentials +# ============================================ +# Get your API credentials from: +# https://www.deribit.com/account/api +DERIBIT_CLIENT_ID= +DERIBIT_CLIENT_SECRET= + +# ============================================ +# Environment Configuration +# ============================================ +# Set to true to use Deribit testnet +# Testnet: https://test.deribit.com +DERIBIT_USE_TESTNET=false + +# API Base URLs (usually no need to change) +DERIBIT_API_URL=https://www.deribit.com/api/v2 +DERIBIT_TESTNET_API_URL=https://test.deribit.com/api/v2 +DERIBIT_WS_URL=wss://www.deribit.com/ws/api/v2 +DERIBIT_TESTNET_WS_URL=wss://test.deribit.com/ws/api/v2 + +# ============================================ +# Optional: Connection Settings +# ============================================ +# Request timeout in seconds +DERIBIT_TIMEOUT=30 + +# Maximum retry attempts for failed requests +DERIBIT_RETRY_COUNT=3 + +# Cache TTL for market data in seconds +DERIBIT_CACHE_TTL=300 + +# ============================================ +# Optional: Logging +# ============================================ +# Log level: DEBUG, INFO, WARNING, ERROR +DERIBIT_LOG_LEVEL=INFO + +# ============================================ +# Optional: Rate Limiting +# ============================================ +# Requests per second (0 = no limit, use with caution) +DERIBIT_RATE_LIMIT=0 + diff --git a/spoon_toolkits/data_platforms/deribit/examples/check_contract_specs.py b/spoon_toolkits/data_platforms/deribit/examples/check_contract_specs.py new file mode 100644 index 0000000..d37ba8d --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/check_contract_specs.py @@ -0,0 +1,129 @@ +"""Inspect Deribit futures contract specs and approximate minimum trade sizes.""" +import asyncio +import sys +from pathlib import Path +sys.path.insert(0, '../../..') + +from spoon_toolkits.deribit.market_data import GetInstrumentsTool, GetTickerTool + + +async def get_contract_specs(): + print("=" * 60) + print("Deribit futures contract specs overview") + print("=" * 60) + + # Fetch BTC perpetual futures + tool = GetInstrumentsTool() + result = await tool.execute(currency="BTC", kind="future", expired=False) + + if result.get("error"): + print(f'❌ Query failed: {result.get("error")}') + return + + instruments = result.get("output", []) + + # Find BTC-PERPETUAL + perpetual = None + for inst in instruments: + if inst.get("instrument_name") == "BTC-PERPETUAL": + perpetual = inst + break + + if perpetual: + print("\n📊 BTC-PERPETUAL contract specs:") + print(f' instrument_name : {perpetual.get("instrument_name")}') + print(f' currency : {perpetual.get("currency")}') + print(f' kind : {perpetual.get("kind")}') + print(f' min_trade_amount: {perpetual.get("min_trade_amount", "unknown")} contracts') + print(f' contract_size : {perpetual.get("contract_size", "unknown")} BTC') + print(f' tick_size : {perpetual.get("tick_size", "unknown")}') + print(f' amount_step : {perpetual.get("amount_step", "unknown")}') + + # Fetch current price + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name="BTC-PERPETUAL") + if not ticker_result.get("error"): + ticker = ticker_result.get("output", {}) + current_price = ticker.get("last_price") or ticker.get("mark_price") + if current_price: + print(f"\n💰 Current price: ${current_price:,.2f}") + + # Estimate minimum notional + min_amount = perpetual.get("min_trade_amount", 1) + contract_size = perpetual.get("contract_size", 1) + min_usd_value = min_amount * contract_size * current_price + + print("\n💵 Estimated minimum notional:") + print(f" min contracts : {min_amount}") + print(f" contract_size : {contract_size} BTC") + print(f" minimum notional: ~${min_usd_value:,.2f} USD") + print( + f" suggested deposit: ${min_usd_value * 2:,.2f}–${min_usd_value * 5:,.2f} USD " + "(2–5× minimum notional)" + ) + + # Margin estimate + print("\n💳 Margin estimate (assuming ~10% margin rate):") + margin_required = min_usd_value * 0.1 + print(f" margin required : ~${margin_required:,.2f} USD") + print( + f" suggested margin: ${margin_required * 2:,.2f}–${margin_required * 3:,.2f} USD " + "(2–3× margin)" + ) + + # Fetch ETH perpetual futures + eth_result = await tool.execute(currency="ETH", kind="future", expired=False) + if not eth_result.get("error"): + eth_instruments = eth_result.get("output", []) + eth_perpetual = None + for inst in eth_instruments: + if inst.get("instrument_name") == "ETH-PERPETUAL": + eth_perpetual = inst + break + + if eth_perpetual: + print("\n" + "=" * 60) + print("📊 ETH-PERPETUAL contract specs:") + print(f' instrument_name : {eth_perpetual.get("instrument_name")}') + print(f' min_trade_amount: {eth_perpetual.get("min_trade_amount", "unknown")} contracts') + print(f' contract_size : {eth_perpetual.get("contract_size", "unknown")} ETH') + + # Fetch ETH futures price + eth_ticker_tool = GetTickerTool() + eth_ticker_result = await eth_ticker_tool.execute(instrument_name="ETH-PERPETUAL") + if not eth_ticker_result.get("error"): + eth_ticker = eth_ticker_result.get("output", {}) + eth_price = eth_ticker.get("last_price") or eth_ticker.get("mark_price") + if eth_price: + print(f" current_price : ${eth_price:,.2f}") + + eth_min_amount = eth_perpetual.get("min_trade_amount", 1) + eth_contract_size = eth_perpetual.get("contract_size", 1) + eth_min_usd = eth_min_amount * eth_contract_size * eth_price + + print(f" minimum notional: ~${eth_min_usd:,.2f} USD") + print( + f" suggested deposit: ${eth_min_usd * 2:,.2f}–${eth_min_usd * 5:,.2f} USD " + "(2–5× minimum notional)" + ) + + # ETH margin estimate + eth_margin = eth_min_usd * 0.1 + print(f" margin required (10%): ~${eth_margin:,.2f} USD") + print( + f" suggested margin: ${eth_margin * 2:,.2f}–${eth_margin * 3:,.2f} USD " + "(2–3× margin)" + ) + + print("\n" + "=" * 60) + print("💡 Notes:") + print(" 1. Deribit uses margin trading; you do not need to fund the full notional.") + print(" 2. Actual margin rates depend on market conditions (often 5–20%).") + print(" 3. Start with the minimum notional and scale up as you gain confidence.") + print(" 4. Prefer limit + post_only orders for safe integration tests.") + print("=" * 60) + + +if __name__ == '__main__': + asyncio.run(get_contract_specs()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/check_spot_instruments.py b/spoon_toolkits/data_platforms/deribit/examples/check_spot_instruments.py new file mode 100644 index 0000000..8e8288d --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/check_spot_instruments.py @@ -0,0 +1,182 @@ +"""Inspect Deribit instruments (spot/futures/options) and highlight spot support.""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent.parent)) + +from spoon_toolkits.deribit.market_data import GetInstrumentsTool, GetTickerTool + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +def print_header(text): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + +def print_success(text): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + +def print_info(text): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + +async def check_spot_instruments(): + """Query Deribit instruments and summarize spot pairs and basic prices.""" + print_header("Inspect Deribit instruments (spot / futures / options)") + + tool = GetInstrumentsTool() + + # Query multiple currencies and instrument kinds + currencies = ["BTC", "ETH", "USDC"] + kinds = ["spot", "future", "option"] + + results = {} + + for currency in currencies: + print(f"\n{Colors.BOLD}{Colors.CYAN}Querying {currency} instruments...{Colors.RESET}") + + for kind in kinds: + try: + result = await tool.execute(currency=currency, kind=kind, expired=False) + + if isinstance(result, dict) and result.get("error"): + print(f" {Colors.RED}❌ {kind} query failed: {result.get('error')}{Colors.RESET}") + continue + + instruments = result.get("output") if isinstance(result, dict) else result + + if instruments: + key = f"{currency}_{kind}" + results[key] = instruments + + print(f" {Colors.GREEN}✅ {kind}: {len(instruments)} instruments{Colors.RESET}") + + # Show first 5 instruments + for inst in instruments[:5]: + inst_name = inst.get("instrument_name", "N/A") + print(f" - {inst_name}") + + if len(instruments) > 5: + print(f" ... and {len(instruments) - 5} more") + else: + print(f" {Colors.YELLOW}⚠️ {kind}: no instruments found{Colors.RESET}") + + except Exception as e: + print(f" {Colors.RED}❌ {kind} query raised exception: {e}{Colors.RESET}") + + # Spot summary + print_header("Spot instrument summary") + + spot_pairs = {} + for key, instruments in results.items(): + if "spot" in key: + currency = key.split("_")[0] + spot_pairs[currency] = instruments + + if spot_pairs: + print_success("Deribit exposes spot instruments.") + print() + + for currency, instruments in spot_pairs.items(): + print(f"{Colors.BOLD}{currency} spot instruments ({len(instruments)} instruments):{Colors.RESET}") + for inst in instruments: + inst_name = inst.get("instrument_name", "N/A") + base_currency = inst.get("base_currency", "N/A") + quote_currency = inst.get("quote_currency", "N/A") + print(f" - {inst_name} ({base_currency}/{quote_currency})") + + # Try to fetch some sample spot prices + print_header("Example spot price lookups") + + # BTC spot + btc_spot = spot_pairs.get("BTC", []) + if btc_spot: + # Prefer BTC-USD-like spot pairs + btc_usd = None + for inst in btc_spot: + inst_name = inst.get("instrument_name", "") + if "USD" in inst_name or "USDC" in inst_name: + btc_usd = inst_name + break + + if btc_usd: + print_info(f"Querying price for {btc_usd}...") + try: + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name=btc_usd) + + if not ticker_result.get("error"): + ticker = ticker_result.get("output") + price = ticker.get("last_price") or ticker.get("mark_price") + print_success(f"{btc_usd} price: ${price:,.2f}") + else: + print(f"{Colors.YELLOW}⚠️ Price query failed: {ticker_result.get('error')}{Colors.RESET}") + except Exception as e: + print(f"{Colors.YELLOW}⚠️ Price query raised exception: {e}{Colors.RESET}") + + # ETH spot + eth_spot = spot_pairs.get("ETH", []) + if eth_spot: + eth_usd = None + for inst in eth_spot: + inst_name = inst.get("instrument_name", "") + if "USD" in inst_name or "USDC" in inst_name: + eth_usd = inst_name + break + + if eth_usd: + print_info(f"Querying price for {eth_usd}...") + try: + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name=eth_usd) + + if not ticker_result.get("error"): + ticker = ticker_result.get("output") + price = ticker.get("last_price") or ticker.get("mark_price") + print_success(f"{eth_usd} price: ${price:,.2f}") + else: + print(f"{Colors.YELLOW}⚠️ Price query failed: {ticker_result.get('error')}{Colors.RESET}") + except Exception as e: + print(f"{Colors.YELLOW}⚠️ Price query raised exception: {e}{Colors.RESET}") + else: + print(f"{Colors.YELLOW}⚠️ No spot instruments found{Colors.RESET}") + print_info("Deribit primarily focuses on derivatives (futures and options).") + + # Compare how many instruments exist by kind + print_header("Instrument type comparison") + + for currency in currencies: + future_count = len(results.get(f"{currency}_future", [])) + spot_count = len(results.get(f"{currency}_spot", [])) + option_count = len(results.get(f"{currency}_option", [])) + + print(f"\n{Colors.BOLD}{currency}:{Colors.RESET}") + print(f" futures: {future_count}") + print(f" spot : {spot_count}") + print(f" options: {option_count}") + + print(f"\n{Colors.CYAN}{'='*70}{Colors.RESET}\n") + + +if __name__ == "__main__": + try: + asyncio.run(check_spot_instruments()) + except KeyboardInterrupt: + print(f"\n\n{Colors.YELLOW}Query interrupted by user{Colors.RESET}") + except Exception as e: + print(f"\n\n{Colors.RED}Query failed with exception: {e}{Colors.RESET}") + diff --git a/spoon_toolkits/data_platforms/deribit/examples/run_all_verification.py b/spoon_toolkits/data_platforms/deribit/examples/run_all_verification.py new file mode 100644 index 0000000..50b2d55 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/run_all_verification.py @@ -0,0 +1,364 @@ +"""Complete verification script - Run all tests and verify all functionality""" + +import asyncio +import sys +import os +from pathlib import Path +from datetime import datetime + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig + + +class Colors: + """ANSI color codes for terminal output""" + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +def print_header(text): + """Print formatted header""" + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + +def print_success(text): + """Print success message""" + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + +def print_error(text): + """Print error message""" + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + +def print_warning(text): + """Print warning message""" + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + +def print_info(text): + """Print info message""" + print(f"{Colors.BLUE}ℹ️ {text}{Colors.RESET}") + + +async def verify_imports(): + """Verify all modules can be imported""" + print_header("Phase 1: Module Import Verification") + + results = { + "passed": 0, + "failed": 0, + "total": 0 + } + + # Test core modules + modules_to_test = [ + ("jsonrpc_client", "DeribitJsonRpcClient"), + ("auth", "DeribitAuth"), + ("base", "DeribitBaseTool"), + ("env", "DeribitConfig"), + ("cache", "time_cache"), + ] + + for module_name, class_name in modules_to_test: + results["total"] += 1 + try: + module = __import__(f"spoon_toolkits.deribit.{module_name}", fromlist=[class_name]) + cls = getattr(module, class_name) + print_success(f"{module_name}.{class_name} imported successfully") + results["passed"] += 1 + except Exception as e: + print_error(f"{module_name}.{class_name} import failed: {e}") + results["failed"] += 1 + + # Test tool modules + tool_modules = [ + ("market_data", ["GetInstrumentsTool", "GetOrderBookTool", "GetTickerTool"]), + ("account", ["GetAccountSummaryTool", "GetPositionsTool"]), + ("trading", ["PlaceBuyOrderTool", "CancelOrderTool"]), + ] + + for module_name, tool_names in tool_modules: + for tool_name in tool_names: + results["total"] += 1 + try: + module = __import__(f"spoon_toolkits.deribit.{module_name}", fromlist=[tool_name]) + cls = getattr(module, tool_name) + print_success(f"{module_name}.{tool_name} imported successfully") + results["passed"] += 1 + except Exception as e: + print_error(f"{module_name}.{tool_name} import failed: {e}") + results["failed"] += 1 + + print(f"\n{Colors.BOLD}Import Test Results: {results['passed']}/{results['total']} passed{Colors.RESET}") + return results["failed"] == 0 + + +async def verify_public_api(): + """Verify public API connection""" + print_header("Phase 2: Public API Connection Verification") + + from spoon_toolkits.deribit.jsonrpc_client import DeribitJsonRpcClient + + tests = [ + ("Get Instruments", "public/get_instruments", {"currency": "BTC", "kind": "future"}), + ("Get Order Book", "public/get_order_book", {"instrument_name": "BTC-PERPETUAL", "depth": 5}), + ("Get Ticker", "public/ticker", {"instrument_name": "BTC-PERPETUAL"}), + ("Get Index Price", "public/get_index_price", {"index_name": "btc_usd"}), + ] + + passed = 0 + failed = 0 + + async with DeribitJsonRpcClient() as client: + for test_name, method, params in tests: + try: + result = await client.call(method, params) + if result is not None: + print_success(f"{test_name}: OK") + passed += 1 + else: + print_error(f"{test_name}: No result returned") + failed += 1 + except Exception as e: + print_error(f"{test_name}: {str(e)}") + failed += 1 + + print(f"\n{Colors.BOLD}Public API Test Results: {passed}/{len(tests)} passed{Colors.RESET}") + return failed == 0 + + +async def verify_market_data_tools(): + """Verify all market data tools""" + print_header("Phase 3: Market Data Tools Verification") + + from spoon_toolkits.deribit.market_data import ( + GetInstrumentsTool, + GetOrderBookTool, + GetTickerTool, + GetLastTradesTool, + GetIndexPriceTool, + GetBookSummaryTool + ) + + tests = [ + (GetInstrumentsTool, {"currency": "BTC", "kind": "future"}, "GetInstrumentsTool"), + (GetOrderBookTool, {"instrument_name": "BTC-PERPETUAL", "depth": 5}, "GetOrderBookTool"), + (GetTickerTool, {"instrument_name": "BTC-PERPETUAL"}, "GetTickerTool"), + (GetLastTradesTool, {"instrument_name": "BTC-PERPETUAL", "count": 5}, "GetLastTradesTool"), + (GetIndexPriceTool, {"index_name": "btc_usd"}, "GetIndexPriceTool"), + (GetBookSummaryTool, {"currency": "BTC", "kind": "future"}, "GetBookSummaryTool"), + ] + + passed = 0 + failed = 0 + + for tool_class, params, tool_name in tests: + try: + tool = tool_class() + result = await tool.execute(**params) + + if isinstance(result, dict) and result.get("error"): + print_error(f"{tool_name}: {result.get('error')}") + failed += 1 + else: + output = result.get("output") if isinstance(result, dict) else result + if output is not None: + print_success(f"{tool_name}: OK") + passed += 1 + else: + print_error(f"{tool_name}: No output") + failed += 1 + except Exception as e: + print_error(f"{tool_name}: Exception - {str(e)}") + failed += 1 + + print(f"\n{Colors.BOLD}Market Data Tools Results: {passed}/{len(tests)} passed{Colors.RESET}") + return failed == 0 + + +async def verify_authentication(): + """Verify authentication (if credentials available)""" + print_header("Phase 4: Authentication Verification") + + if not DeribitConfig.validate_credentials(): + print_warning("API credentials not configured - skipping authentication tests") + print_info("Set DERIBIT_CLIENT_ID and DERIBIT_CLIENT_SECRET to test authentication") + return True # Not a failure, just skipped + + from spoon_toolkits.deribit.auth import DeribitAuth + + try: + auth = DeribitAuth() + result = await auth.authenticate() + + if result and result.get("access_token"): + print_success("Authentication: OK") + print_info(f" Token: {auth.get_access_token()[:20]}...") + print_info(f" Scope: {result.get('scope', 'N/A')}") + print_info(f" Expires in: {result.get('expires_in', 'N/A')} seconds") + + # Test token validity + is_valid = auth.is_token_valid() + print_success(f"Token validity check: {'Valid' if is_valid else 'Invalid'}") + + return True + else: + print_error("Authentication: Failed - No access token") + return False + except Exception as e: + print_error(f"Authentication: Exception - {str(e)}") + return False + + +async def verify_account_tools(): + """Verify account management tools (if credentials available)""" + print_header("Phase 5: Account Management Tools Verification") + + if not DeribitConfig.validate_credentials(): + print_warning("API credentials not configured - skipping account tools tests") + return True + + from spoon_toolkits.deribit.account import ( + GetAccountSummaryTool, + GetPositionsTool, + GetOrderHistoryTool, + GetTradeHistoryTool + ) + + tests = [ + (GetAccountSummaryTool, {"currency": "BTC"}, "GetAccountSummaryTool"), + (GetPositionsTool, {"currency": "BTC"}, "GetPositionsTool"), + (GetOrderHistoryTool, {"instrument_name": "BTC-PERPETUAL", "count": 5}, "GetOrderHistoryTool"), + (GetTradeHistoryTool, {"instrument_name": "BTC-PERPETUAL", "count": 5}, "GetTradeHistoryTool"), + ] + + passed = 0 + failed = 0 + + for tool_class, params, tool_name in tests: + try: + tool = tool_class() + result = await tool.execute(**params) + + if isinstance(result, dict) and result.get("error"): + print_error(f"{tool_name}: {result.get('error')}") + failed += 1 + else: + output = result.get("output") if isinstance(result, dict) else result + if output is not None: + print_success(f"{tool_name}: OK") + passed += 1 + else: + print_error(f"{tool_name}: No output") + failed += 1 + except Exception as e: + print_error(f"{tool_name}: Exception - {str(e)}") + failed += 1 + + print(f"\n{Colors.BOLD}Account Tools Results: {passed}/{len(tests)} passed{Colors.RESET}") + return failed == 0 + + +async def verify_trading_tools(): + """Verify trading tools (read-only operations only)""" + print_header("Phase 6: Trading Tools Verification") + + if not DeribitConfig.validate_credentials(): + print_warning("API credentials not configured - skipping trading tools tests") + return True + + from spoon_toolkits.deribit.trading import GetOpenOrdersTool + + # Only test read-only operations to avoid placing real orders + try: + tool = GetOpenOrdersTool() + result = await tool.execute(instrument_name="BTC-PERPETUAL") + + if isinstance(result, dict) and result.get("error"): + print_warning(f"GetOpenOrdersTool: {result.get('error')} (may be expected if no orders)") + else: + print_success("GetOpenOrdersTool: OK (read-only test)") + + print_warning("Other trading tools (PlaceBuyOrder, PlaceSellOrder, etc.) skipped") + print_warning("These would execute real orders - test manually with caution") + + return True + except Exception as e: + print_error(f"GetOpenOrdersTool: Exception - {str(e)}") + return False + + +async def verify_mcp_integration(): + """Verify MCP service integration""" + print_header("Phase 7: MCP Service Integration Verification") + + try: + from spoon_toolkits.deribit import mcp + + print_success("MCP server initialized") + print_info(f" Server name: {mcp.name if hasattr(mcp, 'name') else 'Deribit Tools'}") + + # Count registered tools + tool_count = len([attr for attr in dir(mcp) if not attr.startswith("_") and callable(getattr(mcp, attr, None))]) + print_success(f"MCP tools registered: {tool_count}") + + return True + except Exception as e: + print_error(f"MCP integration: Exception - {str(e)}") + return False + + +async def main(): + """Run all verification tests""" + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}Deribit API Integration - Complete Verification{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}Started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + results = {} + + # Run all verification phases + results["imports"] = await verify_imports() + results["public_api"] = await verify_public_api() + results["market_data"] = await verify_market_data_tools() + results["authentication"] = await verify_authentication() + results["account_tools"] = await verify_account_tools() + results["trading_tools"] = await verify_trading_tools() + results["mcp_integration"] = await verify_mcp_integration() + + # Final summary + print_header("Final Verification Summary") + + total_tests = len(results) + passed_tests = sum(1 for v in results.values() if v) + failed_tests = total_tests - passed_tests + + for phase, result in results.items(): + status = f"{Colors.GREEN}✅ PASSED{Colors.RESET}" if result else f"{Colors.RED}❌ FAILED{Colors.RESET}" + print(f"{phase.replace('_', ' ').title()}: {status}") + + print(f"\n{Colors.BOLD}Overall Results: {passed_tests}/{total_tests} phases passed{Colors.RESET}") + + if failed_tests == 0: + print_success("\n🎉 All verification tests passed!") + else: + print_error(f"\n⚠️ {failed_tests} phase(s) failed. Please review errors above.") + + print(f"\n{Colors.BOLD}Completed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}\n") + + return failed_tests == 0 + + +if __name__ == "__main__": + success = asyncio.run(main()) + sys.exit(0 if success else 1) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_0.02eth_safe_trading.py b/spoon_toolkits/data_platforms/deribit/examples/test_0.02eth_safe_trading.py new file mode 100644 index 0000000..e9a7bd1 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_0.02eth_safe_trading.py @@ -0,0 +1,395 @@ +"""0.02 ETH safe trading test script – based on the Deribit examples. + +This script is intended for very small balances (around 0.02 ETH) and focuses on: +- Environment and credential checks +- Account and position queries +- A deep, non-filling limit futures order with post_only + +All futures orders are placed far from the market and immediately cancelled, +so no real position should be opened if everything works as expected. +""" + +import asyncio +import sys +from pathlib import Path +from datetime import datetime + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent.parent)) # Adjust path for DeSearchMcp + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.market_data import GetTickerTool, GetInstrumentsTool +from spoon_toolkits.deribit.account import GetAccountSummaryTool, GetPositionsTool +from spoon_toolkits.deribit.trading import ( + PlaceBuyOrderTool, + CancelOrderTool, + GetOpenOrdersTool, +) + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +def print_header(text): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + +def print_success(text): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + +def print_error(text): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + +def print_warning(text): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + +def print_info(text): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + +async def step1_check_environment(): + """Step 1: environment and credential sanity check.""" + print_header("STEP 1: Environment check") + + # Check API configuration + if not DeribitConfig.validate_credentials(): + print_error("API credentials are not configured.") + print_info("Please configure DERIBIT_CLIENT_ID and DERIBIT_CLIENT_SECRET in your .env file.") + return False + + print_success("API credentials are configured.") + client_id_preview = DeribitConfig.CLIENT_ID[:10] + "..." if DeribitConfig.CLIENT_ID else "NOT SET" + print_info(f"Client ID (preview): {client_id_preview}") + + # Check whether we are using mainnet or testnet + api_url = DeribitConfig.get_api_url() + is_testnet = DeribitConfig.USE_TESTNET + + if is_testnet: + print_warning("Using TESTNET") + print_info(f"API URL: {api_url}") + else: + print_warning("Using MAINNET – real funds at risk") + print_info(f"API URL: {api_url}") + + return True + + +async def step2_check_account(): + """Step 2: check ETH account summary for a small-balance account.""" + print_header("STEP 2: Check ETH account summary") + + try: + account_tool = GetAccountSummaryTool() + result = await account_tool.execute(currency="ETH") + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to fetch account summary: {result.get('error')}") + return False, None + + account = result.get("output") if isinstance(result, dict) else result + balance = account.get("balance", 0) + available = account.get("available_funds", 0) + equity = account.get("equity", 0) + + print_success("Account summary fetched successfully") + print_info(f"ETH balance : {balance} ETH") + print_info(f"Available : {available} ETH") + print_info(f"Equity : {equity} ETH") + + if balance < 0.01: + print_warning("Balance is small; futures tests may fail with insufficient margin.") + print_info("Consider funding at least ~0.05 ETH for a smoother experience.") + else: + print_success(f"Balance is sufficient ({balance} ETH), continuing.") + + return True, account + + except Exception as e: + print_error(f"Account summary raised exception: {e}") + return False, None + + +async def step3_check_positions(): + """Step 3: check current ETH positions (if any).""" + print_header("STEP 3: Check open positions") + + try: + positions_tool = GetPositionsTool() + result = await positions_tool.execute(currency="ETH") + + if isinstance(result, dict) and result.get("error"): + print_warning(f"Failed to fetch positions (possibly none): {result.get('error')}") + return True # not an error; likely just no positions + + positions = result.get("output") if isinstance(result, dict) else result + + if positions: + print_warning(f"Found {len(positions)} open positions") + for pos in positions: + print_info( + f" Instrument: {pos.get('instrument_name')}, " + f"size: {pos.get('size')}, " + f"direction: {pos.get('direction')}" + ) + else: + print_success("No open positions found") + + return True + + except Exception as e: + print_error(f"Position query raised exception: {e}") + return False + + +async def step4_get_market_price(): + """Step 4: fetch current price for ETH-PERPETUAL futures.""" + print_header("STEP 4: Get ETH-PERPETUAL price") + + try: + ticker_tool = GetTickerTool() + result = await ticker_tool.execute(instrument_name="ETH-PERPETUAL") + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to fetch ETH-PERPETUAL price: {result.get('error')}") + return False, None + + ticker = result.get("output") if isinstance(result, dict) else result + current_price = ticker.get("last_price") or ticker.get("mark_price") + + if not current_price: + print_error("Unable to get last/mark price for ETH-PERPETUAL") + return False, None + + print_success(f"Current ETH futures price: ${current_price:,.2f}") + print_info(f"Mark price : ${ticker.get('mark_price', 'N/A'):,.2f}") + print_info(f"Best bid : ${ticker.get('best_bid_price', 'N/A'):,.2f}") + print_info(f"Best ask : ${ticker.get('best_ask_price', 'N/A'):,.2f}") + + return True, current_price + + except Exception as e: + print_error(f"Futures price query raised exception: {e}") + return False, None + + +async def step5_place_safe_order(current_price): + """Step 5: place a deep, post_only futures limit order that should not fill.""" + print_header("STEP 5: Place safe futures limit buy order") + + # Use a safe price (40% below current price) so the order will not fill. + safe_price = current_price * 0.6 + order_amount = 1 # 1 contract = 1 ETH + + print_info(f"Current price: ${current_price:,.2f}") + print_info(f"Limit price : ${safe_price:,.2f} (40% below current price)") + print_info(f"Order size : {order_amount} contract(s)") + print_warning("⚠️ Price is far below market; the order should not fill.") + + # Show a dry-run style preview. + print(f"\n{Colors.YELLOW}Placing futures order with parameters:{Colors.RESET}") + print(f" instrument : ETH-PERPETUAL") + print(f" side : buy") + print(f" amount : {order_amount} contract(s)") + print(f" price : ${safe_price:,.2f}") + print(f" type : limit + post_only") + + try: + buy_tool = PlaceBuyOrderTool() + result = await buy_tool.execute( + instrument_name="ETH-PERPETUAL", + amount=order_amount, + price=safe_price, + order_type="limit", + post_only=True, + time_in_force="good_til_cancelled", + ) + + if isinstance(result, dict) and result.get("error"): + error_msg = result.get("error", "") + print_error(f"Order placement failed: {error_msg}") + + if "insufficient" in error_msg.lower() or "balance" in error_msg.lower(): + print_warning("Insufficient margin, but we validated that:") + print_success(" ✅ Account APIs work") + print_success(" ✅ Trading endpoint works") + print_success(" ✅ API key permissions are correct") + print_info(" You can deposit more ETH later to run a full workflow.") + return False, None + + order_info = ( + result.get("output", {}).get("order", {}) + if isinstance(result, dict) + else result.get("order", {}) + ) + order_id = order_info.get("order_id") + + if not order_id: + print_error("Order created but no order_id returned") + return False, None + + print_success("Futures limit order created.") + print_info(f" order_id : {order_id}") + print_info(f" amount : {order_info.get('amount')} contracts") + print_info(f" price : ${order_info.get('price', safe_price):,.2f}") + print_info(f" state : {order_info.get('order_state', 'N/A')}") + + return True, order_id + + except Exception as e: + print_error(f"Order placement raised exception: {e}") + return False, None + + +async def step6_verify_order(order_id): + """Step 6: verify that the created futures order appears in open orders.""" + print_header("STEP 6: Verify futures order") + + try: + await asyncio.sleep(1) # wait for the order to be registered + + orders_tool = GetOpenOrdersTool() + result = await orders_tool.execute(instrument_name="ETH-PERPETUAL") + + if isinstance(result, dict) and result.get("error"): + print_warning(f"Failed to query open orders: {result.get('error')}") + return True # still attempt cancel + + orders = result.get("output") if isinstance(result, dict) else result + found_order = any(o.get("order_id") == order_id for o in orders) + + if found_order: + print_success(f"Order appears in open orders: {order_id}") + for order in orders: + if order.get("order_id") == order_id: + print_info(f" state : {order.get('order_state', 'N/A')}") + print_info(f" amount: {order.get('amount', 'N/A')} contracts") + print_info(f" price : ${order.get('price', 'N/A'):,.2f}") + break + else: + print_warning("Order not present in open orders (might have been rejected).") + print_info("Proceeding to cancel as a safety step...") + + return True + + except Exception as e: + print_error(f"Order verification raised exception: {e}") + return True # still attempt cancel + + +async def step7_cancel_order(order_id): + """Step 7: cancel the futures limit order.""" + print_header("STEP 7: Cancel futures order") + + try: + cancel_tool = CancelOrderTool() + result = await cancel_tool.execute(order_id=order_id) + + if isinstance(result, dict) and result.get("error"): + print_warning(f"Failed to cancel order: {result.get('error')}") + print_warning("Please manually inspect and cancel any remaining orders.") + return False + + print_success(f"Order cancelled: {order_id}") + return True + + except Exception as e: + print_error(f"Order cancel raised exception: {e}") + return False + + +async def main(): + """Main test entrypoint for the 0.02 ETH safe trading scenario.""" + print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'0.02 ETH safe trading test':^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.RESET}\n") + + print(f"{Colors.YELLOW}Test time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}\n") + + print("This script safely validates Deribit futures trading tools with a tiny balance:") + print(" ✅ Uses limit + post_only so orders do not fill") + print(" ✅ Price set 40% below market to avoid execution") + print(" ✅ Orders are cancelled after verification") + print() + + results = {} + + # STEP 1: environment check + results["env_check"] = await step1_check_environment() + if not results["env_check"]: + print_error("\nEnvironment check failed, aborting test.") + return + + # STEP 2: account summary + account_ok, account = await step2_check_account() + results["account_summary"] = account_ok + if not account_ok: + print_error("\nAccount summary failed, aborting test.") + return + + # STEP 3: positions + results["position_query"] = await step3_check_positions() + + # STEP 4: price + price_ok, current_price = await step4_get_market_price() + results["price_query"] = price_ok + if not price_ok: + print_error("\nPrice query failed, aborting test.") + return + + # STEP 5: place order + order_ok, order_id = await step5_place_safe_order(current_price) + results["place_order"] = order_ok + if not order_ok or not order_id: + print_warning("\nOrder placement failed, but earlier API checks have passed.") + print_info("We validated account, price, and trading endpoints.") + return + + # STEP 6: verify order + results["verify_order"] = await step6_verify_order(order_id) + + # STEP 7: cancel order + results["cancel_order"] = await step7_cancel_order(order_id) + + # Summary + print_header("Test summary") + + passed = sum(1 for v in results.values() if v) + total = len(results) + + for step_name, result in results.items(): + status = f"{Colors.GREEN}✅ PASS{Colors.RESET}" if result else f"{Colors.RED}❌ FAIL{Colors.RESET}" + print(f"{step_name:20s}: {status}") + + print(f"\n{Colors.BOLD}Overall: {passed}/{total} steps passed{Colors.RESET}\n") + + if passed == total: + print_success("🎉 All steps passed; small-balance futures tools are validated.") + elif passed >= total - 1: + print_success("✅ Core functionality validated; some steps may have failed due to low balance.") + else: + print_warning("⚠️ Multiple steps failed; please check configuration and network connectivity.") + + print(f"\n{Colors.CYAN}{'='*70}{Colors.RESET}\n") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print(f"\n\n{Colors.YELLOW}Test interrupted by user{Colors.RESET}") + except Exception as e: + print(f"\n\n{Colors.RED}Test failed with exception: {e}{Colors.RESET}") + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_account_tools.py b/spoon_toolkits/data_platforms/deribit/examples/test_account_tools.py new file mode 100644 index 0000000..194d474 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_account_tools.py @@ -0,0 +1,73 @@ +"""Test account management tools (requires authentication)""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.account import GetAccountSummaryTool, GetPositionsTool + + +async def test_account_tools(): + """Test account management tools""" + print("=" * 60) + print("Testing Account Management Tools") + print("=" * 60) + + # Check credentials + if not DeribitConfig.validate_credentials(): + print("❌ Error: API credentials not configured!") + print(" Account tools require authentication.") + return + + tools_tested = 0 + tools_passed = 0 + + # Test 1: GetAccountSummaryTool + print("\n[Test 1] GetAccountSummaryTool") + tools_tested += 1 + try: + tool = GetAccountSummaryTool() + result = await tool.execute(currency="BTC") + if result.error: + print(f"❌ Failed: {result.error}") + else: + print("✅ Success! Account summary retrieved") + print(f" Balance: {result.output.get('balance', 'N/A')} BTC") + print(f" Equity: {result.output.get('equity', 'N/A')} BTC") + print(f" Available: {result.output.get('available_funds', 'N/A')} BTC") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 2: GetPositionsTool + print("\n[Test 2] GetPositionsTool") + tools_tested += 1 + try: + tool = GetPositionsTool() + result = await tool.execute(currency="BTC") + if result.error: + print(f"❌ Failed: {result.error}") + else: + positions = result.output if isinstance(result.output, list) else [] + print(f"✅ Success! Found {len(positions)} positions") + if positions: + pos = positions[0] + print(f" Example position: {pos.get('instrument_name', 'N/A')}") + print(f" Size: {pos.get('size', 'N/A')}") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Summary + print("\n" + "=" * 60) + print(f"Account Tools Test Summary: {tools_passed}/{tools_tested} passed") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(test_account_tools()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_all_trading_types.py b/spoon_toolkits/data_platforms/deribit/examples/test_all_trading_types.py new file mode 100644 index 0000000..ce7be6c --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_all_trading_types.py @@ -0,0 +1,850 @@ +"""Comprehensive test: spot, futures, options – buy/sell + trade logs + funding tracking + final cleanup.""" + +import asyncio +import sys +from pathlib import Path +import importlib.util +import json +from datetime import datetime +from typing import Dict, List, Optional +from decimal import Decimal + +# Add deribit module directory to path +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + +def load_module(name, file_path): + """Load a module from file path, handling relative imports""" + full_name = f'spoon_toolkits.data_platforms.deribit.{name}' + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + module.__package__ = 'spoon_toolkits.data_platforms.deribit' + import types + parent_pkg = 'spoon_toolkits.data_platforms.deribit' + parts = parent_pkg.split('.') + for i in range(len(parts)): + pkg_name = '.'.join(parts[:i+1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + +# Load modules in dependency order +env_module = load_module('env', deribit_path / 'env.py') +jsonrpc_module = load_module('jsonrpc_client', deribit_path / 'jsonrpc_client.py') +auth_module = load_module('auth', deribit_path / 'auth.py') +base_module = load_module('base', deribit_path / 'base.py') +market_module = load_module('market_data', deribit_path / 'market_data.py') +account_module = load_module('account', deribit_path / 'account.py') +trading_module = load_module('trading', deribit_path / 'trading.py') + +GetInstrumentsTool = market_module.GetInstrumentsTool +GetTickerTool = market_module.GetTickerTool +GetAccountSummaryTool = account_module.GetAccountSummaryTool +GetPositionsTool = account_module.GetPositionsTool +GetOrderHistoryTool = account_module.GetOrderHistoryTool +GetTradeHistoryTool = account_module.GetTradeHistoryTool +GetOpenOrdersTool = trading_module.GetOpenOrdersTool +PlaceBuyOrderTool = trading_module.PlaceBuyOrderTool +PlaceSellOrderTool = trading_module.PlaceSellOrderTool +CancelOrderTool = trading_module.CancelOrderTool +CancelAllOrdersTool = trading_module.CancelAllOrdersTool + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + MAGENTA = '\033[95m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +class ComprehensiveTradingTest: + """Comprehensive trading test covering spot, futures, and options.""" + + def __init__(self): + self.initial_eth_balance = None + self.final_eth_balance = None + self.eth_consumed = 0.0 + + # Instruments used in this comprehensive test + self.spot_pair = None + self.spot_contract_size = None + self.futures_pair = "ETH-PERPETUAL" + self.options_pair = None + self.options_contract_size = None + + # Trade records and created orders + self.trade_records: List[Dict] = [] + self.all_order_ids: List[str] = [] + + # Funding/balance tracking at each step + self.funding_tracking: List[Dict] = [] + + # Create a timestamped JSON log file + log_dir = Path(__file__).parent / "logs" + log_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.log_file = log_dir / f"comprehensive_trading_log_{timestamp}.json" + + def print_header(self, text: str): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + def print_success(self, text: str): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + def print_error(self, text: str): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + def print_info(self, text: str): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + def print_warning(self, text: str): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + async def get_eth_balance(self) -> Optional[float]: + """Get ETH balance.""" + try: + tool = GetAccountSummaryTool() + result = await tool.execute(currency="ETH") + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to get account balance: {result.get('error')}") + return None + + account = result.get("output") if isinstance(result, dict) else result + balance = account.get("balance", 0) + return float(balance) + except Exception as e: + self.print_error(f"Exception while getting account balance: {e}") + return None + + async def find_spot_pair(self) -> bool: + """Find a spot trading pair.""" + self.print_header("STEP 1: Find spot trading pair") + try: + tool = GetInstrumentsTool() + result = await tool.execute(currency="ETH", kind="spot", expired=False) + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to query spot instruments: {result.get('error')}") + return False + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + self.print_error("No ETH spot instruments found") + return False + + for inst in instruments: + inst_name = inst.get("instrument_name", "") + if "USDC" in inst_name or "USDT" in inst_name: + self.spot_pair = inst_name + self.spot_contract_size = inst.get("contract_size") + self.print_success(f"Selected spot instrument: {self.spot_pair}") + self.print_info(f"Contract size: {self.spot_contract_size}") + return True + + # If no preferred pair is found, fall back to the first instrument. + self.spot_pair = instruments[0].get("instrument_name") + self.spot_contract_size = instruments[0].get("contract_size") + self.print_success(f"Selected spot instrument: {self.spot_pair}") + return True + except Exception as e: + self.print_error(f"Exception while finding spot instrument: {e}") + return False + + async def find_options_pair(self) -> bool: + """Find an options trading instrument.""" + self.print_header("STEP 2: Find options instrument") + try: + tool = GetInstrumentsTool() + result = await tool.execute(currency="ETH", kind="option", expired=False) + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to query options instruments: {result.get('error')}") + return False + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + self.print_warning("No ETH options instruments found") + return False + + # Prefer a call option + for inst in instruments: + inst_name = inst.get("instrument_name", "") + if inst_name.endswith("-C"): + self.options_pair = inst_name + self.options_contract_size = inst.get("contract_size") + self.print_success(f"Selected options instrument: {self.options_pair}") + self.print_info(f"Contract size: {self.options_contract_size}") + return True + + # If no call option was found, fall back to a put. + for inst in instruments: + inst_name = inst.get("instrument_name", "") + if inst_name.endswith("-P"): + self.options_pair = inst_name + self.options_contract_size = inst.get("contract_size") + self.print_success(f"Selected options instrument: {self.options_pair}") + self.print_info(f"Contract size: {self.options_contract_size}") + return True + + return False + except Exception as e: + self.print_error(f"Exception while finding options instrument: {e}") + return False + + def adjust_amount_to_contract_size(self, amount: float, contract_size: float) -> float: + """Adjust amount to be a multiple of contract_size.""" + if contract_size <= 0: + return amount + + amount_decimal = Decimal(str(amount)) + contract_decimal = Decimal(str(contract_size)) + multiple = round(amount_decimal / contract_decimal) + + if multiple < 1: + multiple = 1 + + adjusted = multiple * contract_decimal + + contract_size_str = str(contract_size) + if '.' in contract_size_str: + decimals = len(contract_size_str.split('.')[1]) + else: + decimals = 0 + + return float(round(adjusted, decimals)) + + async def get_market_price(self, instrument_name: str) -> Optional[float]: + """Get last/mark price for an instrument.""" + try: + tool = GetTickerTool() + result = await tool.execute(instrument_name=instrument_name) + + if isinstance(result, dict) and result.get("error"): + return None + + ticker = result.get("output") if isinstance(result, dict) else result + price = ticker.get("last_price") or ticker.get("mark_price") + return float(price) if price else None + except Exception as e: + self.print_error(f"Exception while getting price: {e}") + return None + + async def place_market_order(self, instrument_name: str, amount: float, side: str) -> Optional[str]: + """Place a market order.""" + try: + tool = PlaceBuyOrderTool() if side == "buy" else PlaceSellOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + order_type="market" + ) + + if isinstance(result, dict) and result.get("error"): + error_msg = result.get("error", "") + self.print_error(f"{side.upper()} order failed: {error_msg}") + self.log_failed_trade(instrument_name, side, amount, error_msg) + return None + + order = result.get("output", {}).get("order", {}) if isinstance(result, dict) else result.get("order", {}) + order_id = order.get("order_id") + + if order_id: + self.all_order_ids.append(order_id) + await asyncio.sleep(2) # wait for fills + await self.query_and_log_trade(instrument_name, order_id, side, amount) + return order_id + + return None + except Exception as e: + self.print_error(f"Exception while placing order: {e}") + self.log_failed_trade(instrument_name, side, amount, str(e)) + return None + + async def query_and_log_trade(self, instrument_name: str, order_id: str, side: str, amount: float): + """Query and log trade/order data for a given order.""" + try: + # Query order history + order_tool = GetOrderHistoryTool() + order_result = await order_tool.execute( + instrument_name=instrument_name, + count=10 + ) + + order_data = None + if isinstance(order_result, dict) and not order_result.get("error"): + orders = order_result.get("output", []) + for order in orders: + if order.get("order_id") == order_id: + order_data = order + break + + # Query trade history + trade_tool = GetTradeHistoryTool() + trade_result = await trade_tool.execute( + instrument_name=instrument_name, + count=10 + ) + + trade_data = None + if isinstance(trade_result, dict) and not trade_result.get("error"): + trades = trade_result.get("output", []) + if isinstance(trades, list): + for trade in trades: + if trade.get("order_id") == order_id: + trade_data = trade + break + + # Build trade record + trade_record = { + "timestamp": datetime.now().isoformat(), + "order_id": order_id, + "instrument_name": instrument_name, + "side": side.upper(), + "amount": amount, + "order_data": order_data, + "trade_data": trade_data, + "status": "success" if order_data else "pending" + } + + self.trade_records.append(trade_record) + + # Print trade record + self.print_header(f"Trade record - {side.upper()} {instrument_name}") + if order_data: + self.print_info(f"Order ID : {order_id}") + self.print_info(f"Order state: {order_data.get('order_state', 'N/A')}") + self.print_info(f"Order type : {order_data.get('order_type', 'N/A')}") + self.print_info(f"Amount : {order_data.get('amount', 'N/A')}") + self.print_info(f"Price : {order_data.get('average_price', order_data.get('price', 'N/A'))}") + else: + self.print_warning("Order data not found") + + if trade_data: + self.print_info(f"Trade price : {trade_data.get('price', 'N/A')}") + self.print_info(f"Trade amount: {trade_data.get('amount', 'N/A')}") + + # Persist record + self.write_trade_log(trade_record) + + except Exception as e: + self.print_error(f"Exception while querying trade record: {e}") + + def log_failed_trade(self, instrument_name: str, side: str, amount: float, error_msg: str): + """Record a failed trade attempt and write it to the log file.""" + failed_record = { + "timestamp": datetime.now().isoformat(), + "instrument_name": instrument_name, + "side": side.upper(), + "amount": amount, + "status": "failed", + "error": error_msg + } + self.trade_records.append(failed_record) + self.write_trade_log(failed_record) + + def write_trade_log(self, record: Dict): + """Write a trade record into the JSON log file.""" + try: + if self.log_file.exists(): + with open(self.log_file, 'r', encoding='utf-8') as f: + data = json.load(f) + else: + data = [] + + data.append(record) + + with open(self.log_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except Exception as e: + self.print_error(f"Exception while writing trade log: {e}") + + async def track_funding(self, action: str, instrument_name: str, amount: float, price: Optional[float] = None): + """Track balance usage and log ETH balance at each step.""" + balance = await self.get_eth_balance() + record = { + "timestamp": datetime.now().isoformat(), + "action": action, + "instrument_name": instrument_name, + "amount": amount, + "price": price, + "eth_balance": balance + } + self.funding_tracking.append(record) + self.print_info(f"Funding: {action} | balance: {balance} ETH") + + async def test_spot_trading(self) -> bool: + """Test spot trading: sell then buy back.""" + self.print_header("TEST 1: Spot trading (sell + buy)") + + if not self.spot_pair: + self.print_error("No spot pair found, skipping spot test") + return False + + price = await self.get_market_price(self.spot_pair) + if not price: + self.print_error("Unable to fetch spot price") + return False + + # Use 30% of balance for the spot test + balance = await self.get_eth_balance() + if not balance: + return False + + eth_amount = balance * 0.3 + + # Adjust amount to contract_size, if any + if self.spot_contract_size: + eth_amount = self.adjust_amount_to_contract_size(eth_amount, self.spot_contract_size) + + self.print_info(f"Current price: ${price:,.2f}") + self.print_info(f"Sell amount : {eth_amount} ETH") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + + # Sell ETH -> USDC + await self.track_funding("spot_sell_before", self.spot_pair, eth_amount, price) + sell_order_id = await self.place_market_order(self.spot_pair, eth_amount, "sell") + await self.track_funding("spot_sell_after", self.spot_pair, eth_amount, price) + + if not sell_order_id: + self.print_error("Spot sell failed") + return False + + await asyncio.sleep(2) + + # Buy ETH <- USDC using the USDC obtained from the previous sell. + new_price = await self.get_market_price(self.spot_pair) + if new_price: + price = new_price + + # Wait for settlement + await asyncio.sleep(3) + + # Calculate how much ETH we can buy based on USDC balance + usdc_balance = await self.get_usdc_balance() + if usdc_balance and usdc_balance > 1.0: # require at least 1 USDC + # Use 90% of USDC balance, reserving some for fees. + usable_usdc = usdc_balance * 0.9 + buy_eth_amount = usable_usdc / price + if self.spot_contract_size: + buy_eth_amount = self.adjust_amount_to_contract_size(buy_eth_amount, self.spot_contract_size) + + # Ensure the ETH amount does not exceed the affordable maximum given usable_usdc. + max_eth = usable_usdc / price + if buy_eth_amount > max_eth: + buy_eth_amount = max_eth + if self.spot_contract_size: + buy_eth_amount = self.adjust_amount_to_contract_size(buy_eth_amount, self.spot_contract_size) + else: + self.print_warning( + f"Insufficient USDC balance: {usdc_balance if usdc_balance else 0}; skipping spot buy" + ) + return False + + await self.track_funding("spot_buy_before", self.spot_pair, buy_eth_amount, price) + buy_order_id = await self.place_market_order(self.spot_pair, buy_eth_amount, "buy") + await self.track_funding("spot_buy_after", self.spot_pair, buy_eth_amount, price) + + if buy_order_id: + self.print_success("Spot round-trip completed") + return True + else: + self.print_error("Spot buy failed") + return False + + async def get_usdc_balance(self) -> Optional[float]: + """Get USDC balance.""" + try: + tool = GetAccountSummaryTool() + result = await tool.execute(currency="USDC") + + if isinstance(result, dict) and result.get("error"): + return None + + account = result.get("output") if isinstance(result, dict) else result + balance = account.get("balance", 0) + return float(balance) + except Exception: + return None + + async def test_futures_trading(self) -> bool: + """Test futures trading: open and then close a small position.""" + self.print_header("TEST 2: Futures trading (buy + sell)") + + # Check current positions + positions_tool = GetPositionsTool() + positions_result = await positions_tool.execute(currency="ETH", kind="future") + + current_position = 0 + if isinstance(positions_result, dict) and not positions_result.get("error"): + positions = positions_result.get("output", []) + for pos in positions: + if pos.get("instrument_name") == self.futures_pair: + current_position = pos.get("size", 0) + break + + # Buy 1 contract + amount = 1.0 + self.print_info(f"Buy amount: {amount} contracts") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + + await self.track_funding("futures_buy_before", self.futures_pair, amount) + buy_order_id = await self.place_market_order(self.futures_pair, amount, "buy") + await self.track_funding("futures_buy_after", self.futures_pair, amount) + + if not buy_order_id: + self.print_error("Futures buy failed") + return False + + await asyncio.sleep(2) + + # Sell to close the position + self.print_info(f"Sell amount: {amount} contracts (closing position)") + await self.track_funding("futures_sell_before", self.futures_pair, amount) + sell_order_id = await self.place_market_order(self.futures_pair, amount, "sell") + await self.track_funding("futures_sell_after", self.futures_pair, amount) + + if sell_order_id: + self.print_success("Futures round-trip completed") + return True + else: + self.print_error("Futures sell failed") + return False + + async def test_options_trading(self) -> bool: + """Test options trading (if an options instrument was found).""" + self.print_header("TEST 3: Options trading (buy + sell)") + + if not self.options_pair: + self.print_warning("No options pair found, skipping options test") + return False + + price = await self.get_market_price(self.options_pair) + if not price: + self.print_warning("Unable to fetch options price; skipping options test") + return False + + # Buy 1 options contract (or 1×contract_size) + amount = self.options_contract_size if self.options_contract_size else 1.0 + + self.print_info(f"Buy amount: {amount} contracts") + self.print_info(f"Current price: ${price:,.4f}") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + + await self.track_funding("options_buy_before", self.options_pair, amount, price) + buy_order_id = await self.place_market_order(self.options_pair, amount, "buy") + await self.track_funding("options_buy_after", self.options_pair, amount, price) + + if not buy_order_id: + self.print_warning("Options buy failed (likely insufficient funds)") + return False + + await asyncio.sleep(2) + + # Sell the same number of contracts + new_price = await self.get_market_price(self.options_pair) + if new_price: + price = new_price + + await self.track_funding("options_sell_before", self.options_pair, amount, price) + sell_order_id = await self.place_market_order(self.options_pair, amount, "sell") + await self.track_funding("options_sell_after", self.options_pair, amount, price) + + if sell_order_id: + self.print_success("Options round-trip completed") + return True + else: + self.print_warning("Options sell failed (maybe no open position)") + return False + + async def cleanup_all_orders(self) -> bool: + """Cancel all open orders (spot + futures + options).""" + self.print_header("STEP 4: Cancel all open orders") + + try: + # Cancel all orders for any instrument/currency + cancel_tool = CancelAllOrdersTool() + result = await cancel_tool.execute(currency="ETH", kind="any") + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to cancel all orders: {result.get('error')}") + return False + + self.print_success("All open orders have been cancelled") + return True + except Exception as e: + self.print_error(f"Exception while cancelling orders: {e}") + return False + + async def close_all_positions(self) -> bool: + """Close all remaining positions.""" + self.print_header("STEP 5: Close all positions") + + try: + positions_tool = GetPositionsTool() + result = await positions_tool.execute(currency="ETH", kind="any") + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to query positions: {result.get('error')}") + return False + + positions = result.get("output", []) if isinstance(result, dict) else result + + if not positions: + self.print_success("No positions need to be closed") + return True + + closed = False + for pos in positions: + inst_name = pos.get("instrument_name") + size = pos.get("size", 0) + direction = pos.get("direction", "") + + if abs(size) > 0.0001: # non-zero position + self.print_info(f"Found position: {inst_name} | size: {size} | direction: {direction}") + + # Close position + side = "sell" if size > 0 else "buy" + close_amount = abs(size) + + self.print_info(f"Closing position: {side} {close_amount} {inst_name}") + order_id = await self.place_market_order(inst_name, close_amount, side) + + if order_id: + closed = True + await asyncio.sleep(2) + + if closed: + self.print_success("All positions have been closed") + else: + self.print_success("No positions required closing") + + return True + except Exception as e: + self.print_error(f"Exception while closing positions: {e}") + return False + + async def convert_all_to_eth(self) -> bool: + """Convert remaining USDC to ETH via spot market (best-effort).""" + self.print_header("STEP 6: Convert all assets to ETH") + + try: + # Wait for all trades to settle. + await asyncio.sleep(3) + + # Check USDC balance + usdc_balance = await self.get_usdc_balance() + + if usdc_balance and usdc_balance > 5.0: # require at least 5 USDC (considering fees) + if not self.spot_pair: + self.print_warning("No spot pair found; cannot convert USDC to ETH") + return False + + price = await self.get_market_price(self.spot_pair) + if not price: + self.print_warning("Unable to fetch price; skipping conversion") + return False + + # Use 90% of USDC balance, reserving some for fees. + usable_usdc = usdc_balance * 0.9 + eth_amount = usable_usdc / price + + if self.spot_contract_size: + eth_amount = self.adjust_amount_to_contract_size(eth_amount, self.spot_contract_size) + + # Ensure the ETH amount does not exceed the affordable maximum. + max_eth = usable_usdc / price + if eth_amount > max_eth: + eth_amount = max_eth + if self.spot_contract_size: + eth_amount = self.adjust_amount_to_contract_size(eth_amount, self.spot_contract_size) + + self.print_info(f"USDC balance: {usdc_balance:.2f}") + self.print_info(f"Usable USDC : {usable_usdc:.2f}") + self.print_info(f"Convert to : {eth_amount:.6f} ETH") + + buy_order_id = await self.place_market_order(self.spot_pair, eth_amount, "buy") + if buy_order_id: + self.print_success("USDC has been converted to ETH") + await asyncio.sleep(3) + return True + else: + self.print_warning("USDC conversion failed (possibly due to balance/fees)") + return False + else: + if usdc_balance: + self.print_info(f"USDC balance: {usdc_balance:.2f} (too small; skipping conversion)") + else: + self.print_info("No USDC balance") + return True + except Exception as e: + self.print_error(f"Exception while converting assets: {e}") + return False + + async def verify_final_state(self) -> bool: + """Verify final post-test account state.""" + self.print_header("STEP 7: Verify final state") + + # Check final balance + balance = await self.get_eth_balance() + if balance: + self.print_info(f"Final ETH balance: {balance} ETH") + self.final_eth_balance = balance + + if self.initial_eth_balance: + consumed = self.initial_eth_balance - balance + self.eth_consumed = consumed + self.print_info(f"ETH consumed: {consumed:.6f} ETH") + + # Check open orders + orders_tool = GetOpenOrdersTool() + orders_result = await orders_tool.execute(currency="ETH", kind="any") + + open_orders = [] + if isinstance(orders_result, dict) and not orders_result.get("error"): + open_orders = orders_result.get("output", []) + + if open_orders: + self.print_error(f"There are still {len(open_orders)} open orders") + for order in open_orders: + self.print_error(f" - {order.get('order_id')} | {order.get('instrument_name')}") + return False + else: + self.print_success("No open orders") + + # Check positions across a few major currencies. + active_positions = [] + try: + for currency in ["ETH", "BTC", "USDC"]: + try: + positions_tool = GetPositionsTool() + positions_result = await positions_tool.execute(currency=currency, kind="any") + + positions = [] + if isinstance(positions_result, dict) and not positions_result.get("error"): + positions = positions_result.get("output", []) + + for pos in positions: + size = abs(pos.get("size", 0)) + if size > 0.0001: + active_positions.append(pos) + except Exception: + continue + except Exception as e: + self.print_warning(f"Exception while querying positions: {e}") + + if active_positions: + self.print_error(f"There are still {len(active_positions)} open positions") + for pos in active_positions: + self.print_error(f" - {pos.get('instrument_name')} | size: {pos.get('size')}") + return False + else: + self.print_success("No open positions") + + self.print_success( + "✅ Final state verified: account holds only ETH, with no open orders or positions" + ) + return True + + async def print_summary(self): + """Print a human-readable test summary.""" + self.print_header("Test summary") + + self.print_info( + f"Initial ETH balance: {self.initial_eth_balance:.6f} ETH" + if self.initial_eth_balance + else "Initial ETH balance: N/A" + ) + self.print_info( + f"Final ETH balance : {self.final_eth_balance:.6f} ETH" + if self.final_eth_balance + else "Final ETH balance : N/A" + ) + self.print_info( + f"ETH consumed : {self.eth_consumed:.6f} ETH" + if self.eth_consumed + else "ETH consumed : N/A" + ) + + self.print_info(f"\nTotal trade records: {len(self.trade_records)}") + successful = [r for r in self.trade_records if r.get("status") == "success"] + failed = [r for r in self.trade_records if r.get("status") == "failed"] + + self.print_success(f"Successful trades: {len(successful)}") + self.print_error(f"Failed trades : {len(failed)}") + + self.print_info(f"\nLog file: {self.log_file}") + + async def run_complete_test(self): + """Run the full multi-asset test: spot, futures, options + cleanup.""" + self.print_header("Comprehensive trading test: spot + futures + options") + + self.print_warning("⚠️ This test performs real trades with real funds!") + self.print_warning( + "⚠️ After all trades, the account should hold only ETH with no open orders or positions." + ) + + # Step 0: get initial balance + self.print_header("STEP 0: Get initial balance") + self.initial_eth_balance = await self.get_eth_balance() + if self.initial_eth_balance: + self.print_success(f"Initial ETH balance: {self.initial_eth_balance:.6f} ETH") + else: + self.print_error("Unable to fetch initial balance") + return + + # Step 1: discover instruments + if not await self.find_spot_pair(): + return + + await self.find_options_pair() # options leg is optional + + await asyncio.sleep(1) + + # Step 2: run trading tests + await self.test_spot_trading() + await asyncio.sleep(2) + + await self.test_futures_trading() + await asyncio.sleep(2) + + await self.test_options_trading() # optional options leg + await asyncio.sleep(2) + + # Step 3: cleanup + await self.cleanup_all_orders() + await asyncio.sleep(2) + + await self.close_all_positions() + await asyncio.sleep(2) + + await self.convert_all_to_eth() + await asyncio.sleep(2) + + # Step 4: verification + await self.verify_final_state() + + # Step 5: summary + await self.print_summary() + + +async def main(): + test = ComprehensiveTradingTest() + await test.run_complete_test() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_authentication.py b/spoon_toolkits/data_platforms/deribit/examples/test_authentication.py new file mode 100644 index 0000000..734a35f --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_authentication.py @@ -0,0 +1,77 @@ +"""Test authentication flow""" + +import asyncio +import sys +import os +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.auth import DeribitAuth + + +async def test_authentication(): + """Test OAuth2 authentication""" + print("=" * 60) + print("Testing Deribit Authentication") + print("=" * 60) + + # Check credentials + if not DeribitConfig.validate_credentials(): + print("❌ Error: API credentials not configured!") + print(" Please set DERIBIT_CLIENT_ID and DERIBIT_CLIENT_SECRET") + print(" in your .env file or environment variables.") + return + + print(f"✅ Credentials found") + print(f" Using {'Testnet' if DeribitConfig.USE_TESTNET else 'Mainnet'}") + + auth = DeribitAuth() + + # Test 1: Initial authentication + print("\n[Test 1] Authenticating...") + try: + result = await auth.authenticate() + print("✅ Authentication successful!") + print(f" Access token: {auth.get_access_token()[:20]}...") + print(f" Scope: {result.get('scope', 'N/A')}") + print(f" Expires in: {result.get('expires_in', 'N/A')} seconds") + except Exception as e: + print(f"❌ Authentication failed: {e}") + return + + # Test 2: Token validity check + print("\n[Test 2] Checking token validity...") + is_valid = auth.is_token_valid() + print(f"✅ Token is {'valid' if is_valid else 'invalid'}") + + # Test 3: Token refresh (if refresh_token available) + if auth.refresh_token: + print("\n[Test 3] Testing token refresh...") + try: + result = await auth.refresh_access_token() + print("✅ Token refresh successful!") + print(f" New access token: {auth.get_access_token()[:20]}...") + except Exception as e: + print(f"⚠️ Token refresh failed (may be expected): {e}") + else: + print("\n[Test 3] Skipping token refresh (no refresh_token)") + + # Test 4: Ensure authenticated + print("\n[Test 4] Testing ensure_authenticated...") + try: + await auth.ensure_authenticated() + print("✅ ensure_authenticated() successful!") + except Exception as e: + print(f"❌ ensure_authenticated() failed: {e}") + + print("\n" + "=" * 60) + print("Authentication Test Complete!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(test_authentication()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_complete_suite.py b/spoon_toolkits/data_platforms/deribit/examples/test_complete_suite.py new file mode 100644 index 0000000..22ee31a --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_complete_suite.py @@ -0,0 +1,485 @@ +"""Deribit comprehensive test suite – futures + spot. + +This script focuses on: +- Environment and credential sanity checks +- Account summary +- Discovering ETH spot instruments +- Placing/cancelling safe limit orders for spot and futures + +All orders are deliberately placed far from market price with post_only, +so they should not fill and are cancelled at the end of each test. +""" + +import asyncio +import sys +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Tuple + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.market_data import GetInstrumentsTool, GetTickerTool +from spoon_toolkits.deribit.account import GetAccountSummaryTool, GetPositionsTool +from spoon_toolkits.deribit.trading import ( + PlaceBuyOrderTool, + CancelOrderTool, + GetOpenOrdersTool, +) + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + MAGENTA = '\033[95m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +class TestResult: + """Container for the result of a single test step.""" + def __init__(self, name: str): + self.name = name + self.passed = False + self.error = None + self.details = {} + + def set_passed(self, details: Dict = None): + self.passed = True + self.details = details or {} + + def set_failed(self, error: str, details: Dict = None): + self.passed = False + self.error = error + self.details = details or {} + + +class TestSuite: + """High-level Deribit integration test suite (spot + futures).""" + + def __init__(self): + self.results: List[TestResult] = [] + self.account_balance = None + self.spot_pair = None + self.futures_pair = "ETH-PERPETUAL" + + def print_header(self, text: str): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + def print_success(self, text: str): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + def print_error(self, text: str): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + def print_warning(self, text: str): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + def print_info(self, text: str): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + def print_section(self, text: str): + print(f"\n{Colors.BOLD}{Colors.MAGENTA}{'─'*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.MAGENTA}{text}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.MAGENTA}{'─'*70}{Colors.RESET}\n") + + async def test_environment(self) -> TestResult: + """Test 1: environment and configuration sanity.""" + result = TestResult("Environment check") + self.print_section("TEST 1: Environment check") + + try: + if not DeribitConfig.validate_credentials(): + result.set_failed("API credentials are not configured") + return result + + self.print_success("API credentials are configured") + api_url = DeribitConfig.get_api_url() + is_testnet = DeribitConfig.USE_TESTNET + + if is_testnet: + self.print_warning("Currently using TESTNET") + else: + self.print_warning("Currently using MAINNET – be careful with real funds!") + + self.print_info(f"API URL: {api_url}") + result.set_passed({"api_url": api_url, "is_testnet": is_testnet}) + + except Exception as e: + result.set_failed(f"Environment check raised an exception: {e}") + + return result + + async def test_account_query(self) -> TestResult: + """Test 2: basic account summary.""" + result = TestResult("Account summary") + self.print_section("TEST 2: Account summary") + + try: + account_tool = GetAccountSummaryTool() + account_result = await account_tool.execute(currency="ETH") + + if isinstance(account_result, dict) and account_result.get("error"): + result.set_failed(f"Failed to get account summary: {account_result.get('error')}") + return result + + account = account_result.get("output") if isinstance(account_result, dict) else account_result + balance = account.get("balance", 0) + available = account.get("available_funds", 0) + + self.account_balance = balance + + self.print_success("Account summary fetched successfully") + self.print_info(f"ETH balance : {balance} ETH") + self.print_info(f"Available : {available} ETH") + + if balance < 0.01: + self.print_warning("Balance is small; some trading tests may fail with insufficient funds") + + result.set_passed({"balance": balance, "available": available}) + + except Exception as e: + result.set_failed(f"Account summary raised an exception: {e}") + + return result + + async def test_find_spot_pairs(self) -> TestResult: + """Test 3: discover ETH spot instruments.""" + result = TestResult("Discover ETH spot instruments") + self.print_section("TEST 3: Discover ETH spot instruments") + + try: + tool = GetInstrumentsTool() + spot_result = await tool.execute(currency="ETH", kind="spot", expired=False) + + if isinstance(spot_result, dict) and spot_result.get("error"): + result.set_failed(f"Failed to query spot instruments: {spot_result.get('error')}") + return result + + instruments = spot_result.get("output") if isinstance(spot_result, dict) else spot_result + + if not instruments: + result.set_failed("No ETH spot instruments found") + return result + + self.print_success(f"Found {len(instruments)} ETH spot instruments") + + # Prefer ETH/USDC or ETH/USDT + preferred_pair = None + for inst in instruments: + inst_name = inst.get("instrument_name", "") + if "USDC" in inst_name or "USDT" in inst_name: + preferred_pair = inst_name + break + + if not preferred_pair and instruments: + preferred_pair = instruments[0].get("instrument_name") + + if preferred_pair: + self.spot_pair = preferred_pair + self.print_success(f"Selected spot pair: {preferred_pair}") + result.set_passed({"pair": preferred_pair, "count": len(instruments)}) + else: + result.set_failed("No usable spot trading pair found") + + except Exception as e: + result.set_failed(f"Exception while querying spot instruments: {e}") + + return result + + async def test_spot_trading(self) -> TestResult: + """Test 4: safe spot trading via deep limit order and cancel.""" + result = TestResult("Spot trading test") + self.print_section("TEST 4: Spot trading (deep limit + cancel)") + + if not self.spot_pair: + result.set_failed("No spot pair selected; skipping test") + return result + + try: + # Fetch spot price for the selected instrument + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name=self.spot_pair) + + if isinstance(ticker_result, dict) and ticker_result.get("error"): + result.set_failed(f"Failed to fetch spot price: {ticker_result.get('error')}") + return result + + ticker = ticker_result.get("output") if isinstance(ticker_result, dict) else ticker_result + current_price = ticker.get("last_price") or ticker.get("mark_price") + + if not current_price: + result.set_failed("No price (last/mark) available for spot instrument") + return result + + self.print_info(f"Current price: ${current_price:,.2f}") + + # Create a deep, non-filling limit order. + safe_price = current_price * 0.7 # 30% below current price + order_amount = 0.01 # 0.01 ETH + + self.print_info(f"Limit price: ${safe_price:,.2f} (30% below spot)") + self.print_info(f"Order size : {order_amount} ETH") + + buy_tool = PlaceBuyOrderTool() + buy_result = await buy_tool.execute( + instrument_name=self.spot_pair, + amount=order_amount, + price=safe_price, + order_type="limit", + post_only=True, + time_in_force="good_til_cancelled", + ) + + if isinstance(buy_result, dict) and buy_result.get("error"): + error_msg = buy_result.get("error", "") + if "insufficient" in error_msg.lower() or "balance" in error_msg.lower(): + self.print_warning("Insufficient balance, but interface and validation are working.") + result.set_passed({"interface_available": True, "error": error_msg}) + else: + result.set_failed(f"Spot limit order failed: {error_msg}") + return result + + order_info = ( + buy_result.get("output", {}).get("order", {}) + if isinstance(buy_result, dict) + else buy_result.get("order", {}) + ) + order_id = order_info.get("order_id") + + if not order_id: + result.set_failed("Order created but no order_id returned") + return result + + self.print_success(f"Spot limit order created: {order_id}") + + # Check the order is visible in open orders. + await asyncio.sleep(1) + orders_tool = GetOpenOrdersTool() + orders_result = await orders_tool.execute(instrument_name=self.spot_pair) + orders = orders_result.get("output") if isinstance(orders_result, dict) else orders_result + found_order = any(o.get("order_id") == order_id for o in orders) + + if found_order: + self.print_success("Order is visible in the order book") + else: + self.print_warning("Order not visible in open orders list (may have been cancelled/rejected).") + + # Now cancel the order. + cancel_tool = CancelOrderTool() + cancel_result = await cancel_tool.execute(order_id=order_id) + + if isinstance(cancel_result, dict) and cancel_result.get("error"): + self.print_warning(f"Cancel failed: {cancel_result.get('error')}") + result.set_passed({"order_created": True, "cancel_failed": True}) + else: + self.print_success("Order cancelled successfully") + result.set_passed({"order_created": True, "order_cancelled": True}) + + except Exception as e: + result.set_failed(f"Spot trading test raised an exception: {e}") + + return result + + async def test_futures_trading(self) -> TestResult: + """Test 5: safe futures trading via deep limit order and cancel.""" + result = TestResult("Futures trading test") + self.print_section("TEST 5: Futures trading (deep limit + cancel)") + + try: + # Fetch spot price for the selected instrument + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name=self.futures_pair) + + if isinstance(ticker_result, dict) and ticker_result.get("error"): + result.set_failed(f"Failed to fetch futures price: {ticker_result.get('error')}") + return result + + ticker = ticker_result.get("output") if isinstance(ticker_result, dict) else ticker_result + current_price = ticker.get("last_price") or ticker.get("mark_price") + + if not current_price: + result.set_failed("No price (last/mark) available for futures instrument") + return result + + self.print_info(f"Current futures price: ${current_price:,.2f}") + + # Create a deep, non-filling limit order for 1 contract. + safe_price = current_price * 0.6 # 40% below current price + order_amount = 1 # 1 contract + + self.print_info(f"Limit price: ${safe_price:,.2f} (40% below futures price)") + self.print_info(f"Order size : {order_amount} contracts") + + buy_tool = PlaceBuyOrderTool() + buy_result = await buy_tool.execute( + instrument_name=self.futures_pair, + amount=order_amount, + price=safe_price, + order_type="limit", + post_only=True, + time_in_force="good_til_cancelled", + ) + + if isinstance(buy_result, dict) and buy_result.get("error"): + error_msg = buy_result.get("error", "") + if "insufficient" in error_msg.lower() or "balance" in error_msg.lower(): + self.print_warning("Insufficient margin, but interface and validation are working.") + result.set_passed({"interface_available": True, "error": error_msg}) + else: + result.set_failed(f"Futures limit order failed: {error_msg}") + return result + + order_info = ( + buy_result.get("output", {}).get("order", {}) + if isinstance(buy_result, dict) + else buy_result.get("order", {}) + ) + order_id = order_info.get("order_id") + + if not order_id: + result.set_failed("Order created but no order_id returned") + return result + + self.print_success(f"Futures limit order created: {order_id}") + + # Check the order is visible in open orders. + await asyncio.sleep(1) + orders_tool = GetOpenOrdersTool() + orders_result = await orders_tool.execute(instrument_name=self.futures_pair) + orders = orders_result.get("output") if isinstance(orders_result, dict) else orders_result + found_order = any(o.get("order_id") == order_id for o in orders) + + if found_order: + self.print_success("Order is visible in the order book") + else: + self.print_warning("Order not visible in open orders list (may have been cancelled/rejected).") + + # Now cancel the order. + cancel_tool = CancelOrderTool() + cancel_result = await cancel_tool.execute(order_id=order_id) + + if isinstance(cancel_result, dict) and cancel_result.get("error"): + self.print_warning(f"Cancel failed: {cancel_result.get('error')}") + result.set_passed({"order_created": True, "cancel_failed": True}) + else: + self.print_success("Order cancelled successfully") + result.set_passed({"order_created": True, "order_cancelled": True}) + + except Exception as e: + result.set_failed(f"Futures trading test raised an exception: {e}") + + return result + + async def run_all_tests(self): + """Run all tests in sequence and then print a compact report.""" + print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'Deribit comprehensive test suite (futures + spot)':^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.RESET}\n") + + print(f"{Colors.YELLOW}Test time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}\n") + + print("The suite will run:") + print(" 1. ✅ Environment check") + print(" 2. ✅ Account summary") + print(" 3. ✅ Discover ETH spot instruments") + print(" 4. ⚠️ Spot trading test (deep limit + cancel)") + print(" 5. ⚠️ Futures trading test (deep limit + cancel)") + print() + + tests = [ + self.test_environment, + self.test_account_query, + self.test_find_spot_pairs, + self.test_spot_trading, + self.test_futures_trading, + ] + + for test_func in tests: + try: + result = await test_func() + self.results.append(result) + + if not result.passed and "Discover ETH spot instruments" in result.name: + self.print_warning("Skipping spot trading test due to missing spot instruments.") + skip_result = TestResult("Spot trading test") + skip_result.set_failed("Skipped: no spot pair discovered") + self.results.append(skip_result) + continue + + except KeyboardInterrupt: + self.print_warning("\nTest suite interrupted by user") + break + except Exception as e: + self.print_error(f"\nUnexpected exception in test: {e}") + error_result = TestResult(test_func.__name__) + error_result.set_failed(f"Test raised unexpected exception: {e}") + self.results.append(error_result) + + self.generate_report() + + def generate_report(self): + """Print a concise summary of all test results.""" + self.print_header("Test report") + + passed = sum(1 for r in self.results if r.passed) + total = len(self.results) + + print(f"{Colors.BOLD}Summary of test results:{Colors.RESET}\n") + + for result in self.results: + if result.passed: + status = f"{Colors.GREEN}✅ PASS{Colors.RESET}" + else: + status = f"{Colors.RED}❌ FAIL{Colors.RESET}" + + print(f" {result.name:28s}: {status}") + if result.error: + print(f" {Colors.RED}Error : {result.error}{Colors.RESET}") + if result.details: + for key, value in result.details.items(): + if key != "error": + print(f" {Colors.CYAN}{key}: {value}{Colors.RESET}") + + print(f"\n{Colors.BOLD}Overall: {passed}/{total} tests passed{Colors.RESET}\n") + + if passed == total: + self.print_success("🎉 All tests passed.") + elif passed >= total - 1: + self.print_success("✅ Core functionality tests passed.") + else: + self.print_warning("⚠️ Some tests failed; please check configuration and connectivity.") + + print(f"\n{Colors.CYAN}{'='*70}{Colors.RESET}\n") + + # Quick guidance based on account balance + if self.account_balance is not None: + print(f"{Colors.BOLD}Account balance: {self.account_balance} ETH{Colors.RESET}") + if self.account_balance < 0.05: + self.print_warning("Consider funding at least ~0.1 ETH for a smoother test experience.") + + print(f"\n{Colors.CYAN}💡 Notes:{Colors.RESET}") + print(" - Spot trading requires no margin and is suitable for small balance tests.") + print(" - Futures trading requires margin; costs depend on leverage and volatility.") + print(" - Even if orders fail with 'insufficient funds', this still validates the tool/endpoint.") + + +async def main(): + suite = TestSuite() + await suite.run_all_tests() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print(f"\n\n{Colors.YELLOW}Test suite interrupted by user{Colors.RESET}") + except Exception as e: + print(f"\n\n{Colors.RED}Test suite failed with exception: {e}{Colors.RESET}") + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_complete_trading_workflow.py b/spoon_toolkits/data_platforms/deribit/examples/test_complete_trading_workflow.py new file mode 100644 index 0000000..0104fae --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_complete_trading_workflow.py @@ -0,0 +1,896 @@ +"""Deribit complete trading workflow test – spot + futures. + +This script performs a small, end-to-end workflow on Deribit: +1) Spot sell ETH -> USDC and buy back ETH +2) Futures buy then sell to close +3) Cleanup all open orders and verify balances/positions +It is designed as a developer-facing integration test with real trades. +""" + +import asyncio +import sys +import os +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional, List + +# Add deribit module directory to path +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + +# Import using importlib.util to handle relative imports +import importlib.util + +def load_module(name, file_path): + """Load a module from file path, handling relative imports""" + full_name = f'spoon_toolkits.data_platforms.deribit.{name}' + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + # Set __package__ for relative imports + module.__package__ = 'spoon_toolkits.data_platforms.deribit' + # Add parent packages to sys.modules for relative imports + parent_pkg = 'spoon_toolkits.data_platforms.deribit' + import types + parts = parent_pkg.split('.') + for i in range(len(parts)): + pkg_name = '.'.join(parts[:i+1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + # Register module in sys.modules + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + +# Load modules in dependency order +# First load env (no dependencies) +env_module = load_module('env', deribit_path / 'env.py') +# Then load jsonrpc_client (depends on env) +jsonrpc_module = load_module('jsonrpc_client', deribit_path / 'jsonrpc_client.py') +# Then load auth (depends on jsonrpc_client and env) +auth_module = load_module('auth', deribit_path / 'auth.py') +# Then load base (depends on jsonrpc_client and auth) +base_module = load_module('base', deribit_path / 'base.py') +# Then load tools that depend on base +market_module = load_module('market_data', deribit_path / 'market_data.py') +account_module = load_module('account', deribit_path / 'account.py') +trading_module = load_module('trading', deribit_path / 'trading.py') + +DeribitConfig = env_module.DeribitConfig +GetInstrumentsTool = market_module.GetInstrumentsTool +GetTickerTool = market_module.GetTickerTool +GetAccountSummaryTool = account_module.GetAccountSummaryTool +GetPositionsTool = account_module.GetPositionsTool +GetOrderHistoryTool = account_module.GetOrderHistoryTool +GetTradeHistoryTool = account_module.GetTradeHistoryTool +PlaceBuyOrderTool = trading_module.PlaceBuyOrderTool +PlaceSellOrderTool = trading_module.PlaceSellOrderTool +CancelOrderTool = trading_module.CancelOrderTool +CancelAllOrdersTool = trading_module.CancelAllOrdersTool +GetOpenOrdersTool = trading_module.GetOpenOrdersTool + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + MAGENTA = '\033[95m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +class TradingWorkflowTest: + """End-to-end trading workflow test (spot + futures).""" + + def __init__(self): + self.initial_eth_balance = None + self.final_eth_balance = None + self.eth_consumed = 0.0 + # Track all created order IDs in this workflow. + self.created_orders: List[str] = [] + # Selected spot instrument (e.g. ETH/USDC). + self.spot_pair = None + # Contract size for the selected spot instrument, if any. + self.spot_contract_size = None + self.futures_pair = "ETH-PERPETUAL" + # All trade records captured during the workflow. + self.trade_records: List[Dict] = [] + # Create log file in a local logs directory. + log_dir = Path(__file__).parent / "logs" + log_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.log_file = log_dir / f"trading_log_{timestamp}.json" + + def print_header(self, text: str): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + def print_success(self, text: str): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + def print_error(self, text: str): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + def print_warning(self, text: str): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + def print_info(self, text: str): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + def print_section(self, text: str): + print(f"\n{Colors.BOLD}{Colors.MAGENTA}{'─'*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.MAGENTA}{text}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.MAGENTA}{'─'*70}{Colors.RESET}\n") + + async def get_account_balance(self, currency: str = "ETH") -> Optional[float]: + """Get account balance for the given currency.""" + try: + account_tool = GetAccountSummaryTool() + result = await account_tool.execute(currency=currency) + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to get account balance: {result.get('error')}") + return None + + account = result.get("output") if isinstance(result, dict) else result + balance = account.get("balance", 0) + return float(balance) + except Exception as e: + self.print_error(f"Exception while getting account balance: {e}") + return None + + async def find_spot_pair(self) -> Optional[str]: + """Find a suitable spot trading pair (prefer ETH/USDC or ETH/USDT).""" + try: + tool = GetInstrumentsTool() + result = await tool.execute(currency="ETH", kind="spot", expired=False) + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to query spot instruments: {result.get('error')}") + return None + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + self.print_error("No ETH spot instruments found") + return None + + # Prefer ETH/USDC or ETH/USDT, and remember contract_size if available. + for inst in instruments: + inst_name = inst.get("instrument_name", "") + if "USDC" in inst_name or "USDT" in inst_name: + # Save contract_size for later adjustment. + contract_size = inst.get("contract_size") + if contract_size: + self.spot_contract_size = contract_size + self.print_info(f"Spot contract_size: {contract_size} ETH") + return inst_name + + # If no preferred pair is found, fall back to the first instrument. + first_inst = instruments[0] + inst_name = first_inst.get("instrument_name") + contract_size = first_inst.get("contract_size") + if contract_size: + self.spot_contract_size = contract_size + self.print_info(f"Spot contract_size: {contract_size} ETH") + return inst_name + except Exception as e: + self.print_error(f"Exception while finding spot instrument: {e}") + return None + + def adjust_amount_to_contract_size(self, amount: float, contract_size: float) -> float: + """Adjust amount to be a multiple of contract_size.""" + if contract_size <= 0: + return amount + + # Calculate the nearest integer multiple. + multiple = round(amount / contract_size) + + # Ensure at least 1×contract_size. + if multiple < 1: + multiple = 1 + + # Compute adjusted amount and handle float precision. + adjusted_amount = multiple * contract_size + + # Determine decimal precision from contract_size. + contract_size_str = str(contract_size) + if '.' in contract_size_str: + decimals = len(contract_size_str.split('.')[1]) + else: + decimals = 0 + + # Round to the correct precision. + adjusted_amount = round(adjusted_amount, decimals) + + return adjusted_amount + + async def get_market_price(self, instrument_name: str) -> Optional[float]: + """Get the last/mark price for an instrument.""" + try: + ticker_tool = GetTickerTool() + result = await ticker_tool.execute(instrument_name=instrument_name) + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to get price: {result.get('error')}") + return None + + ticker = result.get("output") if isinstance(result, dict) else result + price = ticker.get("last_price") or ticker.get("mark_price") + return float(price) if price else None + except Exception as e: + self.print_error(f"Exception while getting price: {e}") + return None + + async def place_market_order(self, instrument_name: str, amount: float, side: str) -> Optional[str]: + """Create a market order (for quick fill, used in this workflow test).""" + try: + if side == "buy": + tool = PlaceBuyOrderTool() + else: + tool = PlaceSellOrderTool() + + # Use a market order so the trade fills immediately. + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + order_type="market" + ) + + if isinstance(result, dict) and result.get("error"): + error_msg = result.get("error", "Unknown error") + self.print_error(f"{side.upper()} order failed: {error_msg}") + # Log failed trade attempt + await self.log_failed_trade(instrument_name, side, amount, error_msg) + return None + + order_info = result.get("output", {}).get("order", {}) if isinstance(result, dict) else result.get("order", {}) + order_id = order_info.get("order_id") + + if order_id: + self.created_orders.append(order_id) + # Wait and then query trade/order history for this order. + await asyncio.sleep(2) # wait for fills + await self.query_and_log_trade(instrument_name, order_id, side, amount) + return order_id + else: + # Order creation failed but there was no explicit error message. + await self.log_failed_trade(instrument_name, side, amount, "Order ID not found in response") + return None + except Exception as e: + error_msg = str(e) + self.print_error(f"Exception while creating {side} order: {error_msg}") + # Log failed trade attempt + await self.log_failed_trade(instrument_name, side, amount, f"Exception: {error_msg}") + return None + + async def query_and_log_trade(self, instrument_name: str, order_id: str, side: str, amount: float): + """Query and log trade/order history for a specific order.""" + try: + self.print_info(f"Querying order/trade history for order {order_id}...") + + # Query order history + order_history_tool = GetOrderHistoryTool() + order_result = await order_history_tool.execute( + instrument_name=instrument_name, + count=10, + offset=0 + ) + + # Query trade history + trade_history_tool = GetTradeHistoryTool() + trade_result = await trade_history_tool.execute( + instrument_name=instrument_name, + count=10, + offset=0 + ) + + # Find matching order and an associated trade (if any) + order_data = None + trade_data = None + + if isinstance(order_result, dict) and not order_result.get("error"): + orders = order_result.get("output", []) + for order in orders: + if order.get("order_id") == order_id: + order_data = order + break + + if isinstance(trade_result, dict) and not trade_result.get("error"): + trades = trade_result.get("output", []) + # Ensure trades is a list of dicts. + if isinstance(trades, list) and trades: + # Prefer a trade whose timestamp is after order creation. + order_time = order_data.get("creation_timestamp") if order_data else None + for trade in trades: + if not isinstance(trade, dict): + continue + trade_time = trade.get("timestamp") + if order_time and trade_time and trade_time >= order_time: + trade_data = trade + break + # If none matched, fall back to the first valid trade entry. + if not trade_data and trades: + for trade in trades: + if isinstance(trade, dict): + trade_data = trade + break + + # Build trade record + trade_record = { + "timestamp": datetime.now().isoformat(), + "order_id": order_id, + "instrument_name": instrument_name, + "side": side, + "amount": amount, + "order_data": order_data, + "trade_data": trade_data + } + + self.trade_records.append(trade_record) + + # Print a human-readable summary to stdout. + self.print_section(f"Trade record - {side.upper()} {instrument_name}") + + if order_data: + self.print_info(f"Order ID : {order_data.get('order_id')}") + self.print_info(f"Order state : {order_data.get('order_state', 'N/A')}") + self.print_info(f"Order type : {order_data.get('order_type', 'N/A')}") + self.print_info(f"Amount : {order_data.get('amount', 'N/A')}") + self.print_info(f"Price : {order_data.get('price', 'N/A')}") + self.print_info(f"Created at : {order_data.get('creation_timestamp', 'N/A')}") + if order_data.get('last_update_timestamp'): + self.print_info(f"Last updated : {order_data.get('last_update_timestamp')}") + + if trade_data: + self.print_success("✅ Matched trade:") + self.print_info(f" trade_id : {trade_data.get('trade_id', 'N/A')}") + self.print_info(f" price : {trade_data.get('price', 'N/A')}") + self.print_info(f" amount : {trade_data.get('amount', 'N/A')}") + self.print_info(f" side : {trade_data.get('direction', 'N/A')}") + self.print_info(f" ts : {trade_data.get('timestamp', 'N/A')}") + if trade_data.get('fee'): + self.print_info(f" fee : {trade_data.get('fee')}") + else: + self.print_warning("⚠️ No matching trade record found (order may not be filled).") + + # Persist to JSON log. + await self.write_trade_log(trade_record) + + except Exception as e: + self.print_error(f"Exception while querying trade record: {e}") + import traceback + traceback.print_exc() + + async def log_failed_trade(self, instrument_name: str, side: str, amount: float, error_msg: str): + """Record a failed trade attempt and append it to the JSON log.""" + try: + failed_record = { + "timestamp": datetime.now().isoformat(), + "order_id": None, + "instrument_name": instrument_name, + "side": side, + "amount": amount, + "status": "failed", + "error": error_msg, + "order_data": None, + "trade_data": None + } + + self.trade_records.append(failed_record) + + # Print a short failure summary + self.print_section(f"Trade record - {side.upper()} {instrument_name} (FAILED)") + self.print_error("Order creation failed") + self.print_error(f"Error: {error_msg}") + self.print_info(f"Instrument: {instrument_name}") + self.print_info(f"Side : {side.upper()}") + self.print_info(f"Amount : {amount}") + + # Persist to JSON log + await self.write_trade_log(failed_record) + + except Exception as e: + self.print_error(f"Exception while recording failed trade: {e}") + + async def write_trade_log(self, trade_record: Dict): + """Append a single trade_record dict into the JSON log file.""" + try: + # Load existing log data (if any) + log_data = [] + if self.log_file.exists(): + try: + with open(self.log_file, 'r', encoding='utf-8') as f: + log_data = json.load(f) + except Exception: + log_data = [] + else: + log_data = [] + + # Append new record and write back + log_data.append(trade_record) + with open(self.log_file, 'w', encoding='utf-8') as f: + json.dump(log_data, f, indent=2, ensure_ascii=False) + + self.print_info(f"Trade record written to: {self.log_file}") + + except Exception as e: + self.print_error(f"Exception while writing JSON log: {e}") + + async def cancel_all_orders(self, instrument_name: str): + """Cancel all open orders for the specified instrument's currency/kind.""" + try: + # CancelAllOrdersTool needs currency and kind, not instrument_name. + # Extract currency/kind from the instrument_name string. + currency = None + kind = None + + if instrument_name: + # Parse instrument_name, e.g.: + # "ETH-PERPETUAL" -> currency="ETH", kind="future" + # "ETH-USDC" -> currency="ETH", kind="spot" + if "PERPETUAL" in instrument_name or "-" in instrument_name and len(instrument_name.split("-")) == 3: + # Perpetual or dated future, e.g. "ETH-PERPETUAL" or "BTC-25JAN25" + currency = instrument_name.split("-")[0] + kind = "future" + elif "-" in instrument_name: + # Spot instrument, e.g. "ETH-USDC" + currency = instrument_name.split("-")[0] + kind = "spot" + else: + # Fallback to ETH when currency cannot be parsed + currency = "ETH" + kind = "any" + + if not currency: + self.print_warning(f"Unable to infer currency from {instrument_name}; skipping cancel_all") + return False + + cancel_tool = CancelAllOrdersTool() + result = await cancel_tool.execute(currency=currency, kind=kind if kind != "any" else "any") + + if isinstance(result, dict) and result.get("error"): + self.print_warning(f"CancelAllOrdersTool failed: {result.get('error')}") + return False + + self.print_success(f"Cancelled all orders for {instrument_name} ({currency}, {kind})") + return True + except Exception as e: + self.print_error(f"Exception while cancelling all orders: {e}") + return False + + async def verify_no_open_orders(self): + """Verify that there are no open spot/futures orders.""" + try: + # Check spot orders + if self.spot_pair: + orders_tool = GetOpenOrdersTool() + spot_result = await orders_tool.execute(instrument_name=self.spot_pair) + spot_orders = spot_result.get("output") if isinstance(spot_result, dict) else spot_result + if spot_orders: + self.print_warning(f"Found {len(spot_orders)} open spot orders") + return False + + # Check futures orders + orders_tool = GetOpenOrdersTool() + futures_result = await orders_tool.execute(instrument_name=self.futures_pair) + futures_orders = futures_result.get("output") if isinstance(futures_result, dict) else futures_result + if futures_orders: + self.print_warning(f"Found {len(futures_orders)} open futures orders") + return False + + self.print_success("Confirmed there are no open spot/futures orders") + return True + except Exception as e: + self.print_error(f"Exception while verifying open orders: {e}") + return False + + async def test_spot_sell(self, eth_amount: float) -> bool: + """Test 1: spot sell leg ETH -> USDC.""" + self.print_section("TEST 1: Spot sell ETH -> USDC") + + if not self.spot_pair: + self.print_error("No spot pair found; skipping spot test") + return False + + price = await self.get_market_price(self.spot_pair) + if not price: + return False + + # Adjust amount to contract_size, if defined. + if self.spot_contract_size: + original_amount = eth_amount + eth_amount = self.adjust_amount_to_contract_size(eth_amount, self.spot_contract_size) + if abs(eth_amount - original_amount) > 0.0001: + self.print_warning( + f"Adjusted amount: {original_amount:.6f} -> {eth_amount:.6f} ETH (multiple of contract_size)" + ) + + self.print_info(f"Current price: ${price:,.2f}") + self.print_info(f"Sell amount : {eth_amount} ETH") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + + order_id = await self.place_market_order(self.spot_pair, eth_amount, "sell") + if not order_id: + return False + + self.print_success(f"Spot sell order created: {order_id}") + await asyncio.sleep(2) # wait for fills + + return True + + async def test_spot_buy(self, usdc_amount: float) -> bool: + """Test 2: spot buy leg ETH <- USDC.""" + self.print_section("TEST 2: Spot buy ETH <- USDC") + + if not self.spot_pair: + self.print_error("No spot pair found; skipping spot buy") + return False + + price = await self.get_market_price(self.spot_pair) + if not price: + return False + + # Calculate ETH amount we can buy. + eth_amount = usdc_amount / price + + # Adjust amount to contract_size, if defined. + if self.spot_contract_size: + original_amount = eth_amount + eth_amount = self.adjust_amount_to_contract_size(eth_amount, self.spot_contract_size) + if abs(eth_amount - original_amount) > 0.0001: + self.print_warning( + f"Adjusted amount: {original_amount:.6f} -> {eth_amount:.6f} ETH (multiple of contract_size)" + ) + # Recalculate required USDC. + usdc_amount = eth_amount * price + + self.print_info(f"Current price: ${price:,.2f}") + self.print_info(f"USDC to use : ${usdc_amount:,.2f}") + self.print_info(f"Expected ETH: {eth_amount:.6f} ETH") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + + order_id = await self.place_market_order(self.spot_pair, eth_amount, "buy") + if not order_id: + return False + + self.print_success(f"Spot buy order created: {order_id}") + await asyncio.sleep(2) # wait for fills + + return True + + async def check_positions(self) -> Dict: + """Fetch current ETH positions and return them as a dict keyed by instrument_name.""" + try: + positions_tool = GetPositionsTool() + result = await positions_tool.execute(currency="ETH") + + if isinstance(result, dict) and result.get("error"): + return {} + + positions = result.get("output") if isinstance(result, dict) else result + + position_dict = {} + for pos in positions: + inst_name = pos.get("instrument_name") + size = pos.get("size", 0) + direction = pos.get("direction", "") + if inst_name and size != 0: + position_dict[inst_name] = { + "size": size, + "direction": direction + } + + return position_dict + except Exception as e: + self.print_error(f"Exception while checking positions: {e}") + return {} + + async def test_futures_buy(self, amount: float = 1.0) -> bool: + """Test 3: open a small futures long position (then closed in the next step).""" + self.print_section("TEST 3: Futures buy (low risk, will be closed later)") + + # Check current positions first + positions = await self.check_positions() + current_position = positions.get(self.futures_pair, {}) + current_size = current_position.get("size", 0) + current_direction = current_position.get("direction", "") + + if current_size > 0: + self.print_warning(f"Existing futures position: {current_size} contracts ({current_direction})") + if current_direction == "buy": + self.print_warning("Existing long position detected; consider closing it first.") + + price = await self.get_market_price(self.futures_pair) + if not price: + return False + + self.print_info(f"Current price: ${price:,.2f}") + self.print_info(f"Buy amount : {amount} contracts") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + self.print_warning("⚠️ This long will be closed immediately in the next step to reduce risk.") + + order_id = await self.place_market_order(self.futures_pair, amount, "buy") + if not order_id: + return False + + self.print_success(f"Futures buy order created: {order_id}") + await asyncio.sleep(2) # wait for fills + + # Verify the position exists + positions_after = await self.check_positions() + position_after = positions_after.get(self.futures_pair, {}) + size_after = position_after.get("size", 0) + + if size_after > 0: + self.print_warning(f"Post-buy position size: {size_after} contracts") + self.print_info("This will be closed in the next step (sell).") + + return True + + async def test_futures_sell(self, amount: float = 1.0) -> bool: + """Test 4: close the futures position with a market sell (low risk).""" + self.print_section("TEST 4: Futures sell (close position, low risk)") + + # Check current positions + positions = await self.check_positions() + current_position = positions.get(self.futures_pair, {}) + current_size = current_position.get("size", 0) + current_direction = current_position.get("direction", "") + + if current_size == 0: + self.print_warning("No existing futures position; selling would open a short.") + self.print_warning("⚠️ To keep this test low-risk, we will not open new shorts automatically.") + return False + + if current_direction != "buy": + self.print_warning(f"Current position direction is {current_direction}, not a long.") + + # Only sell up to the current open size to avoid over-selling. + sell_amount = min(amount, abs(current_size)) + + price = await self.get_market_price(self.futures_pair) + if not price: + return False + + self.print_info(f"Current price : ${price:,.2f}") + self.print_info(f"Current size : {current_size} contracts ({current_direction})") + self.print_info(f"Sell amount : {sell_amount} contracts (close position)") + self.print_warning("⚠️ Using market orders; trades will fill immediately.") + self.print_success("✅ This is a close-position operation, risk is limited.") + + order_id = await self.place_market_order(self.futures_pair, sell_amount, "sell") + if not order_id: + return False + + self.print_success(f"Futures sell order created: {order_id}") + await asyncio.sleep(2) # wait for fills + + # Verify the position is closed + positions_after = await self.check_positions() + position_after = positions_after.get(self.futures_pair, {}) + size_after = position_after.get("size", 0) + + if size_after == 0: + self.print_success("✅ Futures position fully closed.") + else: + self.print_warning(f"⚠️ Remaining futures position: {size_after} contracts") + + return True + + async def cleanup_orders(self): + """Cancel all open spot/futures orders for this test.""" + self.print_section("Cleanup: cancel all spot and futures orders") + + if self.spot_pair: + await self.cancel_all_orders(self.spot_pair) + + await self.cancel_all_orders(self.futures_pair) + + # Sanity-check no open orders remain. + await self.verify_no_open_orders() + + async def calculate_consumption(self): + """Compute net ETH consumption over the entire workflow.""" + self.print_section("Compute ETH consumption") + + self.final_eth_balance = await self.get_account_balance("ETH") + + if self.initial_eth_balance is not None and self.final_eth_balance is not None: + self.eth_consumed = self.initial_eth_balance - self.final_eth_balance + + self.print_info(f"Initial balance: {self.initial_eth_balance:.6f} ETH") + self.print_info(f"Final balance : {self.final_eth_balance:.6f} ETH") + self.print_info(f"Delta (spent) : {self.eth_consumed:.6f} ETH") + + if self.eth_consumed > 0: + usd_value = self.eth_consumed * (await self.get_market_price(self.spot_pair) or 0) + self.print_warning(f"Approximate cost: ~${usd_value:,.2f} USD") + else: + self.print_success("No net ETH consumed (balance may have increased).") + else: + self.print_warning("Unable to compute consumption (missing balance information).") + + async def run_complete_test(self): + """Run the complete workflow: spot sell/buy + futures buy/sell + cleanup + summary.""" + self.print_header("Deribit complete trading workflow test") + + print(f"{Colors.YELLOW}Test time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}\n") + + print("This workflow test will perform:") + print(" 1. ⚠️ Spot sell ETH -> USDC (market, immediate fill)") + print(" 2. ⚠️ Spot buy ETH <- USDC (market, immediate fill)") + print(" 3. ⚠️ Futures buy (market, immediate fill)") + print(" 4. ⚠️ Futures sell to close (market, immediate fill)") + print(" 5. ✅ Cleanup all open orders") + print(" 6. ✅ Compute final ETH consumption") + print() + + self.print_warning("⚠️ WARNING: This script performs real trades with real funds!") + print() + + # STEP 0: initial balance + self.print_section("STEP 0: Get initial ETH balance") + self.initial_eth_balance = await self.get_account_balance("ETH") + if self.initial_eth_balance is None: + self.print_error("Failed to fetch initial balance, aborting test.") + return + + self.print_success(f"Initial ETH balance: {self.initial_eth_balance:.6f} ETH") + + # Basic safeguard for extremely small balances. + if self.initial_eth_balance < 0.01: + self.print_error("Balance is too low (< 0.01 ETH) to safely run this test.") + return + + # STEP 1: spot discovery + self.print_section("STEP 1: Discover spot trading pair") + self.spot_pair = await self.find_spot_pair() + if not self.spot_pair: + self.print_error("No spot pair found; spot leg will be skipped.") + else: + self.print_success(f"Selected spot instrument: {self.spot_pair}") + + # Use 30% of initial ETH balance in the spot leg. + test_eth_amount = self.initial_eth_balance * 0.3 + self.print_info(f"Using {test_eth_amount:.6f} ETH for spot test (~30% of balance).") + + # STEP 2: Spot sell + buy + if self.spot_pair: + success = await self.test_spot_sell(test_eth_amount) + if not success: + self.print_warning("Spot sell leg failed; continuing with the rest of the workflow.") + + # Wait for settlement and then check USDC balance + await asyncio.sleep(2) + usdc_balance = await self.get_account_balance("USDC") + if usdc_balance: + self.print_info(f"Current USDC balance: {usdc_balance:.2f} USDC") + # Use 80% of USDC to buy back ETH, leaving some for fees. + buy_usdc_amount = usdc_balance * 0.8 + + success = await self.test_spot_buy(buy_usdc_amount) + if not success: + self.print_warning("Spot buy leg failed; continuing with futures test.") + + # STEP 3: Futures buy (low-risk leg) + await asyncio.sleep(1) + self.print_warning("⚠️ Futures leg about to run; risk notes:") + self.print_info(" - Buy 1 contract and then immediately sell to close.") + self.print_info(" - Market orders imply some slippage and fees.") + self.print_info(" - If balance is insufficient, the call should still validate the interface.") + + success = await self.test_futures_buy(amount=1.0) + if not success: + self.print_warning("Futures buy leg failed; skipping futures sell.") + else: + # STEP 4: Futures sell (close the position) + await asyncio.sleep(1) + self.print_info("Closing the futures position immediately to limit risk...") + success = await self.test_futures_sell(amount=1.0) + if not success: + self.print_warning("Futures sell leg failed; you may need to close positions manually.") + + # Provide an explicit reminder about any remaining positions. + positions = await self.check_positions() + if positions: + self.print_error("⚠️ Open positions remain; please close them manually!") + for inst, pos in positions.items(): + self.print_error(f" {inst}: {pos['size']} contracts ({pos['direction']})") + + # STEP 5: Cleanup all orders + await self.cleanup_orders() + + # STEP 6: Compute net ETH consumption + await self.calculate_consumption() + + # Final report + self.print_header("Workflow test summary") + + self.print_info(f"Initial ETH: {self.initial_eth_balance:.6f} ETH") + if self.final_eth_balance is not None: + self.print_info(f"Final ETH : {self.final_eth_balance:.6f} ETH") + self.print_info(f"Net delta : {self.eth_consumed:.6f} ETH") + + self.print_info(f"Total orders created: {len(self.created_orders)}") + + # Verify final account state + final_balance = await self.get_account_balance("ETH") + if final_balance is not None: + if final_balance > 0: + self.print_success(f"✅ Account still holds ETH balance: {final_balance:.6f} ETH") + else: + self.print_warning("⚠️ Account ETH balance is zero.") + + # Ensure no positions remain. + final_positions = await self.check_positions() + if final_positions: + self.print_error("⚠️ Positions still open; please close them manually.") + for inst, pos in final_positions.items(): + if pos["size"] != 0: + self.print_error(f" {inst}: {pos['size']} contracts ({pos['direction']})") + else: + self.print_success("✅ Confirmed there are no open positions.") + + await self.verify_no_open_orders() + + # Compact trade-record summary + if self.trade_records: + self.print_section("Recorded trades overview") + self.print_info(f"Total trade records: {len(self.trade_records)}") + + successful_trades = [r for r in self.trade_records if r.get("status") != "failed"] + failed_trades = [r for r in self.trade_records if r.get("status") == "failed"] + + if successful_trades: + self.print_success(f"\n✅ Successful trades: {len(successful_trades)}") + for i, record in enumerate(successful_trades, 1): + self.print_info(f"\nTrade {i} (SUCCESS):") + self.print_info(f" order_id: {record.get('order_id')}") + self.print_info(f" symbol : {record.get('instrument_name')}") + self.print_info(f" side : {record.get('side').upper()}") + self.print_info(f" amount : {record.get('amount')}") + if record.get("order_data"): + order = record["order_data"] + self.print_success(f" state : {order.get('order_state', 'N/A')}") + if order.get("average_price"): + self.print_success(f" avg_px : {order.get('average_price')}") + if record.get("trade_data"): + trade = record["trade_data"] + self.print_success(f" trade_px: {trade.get('price')}") + self.print_success(f" trade_sz: {trade.get('amount')}") + + if failed_trades: + self.print_warning(f"\n⚠️ Failed trades: {len(failed_trades)}") + for i, record in enumerate(failed_trades, 1): + self.print_info(f"\nTrade {i} (FAILED):") + self.print_info(f" symbol : {record.get('instrument_name')}") + self.print_info(f" side : {record.get('side').upper()}") + self.print_info(f" amount : {record.get('amount')}") + self.print_error(f" error : {record.get('error', 'Unknown error')}") + + self.print_success(f"\n✅ All trade records persisted to: {self.log_file}") + + self.print_success("🎉 Complete workflow test finished.") + + +async def main(): + test = TradingWorkflowTest() + await test.run_complete_test() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print(f"\n\n{Colors.YELLOW}Workflow test interrupted by user{Colors.RESET}") + except Exception as e: + print(f"\n\n{Colors.RED}Workflow test failed with exception: {e}{Colors.RESET}") + import traceback + traceback.print_exc() + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_mainnet_readonly.py b/spoon_toolkits/data_platforms/deribit/examples/test_mainnet_readonly.py new file mode 100644 index 0000000..b1f0d5d --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_mainnet_readonly.py @@ -0,0 +1,212 @@ +"""Mainnet read-only test – safely exercise all non-trading endpoints. + +This script verifies: +- Public market-data endpoints +- Authentication +- Account summary +- Positions and open orders + +It never submits any trading orders and is safe to run on mainnet. +""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.market_data import GetInstrumentsTool, GetTickerTool, GetOrderBookTool +from spoon_toolkits.deribit.account import GetAccountSummaryTool, GetPositionsTool +from spoon_toolkits.deribit.trading import GetOpenOrdersTool + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +def print_header(text): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.RESET}\n") + + +def print_success(text): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + +def print_error(text): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + +def print_warning(text): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + +async def main(): + """Entry point for the mainnet read-only test suite.""" + print(f"\n{Colors.BOLD}{Colors.RED}{'='*60}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.RED}Deribit mainnet READ-ONLY test{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.RED}{'='*60}{Colors.RESET}\n") + + # Environment check + if DeribitConfig.USE_TESTNET: + print_warning("Currently using TESTNET, not mainnet.") + print("Set DERIBIT_USE_TESTNET=false to run true mainnet checks.") + return + + print_warning("⚠️ MAINNET WARNING: real funds are in use.") + print_warning("⚠️ This test only uses read-only endpoints and never sends trades.") + print("") + + results = {} + + # 1. Public API + print_header("STEP 1: Public API test (read-only, no credentials required)") + try: + tool = GetInstrumentsTool() + result = await tool.execute(currency="BTC", kind="future") + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to fetch futures instruments: {result.get('error')}") + results["public_api"] = False + else: + instruments = result.get("output") if isinstance(result, dict) else result + print_success(f"Fetched futures instruments: {len(instruments)} instruments") + if instruments: + print(f" Example instrument: {instruments[0].get('instrument_name', 'N/A')}") + results["public_api"] = True + except Exception as e: + print_error(f"Public API test failed: {e}") + results["public_api"] = False + + # 2. Authentication + print_header("STEP 2: Authentication test") + try: + from spoon_toolkits.deribit.auth import DeribitAuth + + auth = DeribitAuth() + result = await auth.authenticate() + + if result and result.get("access_token"): + print_success("Authentication succeeded") + print(f" Token preview: {auth.get_access_token()[:20]}...") + print(f" Scope : {result.get('scope', 'N/A')}") + print(f" Expires in : {result.get('expires_in', 'N/A')} seconds") + results["auth"] = True + else: + print_error("Authentication failed: access_token is missing") + results["auth"] = False + except Exception as e: + print_error(f"Authentication test failed: {e}") + results["auth"] = False + + # 3. Account summary (read-only) + if results.get("auth"): + print_header("STEP 3: Account summary test (read-only)") + try: + account_tool = GetAccountSummaryTool() + result = await account_tool.execute(currency="BTC") + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to fetch account summary: {result.get('error')}") + results["account_summary"] = False + else: + account = result.get("output") if isinstance(result, dict) else result + print_success("Fetched account summary successfully") + print(f" balance : {account.get('balance', 'N/A')} BTC") + print(f" available_funds: {account.get('available_funds', 'N/A')} BTC") + print(f" equity : {account.get('equity', 'N/A')} BTC") + print(f" margin_balance : {account.get('margin_balance', 'N/A')} BTC") + results["account_summary"] = True + except Exception as e: + print_error(f"Account summary test failed: {e}") + results["account_summary"] = False + + # 4. Positions (read-only) + print_header("STEP 4: Positions test (read-only)") + try: + positions_tool = GetPositionsTool() + result = await positions_tool.execute(currency="BTC") + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to fetch positions: {result.get('error')}") + results["positions"] = False + else: + positions = result.get("output") if isinstance(result, dict) else result + print_success(f"Fetched positions: {len(positions)} entries") + if positions: + for pos in positions[:3]: + print( + f" {pos.get('instrument_name')}: " + f"size={pos.get('size')}, " + f"entry_price={pos.get('entry_price')}, " + f"mark_price={pos.get('mark_price')}" + ) + results["positions"] = True + except Exception as e: + print_error(f"Positions test failed: {e}") + results["positions"] = False + + # 5. Open orders (read-only) + print_header("STEP 5: Open orders test (read-only)") + try: + orders_tool = GetOpenOrdersTool() + result = await orders_tool.execute(instrument_name="BTC-PERPETUAL") + + if isinstance(result, dict) and result.get("error"): + print_warning(f"Open-orders query failed (possibly no orders): {result.get('error')}") + results["open_orders"] = True # not a hard failure + else: + orders = result.get("output") if isinstance(result, dict) else result + print_success(f"Fetched open orders: {len(orders)} entries") + if orders: + for order in orders[:3]: + print( + f" order {order.get('order_id')}: " + f"{order.get('direction')} {order.get('amount')} @ {order.get('price')}" + ) + results["open_orders"] = True + except Exception as e: + print_error(f"Open-orders test failed: {e}") + results["open_orders"] = False + + # Summary + print_header("Read-only test summary") + + passed = sum(1 for v in results.values() if v) + total = len(results) + + for name, result in results.items(): + status = f"{Colors.GREEN}✅ PASS{Colors.RESET}" if result else f"{Colors.RED}❌ FAIL{Colors.RESET}" + print(f"{name}: {status}") + + print(f"\n{Colors.BOLD}Overall: {passed}/{total} steps passed{Colors.RESET}") + + if passed == total: + print_success("\n🎉 All read-only tests passed.") + print_warning("\n⚠️ Important reminders:") + print_warning(" - Mainnet uses real funds.") + print_warning(" - Be cautious when enabling trading operations.") + print_warning(" - Prefer small test sizes and good risk controls.") + else: + print_error("\n⚠️ Some read-only tests failed – please verify configuration and connectivity.") + + print() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n\nTest interrupted by user.") + except Exception as e: + print(f"\n❌ Test failed with error: {e}") + import traceback + traceback.print_exc() + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_market_data_tools.py b/spoon_toolkits/data_platforms/deribit/examples/test_market_data_tools.py new file mode 100644 index 0000000..25a9513 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_market_data_tools.py @@ -0,0 +1,131 @@ +"""Test market data tools""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.market_data import ( + GetInstrumentsTool, + GetOrderBookTool, + GetTickerTool, + GetLastTradesTool, + GetIndexPriceTool, + GetBookSummaryTool +) + + +async def test_market_data_tools(): + """Test all market data tools""" + print("=" * 60) + print("Testing Market Data Tools") + print("=" * 60) + + tools_tested = 0 + tools_passed = 0 + + # Test 1: GetInstrumentsTool + print("\n[Test 1] GetInstrumentsTool") + tools_tested += 1 + try: + tool = GetInstrumentsTool() + result = await tool.execute(currency="BTC", kind="future") + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + print(f"✅ Success! Found {len(output)} instruments") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 2: GetOrderBookTool + print("\n[Test 2] GetOrderBookTool") + tools_tested += 1 + try: + tool = GetOrderBookTool() + result = await tool.execute(instrument_name="BTC-PERPETUAL", depth=5) + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + print(f"✅ Success! Order book retrieved") + if output.get("bids"): + print(f" Best bid: {output['bids'][0]}") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 3: GetTickerTool + print("\n[Test 3] GetTickerTool") + tools_tested += 1 + try: + tool = GetTickerTool() + result = await tool.execute(instrument_name="BTC-PERPETUAL") + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + print(f"✅ Success! Ticker retrieved") + print(f" Last price: {output.get('last_price', 'N/A')}") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 4: GetLastTradesTool + print("\n[Test 4] GetLastTradesTool") + tools_tested += 1 + try: + tool = GetLastTradesTool() + result = await tool.execute(instrument_name="BTC-PERPETUAL", count=5) + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + trades = output.get("trades", []) if isinstance(output, dict) else [] + print(f"✅ Success! Retrieved {len(trades)} trades") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 5: GetIndexPriceTool + print("\n[Test 5] GetIndexPriceTool") + tools_tested += 1 + try: + tool = GetIndexPriceTool() + result = await tool.execute(index_name="btc_usd") + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + print(f"✅ Success! Index price: {output.get('index_price', 'N/A')}") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 6: GetBookSummaryTool + print("\n[Test 6] GetBookSummaryTool") + tools_tested += 1 + try: + tool = GetBookSummaryTool() + result = await tool.execute(currency="BTC", kind="future") + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + print(f"✅ Success! Retrieved {len(output)} book summaries") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Summary + print("\n" + "=" * 60) + print(f"Market Data Tools Test Summary: {tools_passed}/{tools_tested} passed") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(test_market_data_tools()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_options_auto_trade.py b/spoon_toolkits/data_platforms/deribit/examples/test_options_auto_trade.py new file mode 100644 index 0000000..e6e837b --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_options_auto_trade.py @@ -0,0 +1,193 @@ +"""Automatically select an affordable ETH option and attempt a real buy trade. + +This script is intended for use under ``examples/`` only. +""" + +import asyncio +import sys +from pathlib import Path +import importlib.util +from decimal import Decimal +from typing import Optional, Tuple + +# Dynamically load deribit modules + +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + + +def load_module(name: str, file_path: Path): + full_name = f"spoon_toolkits.data_platforms.deribit.{name}" + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + module.__package__ = "spoon_toolkits.data_platforms.deribit" + import types + parent_pkg = "spoon_toolkits.data_platforms.deribit" + parts = parent_pkg.split(".") + for i in range(len(parts)): + pkg_name = ".".join(parts[: i + 1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + + +# Load modules in dependency order +env_module = load_module("env", deribit_path / "env.py") +jsonrpc_module = load_module("jsonrpc_client", deribit_path / "jsonrpc_client.py") +auth_module = load_module("auth", deribit_path / "auth.py") +base_module = load_module("base", deribit_path / "base.py") +market_module = load_module("market_data", deribit_path / "market_data.py") +account_module = load_module("account", deribit_path / "account.py") +trading_module = load_module("trading", deribit_path / "trading.py") + +DeribitConfig = env_module.DeribitConfig +GetInstrumentsTool = market_module.GetInstrumentsTool +GetTickerTool = market_module.GetTickerTool +GetAccountSummaryTool = account_module.GetAccountSummaryTool +PlaceBuyOrderTool = trading_module.PlaceBuyOrderTool +GetTradeHistoryTool = account_module.GetTradeHistoryTool + + +async def get_eth_balance() -> Optional[float]: + """Get current ETH balance.""" + tool = GetAccountSummaryTool() + result = await tool.execute(currency="ETH") + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Failed to get ETH balance:", result.get("error")) + return None + acct = result.get("output") if isinstance(result, dict) else result + return float(acct.get("balance", 0)) + + +async def pick_affordable_option(max_cost_eth: float = 0.005) -> Optional[Tuple[str, float, float]]: + """Pick the cheapest ETH option whose estimated cost <= ``max_cost_eth``. + + Returns (instrument_name, mark_price, tick_size). + """ + inst_tool = GetInstrumentsTool() + result = await inst_tool.execute(currency="ETH", kind="option", expired=False) + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Failed to fetch options instruments:", result.get("error")) + return None + + instruments = result.get("output") if isinstance(result, dict) else result + if not instruments: + print("[ERROR] No ETH options instruments found") + return None + + # Limit the number of instruments to keep requests reasonable + instruments = instruments[:80] + + ticker_tool = GetTickerTool() + candidates = [] + + for inst in instruments: + name = inst.get("instrument_name") + if not name: + continue + tick_size = float(inst.get("tick_size", 0.0) or 0.0) + contract_size = float(inst.get("contract_size", 1.0) or 1.0) + + # Get mark_price + t_res = await ticker_tool.execute(instrument_name=name) + if isinstance(t_res, dict) and t_res.get("error"): + continue + ticker = t_res.get("output") if isinstance(t_res, dict) else t_res + mark = ticker.get("mark_price") or ticker.get("last_price") + if not mark: + continue + mark = float(mark) + if mark <= 0: + continue + + # Estimated cost for 1 contract + est_cost = mark * contract_size + if est_cost <= max_cost_eth: + candidates.append((name, mark, tick_size, est_cost)) + + if not candidates: + print(f"[INFO] No affordable ETH options found with cost <= {max_cost_eth} ETH.") + return None + + # Sort by cost ascending, pick the cheapest + candidates.sort(key=lambda x: x[3]) + name, mark, tick_size, est_cost = candidates[0] + + print("[SELECTED OPTION]", name) + print(f" mark_price : {mark}") + print(f" tick_size : {tick_size}") + print(f" estimated cost (1 contract): {est_cost} ETH") + + return name, mark, tick_size + + +def build_market_price(mark: float) -> float: + """Placeholder for potential future price logic; market orders do not need a price.""" + return mark + + +async def place_option_market_buy(instrument_name: str, amount: float) -> bool: + """Buy an option using a market order (1 contract or specified amount).""" + tool = PlaceBuyOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + order_type="market", + ) + + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Option market buy failed:") + print(result.get("error")) + return False + + out = result.get("output") if isinstance(result, dict) else result + order = out.get("order") if isinstance(out, dict) else None + if not order: + print("[WARN] 'order' field not found in response:", out) + return False + + print("[BUY ORDER PLACED] order:") + print(order) + return True + + +async def main(): + print("=== Auto-select an affordable ETH option and buy 1 contract (market) ===") + balance = await get_eth_balance() + if balance is None: + return + print(f"Current ETH balance: {balance:.6f} ETH") + + # Use at most 1/3 of balance for options, capped at 0.005 ETH + max_cost = balance / 3 if balance > 0 else 0.0 + if max_cost <= 0: + print("[ERROR] Not enough balance to buy an option.") + return + + max_cost = min(max_cost, 0.005) + print(f"Options test budget cap: {max_cost:.6f} ETH") + + picked = await pick_affordable_option(max_cost_eth=max_cost) + if not picked: + print("[END] No suitable option found within current budget.") + return + + inst_name, mark, tick_size = picked + + # Use 1 contract (options often have contract_size = 1) + amount = 1.0 + print(f"Preparing to market-buy 1 contract of {inst_name} (amount={amount})") + + ok = await place_option_market_buy(inst_name, amount) + if not ok: + print("[END] Option market buy failed.") + else: + print("[DONE] Market buy request sent. Please confirm fills in Deribit UI or trade history.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_options_close_position.py b/spoon_toolkits/data_platforms/deribit/examples/test_options_close_position.py new file mode 100644 index 0000000..dfa82ec --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_options_close_position.py @@ -0,0 +1,139 @@ +"""Find the current long ETH option position (if any) and close it with a market sell. + +This script is intended for use under ``examples/`` only, not as a public API. +""" + +import asyncio +import sys +from pathlib import Path +import importlib.util +from typing import Optional + +# Dynamically load deribit modules (same pattern as test_options_complete.py) +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + + +def load_module(name: str, file_path: Path): + """Load a module from file path, handling relative imports""" + full_name = f"spoon_toolkits.data_platforms.deribit.{name}" + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + module.__package__ = "spoon_toolkits.data_platforms.deribit" + import types + parent_pkg = "spoon_toolkits.data_platforms.deribit" + parts = parent_pkg.split(".") + for i in range(len(parts)): + pkg_name = ".".join(parts[: i + 1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + + +# Load modules in dependency order +env_module = load_module("env", deribit_path / "env.py") +jsonrpc_module = load_module("jsonrpc_client", deribit_path / "jsonrpc_client.py") +auth_module = load_module("auth", deribit_path / "auth.py") +base_module = load_module("base", deribit_path / "base.py") +market_module = load_module("market_data", deribit_path / "market_data.py") +account_module = load_module("account", deribit_path / "account.py") +trading_module = load_module("trading", deribit_path / "trading.py") + +GetAccountSummaryTool = account_module.GetAccountSummaryTool +GetPositionsTool = account_module.GetPositionsTool +PlaceSellOrderTool = trading_module.PlaceSellOrderTool + + +async def get_eth_balance() -> Optional[float]: + """Get current ETH balance.""" + tool = GetAccountSummaryTool() + result = await tool.execute(currency="ETH") + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Failed to get ETH balance:", result.get("error")) + return None + acct = result.get("output") if isinstance(result, dict) else result + return float(acct.get("balance", 0)) + + +async def pick_long_option_position() -> Optional[tuple[str, float]]: + """Return (instrument_name, size) for a long ETH option position, or None.""" + tool = GetPositionsTool() + result = await tool.execute(currency="ETH", kind="option") + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Failed to fetch option positions:", result.get("error")) + return None + positions = result.get("output") if isinstance(result, dict) else result + if not positions: + print("[INFO] No ETH options positions found.") + return None + + for pos in positions: + size = float(pos.get("size", 0) or 0) + inst = pos.get("instrument_name") + if size > 0 and inst: + print("[FOUND LONG OPTION POSITION]", inst, "size=", size) + return inst, size + + print("[INFO] No long option positions (size>0) found.") + return None + + +async def close_option_position(instrument_name: str, size: float) -> bool: + """Close a long option position by selling ``size`` contracts at market (reduce-only).""" + tool = PlaceSellOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=size, + order_type="market", + reduce_only=True, + ) + + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Option market sell (close) failed:") + print(result.get("error")) + return False + + out = result.get("output") if isinstance(result, dict) else result + order = out.get("order") if isinstance(out, dict) else None + if not order: + print("[WARN] 'order' field not found in response:", out) + return False + + print("[SELL ORDER PLACED] order:") + print(order) + return True + + +async def main(): + print("=== Close current long ETH option position (if any) ===") + balance_before = await get_eth_balance() + if balance_before is None: + return + print(f"ETH balance before close: {balance_before:.6f} ETH") + + picked = await pick_long_option_position() + if not picked: + print("[END] No long ETH option position to close.") + return + + inst_name, size = picked + print(f"Attempting to close position: sell {size} {inst_name} at market (reduce_only)") + + ok = await close_option_position(inst_name, size) + if not ok: + print("[END] Market sell for option close failed.") + return + + balance_after = await get_eth_balance() + if balance_after is not None: + print(f"ETH balance after close: {balance_after:.6f} ETH") + + print("[DONE] Please verify in Deribit UI or trade history that the option position is closed.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_options_complete.py b/spoon_toolkits/data_platforms/deribit/examples/test_options_complete.py new file mode 100644 index 0000000..efd463c --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_options_complete.py @@ -0,0 +1,772 @@ +"""Comprehensive options test: buy + sell + trade logging + funding tracking + final checks.""" + +import asyncio +import sys +from pathlib import Path +import importlib.util +import json +from datetime import datetime +from typing import Dict, List, Optional +from decimal import Decimal, ROUND_DOWN, ROUND_UP + +# Add deribit module directory to path +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + +def load_module(name, file_path): + """Load a module from file path, handling relative imports""" + full_name = f'spoon_toolkits.data_platforms.deribit.{name}' + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + module.__package__ = 'spoon_toolkits.data_platforms.deribit' + import types + parent_pkg = 'spoon_toolkits.data_platforms.deribit' + parts = parent_pkg.split('.') + for i in range(len(parts)): + pkg_name = '.'.join(parts[:i+1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + +# Load modules in dependency order +env_module = load_module('env', deribit_path / 'env.py') +jsonrpc_module = load_module('jsonrpc_client', deribit_path / 'jsonrpc_client.py') +auth_module = load_module('auth', deribit_path / 'auth.py') +base_module = load_module('base', deribit_path / 'base.py') +market_module = load_module('market_data', deribit_path / 'market_data.py') +account_module = load_module('account', deribit_path / 'account.py') +trading_module = load_module('trading', deribit_path / 'trading.py') + +GetInstrumentsTool = market_module.GetInstrumentsTool +GetTickerTool = market_module.GetTickerTool +GetAccountSummaryTool = account_module.GetAccountSummaryTool +GetPositionsTool = account_module.GetPositionsTool +GetOrderHistoryTool = account_module.GetOrderHistoryTool +GetTradeHistoryTool = account_module.GetTradeHistoryTool +GetOpenOrdersTool = trading_module.GetOpenOrdersTool +PlaceBuyOrderTool = trading_module.PlaceBuyOrderTool +PlaceSellOrderTool = trading_module.PlaceSellOrderTool +CancelOrderTool = trading_module.CancelOrderTool +CancelAllOrdersTool = trading_module.CancelAllOrdersTool + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + MAGENTA = '\033[95m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +class OptionsTradingTest: + """Comprehensive options trading test.""" + + def __init__(self): + self.initial_eth_balance = None + self.final_eth_balance = None + self.eth_consumed = 0.0 + + # Option instrument info + self.options_pair = None + self.options_contract_size = None + self.options_tick_size = None + self.options_currency = None + + # Trade records + self.trade_records: List[Dict] = [] + self.all_order_ids: List[str] = [] + + # Funding tracking + self.funding_tracking: List[Dict] = [] + + # Log file + log_dir = Path(__file__).parent / "logs" + log_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.log_file = log_dir / f"options_trading_log_{timestamp}.json" + + def print_header(self, text: str): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + def print_success(self, text: str): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + def print_error(self, text: str): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + def print_info(self, text: str): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + def print_warning(self, text: str): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + async def get_eth_balance(self) -> Optional[float]: + """Get ETH balance.""" + try: + tool = GetAccountSummaryTool() + result = await tool.execute(currency="ETH") + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to get account balance: {result.get('error')}") + return None + + account = result.get("output") if isinstance(result, dict) else result + balance = account.get("balance", 0) + return float(balance) + except Exception as e: + self.print_error(f"Exception while getting account balance: {e}") + return None + + async def find_options_pair(self) -> bool: + """Find an options instrument to trade.""" + self.print_header("STEP 1: Find options instrument") + try: + tool = GetInstrumentsTool() + + # Prefer ETH options + result = await tool.execute(currency="ETH", kind="option", expired=False) + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to query options instruments: {result.get('error')}") + return False + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + self.print_error("No ETH options instruments found") + return False + + # Prefer a call option if available + call_option = None + put_option = None + + for inst in instruments: + inst_name = inst.get("instrument_name", "") + if inst_name.endswith("-C") and not call_option: + call_option = inst + elif inst_name.endswith("-P") and not put_option: + put_option = inst + + selected_option = call_option if call_option else put_option + + if selected_option: + self.options_pair = selected_option.get("instrument_name") + self.options_contract_size = selected_option.get("contract_size") + self.options_tick_size = selected_option.get("tick_size") + self.options_currency = selected_option.get("currency", "ETH") + + self.print_success(f"Selected options instrument: {self.options_pair}") + self.print_info(f" type : {'Call' if self.options_pair.endswith('-C') else 'Put'}") + self.print_info(f" currency : {self.options_currency}") + self.print_info(f" contract : {self.options_contract_size}") + self.print_info(f" tick_size : {self.options_tick_size}") + self.print_info(f" min amount : {selected_option.get('min_trade_amount', 'N/A')}") + + return True + else: + self.print_error("No suitable options instrument found") + return False + except Exception as e: + self.print_error(f"Exception while finding options instrument: {e}") + return False + + async def get_market_price(self, instrument_name: str) -> Optional[float]: + """Get last or mark price for an instrument.""" + try: + tool = GetTickerTool() + result = await tool.execute(instrument_name=instrument_name) + + if isinstance(result, dict) and result.get("error"): + return None + + ticker = result.get("output") if isinstance(result, dict) else result + price = ticker.get("last_price") or ticker.get("mark_price") + return float(price) if price else None + except Exception as e: + self.print_error(f"Exception while getting price: {e}") + return None + + def adjust_price_to_tick_size(self, price: float, tick_size: float) -> float: + """Adjust a price to be a multiple of ``tick_size``.""" + if tick_size <= 0: + return price + + price_decimal = Decimal(str(price)) + tick_decimal = Decimal(str(tick_size)) + + # Round down to a multiple of tick_size + adjusted = (price_decimal / tick_decimal).quantize(Decimal('1'), rounding=ROUND_DOWN) * tick_decimal + + # Compute precision from tick_size + tick_size_str = str(tick_size) + if '.' in tick_size_str: + decimals = len(tick_size_str.split('.')[1]) + else: + decimals = 0 + + return float(round(adjusted, decimals)) + + async def place_market_order(self, instrument_name: str, amount: float, side: str) -> Optional[str]: + """Place a market order.""" + try: + tool = PlaceBuyOrderTool() if side == "buy" else PlaceSellOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + order_type="market" + ) + + if isinstance(result, dict) and result.get("error"): + error_msg = result.get("error", "") + self.print_error(f"{side.upper()} order failed: {error_msg}") + self.log_failed_trade(instrument_name, side, amount, error_msg) + return None + + order = result.get("output", {}).get("order", {}) if isinstance(result, dict) else result.get("order", {}) + order_id = order.get("order_id") + + if order_id: + self.all_order_ids.append(order_id) + await asyncio.sleep(2) # wait for fills + await self.query_and_log_trade(instrument_name, order_id, side, amount) + return order_id + + return None + except Exception as e: + self.print_error(f"Exception while placing market order: {e}") + self.log_failed_trade(instrument_name, side, amount, str(e)) + return None + + async def place_limit_order(self, instrument_name: str, amount: float, price: float, side: str) -> Optional[str]: + """Place a limit order.""" + try: + # Do not manually adjust price here; the tools will validate and adjust + tool = PlaceBuyOrderTool() if side == "buy" else PlaceSellOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + price=price, + order_type="limit" + ) + + if isinstance(result, dict) and result.get("error"): + error_msg = result.get("error", "") + self.print_error(f"{side.upper()} order failed: {error_msg}") + self.log_failed_trade(instrument_name, side, amount, error_msg) + return None + + order = result.get("output", {}).get("order", {}) if isinstance(result, dict) else result.get("order", {}) + order_id = order.get("order_id") + + if order_id: + self.all_order_ids.append(order_id) + self.print_success(f"{side.upper()} limit order created: {order_id}") + self.print_info(f" price : ${price:,.4f}") + self.print_info(f" amount: {amount}") + return order_id + + return None + except Exception as e: + self.print_error(f"Exception while placing limit order: {e}") + self.log_failed_trade(instrument_name, side, amount, str(e)) + return None + + async def query_and_log_trade(self, instrument_name: str, order_id: str, side: str, amount: float): + """Query order/trade history for an order and log the result.""" + try: + # Query order history + order_tool = GetOrderHistoryTool() + order_result = await order_tool.execute( + instrument_name=instrument_name, + count=20 + ) + + order_data = None + if isinstance(order_result, dict) and not order_result.get("error"): + orders = order_result.get("output", []) + if isinstance(orders, list): + for order in orders: + if order.get("order_id") == order_id: + order_data = order + break + + # Query trade history + trade_tool = GetTradeHistoryTool() + trade_result = await trade_tool.execute( + instrument_name=instrument_name, + count=20 + ) + + trade_data = None + if isinstance(trade_result, dict) and not trade_result.get("error"): + trades = trade_result.get("output", []) + if isinstance(trades, list): + for trade in trades: + if isinstance(trade, dict) and trade.get("order_id") == order_id: + trade_data = trade + break + + # Build trade record + trade_record = { + "timestamp": datetime.now().isoformat(), + "order_id": order_id, + "instrument_name": instrument_name, + "side": side.upper(), + "amount": amount, + "order_data": order_data, + "trade_data": trade_data, + "status": "success" if order_data else "pending" + } + + self.trade_records.append(trade_record) + + # Print trade record + self.print_header(f"Trade record - {side.upper()} {instrument_name}") + if order_data: + self.print_info(f"Order ID : {order_id}") + self.print_info(f"Order state: {order_data.get('order_state', 'N/A')}") + self.print_info(f"Order type : {order_data.get('order_type', 'N/A')}") + self.print_info(f"Amount : {order_data.get('amount', 'N/A')}") + avg_price = order_data.get('average_price') or order_data.get('price') + if avg_price: + self.print_info(f"Average price: ${avg_price:,.4f}") + else: + self.print_warning("Order data not found") + + if trade_data: + if trade_data.get('price'): + self.print_info(f"Trade price : ${trade_data.get('price', 'N/A'):,.4f}") + else: + self.print_info("Trade price : N/A") + self.print_info(f"Trade amount: {trade_data.get('amount', 'NA')}") + else: + self.print_warning("No trade data found (order may not be filled yet)") + + # Persist to log file + self.write_trade_log(trade_record) + + except Exception as e: + self.print_error(f"Exception while querying trade record: {e}") + + def log_failed_trade(self, instrument_name: str, side: str, amount: float, error_msg: str): + """Record a failed trade attempt.""" + failed_record = { + "timestamp": datetime.now().isoformat(), + "instrument_name": instrument_name, + "side": side.upper(), + "amount": amount, + "status": "failed", + "error": error_msg + } + self.trade_records.append(failed_record) + self.write_trade_log(failed_record) + + def write_trade_log(self, record: Dict): + """Append a trade record to the JSON log file.""" + try: + if self.log_file.exists(): + with open(self.log_file, 'r', encoding='utf-8') as f: + data = json.load(f) + else: + data = [] + + data.append(record) + + with open(self.log_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except Exception as e: + self.print_error(f"Exception while writing trade log: {e}") + + async def track_funding(self, action: str, instrument_name: str, amount: float, price: Optional[float] = None): + """Track funding usage and current ETH balance at each step.""" + balance = await self.get_eth_balance() + record = { + "timestamp": datetime.now().isoformat(), + "action": action, + "instrument_name": instrument_name, + "amount": amount, + "price": price, + "eth_balance": balance + } + self.funding_tracking.append(record) + if balance is not None: + self.print_info(f"Funding: {action} | balance: {balance:.6f} ETH") + else: + self.print_info(f"Funding: {action} | balance: N/A") + + async def test_options_buy(self, use_limit_order: bool = False) -> bool: + """Test options buy leg.""" + self.print_header("TEST 1: Options buy") + + if not self.options_pair: + self.print_error("No options instrument selected") + return False + + price = await self.get_market_price(self.options_pair) + if not price: + self.print_error("Unable to fetch option price") + return False + + # Use minimal trade unit + amount = self.options_contract_size if self.options_contract_size and self.options_contract_size > 0 else 1.0 + + # Check if balance is sufficient + balance = await self.get_eth_balance() + estimated_cost = price * amount # estimated option premium + + self.print_info(f"Instrument : {self.options_pair}") + self.print_info(f"Buy amount : {amount} contracts") + self.print_info(f"Current price : ${price:,.4f}") + self.print_info(f"Estimated cost : ${estimated_cost:,.2f}") + self.print_info(f"Current ETH bal: {balance:.6f} ETH" if balance else "Current ETH bal: N/A") + + # If funds are likely insufficient, fall back to a deep limit order (won't fill) + if balance and estimated_cost > balance * 3000: + self.print_warning("⚠️ Balance may be insufficient; using a deep limit order test instead.") + use_limit_order = True + + if use_limit_order: + # Use a limit order with price at 50% of current price. + # Note: PlaceBuyOrderTool/PlaceSellOrderTool will validate and adjust to tick size. + limit_price = price * 0.5 + + # Pre-adjust price to conform to tick_size to avoid validation failures. + if self.options_tick_size: + limit_price = self.adjust_price_to_tick_size(limit_price, self.options_tick_size) + + self.print_info( + f"Limit price: ${limit_price:,.4f} (should not fill; already adjusted to tick_size)" + ) + await self.track_funding( + "options_buy_before_limit_order", self.options_pair, amount, limit_price + ) + buy_order_id = await self.place_limit_order(self.options_pair, amount, limit_price, "buy") + await self.track_funding( + "options_buy_after_limit_order", self.options_pair, amount, limit_price + ) + else: + self.print_warning("⚠️ Using market order; this will fill immediately.") + await self.track_funding("options_buy_before_market_order", self.options_pair, amount, price) + buy_order_id = await self.place_market_order(self.options_pair, amount, "buy") + await self.track_funding("options_buy_after_market_order", self.options_pair, amount, price) + + if buy_order_id: + self.print_success("Options buy order created successfully") + return True + else: + self.print_error("Options buy failed") + return False + + async def test_options_sell(self) -> bool: + """Test options sell leg.""" + self.print_header("TEST 2: Options sell") + + if not self.options_pair: + self.print_error("No options instrument found") + return False + + # Check current positions + positions_tool = GetPositionsTool() + positions_result = await positions_tool.execute(currency=self.options_currency, kind="option") + + current_position = 0 + if isinstance(positions_result, dict) and not positions_result.get("error"): + positions = positions_result.get("output", []) + for pos in positions: + if pos.get("instrument_name") == self.options_pair: + current_position = pos.get("size", 0) + break + + if current_position <= 0: + self.print_warning( + f"No open position for {self.options_pair} (current position: {current_position})" + ) + self.print_warning("Cannot sell option (you need an open long position first).") + return False + + price = await self.get_market_price(self.options_pair) + if not price: + self.print_error("Unable to fetch option price") + return False + + # Sell the current position size + amount = abs(current_position) + if self.options_contract_size: + # Ensure the amount is a multiple of contract_size. + amount = round(amount / self.options_contract_size) * self.options_contract_size + + self.print_info(f"Instrument : {self.options_pair}") + self.print_info(f"Sell amount: {amount} contracts (closing position)") + self.print_info(f"Price : ${price:,.4f}") + self.print_warning("⚠️ Using market order; this will fill immediately.") + + await self.track_funding("options_sell_before_market_order", self.options_pair, amount, price) + sell_order_id = await self.place_market_order(self.options_pair, amount, "sell") + await self.track_funding("options_sell_after_market_order", self.options_pair, amount, price) + + if sell_order_id: + self.print_success("Options sell completed successfully") + return True + else: + self.print_error("Options sell failed") + return False + + async def cleanup_all_orders(self) -> bool: + """Cancel all open option orders.""" + self.print_header("STEP 3: Cancel all open orders") + + try: + # Cancel all option orders + cancel_tool = CancelAllOrdersTool() + result = await cancel_tool.execute(currency=self.options_currency, kind="option") + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to cancel all orders: {result.get('error')}") + return False + + self.print_success("All option orders have been cancelled") + return True + except Exception as e: + self.print_error(f"Exception while cancelling orders: {e}") + return False + + async def close_all_positions(self) -> bool: + """Close all remaining option positions.""" + self.print_header("STEP 4: Close all positions") + + try: + positions_tool = GetPositionsTool() + result = await positions_tool.execute(currency=self.options_currency, kind="option") + + if isinstance(result, dict) and result.get("error"): + self.print_error(f"Failed to query positions: {result.get('error')}") + return False + + positions = result.get("output", []) if isinstance(result, dict) else result + + if not positions: + self.print_success("No positions to close") + return True + + closed = False + for pos in positions: + inst_name = pos.get("instrument_name") + size = pos.get("size", 0) + direction = pos.get("direction", "") + + if abs(size) > 0.0001: # non-zero position + self.print_info( + f"Found position: {inst_name} | size: {size} | direction: {direction}" + ) + + # Close position + side = "sell" if size > 0 else "buy" + close_amount = abs(size) + + self.print_info(f"Closing position: {side} {close_amount} {inst_name}") + order_id = await self.place_market_order(inst_name, close_amount, side) + + if order_id: + closed = True + await asyncio.sleep(2) + + if closed: + self.print_success("All option positions have been closed") + else: + self.print_success("No positions required closing") + + return True + except Exception as e: + self.print_error(f"Exception while closing positions: {e}") + return False + + async def verify_final_state(self) -> bool: + """Verify final account state after the test.""" + self.print_header("STEP 5: Verify final state") + + # Check balance + balance = await self.get_eth_balance() + if balance: + self.print_info(f"Final ETH balance: {balance:.6f} ETH") + self.final_eth_balance = balance + + if self.initial_eth_balance: + consumed = self.initial_eth_balance - balance + self.eth_consumed = consumed + self.print_info(f"ETH consumed: {consumed:.6f} ETH") + + # Check open orders + open_orders = [] + try: + orders_tool = GetOpenOrdersTool() + orders_result = await orders_tool.execute(currency=self.options_currency, kind="option") + + if isinstance(orders_result, dict) and not orders_result.get("error"): + open_orders = orders_result.get("output", []) + except Exception as e: + self.print_warning(f"Exception while querying open orders: {e}") + + if open_orders: + self.print_error(f"There are still {len(open_orders)} open orders") + for order in open_orders: + self.print_error(f" - {order.get('order_id')} | {order.get('instrument_name')}") + return False + else: + self.print_success("No open orders") + + # Check positions + positions_tool = GetPositionsTool() + positions_result = await positions_tool.execute(currency=self.options_currency, kind="option") + + positions = [] + if isinstance(positions_result, dict) and not positions_result.get("error"): + positions = positions_result.get("output", []) + + active_positions = [] + for pos in positions: + size = abs(pos.get("size", 0)) + if size > 0.0001: + active_positions.append(pos) + + if active_positions: + self.print_error(f"There are still {len(active_positions)} open positions") + for pos in active_positions: + self.print_error( + f" - {pos.get('instrument_name')} | size: {pos.get('size')}" + ) + return False + else: + self.print_success("No open positions") + + self.print_success( + "✅ Final state verified: account has only ETH, with no open orders or positions" + ) + return True + + async def print_summary(self): + """Print a human-readable test summary.""" + self.print_header("Test summary") + + self.print_info( + f"Initial ETH balance: {self.initial_eth_balance:.6f} ETH" + if self.initial_eth_balance + else "Initial ETH balance: N/A" + ) + self.print_info( + f"Final ETH balance : {self.final_eth_balance:.6f} ETH" + if self.final_eth_balance + else "Final ETH balance : N/A" + ) + self.print_info( + f"ETH consumed : {self.eth_consumed:.6f} ETH" + if self.eth_consumed + else "ETH consumed : N/A" + ) + + self.print_info(f"\nTotal trade records: {len(self.trade_records)}") + successful = [r for r in self.trade_records if r.get("status") == "success"] + failed = [r for r in self.trade_records if r.get("status") == "failed"] + + self.print_success(f"Successful trades: {len(successful)}") + if failed: + self.print_error(f"Failed trades : {len(failed)}") + + if successful: + self.print_info("\nSuccessful trade details:") + for i, record in enumerate(successful, 1): + order_id = record.get("order_id", "N/A") + side = record.get("side", "N/A") + amount = record.get("amount", "N/A") + order_data = record.get("order_data", {}) + avg_price = order_data.get("average_price") or order_data.get("price") if order_data else None + + self.print_info(f" {i}. {side} | amount: {amount} | order: {order_id}") + if avg_price: + self.print_info(f" average price: ${avg_price:,.4f}") + + if failed: + self.print_info("\nFailed trade details:") + for i, record in enumerate(failed, 1): + side = record.get("side", "N/A") + amount = record.get("amount", "N/A") + error = record.get("error", "N/A") + self.print_error(f" {i}. {side} | amount: {amount} | error: {error[:100]}") + + self.print_info(f"\nLog file: {self.log_file}") + + async def run_complete_test(self): + """Run the complete options trading test.""" + self.print_header("Options complete trading test") + + self.print_warning("⚠️ This test performs real trades with real funds.") + self.print_warning( + "⚠️ After all trades, the account should have only ETH, with no open orders or positions." + ) + + # Step 0: Get initial balance + self.print_header("STEP 0: Get initial balance") + self.initial_eth_balance = await self.get_eth_balance() + if self.initial_eth_balance: + self.print_success(f"Initial ETH balance: {self.initial_eth_balance:.6f} ETH") + else: + self.print_error("Unable to fetch initial balance") + return + + # Step 1: Find an options instrument + if not await self.find_options_pair(): + return + + await asyncio.sleep(1) + + # Step 2: Execute trades + # Try market order first; if funds are insufficient, fall back to limit order. + buy_success = await self.test_options_buy(use_limit_order=False) + + if not buy_success: + # Market order failed; try a deep limit order instead. + self.print_warning("Market order failed; trying limit order test instead.") + await asyncio.sleep(2) + buy_success = await self.test_options_buy(use_limit_order=True) + + await asyncio.sleep(2) + + if buy_success: + # If buy leg succeeded, attempt sell leg. + await asyncio.sleep(3) + sell_success = await self.test_options_sell() + await asyncio.sleep(2) + else: + self.print_warning("Buy leg failed; skipping sell test.") + sell_success = False + + # Step 3: Cleanup + await self.cleanup_all_orders() + await asyncio.sleep(2) + + await self.close_all_positions() + await asyncio.sleep(2) + + # Step 4: Verification + await self.verify_final_state() + + # Step 5: Summary + await self.print_summary() + + +async def main(): + test = OptionsTradingTest() + await test.run_complete_test() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_options_safe_roundtrip.py b/spoon_toolkits/data_platforms/deribit/examples/test_options_safe_roundtrip.py new file mode 100644 index 0000000..a89d79f --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_options_safe_roundtrip.py @@ -0,0 +1,283 @@ +"""Safe options round-trip test. + +This example: +- Runs only under ``examples/`` (not part of the public API surface). +- Strategy: + 1. Query all ETH options and pick the cheapest one by ``mark_price * contract_size``; + 2. Buy 1 contract using a market order; + 3. Verify the resulting long position; + 4. Sell the same amount using a reduce-only market order to close the position; + 5. Print initial balance, final balance, PnL, and recent trades. +""" + +import asyncio +import sys +from pathlib import Path +import importlib.util +from decimal import Decimal +from typing import Optional, Tuple, List, Dict + +# Dynamically load deribit modules (same pattern as test_options_complete.py) +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + + +def load_module(name: str, file_path: Path): + """Load a module from file path, handling relative imports""" + full_name = f"spoon_toolkits.data_platforms.deribit.{name}" + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + module.__package__ = "spoon_toolkits.data_platforms.deribit" + import types + parent_pkg = "spoon_toolkits.data_platforms.deribit" + parts = parent_pkg.split(".") + for i in range(len(parts)): + pkg_name = ".".join(parts[: i + 1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + + +# Load modules in dependency order +env_module = load_module("env", deribit_path / "env.py") +jsonrpc_module = load_module("jsonrpc_client", deribit_path / "jsonrpc_client.py") +auth_module = load_module("auth", deribit_path / "auth.py") +base_module = load_module("base", deribit_path / "base.py") +market_module = load_module("market_data", deribit_path / "market_data.py") +account_module = load_module("account", deribit_path / "account.py") +trading_module = load_module("trading", deribit_path / "trading.py") + +DeribitConfig = env_module.DeribitConfig +GetInstrumentsTool = market_module.GetInstrumentsTool +GetTickerTool = market_module.GetTickerTool +GetAccountSummaryTool = account_module.GetAccountSummaryTool +GetPositionsTool = account_module.GetPositionsTool +GetTradeHistoryTool = account_module.GetTradeHistoryTool +PlaceBuyOrderTool = trading_module.PlaceBuyOrderTool +PlaceSellOrderTool = trading_module.PlaceSellOrderTool + + +async def get_eth_balance() -> Optional[float]: + tool = GetAccountSummaryTool() + result = await tool.execute(currency="ETH") + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Failed to get ETH balance:", result.get("error")) + return None + acct = result.get("output") if isinstance(result, dict) else result + return float(acct.get("balance", 0)) + + +async def pick_cheapest_eth_option(max_cost_eth: float) -> Optional[Tuple[str, float, float, float]]: + """Pick the cheapest ETH option whose estimated cost <= ``max_cost_eth``. + + Returns (instrument_name, mark_price, tick_size, est_cost_eth). + """ + inst_tool = GetInstrumentsTool() + result = await inst_tool.execute(currency="ETH", kind="option", expired=False) + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Failed to fetch options instruments:", result.get("error")) + return None + + instruments = result.get("output") if isinstance(result, dict) else result + if not instruments: + print("[ERROR] No ETH options instruments found") + return None + + # Limit the number of instruments checked to keep requests reasonable + instruments = instruments[:100] + + ticker_tool = GetTickerTool() + candidates: List[Tuple[str, float, float, float]] = [] + + for inst in instruments: + name = inst.get("instrument_name") + if not name: + continue + tick_size = float(inst.get("tick_size", 0.0) or 0.0) + contract_size = float(inst.get("contract_size", 1.0) or 1.0) + + t_res = await ticker_tool.execute(instrument_name=name) + if isinstance(t_res, dict) and t_res.get("error"): + continue + ticker = t_res.get("output") if isinstance(t_res, dict) else t_res + mark = ticker.get("mark_price") or ticker.get("last_price") + if not mark: + continue + mark = float(mark) + if mark <= 0: + continue + + est_cost = mark * contract_size + if est_cost <= max_cost_eth: + candidates.append((name, mark, tick_size, est_cost)) + + if not candidates: + print(f"[INFO] No affordable ETH options found with cost <= {max_cost_eth} ETH.") + return None + + candidates.sort(key=lambda x: x[3]) + name, mark, tick_size, est_cost = candidates[0] + + print("[SELECTED OPTION]", name) + print(f" mark_price : {mark}") + print(f" tick_size : {tick_size}") + print(f" estimated cost (1 contract): {est_cost} ETH") + + return name, mark, tick_size, est_cost + + +async def place_option_market_buy(instrument_name: str, amount: float) -> Optional[Dict]: + """Place a market buy order for an option. Returns the ``order`` dict if successful.""" + tool = PlaceBuyOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + order_type="market", + ) + + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Option market buy failed:") + print(result.get("error")) + return None + + out = result.get("output") if isinstance(result, dict) else result + order = out.get("order") if isinstance(out, dict) else None + if not order: + print("[WARN] 'order' field not found in response:", out) + return None + + print("[BUY ORDER PLACED] order:") + print(order) + return order + + +async def place_option_market_sell(instrument_name: str, amount: float) -> Optional[Dict]: + """Place a reduce-only market sell order to close a long option position.""" + tool = PlaceSellOrderTool() + result = await tool.execute( + instrument_name=instrument_name, + amount=amount, + order_type="market", + reduce_only=True, + ) + + if isinstance(result, dict) and result.get("error"): + print("[ERROR] Option market sell (close) failed:") + print(result.get("error")) + return None + + out = result.get("output") if isinstance(result, dict) else result + order = out.get("order") if isinstance(out, dict) else None + if not order: + print("[WARN] 'order' field not found in response:", out) + return None + + print("[SELL ORDER PLACED] order:") + print(order) + return order + + +async def get_option_long_position(instrument_name: str) -> float: + """Return the long position size (>0) for the given option, or 0.0 if none.""" + tool = GetPositionsTool() + result = await tool.execute(currency="ETH", kind="option") + positions = result.get("output") if isinstance(result, dict) else result + if not positions: + return 0.0 + for pos in positions: + if pos.get("instrument_name") == instrument_name: + return float(pos.get("size", 0) or 0) + return 0.0 + + +async def print_recent_trades(instrument_name: str, label: str): + """Print a few recent trades for the given option.""" + tool = GetTradeHistoryTool() + result = await tool.execute(instrument_name=instrument_name, count=5) + if isinstance(result, dict) and result.get("error"): + print(f"[{label}] Failed to fetch trade history:", result.get("error")) + return + trades = result.get("output") if isinstance(result, dict) else result + if not trades: + print(f"[{label}] No trades found.") + return + print(f"[{label}] Recent trades:") + for t in trades: + direction = t.get("direction") + amount = t.get("amount") + price = t.get("price") + fee = t.get("fee") + print(f" {direction} {amount} @ {price}, fee={fee}") + + +async def run_safe_roundtrip(): + print("=== Options safe round-trip: buy + sell 1 ETH option ===") + + initial_balance = await get_eth_balance() + if initial_balance is None: + return + print(f"Initial ETH balance: {initial_balance:.6f} ETH") + + # Budget: use at most 1/3 of the balance, capped at 0.005 ETH + max_cost = initial_balance / 3 if initial_balance > 0 else 0.0 + max_cost = min(max_cost, 0.005) + if max_cost <= 0: + print("[ERROR] Not enough balance to run options test.") + return + print(f"Options budget cap: {max_cost:.6f} ETH") + + picked = await pick_cheapest_eth_option(max_cost_eth=max_cost) + if not picked: + print("[END] No suitable option found within budget.") + return + + inst_name, mark, tick_size, est_cost = picked + + # Buy 1 contract + amount = 1.0 + print(f"\n[STEP 1] Market buy {amount} {inst_name}") + buy_order = await place_option_market_buy(inst_name, amount) + if not buy_order: + print("[END] Buy failed, stopping test.") + return + + await asyncio.sleep(2) + await print_recent_trades(inst_name, "AFTER BUY") + + # Confirm position + pos_size = await get_option_long_position(inst_name) + print(f"Current position: {inst_name} size={pos_size}") + + # Sell to close + print(f"\n[STEP 2] Market sell {amount} {inst_name} (reduce_only close)") + sell_order = await place_option_market_sell(inst_name, amount) + if not sell_order: + print("[END] Sell failed, stopping test.") + return + + await asyncio.sleep(2) + await print_recent_trades(inst_name, "AFTER SELL") + + # Check position again + pos_size_after = await get_option_long_position(inst_name) + print(f"Position after close: {inst_name} size={pos_size_after}") + + # Balance and PnL + final_balance = await get_eth_balance() + if final_balance is None: + return + print(f"Final ETH balance: {final_balance:.6f} ETH") + pnl = final_balance - initial_balance + print(f"Options round-trip PnL: {pnl:.6f} ETH") + + +async def main(): + await run_safe_roundtrip() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_options_trading.py b/spoon_toolkits/data_platforms/deribit/examples/test_options_trading.py new file mode 100644 index 0000000..84b5634 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_options_trading.py @@ -0,0 +1,335 @@ +"""Basic Options trading test script (create test limit buy/sell orders and then cancel them).""" + +import asyncio +import sys +from pathlib import Path +import importlib.util +from decimal import Decimal, ROUND_DOWN, ROUND_UP + +# Add deribit module directory to path +deribit_path = Path(__file__).parent.parent +sys.path.insert(0, str(deribit_path.parent.parent.parent)) + +def load_module(name, file_path): + """Load a module from file path, handling relative imports""" + full_name = f'spoon_toolkits.data_platforms.deribit.{name}' + spec = importlib.util.spec_from_file_location(full_name, file_path) + module = importlib.util.module_from_spec(spec) + module.__package__ = 'spoon_toolkits.data_platforms.deribit' + import types + parent_pkg = 'spoon_toolkits.data_platforms.deribit' + parts = parent_pkg.split('.') + for i in range(len(parts)): + pkg_name = '.'.join(parts[:i+1]) + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [] + sys.modules[pkg_name] = pkg + sys.modules[full_name] = module + spec.loader.exec_module(module) + return module + +# Load modules in dependency order +env_module = load_module('env', deribit_path / 'env.py') +jsonrpc_module = load_module('jsonrpc_client', deribit_path / 'jsonrpc_client.py') +auth_module = load_module('auth', deribit_path / 'auth.py') +base_module = load_module('base', deribit_path / 'base.py') +market_module = load_module('market_data', deribit_path / 'market_data.py') +trading_module = load_module('trading', deribit_path / 'trading.py') + +GetInstrumentsTool = market_module.GetInstrumentsTool +GetTickerTool = market_module.GetTickerTool +PlaceBuyOrderTool = trading_module.PlaceBuyOrderTool +PlaceSellOrderTool = trading_module.PlaceSellOrderTool +CancelOrderTool = trading_module.CancelOrderTool + +class Colors: + HEADER = '\033[95m' + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + RESET = '\033[0m' + BOLD = '\033[1m' + +def print_header(text): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + +def print_success(text): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + +def print_error(text): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + +def print_info(text): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + +def print_warning(text): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + +async def find_available_options(): + """Find an available ETH options instrument to use in the test.""" + print_header("STEP 1: Find available options instrument") + + tool = GetInstrumentsTool() + + # Query ETH options instruments + print_info("Querying ETH options instruments...") + result_eth = await tool.execute(currency="ETH", kind="option", expired=False) + + if isinstance(result_eth, dict) and result_eth.get("error"): + print_error(f"Query failed: {result_eth.get('error')}") + return None, None, None + + instruments_eth = result_eth.get("output") if isinstance(result_eth, dict) else result_eth + + if not instruments_eth: + print_warning("No ETH options instruments found") + return None, None, None + + # Prefer a call option, fall back to a put option + call_option = None + put_option = None + + for inst in instruments_eth: + inst_name = inst.get("instrument_name", "") + if inst_name.endswith("-C") and not call_option: + call_option = inst + elif inst_name.endswith("-P") and not put_option: + put_option = inst + + if call_option: + print_success(f"Found call option: {call_option.get('instrument_name')}") + print_info(f" contract_size : {call_option.get('contract_size', 'N/A')}") + print_info(f" min_trade_amount : {call_option.get('min_trade_amount', 'N/A')}") + print_info(f" price tick_size : {call_option.get('tick_size', 'N/A')}") + + if put_option: + print_success(f"Found put option: {put_option.get('instrument_name')}") + print_info(f" contract_size : {put_option.get('contract_size', 'N/A')}") + print_info(f" min_trade_amount : {put_option.get('min_trade_amount', 'N/A')}") + print_info(f" price tick_size : {put_option.get('tick_size', 'N/A')}") + + # Prefer a call option, fall back to a put option + selected_option = call_option if call_option else put_option + + if selected_option: + return ( + selected_option.get("instrument_name"), + selected_option.get("contract_size"), + selected_option.get("tick_size") + ) + + return None, None, None + +async def test_options_buy(option_name: str, contract_size: float, tick_size: float = None): + """Create a limit buy order for the selected option (deep out-of-the-money; should not fill).""" + print_header("STEP 2: Options buy test") + + # Use minimal trade unit (1×contract_size when available) + amount = contract_size if contract_size and contract_size > 0 else 0.01 + + print_info(f"Instrument : {option_name}") + print_info(f"Buy amount : {amount} (1×contract_size)") + print_warning("⚠️ Using a deep limit order at 50% below market; this should not fill.") + + # Fetch current price and tick_size + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name=option_name) + + current_price = None + if isinstance(ticker_result, dict) and not ticker_result.get("error"): + ticker = ticker_result.get("output", {}) + current_price = ticker.get("last_price") or ticker.get("mark_price") + if not tick_size: + tick_size = ticker.get("tick_size", 0.0001) + + if not tick_size: + tick_size = 0.0001 # default tick_size fallback + + if current_price: + print_info(f"Current price: ${current_price:,.4f}") + print_info(f"Price tick_size: {tick_size}") + # Use Decimal for precise arithmetic + price_decimal = Decimal(str(current_price)) + tick_decimal = Decimal(str(tick_size)) + limit_price_raw = price_decimal * Decimal('0.5') + # Round down to a multiple of tick_size + limit_price = (limit_price_raw / tick_decimal).quantize(Decimal('1'), rounding=ROUND_DOWN) * tick_decimal + # Ensure price is at least one tick + if limit_price < tick_decimal: + limit_price = tick_decimal + limit_price = float(limit_price) + print_info(f"Limit price: ${limit_price:,.4f} (deep, should not fill)") + else: + print_warning("Unable to fetch current price; using default limit price based on tick_size") + tick_decimal = Decimal(str(tick_size)) + limit_price = float(tick_decimal) + + # Create buy order + buy_tool = PlaceBuyOrderTool() + result = await buy_tool.execute( + instrument_name=option_name, + amount=amount, + price=limit_price, + order_type="limit" + ) + + if isinstance(result, dict) and result.get("error"): + print_error(f"Buy order failed: {result.get('error')}") + return None + + order = result.get("output", {}).get("order", {}) if isinstance(result, dict) else result.get("order", {}) + order_id = order.get("order_id") + + if order_id: + print_success(f"Buy order created: {order_id}") + print_info(f" order_state: {order.get('order_state', 'N/A')}") + print_info(f" amount : {order.get('amount', 'N/A')}") + print_info( + f" price : ${order.get('price', 'N/A'):,.2f}" + if order.get('price') + else " price : N/A" + ) + return order_id + + return None + +async def test_options_sell(option_name: str, contract_size: float, tick_size: float = None): + """Create a limit sell order for the selected option (deep above market; should not fill).""" + print_header("STEP 3: Options sell test") + + # Use minimal trade unit (1×contract_size when available) + amount = contract_size if contract_size and contract_size > 0 else 0.01 + + print_info(f"Instrument : {option_name}") + print_info(f"Sell amount: {amount} (1×contract_size)") + print_warning("⚠️ Using a limit order 50% above market; this should not fill.") + + # Fetch current price and tick_size + ticker_tool = GetTickerTool() + ticker_result = await ticker_tool.execute(instrument_name=option_name) + + current_price = None + if isinstance(ticker_result, dict) and not ticker_result.get("error"): + ticker = ticker_result.get("output", {}) + current_price = ticker.get("last_price") or ticker.get("mark_price") + if not tick_size: + tick_size = ticker.get("tick_size", 0.0001) + + if not tick_size: + tick_size = 0.0001 # default tick_size fallback + + if current_price: + print_info(f"Current price: ${current_price:,.4f}") + print_info(f"Price tick_size: {tick_size}") + # Use Decimal for precise arithmetic + price_decimal = Decimal(str(current_price)) + tick_decimal = Decimal(str(tick_size)) + limit_price_raw = price_decimal * Decimal('1.5') + # Round up to a multiple of tick_size + limit_price = (limit_price_raw / tick_decimal).quantize(Decimal('1'), rounding=ROUND_UP) * tick_decimal + limit_price = float(limit_price) + print_info(f"Limit price: ${limit_price:,.4f} (deep, should not fill)") + else: + print_warning("Unable to fetch current price; using default high limit price") + tick_decimal = Decimal(str(tick_size)) + # Fallback to an arbitrarily high price far above typical market levels. + limit_price = float(tick_decimal * Decimal("1000")) + + # Create sell order + sell_tool = PlaceSellOrderTool() + result = await sell_tool.execute( + instrument_name=option_name, + amount=amount, + price=limit_price, + order_type="limit" + ) + + if isinstance(result, dict) and result.get("error"): + print_error(f"Sell order failed: {result.get('error')}") + return None + + order = result.get("output", {}).get("order", {}) if isinstance(result, dict) else result.get("order", {}) + order_id = order.get("order_id") + + if order_id: + print_success(f"Sell order created: {order_id}") + print_info(f" order_state: {order.get('order_state', 'N/A')}") + print_info(f" amount : {order.get('amount', 'N/A')}") + print_info( + f" price : ${order.get('price', 'N/A'):,.2f}" + if order.get('price') + else " price : N/A" + ) + return order_id + + return None + +async def cancel_order(order_id: str): + """Cancel a single order by ID.""" + if not order_id: + return + + print_info(f"Cancelling order: {order_id}") + cancel_tool = CancelOrderTool() + result = await cancel_tool.execute(order_id=order_id) + + if isinstance(result, dict) and result.get("error"): + print_error(f"Cancel failed: {result.get('error')}") + else: + print_success(f"Order cancelled: {order_id}") + +async def main(): + """Entry point for the basic options limit-order test.""" + print_header("Options trading test (limit orders, non-filling)") + + print_warning("⚠️ This test creates real limit orders, far away from market price.") + print_warning("⚠️ All created orders will be cancelled before the script exits.") + + # STEP 1: Find an options instrument + option_name, contract_size, tick_size = await find_available_options() + + if not option_name: + print_error("No suitable options instrument found; aborting test") + return + + await asyncio.sleep(1) + + # STEP 2: Test limit buy + buy_order_id = await test_options_buy(option_name, contract_size, tick_size) + + await asyncio.sleep(2) + + # STEP 3: Test limit sell + print_warning("⚠️ Selling the option requires an open position; otherwise the sell may fail.") + sell_order_id = await test_options_sell(option_name, contract_size, tick_size) + + await asyncio.sleep(2) + + # STEP 4: Cleanup + print_header("STEP 4: Cleanup (cancel any created orders)") + + if buy_order_id: + await cancel_order(buy_order_id) + await asyncio.sleep(1) + + if sell_order_id: + await cancel_order(sell_order_id) + await asyncio.sleep(1) + + # Summary + print_header("Test summary") + print_success("Options trading limit-order test completed.") + print_info(f"Tested instrument: {option_name}") + if buy_order_id: + print_success(f"Buy order ID : {buy_order_id} ✅") + if sell_order_id: + print_success(f"Sell order ID: {sell_order_id} ✅") + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_public_api.py b/spoon_toolkits/data_platforms/deribit/examples/test_public_api.py new file mode 100644 index 0000000..15813c2 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_public_api.py @@ -0,0 +1,81 @@ +"""Test public API (no authentication required)""" + +import asyncio +import sys +import os +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.jsonrpc_client import DeribitJsonRpcClient + + +async def test_public_api(): + """Test public API calls""" + print("=" * 60) + print("Testing Deribit Public API (No Authentication Required)") + print("=" * 60) + + async with DeribitJsonRpcClient() as client: + # Test 1: Get instruments + print("\n[Test 1] Getting BTC instruments...") + try: + result = await client.call( + "public/get_instruments", + {"currency": "BTC", "kind": "future"} + ) + print(f"✅ Success! Found {len(result)} BTC futures instruments") + if result: + print(f" Example: {result[0].get('instrument_name', 'N/A')}") + except Exception as e: + print(f"❌ Failed: {e}") + + # Test 2: Get order book + print("\n[Test 2] Getting order book for BTC-PERPETUAL...") + try: + result = await client.call( + "public/get_order_book", + {"instrument_name": "BTC-PERPETUAL", "depth": 5} + ) + print(f"✅ Success! Order book retrieved") + if result.get("bids"): + print(f" Best bid: {result['bids'][0]}") + if result.get("asks"): + print(f" Best ask: {result['asks'][0]}") + except Exception as e: + print(f"❌ Failed: {e}") + + # Test 3: Get ticker + print("\n[Test 3] Getting ticker for BTC-PERPETUAL...") + try: + result = await client.call( + "public/ticker", + {"instrument_name": "BTC-PERPETUAL"} + ) + print(f"✅ Success! Ticker retrieved") + print(f" Last price: {result.get('last_price', 'N/A')}") + print(f" Mark price: {result.get('mark_price', 'N/A')}") + except Exception as e: + print(f"❌ Failed: {e}") + + # Test 4: Get index price + print("\n[Test 4] Getting BTC index price...") + try: + result = await client.call( + "public/get_index_price", + {"index_name": "btc_usd"} + ) + print(f"✅ Success! Index price retrieved") + print(f" Index price: {result.get('index_price', 'N/A')}") + except Exception as e: + print(f"❌ Failed: {e}") + + print("\n" + "=" * 60) + print("Public API Test Complete!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(test_public_api()) + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_spot_trading.py b/spoon_toolkits/data_platforms/deribit/examples/test_spot_trading.py new file mode 100644 index 0000000..76a2496 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_spot_trading.py @@ -0,0 +1,387 @@ +"""Deribit spot trading test script – designed for small balances (e.g. ~0.02 ETH). + +This script: +- Finds ETH spot instruments (prefers ETH/USDC or ETH/USDT) +- Checks account balance +- Places a deep, non-filling limit order with post_only +- Verifies and cancels the order + +All trades are deliberately placed far from market price to avoid actual fills. +""" + +import asyncio +import sys +from pathlib import Path +from datetime import datetime + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.market_data import GetInstrumentsTool, GetTickerTool +from spoon_toolkits.deribit.account import GetAccountSummaryTool +from spoon_toolkits.deribit.trading import ( + PlaceBuyOrderTool, + CancelOrderTool, + GetOpenOrdersTool, +) + + +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +def print_header(text): + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.RESET}\n") + + +def print_success(text): + print(f"{Colors.GREEN}✅ {text}{Colors.RESET}") + + +def print_error(text): + print(f"{Colors.RED}❌ {text}{Colors.RESET}") + + +def print_warning(text): + print(f"{Colors.YELLOW}⚠️ {text}{Colors.RESET}") + + +def print_info(text): + print(f"{Colors.CYAN}ℹ️ {text}{Colors.RESET}") + + +async def step1_find_spot_pairs(): + """Step 1: find available ETH spot instruments (prefer ETH/USDC or ETH/USDT).""" + print_header("STEP 1: Find available ETH spot instruments") + + try: + tool = GetInstrumentsTool() + + # Query ETH spot instruments + result = await tool.execute(currency="ETH", kind="spot", expired=False) + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to query spot instruments: {result.get('error')}") + return None + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + print_warning("No ETH spot instruments found") + return None + + print_success(f"Found {len(instruments)} ETH spot instruments") + + # Print all spot instruments + spot_pairs = [] + for inst in instruments: + inst_name = inst.get("instrument_name", "N/A") + base_currency = inst.get("base_currency", "N/A") + quote_currency = inst.get("quote_currency", "N/A") + print_info(f" - {inst_name} ({base_currency}/{quote_currency})") + spot_pairs.append(inst_name) + + # Prefer ETH/USDC or ETH/USDT + preferred_pair = None + for pair in spot_pairs: + if "USDC" in pair or "USDT" in pair: + preferred_pair = pair + break + + if preferred_pair: + print_success(f"Selected spot pair: {preferred_pair}") + return preferred_pair + elif spot_pairs: + print_info(f"Using first spot pair as fallback: {spot_pairs[0]}") + return spot_pairs[0] + else: + return None + + except Exception as e: + print_error(f"Exception while querying spot instruments: {e}") + return None + + +async def step2_check_account(currency="ETH"): + """Step 2: check account balance for the given currency.""" + print_header("STEP 2: Check account balance") + + try: + account_tool = GetAccountSummaryTool() + result = await account_tool.execute(currency=currency) + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to get account summary: {result.get('error')}") + return False, None + + account = result.get("output") if isinstance(result, dict) else result + balance = account.get("balance", 0) + available = account.get("available_funds", 0) + + print_success("Account summary fetched successfully") + print_info(f"{currency} balance : {balance} {currency}") + print_info(f"Available funds: {available} {currency}") + + if balance < 0.01: + print_warning("Balance is small; the trade test may fail with insufficient funds") + else: + print_success(f"Balance is sufficient ({balance} {currency}), continuing test.") + + return True, account + + except Exception as e: + print_error(f"Account summary raised exception: {e}") + return False, None + + +async def step3_get_spot_price(instrument_name): + """Step 3: fetch current spot price for the given instrument.""" + print_header("STEP 3: Get current spot price") + + try: + ticker_tool = GetTickerTool() + result = await ticker_tool.execute(instrument_name=instrument_name) + + if isinstance(result, dict) and result.get("error"): + print_error(f"Failed to fetch price: {result.get('error')}") + return False, None + + ticker = result.get("output") if isinstance(result, dict) else result + current_price = ticker.get("last_price") or ticker.get("mark_price") + + if not current_price: + print_error("Unable to get last/mark price") + return False, None + + print_success(f"Current price: ${current_price:,.2f}") + print_info(f"Best bid : ${ticker.get('best_bid_price', 'N/A'):,.2f}") + print_info(f"Best ask : ${ticker.get('best_ask_price', 'N/A'):,.2f}") + + return True, current_price + + except Exception as e: + print_error(f"Price query raised exception: {e}") + return False, None + + +async def step4_place_spot_order(instrument_name, current_price): + """Step 4: place a deep, non-filling limit buy order using post_only.""" + print_header("STEP 4: Place deep spot limit buy (post_only)") + + # Use a safe price (30% below current price) so the order will not fill. + safe_price = current_price * 0.7 + order_amount = 0.01 # 0.01 ETH (small test amount) + + print_info(f"Current price: ${current_price:,.2f}") + print_info(f"Limit price : ${safe_price:,.2f} (30% below current price)") + print_info(f"Order size : {order_amount} ETH") + print_warning("⚠️ Price is far below spot; the order will not fill.") + + print(f"\n{Colors.YELLOW}Placing order with the following parameters:{Colors.RESET}") + print(f" instrument : {instrument_name}") + print(f" side : buy") + print(f" amount : {order_amount} ETH") + print(f" price : ${safe_price:,.2f}") + print(f" type : limit + post_only") + + try: + buy_tool = PlaceBuyOrderTool() + result = await buy_tool.execute( + instrument_name=instrument_name, + amount=order_amount, + price=safe_price, + order_type="limit", + post_only=True, + time_in_force="good_til_cancelled", + ) + + if isinstance(result, dict) and result.get("error"): + error_msg = result.get("error", "") + print_error(f"Order placement failed: {error_msg}") + + if "insufficient" in error_msg.lower() or "balance" in error_msg.lower(): + print_warning("Insufficient balance, but this still validates:") + print_success(" ✅ Account APIs work") + print_success(" ✅ Trading tool works") + print_success(" ✅ API permissions are correct") + return False, None + + order_info = ( + result.get("output", {}).get("order", {}) + if isinstance(result, dict) + else result.get("order", {}) + ) + order_id = order_info.get("order_id") + + if not order_id: + print_error("Order created but no order_id returned") + return False, None + + print_success("Spot limit order created.") + print_info(f" order_id : {order_id}") + print_info(f" amount : {order_info.get('amount')} ETH") + print_info(f" price : ${order_info.get('price', safe_price):,.2f}") + print_info(f" state : {order_info.get('order_state', 'N/A')}") + + return True, order_id + + except Exception as e: + print_error(f"Order placement raised exception: {e}") + return False, None + + +async def step5_verify_order(instrument_name, order_id): + """Step 5: verify that the created order appears in open orders.""" + print_header("STEP 5: Verify order presence in open orders") + + try: + await asyncio.sleep(1) # wait for order to be registered + + orders_tool = GetOpenOrdersTool() + result = await orders_tool.execute(instrument_name=instrument_name) + + if isinstance(result, dict) and result.get("error"): + print_warning(f"Failed to query open orders: {result.get('error')}") + return True # still attempt to cancel + + orders = result.get("output") if isinstance(result, dict) else result + found_order = any(o.get("order_id") == order_id for o in orders) + + if found_order: + print_success(f"Order appears in open orders: {order_id}") + for order in orders: + if order.get("order_id") == order_id: + print_info(f" state : {order.get('order_state', 'N/A')}") + print_info(f" amount: {order.get('amount', 'N/A')} ETH") + print_info(f" price : ${order.get('price', 'N/A'):,.2f}") + break + else: + print_warning("Order not found in open orders (may have been rejected).") + print_info("Continuing to cancel just in case...") + + return True + + except Exception as e: + print_error(f"Order verification raised exception: {e}") + return True # still attempt to cancel + + +async def step6_cancel_order(order_id): + """Step 6: cancel the created spot order.""" + print_header("STEP 6: Cancel order") + + try: + cancel_tool = CancelOrderTool() + result = await cancel_tool.execute(order_id=order_id) + + if isinstance(result, dict) and result.get("error"): + print_warning(f"Failed to cancel order: {result.get('error')}") + print_warning("Please manually verify and cancel the order if needed.") + return False + + print_success(f"Order cancelled: {order_id}") + return True + + except Exception as e: + print_error(f"Order cancel raised exception: {e}") + return False + + +async def main(): + """Main test flow for safe spot trading.""" + print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'Deribit spot trading test':^70}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.RESET}\n") + + print(f"{Colors.YELLOW}Test time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}{Colors.RESET}\n") + + print("This script safely tests Deribit spot trading via the toolkit:") + print(" ✅ Uses limit orders with post_only") + print(" ✅ Price set 30% below spot so the order will not fill") + print(" ✅ Order is cancelled after verification to preserve funds") + print(" ✅ Spot trading does not require margin and is suitable for small balances") + print() + + results = {} + + # STEP 1: find a spot pair + spot_pair = await step1_find_spot_pairs() + results["find_spot_instrument"] = spot_pair is not None + if not spot_pair: + print_error("\nNo spot instruments found, aborting test.") + print_info("Deribit may focus primarily on derivatives for this environment.") + return + + # STEP 2: check ETH account balance + account_ok, account = await step2_check_account("ETH") + results["account_summary"] = account_ok + if not account_ok: + print_error("\nFailed to get account summary, aborting test.") + return + + # STEP 3: get current price + price_ok, current_price = await step3_get_spot_price(spot_pair) + results["price_query"] = price_ok + if not price_ok: + print_error("\nPrice query failed, aborting test.") + return + + # STEP 4: place deep limit order + order_ok, order_id = await step4_place_spot_order(spot_pair, current_price) + results["place_order"] = order_ok + if not order_ok or not order_id: + print_warning("\nOrder placement failed, but earlier API checks have passed.") + print_info("We already validated account, price, and order endpoints.") + return + + # STEP 5: verify order appears in open orders + results["verify_order"] = await step5_verify_order(spot_pair, order_id) + + # STEP 6: cancel order + results["cancel_order"] = await step6_cancel_order(order_id) + + # Summary + print_header("Test summary") + + passed = sum(1 for v in results.values() if v) + total = len(results) + + for step_name, result in results.items(): + status = f"{Colors.GREEN}✅ PASS{Colors.RESET}" if result else f"{Colors.RED}❌ FAIL{Colors.RESET}" + print(f"{step_name:20s}: {status}") + + print(f"\n{Colors.BOLD}Overall: {passed}/{total} steps passed{Colors.RESET}\n") + + if passed == total: + print_success("🎉 All steps passed.") + print_info("Spot trading functionality is validated.") + elif passed >= total - 1: + print_success("✅ Core functionality is validated.") + print_info("Some steps failed, but primary APIs appear usable.") + else: + print_warning("⚠️ Several steps failed; please check configuration and network connectivity.") + + print(f"\n{Colors.CYAN}{'='*70}{Colors.RESET}\n") + print_info("💡 Spot vs futures:") + print(" - Spot trading: full notional, no margin; ideal for small, low-risk tests.") + print(" - Futures trading: requires margin and is more sensitive to volatility.") + print(" - Starting with spot tests is recommended for new environments.") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print(f"\n\n{Colors.YELLOW}Test interrupted by user{Colors.RESET}") + except Exception as e: + print(f"\n\n{Colors.RED}Test failed with exception: {e}{Colors.RESET}") + diff --git a/spoon_toolkits/data_platforms/deribit/examples/test_trading_tools.py b/spoon_toolkits/data_platforms/deribit/examples/test_trading_tools.py new file mode 100644 index 0000000..85ffa2a --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/examples/test_trading_tools.py @@ -0,0 +1,110 @@ +"""Test trading tools (requires authentication)""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + +from spoon_toolkits.deribit.env import DeribitConfig +from spoon_toolkits.deribit.trading import ( + PlaceBuyOrderTool, + PlaceSellOrderTool, + CancelOrderTool, + CancelAllOrdersTool, + GetOpenOrdersTool, + EditOrderTool +) + + +async def test_trading_tools(): + """Test all trading tools""" + print("=" * 60) + print("Testing Trading Tools") + print("=" * 60) + + # Check credentials + if not DeribitConfig.validate_credentials(): + print("❌ Error: API credentials not configured!") + print(" Trading tools require authentication.") + print(" Please set DERIBIT_CLIENT_ID and DERIBIT_CLIENT_SECRET") + return + + print(f"✅ Credentials found") + print(f" Using {'Testnet' if DeribitConfig.USE_TESTNET else 'Mainnet'}") + print("\n⚠️ WARNING: Trading tools will execute real orders!") + print(" Make sure you are using Testnet for testing.") + print(" Press Ctrl+C to cancel, or wait 5 seconds to continue...") + + try: + await asyncio.sleep(5) + except KeyboardInterrupt: + print("\n❌ Test cancelled by user") + return + + tools_tested = 0 + tools_passed = 0 + + # Test 1: GetOpenOrdersTool (read-only, safe to test) + print("\n[Test 1] GetOpenOrdersTool") + tools_tested += 1 + try: + tool = GetOpenOrdersTool() + result = await tool.execute(instrument_name="ETH-PERPETUAL") + if isinstance(result, dict) and result.get("error"): + print(f"❌ Failed: {result.get('error')}") + else: + output = result.get("output") if isinstance(result, dict) else result + orders = output if isinstance(output, list) else [] + print(f"✅ Success! Found {len(orders)} open orders") + tools_passed += 1 + except Exception as e: + print(f"❌ Exception: {e}") + + # Test 2: PlaceBuyOrderTool (WARNING: This will place a real order!) + print("\n[Test 2] PlaceBuyOrderTool") + print("⚠️ SKIPPED: This would place a real order. Test manually with caution.") + tools_tested += 1 + # Uncomment to test (USE WITH CAUTION): + # tool = PlaceBuyOrderTool() + # result = await tool.execute( + # instrument_name="BTC-PERPETUAL", + # amount=0.001, + # price=50000, + # order_type="limit" + # ) + + # Test 3: PlaceSellOrderTool (WARNING: This will place a real order!) + print("\n[Test 3] PlaceSellOrderTool") + print("⚠️ SKIPPED: This would place a real order. Test manually with caution.") + tools_tested += 1 + + # Test 4: CancelOrderTool (WARNING: This will cancel a real order!) + print("\n[Test 4] CancelOrderTool") + print("⚠️ SKIPPED: This would cancel a real order. Test manually with caution.") + tools_tested += 1 + + # Test 5: CancelAllOrdersTool (WARNING: This will cancel all orders!) + print("\n[Test 5] CancelAllOrdersTool") + print("⚠️ SKIPPED: This would cancel all orders. Test manually with caution.") + tools_tested += 1 + + # Test 6: EditOrderTool (WARNING: This will edit a real order!) + print("\n[Test 6] EditOrderTool") + print("⚠️ SKIPPED: This would edit a real order. Test manually with caution.") + tools_tested += 1 + + # Summary + print("\n" + "=" * 60) + print(f"Trading Tools Test Summary: {tools_passed}/{tools_tested} passed") + print("=" * 60) + print("\nNote: Most trading tools are skipped to prevent accidental orders.") + print("To test trading tools:") + print("1. Ensure you are on Testnet (DERIBIT_USE_TESTNET=true)") + print("2. Uncomment test code in this file") + print("3. Test with small amounts first") + + +if __name__ == "__main__": + asyncio.run(test_trading_tools()) diff --git a/spoon_toolkits/data_platforms/deribit/jsonrpc_client.py b/spoon_toolkits/data_platforms/deribit/jsonrpc_client.py new file mode 100644 index 0000000..b9d3ddf --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/jsonrpc_client.py @@ -0,0 +1,207 @@ +"""JSON-RPC 2.0 client for Deribit API""" + +import asyncio +import logging +import uuid +import json +from typing import Any, Dict, Optional +import httpx +from .env import DeribitConfig + +logger = logging.getLogger(__name__) + + +class DeribitJsonRpcError(Exception): + """Base exception for Deribit JSON-RPC errors""" + pass + + +class DeribitJsonRpcClient: + """JSON-RPC 2.0 client for Deribit API""" + + def __init__( + self, + base_url: Optional[str] = None, + timeout: Optional[int] = None, + access_token: Optional[str] = None + ): + """ + Initialize JSON-RPC client + + Args: + base_url: API base URL (defaults to config) + timeout: Request timeout in seconds (defaults to config) + access_token: OAuth2 access token + """ + self.base_url = base_url or DeribitConfig.get_api_url() + self.timeout = timeout or DeribitConfig.TIMEOUT + self.access_token = access_token + self.session: Optional[httpx.AsyncClient] = None + self._request_id = 0 + + async def __aenter__(self): + """Async context manager entry""" + self.session = httpx.AsyncClient(timeout=self.timeout) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit""" + if self.session: + await self.session.aclose() + + def _generate_id(self) -> int: + """Generate unique request ID""" + self._request_id += 1 + return self._request_id + + def set_access_token(self, token: str): + """Set OAuth2 access token""" + self.access_token = token + + def clear_access_token(self): + """Clear OAuth2 access token""" + self.access_token = None + + async def call( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + retry_count: Optional[int] = None + ) -> Dict[str, Any]: + """ + Call JSON-RPC method + + Args: + method: JSON-RPC method name (e.g., "public/get_instruments") + params: Method parameters + retry_count: Number of retry attempts (defaults to config) + + Returns: + JSON-RPC response result + + Raises: + DeribitJsonRpcError: If API call fails + """ + if not self.session: + self.session = httpx.AsyncClient(timeout=self.timeout) + + retry_count = retry_count or DeribitConfig.RETRY_COUNT + params = params or {} + + request = { + "jsonrpc": "2.0", + "id": self._generate_id(), + "method": method, + "params": params + } + + headers = { + "Content-Type": "application/json" + } + + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + + last_error = None + + for attempt in range(retry_count + 1): + try: + logger.debug(f"Calling {method} (attempt {attempt + 1}/{retry_count + 1})") + + response = await self.session.post( + self.base_url, + json=request, + headers=headers + ) + response.raise_for_status() + + result = response.json() + + # Check for JSON-RPC error + if "error" in result: + error = result["error"] + error_code = error.get("code", -1) + error_message = error.get("message", "Unknown error") + error_data = error.get("data", {}) + + # Build detailed error message + detailed_error = f"API error {error_code}: {error_message}" + if error_data: + if isinstance(error_data, dict): + detailed_error += f" | Data: {json.dumps(error_data, ensure_ascii=False)}" + else: + detailed_error += f" | Data: {error_data}" + + # Don't retry on client errors (4xx) + if 400 <= error_code < 500: + raise DeribitJsonRpcError(detailed_error) + + # Retry on server errors (5xx) or network errors + if attempt < retry_count: + logger.warning( + f"API error {error_code}: {error_message}. " + f"Retrying... ({attempt + 1}/{retry_count})" + ) + await asyncio.sleep(2 ** attempt) # Exponential backoff + continue + + raise DeribitJsonRpcError( + f"API error {error_code}: {error_message}" + ) + + # Return result + if "result" in result: + return result["result"] + else: + raise DeribitJsonRpcError("Response missing 'result' field") + + except httpx.HTTPStatusError as e: + # HTTP error with status code + last_error = e + # Try to get error details from response + error_details = "" + try: + if e.response is not None: + response_text = e.response.text + error_details = f" | Response: {response_text[:500]}" + except: + pass + + if attempt < retry_count: + logger.warning( + f"HTTP error: {e}{error_details}. Retrying... ({attempt + 1}/{retry_count})" + ) + await asyncio.sleep(2 ** attempt) + continue + raise DeribitJsonRpcError(f"HTTP error: {str(e)}{error_details}") + except httpx.HTTPError as e: + last_error = e + if attempt < retry_count: + logger.warning( + f"HTTP error: {e}. Retrying... ({attempt + 1}/{retry_count})" + ) + await asyncio.sleep(2 ** attempt) + continue + raise DeribitJsonRpcError(f"HTTP error: {str(e)}") + + except Exception as e: + last_error = e + if attempt < retry_count: + logger.warning( + f"Unexpected error: {e}. Retrying... ({attempt + 1}/{retry_count})" + ) + await asyncio.sleep(2 ** attempt) + continue + raise DeribitJsonRpcError(f"Unexpected error: {str(e)}") + + # If we get here, all retries failed + raise DeribitJsonRpcError( + f"Failed after {retry_count + 1} attempts. Last error: {last_error}" + ) + + async def close(self): + """Close HTTP session""" + if self.session: + await self.session.aclose() + self.session = None + diff --git a/spoon_toolkits/data_platforms/deribit/market_data.py b/spoon_toolkits/data_platforms/deribit/market_data.py new file mode 100644 index 0000000..d474854 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/market_data.py @@ -0,0 +1,339 @@ +"""Market data tools for Deribit API (Public methods)""" + +import logging +from typing import Any, Dict, Optional +from pydantic import Field + +from .base import DeribitBaseTool, ToolResult + +logger = logging.getLogger(__name__) + + +class GetInstrumentsTool(DeribitBaseTool): + """Get list of available instruments on Deribit""" + + name: str = "deribit_get_instruments" + description: str = ( + "Get list of available instruments (futures, options, spot) on Deribit. " + "Returns instrument details including name, currency, kind, expiration, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": ["BTC", "ETH", "USDC"], + "description": "Currency code (BTC, ETH, or USDC)" + }, + "kind": { + "type": "string", + "enum": ["future", "option", "spot", "any"], + "default": "any", + "description": "Instrument kind filter" + }, + "expired": { + "type": "boolean", + "default": False, + "description": "Include expired instruments" + } + }, + "required": ["currency"] + } + + currency: Optional[str] = Field(default=None, description="Currency code") + kind: str = Field(default="any", description="Instrument kind") + expired: bool = Field(default=False, description="Include expired instruments") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get instruments tool""" + try: + currency = kwargs.get("currency", self.currency) + kind = kwargs.get("kind", self.kind) + expired = kwargs.get("expired", self.expired) + + if not currency: + return ToolResult(error="Parameter 'currency' is required") + + params = { + "currency": currency, + "kind": kind if kind != "any" else None, + "expired": expired + } + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + result = await self._call_public_method("public/get_instruments", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetInstrumentsTool: {e}") + return ToolResult(error=f"Failed to get instruments: {str(e)}") + + +class GetOrderBookTool(DeribitBaseTool): + """Get order book for a specific instrument""" + + name: str = "deribit_get_order_book" + description: str = ( + "Get order book (bids and asks) for a specific instrument. " + "Returns current market depth with price and size information." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL', 'BTC-25JAN25-50000-C')" + }, + "depth": { + "type": "integer", + "default": 20, + "description": "Number of price levels to return (1-2500)" + } + }, + "required": ["instrument_name"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + depth: int = Field(default=20, description="Order book depth") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get order book tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + depth = kwargs.get("depth", self.depth) + + if not instrument_name: + return ToolResult(error="Parameter 'instrument_name' is required") + + params = { + "instrument_name": instrument_name, + "depth": depth + } + + result = await self._call_public_method("public/get_order_book", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetOrderBookTool: {e}") + return ToolResult(error=f"Failed to get order book: {str(e)}") + + +class GetTickerTool(DeribitBaseTool): + """Get ticker data for a specific instrument""" + + name: str = "deribit_get_ticker" + description: str = ( + "Get ticker data for a specific instrument. " + "Returns current price, 24h statistics, volume, open interest, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL', 'BTC-25JAN25-50000-C')" + } + }, + "required": ["instrument_name"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get ticker tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + + if not instrument_name: + return ToolResult(error="Parameter 'instrument_name' is required") + + params = { + "instrument_name": instrument_name + } + + result = await self._call_public_method("public/ticker", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetTickerTool: {e}") + return ToolResult(error=f"Failed to get ticker: {str(e)}") + + +class GetLastTradesTool(DeribitBaseTool): + """Get last trades for a specific instrument""" + + name: str = "deribit_get_last_trades" + description: str = ( + "Get last trades (recent transactions) for a specific instrument. " + "Returns trade history with price, amount, direction, timestamp, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL', 'BTC-25JAN25-50000-C')" + }, + "count": { + "type": "integer", + "default": 10, + "description": "Number of trades to return (1-1000)" + }, + "include_old": { + "type": "boolean", + "default": False, + "description": "Include older trades" + }, + "sorting": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc", + "description": "Sort order: 'asc' for ascending, 'desc' for descending" + } + }, + "required": ["instrument_name"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + count: int = Field(default=10, description="Number of trades") + include_old: bool = Field(default=False, description="Include old trades") + sorting: str = Field(default="desc", description="Sort order") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get last trades tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + count = kwargs.get("count", self.count) + include_old = kwargs.get("include_old", self.include_old) + sorting = kwargs.get("sorting", self.sorting) + + if not instrument_name: + return ToolResult(error="Parameter 'instrument_name' is required") + + # Validate count + if count < 1 or count > 1000: + return ToolResult(error="Parameter 'count' must be between 1 and 1000") + + params = { + "instrument_name": instrument_name, + "count": count, + "include_old": include_old, + "sorting": sorting + } + + result = await self._call_public_method("public/get_last_trades_by_instrument", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetLastTradesTool: {e}") + return ToolResult(error=f"Failed to get last trades: {str(e)}") + + +class GetIndexPriceTool(DeribitBaseTool): + """Get index price for a currency""" + + name: str = "deribit_get_index_price" + description: str = ( + "Get index price for a specific currency. " + "Returns the current index price used for margin calculations." + ) + + parameters: dict = { + "type": "object", + "properties": { + "index_name": { + "type": "string", + "description": "Index name (e.g., 'btc_usd', 'eth_usd')" + } + }, + "required": ["index_name"] + } + + index_name: Optional[str] = Field(default=None, description="Index name") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get index price tool""" + try: + index_name = kwargs.get("index_name", self.index_name) + + if not index_name: + return ToolResult(error="Parameter 'index_name' is required") + + params = { + "index_name": index_name + } + + result = await self._call_public_method("public/get_index_price", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetIndexPriceTool: {e}") + return ToolResult(error=f"Failed to get index price: {str(e)}") + + +class GetBookSummaryTool(DeribitBaseTool): + """Get book summary by currency""" + + name: str = "deribit_get_book_summary" + description: str = ( + "Get book summary for all instruments of a specific currency and kind. " + "Returns summary information including best bid/ask, volume, open interest, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": ["BTC", "ETH", "USDC"], + "description": "Currency code (BTC, ETH, or USDC)" + }, + "kind": { + "type": "string", + "enum": ["future", "option", "spot", "any"], + "default": "any", + "description": "Instrument kind filter" + } + }, + "required": ["currency"] + } + + currency: Optional[str] = Field(default=None, description="Currency code") + kind: str = Field(default="any", description="Instrument kind") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get book summary tool""" + try: + currency = kwargs.get("currency", self.currency) + kind = kwargs.get("kind", self.kind) + + if not currency: + return ToolResult(error="Parameter 'currency' is required") + + params = { + "currency": currency, + "kind": kind if kind != "any" else None + } + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + result = await self._call_public_method("public/get_book_summary_by_currency", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetBookSummaryTool: {e}") + return ToolResult(error=f"Failed to get book summary: {str(e)}") + diff --git a/spoon_toolkits/data_platforms/deribit/tests/conftest.py b/spoon_toolkits/data_platforms/deribit/tests/conftest.py new file mode 100644 index 0000000..ba3c12e --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/tests/conftest.py @@ -0,0 +1,27 @@ +"""Pytest configuration for Deribit toolkit tests""" + +import pytest +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + + +@pytest.fixture(scope="session") +def test_credentials(): + """Fixture for test credentials""" + return { + "client_id": os.getenv("DERIBIT_CLIENT_ID"), + "client_secret": os.getenv("DERIBIT_CLIENT_SECRET"), + "use_testnet": os.getenv("DERIBIT_USE_TESTNET", "false").lower() == "true" + } + + +@pytest.fixture(scope="session") +def has_credentials(): + """Check if credentials are available""" + client_id = os.getenv("DERIBIT_CLIENT_ID") + client_secret = os.getenv("DERIBIT_CLIENT_SECRET") + return bool(client_id and client_secret) + diff --git a/spoon_toolkits/data_platforms/deribit/tests/test_account_tools.py b/spoon_toolkits/data_platforms/deribit/tests/test_account_tools.py new file mode 100644 index 0000000..d60d630 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/tests/test_account_tools.py @@ -0,0 +1,164 @@ +"""Unit tests for account management tools""" + +import pytest +from unittest.mock import AsyncMock, patch + +from spoon_toolkits.data_platforms.deribit.account import ( + GetAccountSummaryTool, + GetPositionsTool, + GetOrderHistoryTool, + GetTradeHistoryTool +) + + +class TestGetAccountSummaryTool: + """Test GetAccountSummaryTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful account summary retrieval""" + tool = GetAccountSummaryTool() + + mock_result = { + "balance": 1.0, + "equity": 1.0, + "available_funds": 0.9, + "margin_balance": 1.0 + } + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(currency="BTC") + + assert result.error is None + assert result.output == mock_result + mock_call.assert_called_once_with( + "private/get_account_summary", + {"currency": "BTC", "extended": False} + ) + + @pytest.mark.asyncio + async def test_execute_with_extended(self): + """Test with extended parameter""" + tool = GetAccountSummaryTool() + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = {} + + await tool.execute(currency="BTC", extended=True) + + call_params = mock_call.call_args[0][1] + assert call_params["extended"] is True + + @pytest.mark.asyncio + async def test_execute_missing_currency(self): + """Test missing currency parameter""" + tool = GetAccountSummaryTool() + + result = await tool.execute() + + assert result.error is not None + assert "currency" in (result.error or "").lower() + + +class TestGetPositionsTool: + """Test GetPositionsTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful positions retrieval""" + tool = GetPositionsTool() + + mock_result = [ + { + "instrument_name": "BTC-PERPETUAL", + "size": 1.0, + "entry_price": 50000.0, + "mark_price": 51000.0 + } + ] + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(currency="BTC") + + assert result.error is None + assert result.output == mock_result + + @pytest.mark.asyncio + async def test_execute_with_kind_filter(self): + """Test with kind filter""" + tool = GetPositionsTool() + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = [] + + await tool.execute(currency="BTC", kind="future") + + call_params = mock_call.call_args[0][1] + assert call_params["kind"] == "future" + + +class TestGetOrderHistoryTool: + """Test GetOrderHistoryTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful order history retrieval""" + tool = GetOrderHistoryTool() + + mock_result = { + "orders": [ + {"order_id": "12345", "order_state": "filled"}, + {"order_id": "12346", "order_state": "cancelled"} + ] + } + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(instrument_name="BTC-PERPETUAL", count=20) + + assert result.error is None + assert result.output == mock_result + + @pytest.mark.asyncio + async def test_execute_invalid_count(self): + """Test invalid count parameter""" + tool = GetOrderHistoryTool() + + result = await tool.execute(instrument_name="BTC-PERPETUAL", count=2000) + + assert result.error is not None + assert "count" in (result.error or "").lower() + + +class TestGetTradeHistoryTool: + """Test GetTradeHistoryTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful trade history retrieval""" + tool = GetTradeHistoryTool() + + mock_result = { + "trades": [ + {"trade_id": "t1", "price": 50000, "amount": 1.0}, + {"trade_id": "t2", "price": 50001, "amount": 0.5} + ] + } + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(instrument_name="BTC-PERPETUAL", count=20) + + assert result.error is None + assert result.output == mock_result + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + diff --git a/spoon_toolkits/data_platforms/deribit/tests/test_basic.py b/spoon_toolkits/data_platforms/deribit/tests/test_basic.py new file mode 100644 index 0000000..1e1fe11 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/tests/test_basic.py @@ -0,0 +1,149 @@ +"""Basic tests for Deribit API toolkit""" + +import pytest +import asyncio +from unittest.mock import AsyncMock, patch, MagicMock + +from spoon_toolkits.data_platforms.deribit.jsonrpc_client import DeribitJsonRpcClient, DeribitJsonRpcError +from spoon_toolkits.data_platforms.deribit.auth import DeribitAuth, DeribitAuthError +from spoon_toolkits.data_platforms.deribit.market_data import GetInstrumentsTool, GetOrderBookTool +from spoon_toolkits.data_platforms.deribit.account import GetAccountSummaryTool + + +class TestDeribitJsonRpcClient: + """Test JSON-RPC client""" + + @pytest.mark.asyncio + async def test_call_public_method(self): + """Test calling a public method""" + async with DeribitJsonRpcClient() as client: + # Mock the HTTP response + with patch('httpx.AsyncClient.post') as mock_post: + mock_response = MagicMock() + mock_response.json.return_value = { + "jsonrpc": "2.0", + "id": 1, + "result": [{"instrument_name": "BTC-PERPETUAL"}] + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + result = await client.call("public/get_instruments", {"currency": "BTC"}) + assert result is not None + assert isinstance(result, list) + + @pytest.mark.asyncio + async def test_call_with_error(self): + """Test handling API errors""" + async with DeribitJsonRpcClient() as client: + with patch('httpx.AsyncClient.post') as mock_post: + mock_response = MagicMock() + mock_response.json.return_value = { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32602, + "message": "Invalid params" + } + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + with pytest.raises(DeribitJsonRpcError): + await client.call("public/get_instruments", {"currency": "INVALID"}) + + +class TestDeribitAuth: + """Test authentication""" + + @pytest.mark.asyncio + async def test_authenticate_success(self): + """Test successful authentication""" + auth = DeribitAuth(client_id="test_id", client_secret="test_secret") + + with patch.object(auth.jsonrpc_client, 'call') as mock_call: + mock_call.return_value = { + "access_token": "test_token", + "refresh_token": "test_refresh", + "expires_in": 3600, + "scope": "account:read" + } + + result = await auth.authenticate() + assert result["access_token"] == "test_token" + assert auth.get_access_token() == "test_token" + assert auth.is_token_valid() is True + + @pytest.mark.asyncio + async def test_token_refresh(self): + """Test token refresh""" + auth = DeribitAuth(client_id="test_id", client_secret="test_secret") + auth.refresh_token = "test_refresh" + + with patch.object(auth.jsonrpc_client, 'call') as mock_call: + mock_call.return_value = { + "access_token": "new_token", + "refresh_token": "new_refresh", + "expires_in": 3600 + } + + result = await auth.refresh_access_token() + assert result["access_token"] == "new_token" + assert auth.get_access_token() == "new_token" + + +class TestMarketDataTools: + """Test market data tools""" + + @pytest.mark.asyncio + async def test_get_instruments_tool(self): + """Test GetInstrumentsTool""" + tool = GetInstrumentsTool() + + with patch.object(tool, '_call_public_method') as mock_call: + mock_call.return_value = [{"instrument_name": "BTC-PERPETUAL"}] + + result = await tool.execute(currency="BTC") + assert result.error is None + assert result.output is not None + + @pytest.mark.asyncio + async def test_get_order_book_tool(self): + """Test GetOrderBookTool""" + tool = GetOrderBookTool() + + with patch.object(tool, '_call_public_method') as mock_call: + mock_call.return_value = { + "bids": [[50000, 1.0]], + "asks": [[50001, 1.0]] + } + + result = await tool.execute(instrument_name="BTC-PERPETUAL") + assert result.error is None + assert result.output is not None + + +class TestAccountTools: + """Test account tools""" + + @pytest.mark.asyncio + async def test_get_account_summary_tool(self): + """Test GetAccountSummaryTool""" + tool = GetAccountSummaryTool() + + with patch.object(tool, '_ensure_authenticated') as mock_auth: + with patch.object(tool, '_call_private_method') as mock_call: + mock_call.return_value = { + "balance": 1.0, + "equity": 1.0, + "available_funds": 0.9 + } + + result = await tool.execute(currency="BTC") + assert result.error is None + assert result.output is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + diff --git a/spoon_toolkits/data_platforms/deribit/tests/test_integration.py b/spoon_toolkits/data_platforms/deribit/tests/test_integration.py new file mode 100644 index 0000000..81eee3e --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/tests/test_integration.py @@ -0,0 +1,114 @@ +"""Integration tests for Deribit API toolkit""" + +import pytest +import asyncio +from spoon_toolkits.data_platforms.deribit.env import DeribitConfig +from spoon_toolkits.data_platforms.deribit.jsonrpc_client import DeribitJsonRpcClient +from spoon_toolkits.data_platforms.deribit.auth import DeribitAuth + + +@pytest.mark.integration +class TestDeribitJsonRpcClientIntegration: + """Integration tests for JSON-RPC client""" + + @pytest.mark.asyncio + async def test_public_api_connection(self): + """Test connection to Deribit public API""" + async with DeribitJsonRpcClient() as client: + result = await client.call( + "public/get_instruments", + {"currency": "BTC", "kind": "future"} + ) + assert result is not None + assert isinstance(result, list) + if result: + assert "instrument_name" in result[0] + + @pytest.mark.asyncio + async def test_get_order_book(self): + """Test getting order book""" + async with DeribitJsonRpcClient() as client: + result = await client.call( + "public/get_order_book", + {"instrument_name": "BTC-PERPETUAL", "depth": 5} + ) + assert result is not None + assert "bids" in result or "asks" in result + + +@pytest.mark.integration +@pytest.mark.skipif( + not DeribitConfig.validate_credentials(), + reason="API credentials not configured" +) +class TestDeribitAuthIntegration: + """Integration tests for authentication (requires API credentials)""" + + @pytest.mark.asyncio + async def test_authentication(self): + """Test OAuth2 authentication""" + auth = DeribitAuth() + + result = await auth.authenticate() + + assert result is not None + assert "access_token" in result + assert auth.get_access_token() is not None + assert auth.is_token_valid() is True + + @pytest.mark.asyncio + async def test_token_refresh(self): + """Test token refresh""" + auth = DeribitAuth() + + # First authenticate + await auth.authenticate() + original_token = auth.get_access_token() + + # Refresh token + await auth.refresh_access_token() + new_token = auth.get_access_token() + + assert new_token is not None + # Token might be the same or different depending on implementation + assert auth.is_token_valid() is True + + +@pytest.mark.integration +@pytest.mark.skipif( + not DeribitConfig.validate_credentials(), + reason="API credentials not configured" +) +class TestAccountToolsIntegration: + """Integration tests for account tools (requires API credentials)""" + + @pytest.mark.asyncio + async def test_get_account_summary(self): + """Test getting account summary""" + from spoon_toolkits.data_platforms.deribit.account import GetAccountSummaryTool + + tool = GetAccountSummaryTool() + result = await tool.execute(currency="BTC") + + assert result.get("error") is None + output = result.get("output") + assert output is not None + assert "balance" in output or "equity" in output + + @pytest.mark.asyncio + async def test_get_positions(self): + """Test getting positions""" + from spoon_toolkits.data_platforms.deribit.account import GetPositionsTool + + tool = GetPositionsTool() + result = await tool.execute(currency="BTC") + + assert result.get("error") is None + output = result.get("output") + assert output is not None + assert isinstance(output, list) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-m", "integration"]) + diff --git a/spoon_toolkits/data_platforms/deribit/tests/test_market_data_tools.py b/spoon_toolkits/data_platforms/deribit/tests/test_market_data_tools.py new file mode 100644 index 0000000..75a4b37 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/tests/test_market_data_tools.py @@ -0,0 +1,201 @@ +"""Unit tests for market data tools""" + +import pytest +from unittest.mock import AsyncMock, patch + +from spoon_toolkits.data_platforms.deribit.market_data import ( + GetInstrumentsTool, + GetOrderBookTool, + GetTickerTool, + GetLastTradesTool, + GetIndexPriceTool, + GetBookSummaryTool +) + + +class TestGetInstrumentsTool: + """Test GetInstrumentsTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful execution""" + tool = GetInstrumentsTool() + + mock_result = [ + {"instrument_name": "BTC-PERPETUAL", "currency": "BTC", "kind": "future"}, + {"instrument_name": "BTC-25JAN25", "currency": "BTC", "kind": "future"} + ] + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(currency="BTC", kind="future") + + assert result.error is None + assert result.output == mock_result + mock_call.assert_called_once_with( + "public/get_instruments", + {"currency": "BTC", "kind": "future", "expired": False} + ) + + @pytest.mark.asyncio + async def test_execute_missing_currency(self): + """Test missing required parameter""" + tool = GetInstrumentsTool() + + result = await tool.execute() + + assert result.error is not None + assert "currency" in (result.error or "").lower() + + @pytest.mark.asyncio + async def test_execute_with_expired(self): + """Test with expired parameter""" + tool = GetInstrumentsTool() + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = [] + + await tool.execute(currency="BTC", expired=True) + + mock_call.assert_called_once() + call_args = mock_call.call_args[0][1] + assert call_args["expired"] is True + + +class TestGetOrderBookTool: + """Test GetOrderBookTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful execution""" + tool = GetOrderBookTool() + + mock_result = { + "bids": [[50000, 1.0], [49999, 2.0]], + "asks": [[50001, 1.0], [50002, 2.0]] + } + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(instrument_name="BTC-PERPETUAL", depth=5) + + assert result.error is None + assert result.output == mock_result + mock_call.assert_called_once_with( + "public/get_order_book", + {"instrument_name": "BTC-PERPETUAL", "depth": 5} + ) + + @pytest.mark.asyncio + async def test_execute_missing_instrument_name(self): + """Test missing required parameter""" + tool = GetOrderBookTool() + + result = await tool.execute() + + assert result.error is not None + assert "instrument_name" in (result.error or "").lower() + + +class TestGetTickerTool: + """Test GetTickerTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful execution""" + tool = GetTickerTool() + + mock_result = { + "last_price": 50000.0, + "mark_price": 50001.0, + "index_price": 50000.5 + } + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(instrument_name="BTC-PERPETUAL") + + assert result.error is None + assert result.output == mock_result + + +class TestGetLastTradesTool: + """Test GetLastTradesTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful execution""" + tool = GetLastTradesTool() + + mock_result = { + "trades": [ + {"price": 50000, "amount": 1.0, "direction": "buy"}, + {"price": 50001, "amount": 0.5, "direction": "sell"} + ] + } + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(instrument_name="BTC-PERPETUAL", count=5) + + assert result.error is None + assert result.output == mock_result + + @pytest.mark.asyncio + async def test_execute_invalid_count(self): + """Test invalid count parameter""" + tool = GetLastTradesTool() + + result = await tool.execute(instrument_name="BTC-PERPETUAL", count=2000) + + assert result.error is not None + assert "count" in (result.error or "").lower() + + +class TestGetIndexPriceTool: + """Test GetIndexPriceTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful execution""" + tool = GetIndexPriceTool() + + mock_result = {"index_price": 50000.5} + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(index_name="btc_usd") + + assert result.error is None + assert result.output == mock_result + + +class TestGetBookSummaryTool: + """Test GetBookSummaryTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful execution""" + tool = GetBookSummaryTool() + + mock_result = [ + {"instrument_name": "BTC-PERPETUAL", "best_bid": 50000, "best_ask": 50001} + ] + + with patch.object(tool, '_call_public_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(currency="BTC", kind="future") + + assert result.error is None + assert result.output == mock_result + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + diff --git a/spoon_toolkits/data_platforms/deribit/tests/test_trading_tools.py b/spoon_toolkits/data_platforms/deribit/tests/test_trading_tools.py new file mode 100644 index 0000000..4cc3148 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/tests/test_trading_tools.py @@ -0,0 +1,252 @@ +"""Unit tests for trading tools""" + +import pytest +from unittest.mock import AsyncMock, patch + +from spoon_toolkits.data_platforms.deribit.trading import ( + PlaceBuyOrderTool, + PlaceSellOrderTool, + CancelOrderTool, + CancelAllOrdersTool, + GetOpenOrdersTool, + EditOrderTool +) + + +class TestPlaceBuyOrderTool: + """Test PlaceBuyOrderTool""" + + @pytest.mark.asyncio + async def test_execute_success_limit_order(self): + """Test successful limit order placement""" + tool = PlaceBuyOrderTool() + + mock_result = { + "order_id": "12345", + "order_state": "open", + "amount": 1.0, + "price": 50000.0 + } + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute( + instrument_name="BTC-PERPETUAL", + amount=1.0, + price=50000.0, + order_type="limit" + ) + + assert result.error is None + assert result.output == mock_result + mock_call.assert_called_once() + call_params = mock_call.call_args[0][1] + assert call_params["instrument_name"] == "BTC-PERPETUAL" + assert call_params["amount"] == 1.0 + assert call_params["price"] == 50000.0 + + @pytest.mark.asyncio + async def test_execute_market_order(self): + """Test market order (no price required)""" + tool = PlaceBuyOrderTool() + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = {"order_id": "12345"} + + result = await tool.execute( + instrument_name="BTC-PERPETUAL", + amount=1.0, + order_type="market" + ) + + assert result.error is None + + @pytest.mark.asyncio + async def test_execute_missing_required_params(self): + """Test missing required parameters""" + tool = PlaceBuyOrderTool() + + result = await tool.execute() + assert result.error is not None + + result = await tool.execute(instrument_name="BTC-PERPETUAL") + assert result.error is not None + + @pytest.mark.asyncio + async def test_execute_limit_order_missing_price(self): + """Test limit order without price""" + tool = PlaceBuyOrderTool() + + result = await tool.execute( + instrument_name="BTC-PERPETUAL", + amount=1.0, + order_type="limit" + ) + + assert result.error is not None + assert "price" in (result.error or "").lower() + + +class TestPlaceSellOrderTool: + """Test PlaceSellOrderTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful sell order placement""" + tool = PlaceSellOrderTool() + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = {"order_id": "12345"} + + result = await tool.execute( + instrument_name="BTC-PERPETUAL", + amount=1.0, + price=50000.0 + ) + + assert result.error is None + + +class TestCancelOrderTool: + """Test CancelOrderTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful order cancellation""" + tool = CancelOrderTool() + + mock_result = { + "order_id": "12345", + "order_state": "cancelled" + } + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(order_id="12345") + + assert result.error is None + assert result.output == mock_result + mock_call.assert_called_once_with( + "private/cancel", + {"order_id": "12345"} + ) + + @pytest.mark.asyncio + async def test_execute_missing_order_id(self): + """Test missing order_id""" + tool = CancelOrderTool() + + result = await tool.execute() + + assert result.error is not None + assert "order_id" in (result.error or "").lower() + + +class TestCancelAllOrdersTool: + """Test CancelAllOrdersTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful cancellation of all orders""" + tool = CancelAllOrdersTool() + + mock_result = {"cancelled": 5} + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(currency="BTC", kind="future") + + assert result.error is None + assert result.output == mock_result + + @pytest.mark.asyncio + async def test_execute_missing_currency(self): + """Test missing currency""" + tool = CancelAllOrdersTool() + + result = await tool.execute() + + assert result.error is not None + + +class TestGetOpenOrdersTool: + """Test GetOpenOrdersTool""" + + @pytest.mark.asyncio + async def test_execute_success(self): + """Test successful retrieval of open orders""" + tool = GetOpenOrdersTool() + + mock_result = [ + {"order_id": "12345", "order_state": "open"}, + {"order_id": "12346", "order_state": "open"} + ] + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(instrument_name="BTC-PERPETUAL") + + assert result.error is None + assert result.output == mock_result + + +class TestEditOrderTool: + """Test EditOrderTool""" + + @pytest.mark.asyncio + async def test_execute_success_edit_price(self): + """Test successful order edit (price)""" + tool = EditOrderTool() + + mock_result = { + "order_id": "12345", + "price": 51000.0 + } + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = mock_result + + result = await tool.execute(order_id="12345", price=51000.0) + + assert result.error is None + assert result.output == mock_result + + @pytest.mark.asyncio + async def test_execute_success_edit_amount(self): + """Test successful order edit (amount)""" + tool = EditOrderTool() + + with patch.object(tool, '_call_private_method', new_callable=AsyncMock) as mock_call: + mock_call.return_value = {"order_id": "12345"} + + result = await tool.execute(order_id="12345", amount=2.0) + + assert result.error is None + + @pytest.mark.asyncio + async def test_execute_missing_order_id(self): + """Test missing order_id""" + tool = EditOrderTool() + + result = await tool.execute() + + assert result.error is not None + + @pytest.mark.asyncio + async def test_execute_no_changes(self): + """Test edit without amount or price""" + tool = EditOrderTool() + + result = await tool.execute(order_id="12345") + + assert result.error is not None + assert "amount" in (result.error or "").lower() or "price" in (result.error or "").lower() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + diff --git a/spoon_toolkits/data_platforms/deribit/trading.py b/spoon_toolkits/data_platforms/deribit/trading.py new file mode 100644 index 0000000..7d28bf6 --- /dev/null +++ b/spoon_toolkits/data_platforms/deribit/trading.py @@ -0,0 +1,1123 @@ +"""Trading tools for Deribit API (Private methods)""" + +import logging +from typing import Any, Dict, Optional, Tuple +from pydantic import Field +from decimal import Decimal, ROUND_DOWN, ROUND_UP + +from .base import DeribitBaseTool, ToolResult +from .market_data import GetInstrumentsTool + +logger = logging.getLogger(__name__) + + +class ValidationResult: + """Validation result container for pre-flight checks.""" + def __init__(self, is_valid: bool, adjusted_value: Optional[float] = None, + original_value: Optional[float] = None, + contract_size: Optional[float] = None, + tick_size: Optional[float] = None, + message: str = ""): + self.is_valid = is_valid + self.adjusted_value = adjusted_value + self.original_value = original_value + self.contract_size = contract_size + self.tick_size = tick_size + self.message = message + + def get_error_message(self) -> str: + """Return a human-friendly error message.""" + if self.is_valid: + return "" + + parts = [] + if self.contract_size: + parts.append(f"contract size: {self.contract_size}") + if self.tick_size: + parts.append(f"tick size: {self.tick_size}") + if self.original_value is not None: + parts.append(f"original value: {self.original_value}") + if self.adjusted_value is not None: + parts.append(f"suggested value: {self.adjusted_value}") + + if parts: + return f"{self.message} ({', '.join(parts)})" + return self.message + + +def _adjust_amount_to_contract_size(amount: float, contract_size: float) -> float: + """ + Adjust amount to be a multiple of contract size + + Args: + amount: Original amount + contract_size: Contract size (e.g., 0.0001 for ETH_USDC) + + Returns: + Adjusted amount that is a multiple of contract size + """ + if contract_size <= 0: + return amount + + # Calculate the closest multiple + multiple = round(amount / contract_size) + + # Ensure at least 1 multiple + if multiple < 1: + multiple = 1 + + # Calculate adjusted amount + adjusted_amount = multiple * contract_size + + # Handle floating point precision + contract_size_str = str(contract_size) + if '.' in contract_size_str: + decimals = len(contract_size_str.split('.')[1]) + else: + decimals = 0 + + # Round to correct precision + adjusted_amount = round(adjusted_amount, decimals) + + return adjusted_amount + + +class PlaceBuyOrderTool(DeribitBaseTool): + """Place a buy order on Deribit""" + + name: str = "deribit_place_buy_order" + description: str = ( + "Place a buy order on Deribit. " + "Supports market, limit, stop_market, and stop_limit order types." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL', 'BTC-25JAN25-50000-C')" + }, + "amount": { + "type": "number", + "description": "Order amount (in contracts)" + }, + "price": { + "type": "number", + "description": "Order price (required for limit orders)" + }, + "order_type": { + "type": "string", + "enum": ["market", "limit", "stop_market", "stop_limit"], + "default": "limit", + "description": "Order type" + }, + "time_in_force": { + "type": "string", + "enum": ["good_til_cancelled", "fill_or_kill", "immediate_or_cancel"], + "default": "good_til_cancelled", + "description": "Time in force" + }, + "reduce_only": { + "type": "boolean", + "default": False, + "description": "Reduce only order (only reduce position, not increase)" + }, + "post_only": { + "type": "boolean", + "default": False, + "description": "Post only order (maker order)" + } + }, + "required": ["instrument_name", "amount"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + amount: Optional[float] = Field(default=None, description="Order amount") + price: Optional[float] = Field(default=None, description="Order price") + order_type: str = Field(default="limit", description="Order type") + time_in_force: str = Field(default="good_til_cancelled", description="Time in force") + reduce_only: bool = Field(default=False, description="Reduce only") + post_only: bool = Field(default=False, description="Post only") + + async def execute(self, **kwargs) -> ToolResult: + """Execute place buy order tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + amount = kwargs.get("amount", self.amount) + price = kwargs.get("price", self.price) + order_type = kwargs.get("order_type", self.order_type) + time_in_force = kwargs.get("time_in_force", self.time_in_force) + reduce_only = kwargs.get("reduce_only", self.reduce_only) + post_only = kwargs.get("post_only", self.post_only) + + # Basic parameter validation + if not instrument_name: + return ToolResult(error="❌ Parameter error: 'instrument_name' is required") + if amount is None: + return ToolResult(error="❌ Parameter error: 'amount' is required") + if amount <= 0: + return ToolResult(error=f"❌ Parameter error: 'amount' must be greater than 0, got: {amount}") + + # Validate price for limit orders + if order_type in ["limit", "stop_limit"] and price is None: + return ToolResult(error=f"❌ Parameter error: '{order_type}' orders require a 'price' parameter") + if price is not None and price <= 0: + return ToolResult(error=f"❌ Parameter error: 'price' must be greater than 0, got: {price}") + + # Validate and adjust amount (contract size) + amount_result = await self._validate_and_adjust_amount(instrument_name, amount) + if not amount_result.is_valid: + error_msg = f"❌ Amount validation failed: {amount_result.get_error_message()}" + if amount_result.adjusted_value: + error_msg += ( + f"\n💡 Suggestion: adjust 'amount' to {amount_result.adjusted_value} " + f"(a multiple of contract size)" + ) + return ToolResult(error=error_msg) + + # Log if amount was adjusted + if amount_result.adjusted_value != amount_result.original_value: + logger.info( + "Amount automatically adjusted: %s -> %s (contract size: %s)", + amount_result.original_value, + amount_result.adjusted_value, + amount_result.contract_size, + ) + + amount = amount_result.adjusted_value + + # Validate price precision (tick size) for limit orders + if price is not None and order_type in ["limit", "stop_limit"]: + price_result = await self._validate_price(instrument_name, price) + if not price_result.is_valid: + error_msg = f"❌ Price validation failed: {price_result.get_error_message()}" + if price_result.adjusted_value: + error_msg += ( + f"\n💡 Suggestion: adjust 'price' to {price_result.adjusted_value} " + f"(conforms to tick size)" + ) + return ToolResult(error=error_msg) + + # If price was adjusted, log and use adjusted value + if price_result.adjusted_value != price_result.original_value: + logger.info( + "Price automatically adjusted: %s -> %s (tick size: %s)", + price_result.original_value, + price_result.adjusted_value, + price_result.tick_size, + ) + price = price_result.adjusted_value + + # Format price with proper precision based on tick_size + if price_result.tick_size: + tick_size_str = str(price_result.tick_size) + if '.' in tick_size_str: + decimals = len(tick_size_str.split('.')[1]) + else: + decimals = 0 + price = round(price, decimals) + + params = { + "instrument_name": instrument_name, + "amount": amount, + "type": order_type, + "time_in_force": time_in_force, + "reduce_only": reduce_only, + "post_only": post_only + } + + if price is not None: + params["price"] = price + + result = await self._call_private_method("private/buy", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in PlaceBuyOrderTool: {e}") + return ToolResult(error=f"Failed to place buy order: {str(e)}") + + async def _validate_price(self, instrument_name: str, price: float) -> ValidationResult: + """ + Validate and adjust price to conform to tick size + + Args: + instrument_name: Instrument name + price: Original price + + Returns: + ValidationResult with validation status and adjusted price + """ + try: + # Query instrument to get tick_size + instruments_tool = GetInstrumentsTool() + + # Extract currency from instrument name + currency = None + if "_" in instrument_name: + currency = instrument_name.split("_")[0] + kind = "spot" + elif "-" in instrument_name: + if instrument_name.endswith("-C") or instrument_name.endswith("-P"): + currency = instrument_name.split("-")[0] + kind = "option" + elif "PERPETUAL" in instrument_name.upper(): + currency = instrument_name.split("-")[0] + kind = "future" + else: + currency = instrument_name.split("-")[0] + kind = "future" + else: + for curr in ["BTC", "ETH", "USDC"]: + if instrument_name.startswith(curr): + currency = curr + kind = "any" + break + + if not currency: + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="Could not determine currency; skipping price tick-size validation", + ) + + # Query instruments + result = await instruments_tool.execute(currency=currency, kind=kind, expired=False) + + if isinstance(result, dict) and result.get("error"): + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="Failed to query instrument specs; skipping price tick-size validation", + ) + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="No instrument specs found; skipping price tick-size validation", + ) + + # Find matching instrument + tick_size = None + for inst in instruments: + if inst.get("instrument_name") == instrument_name: + tick_size = inst.get("tick_size") + break + + if tick_size and tick_size > 0: + # Always normalize price to conform to tick_size to avoid float precision issues + price_decimal = Decimal(str(price)) + tick_decimal = Decimal(str(tick_size)) + + # Calculate the normalized price (always round down for buy orders) + normalized_price = (price_decimal / tick_decimal).quantize(Decimal('1'), rounding=ROUND_DOWN) * tick_decimal + + # Determine precision from tick_size + tick_size_str = str(tick_size) + if '.' in tick_size_str: + decimals = len(tick_size_str.split('.')[1]) + else: + decimals = 0 + + # Convert to float with proper precision + normalized_price_float = float(round(normalized_price, decimals)) + + # Check if original price was significantly different + price_diff = abs(price_decimal - normalized_price) + tolerance = tick_decimal * Decimal('0.00001') + + if price_diff > tolerance: + # Price was not aligned, return adjusted value + return ValidationResult( + is_valid=False, + adjusted_value=normalized_price_float, + original_value=price, + tick_size=tick_size, + message=f"Price {price} does not conform to tick size {tick_size}", + ) + else: + # Price is valid, but still return normalized value to ensure precision + return ValidationResult( + is_valid=True, + adjusted_value=normalized_price_float, + original_value=price, + tick_size=tick_size + ) + else: + # No tick_size found, assume valid + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="No tick-size information found; skipping validation", + ) + + except Exception as e: + logger.warning("Error validating price for %s: %s", instrument_name, e) + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message=f"Price validation raised an exception: {e}", + ) + + async def _validate_and_adjust_amount(self, instrument_name: str, amount: float) -> ValidationResult: + """ + Validate and adjust amount to be a multiple of contract size + + Args: + instrument_name: Instrument name (e.g., 'ETH_USDC', 'ETH-PERPETUAL', 'BTC-25JAN25-50000-C') + amount: Original amount + + Returns: + ValidationResult with validation status and adjusted amount + """ + try: + # Query instrument to get contract size + instruments_tool = GetInstrumentsTool() + + # Extract currency from instrument name + currency = None + if "_" in instrument_name: + # Spot trading pair (e.g., ETH_USDC) + currency = instrument_name.split("_")[0] + kind = "spot" + elif "-" in instrument_name: + # Check if it's an option (ends with -C or -P) + if instrument_name.endswith("-C") or instrument_name.endswith("-P"): + # Options (e.g., BTC-25JAN25-50000-C, ETH-25JAN25-3000-P) + currency = instrument_name.split("-")[0] + kind = "option" + elif "PERPETUAL" in instrument_name.upper(): + # Perpetual futures (e.g., ETH-PERPETUAL) + currency = instrument_name.split("-")[0] + kind = "future" + else: + # Other futures (e.g., BTC-25JAN25, ETH-25JAN25) + currency = instrument_name.split("-")[0] + kind = "future" + else: + # Try to determine from instrument name + for curr in ["BTC", "ETH", "USDC"]: + if instrument_name.startswith(curr): + currency = curr + kind = "any" + break + + if not currency: + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message="Could not determine currency; skipping contract-size validation", + ) + + # Query instruments + result = await instruments_tool.execute(currency=currency, kind=kind, expired=False) + + if isinstance(result, dict) and result.get("error"): + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"Failed to query instrument specs: {result.get('error')}; skipping contract-size validation", + ) + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"No instrument specs found for {currency}; skipping contract-size validation", + ) + + # Find matching instrument + contract_size = None + for inst in instruments: + if inst.get("instrument_name") == instrument_name: + contract_size = inst.get("contract_size") + break + + if contract_size and contract_size > 0: + # Check if amount is already a multiple + amount_decimal = Decimal(str(amount)) + contract_decimal = Decimal(str(contract_size)) + remainder = amount_decimal % contract_decimal + + # Small tolerance for floating point errors + tolerance = contract_decimal * Decimal('0.0001') + + if remainder > tolerance and (contract_decimal - remainder) > tolerance: + # Amount is not a multiple, adjust it + adjusted_amount = _adjust_amount_to_contract_size(amount, contract_size) + + return ValidationResult( + is_valid=False, + adjusted_value=adjusted_amount, + original_value=amount, + contract_size=contract_size, + message=f"Amount {amount} is not a multiple of contract size {contract_size}", + ) + else: + # Amount is already a multiple (within tolerance) + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + contract_size=contract_size + ) + else: + # No contract size found + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"No contract-size information for {instrument_name}; skipping validation", + ) + + except Exception as e: + logger.warning("Error validating contract size for %s: %s", instrument_name, e) + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"Contract-size validation raised an exception: {e}", + ) + + +class PlaceSellOrderTool(DeribitBaseTool): + """Place a sell order on Deribit""" + + name: str = "deribit_place_sell_order" + description: str = ( + "Place a sell order on Deribit. " + "Supports market, limit, stop_market, and stop_limit order types." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL', 'BTC-25JAN25-50000-C')" + }, + "amount": { + "type": "number", + "description": "Order amount (in contracts)" + }, + "price": { + "type": "number", + "description": "Order price (required for limit orders)" + }, + "order_type": { + "type": "string", + "enum": ["market", "limit", "stop_market", "stop_limit"], + "default": "limit", + "description": "Order type" + }, + "time_in_force": { + "type": "string", + "enum": ["good_til_cancelled", "fill_or_kill", "immediate_or_cancel"], + "default": "good_til_cancelled", + "description": "Time in force" + }, + "reduce_only": { + "type": "boolean", + "default": False, + "description": "Reduce only order (only reduce position, not increase)" + }, + "post_only": { + "type": "boolean", + "default": False, + "description": "Post only order (maker order)" + } + }, + "required": ["instrument_name", "amount"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + amount: Optional[float] = Field(default=None, description="Order amount") + price: Optional[float] = Field(default=None, description="Order price") + order_type: str = Field(default="limit", description="Order type") + time_in_force: str = Field(default="good_til_cancelled", description="Time in force") + reduce_only: bool = Field(default=False, description="Reduce only") + post_only: bool = Field(default=False, description="Post only") + + async def execute(self, **kwargs) -> ToolResult: + """Execute place sell order tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + amount = kwargs.get("amount", self.amount) + price = kwargs.get("price", self.price) + order_type = kwargs.get("order_type", self.order_type) + time_in_force = kwargs.get("time_in_force", self.time_in_force) + reduce_only = kwargs.get("reduce_only", self.reduce_only) + post_only = kwargs.get("post_only", self.post_only) + + # Basic parameter validation + if not instrument_name: + return ToolResult(error="❌ Parameter error: 'instrument_name' is required") + if amount is None: + return ToolResult(error="❌ Parameter error: 'amount' is required") + if amount <= 0: + return ToolResult(error=f"❌ Parameter error: 'amount' must be greater than 0, got: {amount}") + + # Validate price for limit orders + if order_type in ["limit", "stop_limit"] and price is None: + return ToolResult(error=f"❌ Parameter error: '{order_type}' orders require a 'price' parameter") + if price is not None and price <= 0: + return ToolResult(error=f"❌ Parameter error: 'price' must be greater than 0, got: {price}") + + # Validate and adjust amount (contract size) + amount_result = await self._validate_and_adjust_amount(instrument_name, amount) + if not amount_result.is_valid: + error_msg = f"❌ Amount validation failed: {amount_result.get_error_message()}" + if amount_result.adjusted_value: + error_msg += ( + f"\n💡 Suggestion: adjust 'amount' to {amount_result.adjusted_value} " + f"(a multiple of contract size)" + ) + return ToolResult(error=error_msg) + + # Log if amount was adjusted + if amount_result.adjusted_value != amount_result.original_value: + logger.info( + "Amount automatically adjusted: %s -> %s (contract size: %s)", + amount_result.original_value, + amount_result.adjusted_value, + amount_result.contract_size, + ) + + amount = amount_result.adjusted_value + + # Validate price precision (tick size) for limit orders + if price is not None and order_type in ["limit", "stop_limit"]: + price_result = await self._validate_price(instrument_name, price) + if not price_result.is_valid: + error_msg = f"❌ Price validation failed: {price_result.get_error_message()}" + if price_result.adjusted_value: + error_msg += ( + f"\n💡 Suggestion: adjust 'price' to {price_result.adjusted_value} " + f"(conforms to tick size)" + ) + return ToolResult(error=error_msg) + + # If price was adjusted, log and use adjusted value + if price_result.adjusted_value != price_result.original_value: + logger.info( + "Price automatically adjusted: %s -> %s (tick size: %s)", + price_result.original_value, + price_result.adjusted_value, + price_result.tick_size, + ) + price = price_result.adjusted_value + + # Format price with proper precision based on tick_size + if price_result.tick_size: + tick_size_str = str(price_result.tick_size) + if '.' in tick_size_str: + decimals = len(tick_size_str.split('.')[1]) + else: + decimals = 0 + price = round(price, decimals) + + params = { + "instrument_name": instrument_name, + "amount": amount, + "type": order_type, + "time_in_force": time_in_force, + "reduce_only": reduce_only, + "post_only": post_only + } + + if price is not None: + params["price"] = price + + result = await self._call_private_method("private/sell", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in PlaceSellOrderTool: {e}") + return ToolResult(error=f"Failed to place sell order: {str(e)}") + + async def _validate_price(self, instrument_name: str, price: float) -> ValidationResult: + """ + Validate and adjust price to conform to tick size + + Args: + instrument_name: Instrument name + price: Original price + + Returns: + ValidationResult with validation status and adjusted price + """ + try: + # Query instrument to get tick_size + instruments_tool = GetInstrumentsTool() + + # Extract currency from instrument name + currency = None + if "_" in instrument_name: + currency = instrument_name.split("_")[0] + kind = "spot" + elif "-" in instrument_name: + if instrument_name.endswith("-C") or instrument_name.endswith("-P"): + currency = instrument_name.split("-")[0] + kind = "option" + elif "PERPETUAL" in instrument_name.upper(): + currency = instrument_name.split("-")[0] + kind = "future" + else: + currency = instrument_name.split("-")[0] + kind = "future" + else: + for curr in ["BTC", "ETH", "USDC"]: + if instrument_name.startswith(curr): + currency = curr + kind = "any" + break + + if not currency: + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="Could not determine currency; skipping price tick-size validation", + ) + + # Query instruments + result = await instruments_tool.execute(currency=currency, kind=kind, expired=False) + + if isinstance(result, dict) and result.get("error"): + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="Failed to query instrument specs; skipping price tick-size validation", + ) + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="No instrument specs found; skipping price tick-size validation", + ) + + # Find matching instrument + tick_size = None + for inst in instruments: + if inst.get("instrument_name") == instrument_name: + tick_size = inst.get("tick_size") + break + + if tick_size and tick_size > 0: + # Always normalize price to conform to tick_size to avoid float precision issues + price_decimal = Decimal(str(price)) + tick_decimal = Decimal(str(tick_size)) + + # Calculate the normalized price (round UP for sell orders to preserve minimum acceptable price) + normalized_price = (price_decimal / tick_decimal).quantize(Decimal('1'), rounding=ROUND_UP) * tick_decimal + + # Determine precision from tick_size + tick_size_str = str(tick_size) + if '.' in tick_size_str: + decimals = len(tick_size_str.split('.')[1]) + else: + decimals = 0 + + # Convert to float with proper precision + normalized_price_float = float(round(normalized_price, decimals)) + + # Check if original price was significantly different + price_diff = abs(price_decimal - normalized_price) + tolerance = tick_decimal * Decimal('0.00001') + + if price_diff > tolerance: + # Price was not aligned, return adjusted value + return ValidationResult( + is_valid=False, + adjusted_value=normalized_price_float, + original_value=price, + tick_size=tick_size, + message=f"Price {price} does not conform to tick size {tick_size}", + ) + else: + # Price is valid, but still return normalized value to ensure precision + return ValidationResult( + is_valid=True, + adjusted_value=normalized_price_float, + original_value=price, + tick_size=tick_size + ) + else: + # No tick_size found, assume valid + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message="No tick-size information found; skipping validation", + ) + + except Exception as e: + logger.warning("Error validating price for %s: %s", instrument_name, e) + return ValidationResult( + is_valid=True, + adjusted_value=price, + original_value=price, + message=f"Price validation raised an exception: {e}", + ) + + async def _validate_and_adjust_amount(self, instrument_name: str, amount: float) -> ValidationResult: + """ + Validate and adjust amount to be a multiple of contract size + + Args: + instrument_name: Instrument name (e.g., 'ETH_USDC', 'ETH-PERPETUAL', 'BTC-25JAN25-50000-C') + amount: Original amount + + Returns: + ValidationResult with validation status and adjusted amount + """ + try: + # Query instrument to get contract size + instruments_tool = GetInstrumentsTool() + + # Extract currency from instrument name + currency = None + if "_" in instrument_name: + # Spot trading pair (e.g., ETH_USDC) + currency = instrument_name.split("_")[0] + kind = "spot" + elif "-" in instrument_name: + # Check if it's an option (ends with -C or -P) + if instrument_name.endswith("-C") or instrument_name.endswith("-P"): + # Options (e.g., BTC-25JAN25-50000-C, ETH-25JAN25-3000-P) + currency = instrument_name.split("-")[0] + kind = "option" + elif "PERPETUAL" in instrument_name.upper(): + # Perpetual futures (e.g., ETH-PERPETUAL) + currency = instrument_name.split("-")[0] + kind = "future" + else: + # Other futures (e.g., BTC-25JAN25, ETH-25JAN25) + currency = instrument_name.split("-")[0] + kind = "future" + else: + # Try to determine from instrument name + for curr in ["BTC", "ETH", "USDC"]: + if instrument_name.startswith(curr): + currency = curr + kind = "any" + break + + if not currency: + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message="Could not determine currency; skipping contract-size validation", + ) + + # Query instruments + result = await instruments_tool.execute(currency=currency, kind=kind, expired=False) + + if isinstance(result, dict) and result.get("error"): + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"Failed to query instrument specs: {result.get('error')}; skipping contract-size validation", + ) + + instruments = result.get("output") if isinstance(result, dict) else result + + if not instruments: + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"No instrument specs found for {currency}; skipping contract-size validation", + ) + + # Find matching instrument + contract_size = None + for inst in instruments: + if inst.get("instrument_name") == instrument_name: + contract_size = inst.get("contract_size") + break + + if contract_size and contract_size > 0: + # Check if amount is already a multiple + amount_decimal = Decimal(str(amount)) + contract_decimal = Decimal(str(contract_size)) + remainder = amount_decimal % contract_decimal + + # Small tolerance for floating point errors + tolerance = contract_decimal * Decimal('0.0001') + + if remainder > tolerance and (contract_decimal - remainder) > tolerance: + # Amount is not a multiple, adjust it + adjusted_amount = _adjust_amount_to_contract_size(amount, contract_size) + + return ValidationResult( + is_valid=False, + adjusted_value=adjusted_amount, + original_value=amount, + contract_size=contract_size, + message=f"Amount {amount} is not a multiple of contract size {contract_size}", + ) + else: + # Amount is already a multiple (within tolerance) + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + contract_size=contract_size + ) + else: + # No contract size found + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"No contract-size information for {instrument_name}; skipping validation", + ) + + except Exception as e: + logger.warning("Error validating contract size for %s: %s", instrument_name, e) + return ValidationResult( + is_valid=True, + adjusted_value=amount, + original_value=amount, + message=f"Contract-size validation raised an exception: {e}", + ) + + +class CancelOrderTool(DeribitBaseTool): + """Cancel an order on Deribit""" + + name: str = "deribit_cancel_order" + description: str = ( + "Cancel a specific order by order ID. " + "Returns the cancelled order information." + ) + + parameters: dict = { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Order ID to cancel" + } + }, + "required": ["order_id"] + } + + order_id: Optional[str] = Field(default=None, description="Order ID") + + async def execute(self, **kwargs) -> ToolResult: + """Execute cancel order tool""" + try: + order_id = kwargs.get("order_id", self.order_id) + + if not order_id: + return ToolResult(error="Parameter 'order_id' is required") + + params = { + "order_id": order_id + } + + result = await self._call_private_method("private/cancel", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in CancelOrderTool: {e}") + return ToolResult(error=f"Failed to cancel order: {str(e)}") + + +class CancelAllOrdersTool(DeribitBaseTool): + """Cancel all orders for a currency and kind""" + + name: str = "deribit_cancel_all_orders" + description: str = ( + "Cancel all orders for a specific currency and instrument kind. " + "Returns the number of cancelled orders." + ) + + parameters: dict = { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": ["BTC", "ETH", "USDC"], + "description": "Currency code (BTC, ETH, or USDC)" + }, + "kind": { + "type": "string", + "enum": ["future", "option", "spot", "any"], + "default": "any", + "description": "Instrument kind filter" + }, + "type": { + "type": "string", + "enum": ["all", "limit", "stop"], + "default": "all", + "description": "Order type filter" + } + }, + "required": ["currency"] + } + + currency: Optional[str] = Field(default=None, description="Currency code") + kind: str = Field(default="any", description="Instrument kind") + type: str = Field(default="all", description="Order type filter") + + async def execute(self, **kwargs) -> ToolResult: + """Execute cancel all orders tool""" + try: + currency = kwargs.get("currency", self.currency) + kind = kwargs.get("kind", self.kind) + order_type = kwargs.get("type", self.type) + + if not currency: + return ToolResult(error="Parameter 'currency' is required") + + params = { + "currency": currency, + "kind": kind if kind != "any" else None, + "type": order_type if order_type != "all" else None + } + + # Remove None values + params = {k: v for k, v in params.items() if v is not None} + + result = await self._call_private_method("private/cancel_all", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in CancelAllOrdersTool: {e}") + return ToolResult(error=f"Failed to cancel all orders: {str(e)}") + + +class GetOpenOrdersTool(DeribitBaseTool): + """Get open orders for an instrument""" + + name: str = "deribit_get_open_orders" + description: str = ( + "Get all open orders for a specific instrument. " + "Returns order details including status, price, amount, etc." + ) + + parameters: dict = { + "type": "object", + "properties": { + "instrument_name": { + "type": "string", + "description": "Instrument name (e.g., 'BTC-PERPETUAL')" + } + }, + "required": ["instrument_name"] + } + + instrument_name: Optional[str] = Field(default=None, description="Instrument name") + + async def execute(self, **kwargs) -> ToolResult: + """Execute get open orders tool""" + try: + instrument_name = kwargs.get("instrument_name", self.instrument_name) + + if not instrument_name: + return ToolResult(error="Parameter 'instrument_name' is required") + + params = { + "instrument_name": instrument_name + } + + result = await self._call_private_method("private/get_open_orders_by_instrument", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in GetOpenOrdersTool: {e}") + return ToolResult(error=f"Failed to get open orders: {str(e)}") + + +class EditOrderTool(DeribitBaseTool): + """Edit an existing order""" + + name: str = "deribit_edit_order" + description: str = ( + "Edit an existing order by order ID. " + "Can modify amount and/or price." + ) + + parameters: dict = { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Order ID to edit" + }, + "amount": { + "type": "number", + "description": "New order amount (optional)" + }, + "price": { + "type": "number", + "description": "New order price (optional)" + } + }, + "required": ["order_id"] + } + + order_id: Optional[str] = Field(default=None, description="Order ID") + amount: Optional[float] = Field(default=None, description="New amount") + price: Optional[float] = Field(default=None, description="New price") + + async def execute(self, **kwargs) -> ToolResult: + """Execute edit order tool""" + try: + order_id = kwargs.get("order_id", self.order_id) + amount = kwargs.get("amount", self.amount) + price = kwargs.get("price", self.price) + + if not order_id: + return ToolResult(error="Parameter 'order_id' is required") + + if amount is None and price is None: + return ToolResult(error="At least one of 'amount' or 'price' must be provided") + + params = { + "order_id": order_id + } + + if amount is not None: + params["amount"] = amount + if price is not None: + # Ensure price is formatted with proper precision to avoid float precision issues + # Convert to string first, then back to float to ensure exact representation + price_str = f"{price:.10f}".rstrip('0').rstrip('.') + params["price"] = float(price_str) + + result = await self._call_private_method("private/edit", params) + + return ToolResult(output=result) + + except Exception as e: + logger.error(f"Error in EditOrderTool: {e}") + return ToolResult(error=f"Failed to edit order: {str(e)}")