diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2fa4b21 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# Decibel Python SDK — Claude Code Instructions + +## Pre-commit Checklist + +Before EVERY commit, run the full CI pipeline and verify all steps pass: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pyright +uv run pytest -k "not testnet" +``` + +Or equivalently: `make all` + +If `ruff format --check` fails, run `uv run ruff format src tests` to fix. + +Do NOT commit if any of these fail. + +## Project Structure + +- `src/decibel/` — SDK source code +- `src/decibel/read/` — Read-only API readers (REST + WebSocket subscriptions) +- `src/decibel/write/` — On-chain transaction writers (async + sync) +- `tests/api_resources/` — Spec compliance and integration tests +- `docs/SPEC*.md` — API specification documents + +## Testing + +- Unit/spec tests (no network): `uv run pytest -k "not testnet"` +- Integration tests (needs API key): `DECIBEL_API_KEY= uv run pytest tests/api_resources/test_testnet_integration.py -v` +- Integration tests auto-skip without `DECIBEL_API_KEY` + +## Code Style + +- Line length: 100 (enforced by ruff) +- Format: `uv run ruff format src tests` +- Lint: `uv run ruff check src tests` (auto-fix with `--fix`) +- Type checking: `uv run pyright` (strict mode on `src/`) +- Python 3.11+, async-first with sync wrappers +- Pydantic v2 for all data models diff --git a/docs/SPEC-REST.md b/docs/SPEC-REST.md new file mode 100644 index 0000000..008e621 --- /dev/null +++ b/docs/SPEC-REST.md @@ -0,0 +1,1223 @@ +# Decibel Python SDK — REST API Specification + +> **Source:** OpenAPI 3.1.0 spec at `https://docs.decibel.trade/api-reference/openapi.json` +> **Base URLs:** +> - Mainnet: `https://api.mainnet.aptoslabs.com/decibel` +> - Testnet: `https://api.testnet.aptoslabs.com/decibel` + +## Table of Contents + +1. [Transport & Headers](#1-transport--headers) +2. [Market Data Endpoints](#2-market-data-endpoints) +3. [Account Endpoints](#3-account-endpoints) +4. [User Endpoints](#4-user-endpoints) +5. [TWAP Endpoints](#5-twap-endpoints) +6. [Bulk Order Endpoints](#6-bulk-order-endpoints) +7. [Vault Endpoints](#7-vault-endpoints) +8. [Analytics Endpoints](#8-analytics-endpoints) +9. [Referral Endpoints](#9-referral-endpoints) +10. [Predeposit Endpoints](#10-predeposit-endpoints) +11. [Shared Data Schemas](#11-shared-data-schemas) + +--- + +## 1. Transport & Headers + +### 1.1 Protocol + +All REST endpoints SHALL use HTTPS GET requests. There are no POST/PUT/DELETE endpoints in the read API (writes go on-chain). + +**Exception:** Subaccount rename uses PATCH (see Section 4). + +### 1.2 Required Headers + +| Header | Value | Required | +|--------|-------|----------| +| `Authorization` | `Bearer ` | YES | +| `Content-Type` | `application/json` | For PATCH only | + +> **Note:** The `Origin` header is required by the server for browser-based requests but the SDK does NOT send it. Server-side enforcement may vary by deployment. + +### 1.3 Response Format + +All successful responses SHALL return JSON with `Content-Type: application/json`. + +### 1.4 Query Parameter Encoding + +- Pagination parameters SHALL be sent as flat query params: `?limit=10&offset=0` +- Sorting parameters SHALL be sent as: `?sort_key=volume&sort_dir=DESC` +- Filter parameters SHALL be sent as: `?from=1634567890000&to=1634654290000` + +--- + +## 2. Market Data Endpoints + +### 2.1 Get Market Prices + +Retrieve current prices for one or all markets. + +``` +GET /api/v1/prices +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | No | Market address. Use `"all"` or omit for all markets. | + +**Response:** `200 OK` +```json +[ + { + "market": "0x...", + "oracle_px": 50125.75, + "mark_px": 50120.5, + "mid_px": 50122.25, + "funding_rate_bps": 5.0, + "is_funding_positive": true, + "transaction_unix_ms": 1699564800000, + "open_interest": 125000.5 + } +] +``` + +**Schema:** Array of `PriceDto` + +**Edge behaviors:** +- `funding_rate_bps` from REST is the raw (unsmoothed) on-chain value. WebSocket provides EMA-smoothed values. +- Returns `404` if a specific market address is not found. + +--- + +### 2.2 Get All Available Markets + +``` +GET /api/v1/markets +``` + +**Query Parameters:** None + +**Response:** `200 OK` +```json +[ + { + "market_addr": "0x...", + "market_name": "BTC-PERP", + "sz_decimals": 4, + "max_leverage": 50, + "tick_size": 100, + "min_size": 1000, + "lot_size": 100, + "max_open_interest": 1000000.0, + "px_decimals": 1, + "mode": "Open", + "unrealized_pnl_haircut_bps": 1000 + } +] +``` + +**Schema:** Array of `MarketDto` + +**Notes:** +- `tick_size`, `min_size`, `lot_size` are in chain units (integers) +- `mode` values: `"Open"`, `"ReduceOnly"`, `"CloseOnly"` +- `unrealized_pnl_haircut_bps`: basis points (1000 = 10%) + +--- + +### 2.3 Get Candlestick (OHLC) Data + +``` +GET /api/v1/candlesticks +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `interval` | string | Yes | One of: `1m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `1d`, `1w`, `1mo` | +| `startTime` | int64 | Yes | Start time in Unix milliseconds | +| `endTime` | int64 | Yes | End time in Unix milliseconds | + +**Response:** `200 OK` +```json +[ + { + "t": 1761588000000, + "T": 1761591599999, + "o": 100.0, + "h": 102.0, + "l": 98.0, + "c": 100.0, + "v": 1000.0, + "i": "1h" + } +] +``` + +**Schema:** Array of `CandlestickResponseItemDto` + +| Field | Description | +|-------|-------------| +| `t` | Open time (Unix ms) | +| `T` | Close time (Unix ms) | +| `o` | Open price | +| `h` | High price | +| `l` | Low price | +| `c` | Close price | +| `v` | Volume | +| `i` | Interval string | + +**Edge behaviors:** +- Maximum 1000 candles per request. Returns `400` if exceeded. +- Returns `400` if `startTime > endTime`. +- Returns `404` if market not found. +- Missing intervals are interpolated using last known close price. + +--- + +### 2.4 Get Trades (Market) + +``` +GET /api/v1/trades +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `order_id` | string | No | Filter by specific order ID | +| `limit` | int32 | Yes* | Max results (0–1000) | +| `offset` | int32 | Yes* | Pagination offset (0–10000) | + +**Response:** `200 OK` — `PaginatedResponse` + +```json +{ + "items": [ + { + "account": "0x...", + "market": "0x...", + "action": "Open Long", + "source": "OrderFill", + "trade_id": "3647276", + "size": 1.5, + "price": 50125.75, + "is_profit": true, + "realized_pnl_amount": 187.5, + "realized_funding_amount": -12.3, + "is_rebate": true, + "fee_amount": 25.06, + "order_id": "45678", + "client_order_id": "order_123", + "transaction_unix_ms": 1699564800000, + "transaction_version": 3647276285 + } + ], + "total_count": 1 +} +``` + +**Notes:** +- `action` values: `"Open Long"`, `"Close Long"`, `"Open Short"`, `"Close Short"` +- `source` values: `"OrderFill"`, `"MarginCall"`, `"BackStopLiquidation"`, `"ADL"`, `"MarketDelisted"` +- Results ordered by most recent first. + +--- + +### 2.5 Get Asset Contexts + +``` +GET /api/v1/asset_contexts +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | No | Filter by market address | + +**Response:** `200 OK` +```json +[ + { + "market": "0x...", + "volume_24h": 5000000.0, + "open_interest": 125000.5, + "mark_price": 50120.5, + "mid_price": 50122.25, + "oracle_price": 50125.75, + "previous_day_price": 49800.0, + "price_change_pct_24h": 0.65 + } +] +``` + +**Schema:** Array of `AssetContextDto` + +--- + +## 3. Account Endpoints + +### 3.1 Get Account Overview + +``` +GET /api/v1/account_overviews +``` + +**Query Parameters:** + +| Param | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `account` | string | Yes | — | Subaccount address | +| `volume_window` | string | No | — | `"7d"`, `"14d"`, `"30d"`, or `"90d"` | +| `include_performance` | bool | No | `false` | Include historical return metrics | +| `performance_lookback_days` | int32 | No | `90` | Lookback window in days | + +**Response:** `200 OK` +```json +{ + "perp_equity_balance": 10064.88, + "unrealized_pnl": 154.0, + "realized_pnl": 1250.5, + "unrealized_funding_cost": -87.84, + "cross_margin_ratio": 0.01, + "maintenance_margin": 115.29, + "cross_account_leverage_ratio": 40.99, + "total_margin": 9998.72, + "usdc_cross_withdrawable_balance": 9843.79, + "usdc_isolated_withdrawable_balance": 0.0, + "margin_deficit": -12.06, + "volume": null, + "all_time_return": null, + "pnl_90d": null, + "sharpe_ratio": null, + "max_drawdown": null, + "weekly_win_rate_12w": null, + "average_cash_position": null, + "average_leverage": null, + "cross_account_position": null, + "vault_equity": 259.73, + "net_deposits": 30277044.96, + "liquidation_fees_paid": 45.5, + "liquidation_losses": null +} +``` + +**Schema:** `AccountOverviewDto` + +**Required fields:** `perp_equity_balance`, `unrealized_pnl`, `unrealized_funding_cost`, `cross_margin_ratio`, `maintenance_margin`, `cross_account_leverage_ratio`, `total_margin`, `usdc_cross_withdrawable_balance`, `usdc_isolated_withdrawable_balance`, `margin_deficit` + +**Nullable fields:** `all_time_return`, `average_cash_position`, `average_leverage`, `cross_account_position`, `liquidation_fees_paid`, `liquidation_losses`, `max_drawdown`, `net_deposits`, `pnl_90d`, `realized_pnl`, `sharpe_ratio`, `vault_equity`, `volume`, `weekly_win_rate_12w` + +**Edge behaviors:** +- `margin_deficit`: 0 when healthy, negative when account has margin hole +- `liquidation_losses`: null for regular users (only vault/BLP accounts) +- Performance fields are null unless `include_performance=true` +- `volume` is null unless `volume_window` is provided + +--- + +### 3.2 Get Account Positions + +``` +GET /api/v1/account_positions +``` + +**Query Parameters:** + +| Param | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `account` | string | Yes | — | Subaccount address | +| `limit` | int32 | No | `500` | Max results | +| `include_deleted` | bool | No | `false` | Include closed positions | +| `market_address` | string | No | — | Filter by market | + +**Response:** `200 OK` +```json +[ + { + "market": "0x...", + "user": "0x...", + "size": 2.5, + "user_leverage": 10, + "entry_price": 49800.0, + "is_isolated": false, + "is_deleted": false, + "unrealized_funding": -25.5, + "estimated_liquidation_price": 45000.0, + "transaction_version": 12345681, + "has_fixed_sized_tpsls": false, + "tp_order_id": "tp_001", + "tp_trigger_price": 52000.0, + "tp_limit_price": 51900.0, + "sl_order_id": "sl_001", + "sl_trigger_price": 48000.0, + "sl_limit_price": null + } +] +``` + +**Schema:** Array of `PositionDto` + +**Notes:** +- `size` > 0 = long, `size` < 0 = short +- TP/SL fields are nullable (null when no TP/SL set) +- `tp_trigger_price` and `sl_trigger_price` are in human-readable price format (float) + +--- + +### 3.3 Get Account's Open Orders + +``` +GET /api/v1/open_orders +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `limit` | int32 | Yes | Max results (0–1000) | +| `offset` | int32 | Yes | Pagination offset (0–10000) | + +**Response:** `200 OK` — `PaginatedResponse` + +--- + +### 3.4 Get User Order History + +``` +GET /api/v1/order_history +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `from` | int64 | Yes | Start timestamp (Unix ms) | +| `to` | int64 | Yes | End timestamp (Unix ms) | +| `sort_key` | string | Yes | Sort field (e.g. `"timestamp"`) | +| `sort_dir` | string | No | `"ASC"` or `"DESC"` | +| `market` | string | No | Filter by market address | +| `order_type` | string | No | `"Limit"`, `"Market"`, `"Stop Limit"`, `"Stop Market"` | +| `status` | string | No | `"Open"`, `"Filled"`, `"Cancelled"`, `"Expired"` | +| `side` | string | No | `"buy"` or `"sell"` | +| `reduce_only` | bool | No | Filter reduce-only orders | + +**Response:** `200 OK` — `PaginatedResponse` + +**Note:** Page size capped at 200. + +--- + +### 3.5 Get User Trade History + +``` +GET /api/v1/trade_history +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `from` | int64 | Yes | Start timestamp (Unix ms) | +| `to` | int64 | Yes | End timestamp (Unix ms) | +| `sort_key` | string | Yes | Sort field | +| `sort_dir` | string | No | `"ASC"` or `"DESC"` | +| `market` | string | No | Filter by market | +| `order_id` | string | No | Filter by order ID (requires `market`) | +| `side` | string | No | `"buy"` or `"sell"` | + +**Response:** `200 OK` — `PaginatedResponse` + +**Edge behaviors:** +- Returns `400` if `order_id` provided without `market`. +- Page size capped at 200. + +--- + +### 3.6 Get User Funding Rate History + +``` +GET /api/v1/funding_rate_history +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `from` | int64 | Yes | Start timestamp | +| `to` | int64 | Yes | End timestamp | +| `sort_key` | string | Yes | Sort field | +| `sort_dir` | string | No | Direction | +| `market` | string | No | Filter by market | +| `side` | string | No | `"buy"` or `"sell"` | + +**Response:** `200 OK` — `PaginatedResponse` + +```json +{ + "items": [ + { + "market": "0x...", + "action": "Close Long", + "size": 1.0, + "realized_funding_amount": -15.5, + "is_rebate": false, + "fee_amount": 5.15, + "transaction_unix_ms": 1735758000000 + } + ], + "total_count": 1 +} +``` + +**Notes:** +- `realized_funding_amount`: negative = trader PAID funding, positive = trader RECEIVED funding + +--- + +### 3.7 Get Subaccounts + +``` +GET /api/v1/subaccounts +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `owner` | string | Yes | Owner wallet address | + +**Response:** `200 OK` +```json +[ + { + "subaccount_address": "0x...", + "subaccount_name": "Primary", + "subaccount_number": 0, + "is_active": true + } +] +``` + +**Schema:** Array of `SubaccountDto` + +--- + +## 4. User Endpoints + +### 4.1 Get Single Order Details + +``` +GET /api/v1/orders +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `account` | string | Yes | Subaccount address | +| `order_id` | string | No* | Order ID (provide one of `order_id` or `client_order_id`) | +| `client_order_id` | string | No* | Client order ID | + +**Response:** `200 OK` +```json +{ + "status": "Filled", + "details": "", + "order": { ... } +} +``` + +**Schema:** `OrderUpdate` + +**Error responses:** +```json +{ + "status": "notFound", + "message": "Order with order_id: 123 not found" +} +``` + +**Order Details Field Values:** + +| Details Value | Explanation | +|--------------|-------------| +| `PostOnlyViolation` | Would take liquidity but was marked post-only | +| `IOCViolation` | IOC order could not be fully executed | +| `PositionUpdateViolation` | Violates position update constraints | +| `ReduceOnlyViolation` | Reduce-only order would increase position | +| `ClearinghouseSettleViolation` | Conflicts with clearinghouse settlement | +| `MaxFillLimitViolation` | Exceeds maximum fill limit | +| `DuplicateClientOrderIdViolation` | Client order ID already exists | +| `OrderPreCancelled` | Cancelled before execution | +| `PlaceMakerOrderViolation` | Maker order placement rules violated | +| `DeadMansSwitchExpired` | Dead man's switch timeout | +| `DisallowedSelfTrading` | Self-trading not permitted | +| `OrderCancelledByUser` | User cancelled | +| `OrderCancelledBySystem` | System auto-cancelled | +| `OrderCancelledBySystemDueToError` | System cancelled due to error | + +--- + +### 4.2 Get Delegations + +``` +GET /api/v1/delegations +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `subaccount` | string | Yes | Subaccount address | + +**Response:** `200 OK` +```json +[ + { + "delegated_account": "0x...", + "permission_type": "TradePerpsAllMarkets", + "expiration_time_s": 1736326800000 + } +] +``` + +**Schema:** Array of `DelegationDto` + +--- + +### 4.3 Get User Fund History + +``` +GET /api/v1/account_fund_history +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `from` | int64 | Yes | Start timestamp | +| `to` | int64 | Yes | End timestamp | +| `sort_key` | string | Yes | Sort field | +| `sort_dir` | string | No | Direction | + +**Response:** `200 OK` — `UserFundHistoryResponse` + +Fund movement types: `"deposit"`, `"withdrawal"` + +--- + +## 5. TWAP Endpoints + +### 5.1 Get Active TWAP Orders + +``` +GET /api/v1/active_twaps +``` + +**Query Parameters:** + +| Param | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `account` | string | Yes | — | Subaccount address | +| `limit` | int32 | No | `10` | Max results | + +**Response:** `200 OK` +```json +[ + { + "market": "0x...", + "is_buy": true, + "order_id": "78901", + "is_reduce_only": false, + "start_unix_ms": 1699564800000, + "frequency_s": 300, + "duration_s": 3600, + "orig_size": 100.0, + "remaining_size": 75.0, + "status": "Open", + "client_order_id": "twap_123", + "transaction_unix_ms": 1699564800000, + "transaction_version": 12345679 + } +] +``` + +**Schema:** Array of `TwapDto` + +--- + +### 5.2 Get TWAP Order History + +``` +GET /api/v1/twap_history +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `from` | int64 | Yes | Start timestamp | +| `to` | int64 | Yes | End timestamp | +| `sort_key` | string | Yes | Sort field | +| `sort_dir` | string | No | Direction | + +**Response:** `200 OK` — `PaginatedResponse` + +--- + +## 6. Bulk Order Endpoints + +### 6.1 Get Bulk Orders + +``` +GET /api/v1/bulk_orders +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `market` | string | No | Filter by market | + +**Response:** `200 OK` +```json +[ + { + "market": "0x...", + "user": "0x...", + "sequence_number": 12345, + "previous_seq_num": 12344, + "bid_prices": [50000, 49900], + "bid_sizes": [1.0, 2.0], + "ask_prices": [50100, 50200], + "ask_sizes": [1.5, 2.5], + "cancelled_bid_prices": [], + "cancelled_bid_sizes": [], + "cancelled_ask_prices": [], + "cancelled_ask_sizes": [], + "cancellation_reason": "", + "transaction_version": 12345678, + "transaction_unix_ms": 1699564800000, + "event_uid": "123456789012345678901234567890123456" + } +] +``` + +**Schema:** Array of `BulkOrderDto` + +**Notes:** +- Returns one bulk order per market (latest) +- `event_uid` is a u128 represented as a string + +--- + +### 6.2 Get Bulk Order Status + +``` +GET /api/v1/bulk_order_status +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `market` | string | Yes | Market address | +| `sequence_number` | int64 | Yes | Bulk order sequence number | + +**Response:** `200 OK` — `BulkOrderStatusResponse` + +Status values: `"Placed"`, `"Rejected"`, `"notFound"` + +--- + +### 6.3 Get Bulk Order Fills + +``` +GET /api/v1/bulk_order_fills +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `market` | string | No | Filter by market | +| `sequence_number` | int64 | No | Single sequence number | +| `start_sequence_number` | int64 | No | Range start | +| `end_sequence_number` | int64 | No | Range end (requires `start_sequence_number`) | +| `limit` | int32 | No | Max results | +| `offset` | int32 | No | Pagination offset | + +**Response:** `200 OK` +```json +[ + { + "market": "0x...", + "sequence_number": 12345, + "user": "0x...", + "filled_size": 1.5, + "price": 50000.0, + "is_bid": true, + "trade_id": "3647276", + "transaction_unix_ms": 1699564800000, + "transaction_version": 12345682, + "event_uid": "123456789012345678901234567890123456" + } +] +``` + +**Schema:** Array of `BulkOrderFillDto` + +--- + +## 7. Vault Endpoints + +### 7.1 Get Public Vaults + +``` +GET /api/v1/vaults +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `sort_key` | string | Yes | One of: `tvl`, `age`, `pnl`, `sharpe_ratio`, `weekly_win_rate`, `max_drawdown` | +| `sort_dir` | string | No | `"ASC"` or `"DESC"` | +| `status` | string | No | `"created"`, `"active"`, `"inactive"` | +| `vault_type` | string | No | `"user"` or `"protocol"` | +| `vault_address` | string | No | Exact vault address | +| `search` | string | No | Case-insensitive search on address/name/manager | + +**Response:** `200 OK` — `PublicVaultsResponse` + +--- + +### 7.2 Get Account-Owned Vaults + +``` +GET /api/v1/account_owned_vaults +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Owner address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | + +**Response:** `200 OK` — `PaginatedResponse` + +--- + +### 7.3 Get Account Vault Performance + +``` +GET /api/v1/account_vault_performance +``` + +**Query Parameters:** + +| Param | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `account` | string | Yes | — | Account address | +| `limit` | int64 | No | `20` | Max results (0–1000) | +| `offset` | int64 | No | `0` | Pagination offset (0–10000) | + +**Response:** `200 OK` +```json +[ + { + "vault": { ... }, + "account_address": "0x...", + "total_deposited": 10000.0, + "total_withdrawn": 2000.0, + "current_value_of_shares": 9500.0, + "current_num_shares": 9500000000, + "share_price": 1.05, + "locked_amount": 1000.0, + "all_time_earned": 1500.0, + "all_time_return": 15.0, + "unrealized_pnl": 500.0, + "volume": 50000.0, + "weekly_win_rate_12w": 0.65, + "deposits": [ ... ], + "withdrawals": [ ... ] + } +] +``` + +**Schema:** Array of `AccountVaultPerformanceDto` + +--- + +## 8. Analytics Endpoints + +### 8.1 Get Leaderboard + +``` +GET /api/v1/leaderboard +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `limit` | int32 | Yes | Max results (default 100) | +| `offset` | int32 | Yes | Pagination offset | +| `sort_key` | string | Yes | `"account_value"`, `"realized_pnl"`, `"volume"`, `"roi"` | +| `sort_dir` | string | No | `"ASC"` or `"DESC"` | +| `search_term` | string | No | Filter by account address prefix | + +**Response:** `200 OK` — `PaginatedResponse` + +```json +{ + "items": [ + { + "rank": 1, + "account": "0x...", + "account_value": 1000000.0, + "realized_pnl": 50000.0, + "roi": 25.5, + "volume": 5000000.0 + } + ], + "total_count": 100 +} +``` + +--- + +### 8.2 Get Points Leaderboard + +``` +GET /api/v1/points_leaderboard +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | +| `sort_key` | string | Yes | `"total_amps"` or `"realized_pnl"` | +| `sort_dir` | string | No | Direction | +| `search_term` | string | No | Filter by owner address prefix | +| `tier` | string | No | `"top20"`, `"diamond"`, `"doublePlatinum"`, `"gold"` | + +**Response:** `200 OK` — `PaginatedResponse` + +--- + +### 8.3 Get Portfolio Chart Data + +``` +GET /api/v1/portfolio_chart +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Subaccount address | +| `range` | string | Yes | Time range enum | +| `data_type` | string | Yes | `"pnl"` or `"account_value"` | + +**Response:** `200 OK` +```json +[ + { + "timestamp": 1699564800000, + "data_points": 10500.0, + "vault_equity": 259.73 + } +] +``` + +**Schema:** Array of `PortfolioPointDto` + +**Notes:** +- `vault_equity` is null for users with no vault deposits or when `data_type` is `"pnl"` + +--- + +## 9. Referral Endpoints + +### 9.1 Get Referral Info + +``` +GET /api/v1/referrals/account/{account} +``` + +**Path Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Wallet address (not subaccount) | + +**Response:** `200 OK` — `AccountReferralInfo` + +--- + +### 9.2 Get Referrer Statistics + +``` +GET /api/v1/referrals/stats/{account} +``` + +**Path Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Referrer wallet address | + +**Response:** `200 OK` — `ReferrerStatsDto` + +--- + +### 9.3 Get Users Referred by Referrer + +``` +GET /api/v1/referrals/users +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `referrer_account` | string | Yes | Referrer wallet address | +| `limit` | int32 | Yes | Max results | +| `offset` | int32 | Yes | Pagination offset | + +**Response:** `200 OK` — Array of `UserReferralInfo` + +--- + +## 10. Predeposit Endpoints + +### 10.1 Get S0 Predeposit USDC Reward + +``` +GET /api/v1/predeposits/rewards +``` + +**Query Parameters:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | Account address | + +**Response:** `200 OK` +```json +{ + "account": "0x...", + "usdc_reward": 100.0 +} +``` + +--- + +## 11. Shared Data Schemas + +### 11.1 PriceDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `oracle_px` | float64 | Yes | Oracle price | +| `mark_px` | float64 | Yes | Mark price | +| `mid_px` | float64 | Yes | Mid price | +| `funding_rate_bps` | float64 | Yes | Funding rate in basis points | +| `is_funding_positive` | bool | Yes | Funding direction | +| `transaction_unix_ms` | int64 | Yes | Last update timestamp | +| `open_interest` | float64 | Yes | Open interest | + +### 11.2 OrderDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `parent` | string | Yes | Parent account address | +| `market` | string | Yes | Market address | +| `client_order_id` | string | Yes | Client-specified ID | +| `order_id` | string | Yes | Server-assigned ID | +| `status` | string | Yes | `"Open"`, `"Filled"`, `"Cancelled"`, `"Expired"`, `"Rejected"` | +| `order_type` | string | Yes | `"Limit"`, `"Market"`, `"Stop Limit"`, `"Stop Market"` | +| `trigger_condition` | string | Yes | `"None"`, `"Above"`, `"Below"` | +| `order_direction` | string | Yes | `"Open Long"`, `"Close Long"`, `"Open Short"`, `"Close Short"` | +| `is_buy` | bool | Yes | Buy side flag | +| `is_reduce_only` | bool | Yes | Reduce-only flag | +| `is_tpsl` | bool | Yes | Is TP/SL order | +| `cancellation_reason` | string | Yes | Reason for cancellation (empty if not cancelled) | +| `details` | string | Yes | Additional details (violation reasons) | +| `transaction_version` | uint64 | Yes | Aptos transaction version | +| `unix_ms` | uint64 | Yes | Timestamp | +| `orig_size` | float64? | No | Original order size | +| `remaining_size` | float64? | No | Remaining unfilled size | +| `size_delta` | float64? | No | Size change | +| `price` | float64? | No | Order price | +| `tp_order_id` | string? | No | Take-profit order ID | +| `tp_trigger_price` | float64? | No | TP trigger price | +| `tp_limit_price` | float64? | No | TP limit price | +| `sl_order_id` | string? | No | Stop-loss order ID | +| `sl_trigger_price` | float64? | No | SL trigger price | +| `sl_limit_price` | float64? | No | SL limit price | + +### 11.3 PositionDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `user` | string | Yes | Subaccount address | +| `size` | float64 | Yes | Position size (>0=long, <0=short) | +| `user_leverage` | uint32 | Yes | User-selected leverage | +| `entry_price` | float64 | Yes | Average entry price | +| `is_isolated` | bool | Yes | Isolated margin flag | +| `is_deleted` | bool | Yes | Position closed flag | +| `unrealized_funding` | float64 | Yes | Unrealized funding | +| `estimated_liquidation_price` | float64 | Yes | Estimated liquidation price | +| `transaction_version` | uint64 | Yes | Transaction version | +| `has_fixed_sized_tpsls` | bool | Yes | Has fixed-size TP/SL | +| `tp_order_id` | string? | No | TP order ID | +| `tp_trigger_price` | float64? | No | TP trigger price | +| `tp_limit_price` | float64? | No | TP limit price | +| `sl_order_id` | string? | No | SL order ID | +| `sl_trigger_price` | float64? | No | SL trigger price | +| `sl_limit_price` | float64? | No | SL limit price | + +### 11.4 TradeDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `account` | string | Yes | User's subaccount address | +| `market` | string | Yes | Market address | +| `action` | string | Yes | Trade action type | +| `source` | string | Yes | Trade source | +| `trade_id` | string | Yes | Trade ID | +| `size` | float64 | Yes | Trade size | +| `price` | float64 | Yes | Trade price | +| `is_profit` | bool | Yes | Profitable trade flag | +| `realized_pnl_amount` | float64 | Yes | Realized PnL | +| `realized_funding_amount` | float64 | Yes | Realized funding | +| `is_rebate` | bool | Yes | Rebate flag | +| `fee_amount` | float64 | Yes | Fee amount | +| `order_id` | string | Yes | Associated order ID | +| `client_order_id` | string | Yes | Client order ID | +| `transaction_unix_ms` | int64 | Yes | Timestamp | +| `transaction_version` | uint64 | Yes | Transaction version | + +### 11.5 TwapDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `is_buy` | bool | Yes | Buy direction | +| `order_id` | string | Yes | TWAP order ID | +| `is_reduce_only` | bool | Yes | Reduce-only flag | +| `start_unix_ms` | int64 | Yes | TWAP start time | +| `frequency_s` | uint64 | Yes | Order frequency in seconds | +| `duration_s` | uint64 | Yes | Total duration in seconds | +| `orig_size` | float64 | Yes | Original total size | +| `remaining_size` | float64 | Yes | Remaining size | +| `status` | string | Yes | Order status | +| `client_order_id` | string | Yes | Client order ID | +| `transaction_unix_ms` | int64 | Yes | Timestamp | +| `transaction_version` | uint64 | Yes | Transaction version | + +### 11.6 MarketDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `market_addr` | string | Yes | Market address | +| `market_name` | string | Yes | Human-readable name (e.g., `"BTC-PERP"`) | +| `sz_decimals` | uint32 | Yes | Size decimal precision | +| `max_leverage` | uint32 | Yes | Maximum allowed leverage | +| `tick_size` | uint64 | Yes | Minimum price increment (chain units) | +| `min_size` | uint64 | Yes | Minimum order size (chain units) | +| `lot_size` | uint64 | Yes | Size increment (chain units) | +| `max_open_interest` | float64 | Yes | Maximum open interest | +| `px_decimals` | uint32 | Yes | Price decimal precision | +| `mode` | string | Yes | Market mode: `"Open"`, `"ReduceOnly"`, `"CloseOnly"` | +| `unrealized_pnl_haircut_bps` | uint32 | Yes | PnL haircut in basis points | + +### 11.7 BulkOrderDto + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `market` | string | Yes | Market address | +| `user` | string | Yes | User address | +| `sequence_number` | uint64 | Yes | Sequence number | +| `previous_seq_num` | uint64? | No | Previous sequence number | +| `bid_prices` | float64[] | Yes | Bid price levels | +| `bid_sizes` | float64[] | Yes | Bid sizes per level | +| `ask_prices` | float64[] | Yes | Ask price levels | +| `ask_sizes` | float64[] | Yes | Ask sizes per level | +| `cancelled_bid_prices` | float64[] | Yes | Cancelled bid prices | +| `cancelled_bid_sizes` | float64[] | Yes | Cancelled bid sizes | +| `cancelled_ask_prices` | float64[] | Yes | Cancelled ask prices | +| `cancelled_ask_sizes` | float64[] | Yes | Cancelled ask sizes | +| `cancellation_reason` | string | Yes | Reason for cancellation | +| `transaction_version` | uint64 | Yes | Transaction version | +| `transaction_unix_ms` | int64 | Yes | Timestamp | +| `event_uid` | string (u128) | Yes | Event unique identifier | + +### 11.8 PaginatedResponse\ + +```json +{ + "items": [ T, ... ], + "total_count": 42 +} +``` + +All paginated responses SHALL include `items` (array) and `total_count` (int32). + +### 11.9 Enum Types + +**Interval:** `"1m"`, `"5m"`, `"15m"`, `"30m"`, `"1h"`, `"2h"`, `"4h"`, `"1d"`, `"1w"`, `"1mo"` + +**SortDir:** `"ASC"`, `"DESC"` + +**SideFilter:** `"buy"`, `"sell"` + +**VaultStatus:** `"created"`, `"active"`, `"inactive"` + +**VaultSortKey:** `"tvl"`, `"age"`, `"pnl"`, `"sharpe_ratio"`, `"weekly_win_rate"`, `"max_drawdown"` + +**LeaderboardSortKey:** `"account_value"`, `"realized_pnl"`, `"volume"`, `"roi"` + +**PointsLeaderboardSortKey:** `"total_amps"`, `"realized_pnl"` + +**PointsLeaderboardTier:** `"top20"`, `"diamond"`, `"doublePlatinum"`, `"gold"` + +**FundMovementType:** `"deposit"`, `"withdrawal"` + +**NotificationType:** `"MarketOrderPlaced"`, `"LimitOrderPlaced"`, `"StopMarketOrderPlaced"`, `"StopMarketOrderTriggered"`, `"StopLimitOrderPlaced"`, `"StopLimitOrderTriggered"`, `"OrderPartiallyFilled"`, `"OrderFilled"`, `"OrderSizeReduced"`, `"OrderCancelled"`, `"OrderRejected"`, `"OrderErrored"`, `"TwapOrderPlaced"`, `"TwapOrderTriggered"`, `"TwapOrderCompleted"`, `"TwapOrderCancelled"`, `"TwapOrderErrored"`, `"AccountDeposit"`, `"AccountWithdrawal"`, `"TpSlSet"`, `"TpHit"`, `"SlHit"`, `"TpCancelled"`, `"SlCancelled"` diff --git a/docs/SPEC-WEBSOCKET.md b/docs/SPEC-WEBSOCKET.md new file mode 100644 index 0000000..78d0a86 --- /dev/null +++ b/docs/SPEC-WEBSOCKET.md @@ -0,0 +1,888 @@ +# Decibel Python SDK — WebSocket API Specification + +> **Source:** AsyncAPI 3.0.0 spec at `https://docs.decibel.trade/api-reference/asyncapi.json` +> **Servers:** +> - Mainnet: `wss://api.mainnet.aptoslabs.com/decibel/ws` +> - Testnet: `wss://api.testnet.aptoslabs.com/decibel/ws` + +## Table of Contents + +1. [Connection Protocol](#1-connection-protocol) +2. [Subscription Protocol](#2-subscription-protocol) +3. [Market Data Channels](#3-market-data-channels) +4. [Account Channels](#4-account-channels) +5. [Order Management Channels](#5-order-management-channels) +6. [TWAP Channels](#6-twap-channels) +7. [Notification Channels](#7-notification-channels) +8. [SDK Subscription Interface](#8-sdk-subscription-interface) +9. [Message Schemas](#9-message-schemas) + +--- + +## 1. Connection Protocol + +### 1.1 Connection Establishment + +The client SHALL connect via WSS with authentication in the subprotocol header: + +``` +WebSocket URL: wss://api.mainnet.aptoslabs.com/decibel/ws +Sec-Websocket-Protocol: decibel, +``` + +### 1.2 Connection Lifecycle + +``` +┌──────────┐ WSS Connect ┌──────────┐ +│ Client │ ───────────────> │ Server │ +│ │ (w/ API key) │ │ +│ │ <─────────────── │ │ +│ │ Connection OK │ │ +│ │ │ │ +│ │ subscribe msg │ │ +│ │ ───────────────> │ │ +│ │ <─────────────── │ │ +│ │ {success:true} │ │ +│ │ │ │ +│ │ <─────────────── │ │ +│ │ data messages │ │ +│ │ (continuous) │ │ +│ │ │ │ +│ │ ping frame │ │ +│ │ <─────────────── │ │ (every 30s) +│ │ ───────────────> │ │ +│ │ pong frame │ │ +│ │ │ │ +│ │ unsubscribe │ │ +│ │ ───────────────> │ │ +│ │ <─────────────── │ │ +│ │ {success:true} │ │ +│ │ │ │ +│ │ close frame │ │ +│ │ ───────────────> │ │ +└──────────┘ └──────────┘ +``` + +### 1.3 Session Constraints + +| Constraint | Value | +|-----------|-------| +| Maximum session duration | 1 hour | +| Heartbeat interval | 30 seconds (server → client ping) | +| Maximum subscriptions per connection | 100 topics | + +### 1.4 Heartbeat + +- The server SHALL send WebSocket ping frames every 30 seconds. +- The ping timer resets upon receiving a pong response or when the client subscribes/unsubscribes. +- The client SHALL respond with pong frames to maintain the connection. +- If the server does not receive a pong within the heartbeat interval, it MAY close the connection. + +### 1.5 Reconnection Strategy + +The SDK SHALL implement automatic reconnection with: +1. Exponential backoff between retry attempts +2. Preservation of active subscription list for restoration after reconnect +3. Automatic re-subscription to all previously subscribed topics upon reconnection + +--- + +## 2. Subscription Protocol + +### 2.1 Subscribe Request + +```json +{ + "method": "subscribe", + "topic": "user_open_orders:0x1234..." +} +``` + +### 2.2 Subscribe Response (Success) + +```json +{ + "success": true, + "message": "Subscribed to user_open_orders:0x1234..." +} +``` + +> **Note:** Subscribe/unsubscribe response (ACK) frames do NOT contain a `topic` field. +> They are control messages identified by the `success` field. Clients SHALL treat any +> message containing a `success` field as a non-data ACK and silently ignore it. + +### 2.3 Subscribe Response (Error) + +```json +{ + "success": false, + "message": "Unknown topic type 'invalid_topic'" +} +``` + +Other error messages: +- `"Maximum client topic subscription count of 100 reached"` + +### 2.4 Unsubscribe Request + +```json +{ + "method": "unsubscribe", + "topic": "user_open_orders:0x1234..." +} +``` + +### 2.5 Unsubscribe Response + +```json +{ + "success": true, + "message": "Unsubscribed from user_open_orders:0x1234..." +} +``` + +### 2.6 Data Messages + +All data messages SHALL include a `topic` field matching the subscribed topic string: + +```json +{ + "topic": "user_open_orders:0x1234...", + "orders": [ ... ] +} +``` + +### 2.7 Initial Snapshot Behavior + +Upon subscribing to a topic, the server SHALL send the current state as the first message. Subsequent messages are incremental updates. + +--- + +## 3. Market Data Channels + +### 3.1 All Market Prices + +**Topic:** `all_market_prices` + +**Description:** Price updates for all markets. Global topic (no parameters). + +**Message Schema:** +```json +{ + "topic": "all_market_prices", + "prices": [ + { + "market": "0x...", + "oracle_px": 50125.75, + "mark_px": 50120.5, + "mid_px": 50122.25, + "funding_rate_bps": 5, + "is_funding_positive": true, + "transaction_unix_ms": 1699564800000, + "open_interest": 125000.5 + } + ] +} +``` + +**Payload Type:** `AllMarketPricesResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `prices` | PriceDto[] | Yes | + +**Notes:** +- `funding_rate_bps` is EMA-smoothed (unlike REST which returns raw values) +- Updates are broadcast whenever any market price changes + +--- + +### 3.2 Market Price (Single) + +**Topic:** `market_price:{marketAddr}` + +**Example:** `market_price:0xabcdef...` + +**Message Schema:** +```json +{ + "topic": "market_price:0xabcdef...", + "price": { + "market": "0xabcdef...", + "oracle_px": 50125.75, + "mark_px": 50120.5, + "mid_px": 50122.25, + "funding_rate_bps": 5, + "is_funding_positive": true, + "transaction_unix_ms": 1699564800000, + "open_interest": 125000.5 + } +} +``` + +**Payload Type:** `MarketPriceResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `price` | PriceDto | Yes | + +--- + +### 3.3 Market Depth (Order Book) + +**Topic:** `depth:{marketAddr}:{aggregationLevel}` + +**Examples:** +- `depth:0xabcdef...:1` (tick-level granularity) +- `depth:0xabcdef...:100` (100-tick aggregation) + +**Aggregation Levels:** `1`, `2`, `5`, `10`, `100`, `1000` + +If aggregation level is omitted, defaults to `1`. + +**Message Schema:** +```json +{ + "topic": "depth:0xabcdef...:1", + "market": "0xabcdef...", + "bids": [ + { "price": 50000.0, "size": 10.5 }, + { "price": 49950.0, "size": 15.2 } + ], + "asks": [ + { "price": 50050.0, "size": 8.3 }, + { "price": 50100.0, "size": 12.7 } + ] +} +``` + +**Payload Type:** `MarketDepthResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `market` | string | Yes | +| `bids` | NormalizedPriceLevel[] | Yes | +| `asks` | NormalizedPriceLevel[] | Yes | + +**NormalizedPriceLevel:** + +| Field | Type | Required | +|-------|------|----------| +| `price` | float64 | Yes | +| `size` | float64 | Yes | + +**Notes:** +- Each message is a full snapshot of the order book (not incremental deltas) +- Use sequence tracking for ordering if needed + +--- + +### 3.4 Market Trades + +**Topic:** `trades:{marketAddr}` + +**Example:** `trades:0xabcdef...` + +**Message Schema:** +```json +{ + "topic": "trades:0xabcdef...", + "trades": [ + { + "account": "0x...", + "market": "0xabcdef...", + "action": "Open Long", + "trade_id": 3647277, + "size": 0.8, + "price": 50100.0, + "is_profit": false, + "realized_pnl_amount": -45.2, + "is_funding_positive": true, + "realized_funding_amount": 5.1, + "is_rebate": false, + "fee_amount": 20.04, + "order_id": "45680", + "client_order_id": "order_123", + "transaction_unix_ms": 1699564900000, + "transaction_version": 3647276286 + } + ] +} +``` + +**Payload Type:** `MarketTradesResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `trades` | TradeDto[] | Yes | + +--- + +### 3.5 Market Candlestick + +**Topic:** `market_candlestick:{marketAddr}:{interval}` + +**Example:** `market_candlestick:0xabcdef...:1h` + +**Supported intervals:** `1m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `1d`, `1w`, `1mo` + +**Message Schema:** +```json +{ + "topic": "market_candlestick:0xabcdef...:1h", + "candle": { + "t": 1699564800000, + "T": 1699568400000, + "o": 49800.0, + "h": 50300.0, + "l": 49600.0, + "c": 50125.75, + "v": 1250.5, + "i": "1h" + } +} +``` + +**Payload Type:** `MarketCandlestickResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `candle` | CandlestickResponseItemDto | Yes | + +--- + +## 4. Account Channels + +### 4.1 Account Overview + +**Topic:** `account_overview:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "account_overview:0x1234...", + "account_overview": { + "perp_equity_balance": 50250.75, + "unrealized_pnl": 1250.5, + "realized_pnl": 0, + "unrealized_funding_cost": -125.25, + "cross_margin_ratio": 0.15, + "maintenance_margin": 2500.0, + "cross_account_leverage_ratio": 500.0, + "volume": 125000.0, + "all_time_return": 0.25, + "pnl_90d": 5000.0, + "sharpe_ratio": 1.8, + "max_drawdown": -0.08, + "weekly_win_rate_12w": 0.65, + "average_cash_position": 45000.0, + "average_leverage": 5.5, + "cross_account_position": 25000.0, + "total_margin": 10000.0, + "usdc_cross_withdrawable_balance": 7500.0, + "usdc_isolated_withdrawable_balance": 2500.0 + } +} +``` + +**Payload Type:** `AccountOverviewResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `account_overview` | AccountOverviewDto | Yes | + +--- + +### 4.2 User Positions + +**Topic:** `account_positions:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "user_positions:0x1234...", + "positions": [ + { + "market": "0x...", + "user": "0x1234...", + "size": 2.5, + "user_leverage": 10, + "max_allowed_leverage": 20, + "entry_price": 49800.0, + "is_isolated": false, + "is_deleted": false, + "unrealized_funding": -25.5, + "event_uid": 123456789012345678901234567890123456, + "estimated_liquidation_price": 45000.0, + "transaction_version": 12345681, + "tp_order_id": "tp_001", + "tp_trigger_price": 52000, + "tp_limit_price": 51900, + "sl_order_id": "sl_001", + "sl_trigger_price": 48000, + "sl_limit_price": null, + "has_fixed_sized_tpsls": false + } + ] +} +``` + +**Payload Type:** `UserPositionsResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `positions` | PositionDto[] | Yes | + +**WebSocket-specific PositionDto fields (not in REST):** +- `max_allowed_leverage` (uint32, required) +- `event_uid` (u128, required) + +**WebSocket TP/SL price fields** are `int64` (chain units), unlike REST which uses `float64`. + +--- + +### 4.3 User Open Orders + +**Topic:** `account_open_orders:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "user_open_orders:0x1234...", + "orders": [ + { + "parent": "0x...", + "market": "0x...", + "client_order_id": "order_123", + "order_id": "45678", + "status": "Open", + "order_type": "Limit", + "trigger_condition": "None", + "order_direction": "Open Long", + "orig_size": 1.5, + "remaining_size": 1.5, + "size_delta": null, + "price": 50000.5, + "is_buy": true, + "is_reduce_only": false, + "details": "", + "tp_order_id": null, + "tp_trigger_price": null, + "tp_limit_price": null, + "sl_order_id": null, + "sl_trigger_price": null, + "sl_limit_price": null, + "transaction_version": 12345678, + "unix_ms": 1699564800000 + } + ] +} +``` + +**Payload Type:** `UserOpenOrdersResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `orders` | OrderDto[] | Yes | + +--- + +### 4.4 User Trades + +**Topic:** `user_trades:{userAddr}` + +**Description:** Live trade execution stream (different from trade history which includes historical data). + +**Message Schema:** +```json +{ + "topic": "user_trades:0x1234...", + "trades": [ ... ] +} +``` + +**Payload Type:** `UserTradesResponse` (same schema as UserTradeHistoryResponse) + +--- + +### 4.7 User Funding Rate History + +**Topic:** `user_funding_rate_history:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "user_funding_rate_history:0x1234...", + "funding_rates": [ + { + "market": "0x...", + "action": "Close Long", + "size": 1.5, + "is_funding_positive": false, + "realized_funding_amount": -12.3, + "is_rebate": false, + "fee_amount": 5.15, + "transaction_unix_ms": 1699564800000 + } + ] +} +``` + +**Payload Type:** `UserFundingRateHistoryResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `funding_rates` | FundingRateHistory[] | Yes | + +**Note:** WebSocket FundingRateHistory includes `is_funding_positive` field which is absent from REST FundingRateHistory. + +--- + +## 5. Order Management Channels + +### 5.1 Order Update + +**Topic:** `order_updates:{userAddr}` + +**Description:** Real-time order status change events. Fires for each individual order state transition. + +**Message Schema:** +```json +{ + "topic": "order_updates:0x1234...", + "order": { + "status": "Filled", + "details": "", + "order": { + "parent": "0x...", + "market": "0x...", + "client_order_id": "historical_order_456", + "order_id": "45679", + "status": "Filled", + "order_type": "Market", + "trigger_condition": "None", + "order_direction": "Close Short", + "orig_size": 2.0, + "remaining_size": 0.0, + "size_delta": null, + "price": 49500.0, + "is_buy": false, + "is_reduce_only": false, + "details": "", + "tp_order_id": null, + "tp_trigger_price": null, + "tp_limit_price": null, + "sl_order_id": null, + "sl_trigger_price": null, + "sl_limit_price": null, + "transaction_version": 12345680, + "unix_ms": 1699565000000 + } + } +} +``` + +**Payload Type:** `OrderUpdateResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `order` | OrderUpdate | Yes | + +**OrderUpdate:** + +| Field | Type | Required | +|-------|------|----------| +| `status` | string | Yes | +| `details` | string | Yes | +| `order` | OrderDto | Yes | + +--- + +### 5.2 Bulk Orders + +**Topic:** `bulk_orders:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "bulk_orders:0x1234...", + "bulk_order": { + "status": "Placed", + "details": "", + "bulk_order": { + "market": "0x...", + "user": "0x1234...", + "sequence_number": 100, + "previous_seq_num": 99, + "bid_prices": [50000, 49900], + "bid_sizes": [1, 2], + "ask_prices": [50100, 50200], + "ask_sizes": [1.5, 2.5], + "cancelled_bid_prices": [], + "cancelled_bid_sizes": [], + "cancelled_ask_prices": [], + "cancelled_ask_sizes": [], + "transaction_version": 12345678, + "transaction_unix_ms": 1699564800000, + "event_uid": 123456789012345678901234567890123456 + } + } +} +``` + +**Payload Type:** `BulkOrdersResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `bulk_order` | BulkOrderStatusResponse | Yes | + +--- + +### 5.3 Bulk Order Fills + +**Topic:** `bulk_order_fills:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "bulk_order_fills:0x1234...", + "bulk_order_fills": [ + { + "market": "0x...", + "sequence_number": 100, + "user": "0x1234...", + "filled_size": 1.5, + "price": 50000.0, + "is_bid": true, + "transaction_unix_ms": 1699564800000, + "transaction_version": 12345682, + "event_uid": 123456789012345678901234567890123456 + } + ] +} +``` + +**Payload Type:** `BulkOrderFillsResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `bulk_order_fills` | BulkOrderFillDto[] | Yes | + +--- + +## 6. TWAP Channels + +### 6.1 User Active TWAPs + +**Topic:** `user_active_twaps:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "user_active_twaps:0x1234...", + "twaps": [ + { + "market": "0x...", + "is_buy": true, + "order_id": "78901", + "is_reduce_only": false, + "start_unix_ms": 1699564800000, + "frequency_s": 300, + "duration_s": 3600, + "orig_size": 100.0, + "remaining_size": 75.0, + "status": "Open", + "transaction_unix_ms": 1699564800000, + "transaction_version": 12345679 + } + ] +} +``` + +**Payload Type:** `UserActiveTwapsResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `twaps` | TwapDto[] | Yes | + +--- + +## 7. Notification Channels + +### 7.1 Notifications + +**Topic:** `notifications:{userAddr}` + +**Message Schema:** +```json +{ + "topic": "notifications:0x1234...", + "notification": { + "account": "0x1234...", + "notification_type": "OrderFilled", + "order": { ... }, + "twap": null, + "notification_metadata": null + } +} +``` + +**Payload Type:** `UserNotificationResponse` + +| Field | Type | Required | +|-------|------|----------| +| `topic` | string | Yes | +| `notification` | NotificationDto | Yes | + +**NotificationDto:** + +| Field | Type | Required | +|-------|------|----------| +| `account` | string | Yes | +| `notification_type` | NotificationType | Yes | +| `order` | OrderDto? | No | +| `twap` | TwapDto? | No | +| `notification_metadata` | NotificationMetadata? | No | + +**NotificationMetadata:** + +| Field | Type | Required | +|-------|------|----------| +| `amount` | int64? | No | +| `filled_size` | float64? | No | +| `reason` | string? | No | +| `trigger_price` | uint64? | No | + +**NotificationType values:** +- Order lifecycle: `MarketOrderPlaced`, `LimitOrderPlaced`, `StopMarketOrderPlaced`, `StopMarketOrderTriggered`, `StopLimitOrderPlaced`, `StopLimitOrderTriggered`, `OrderPartiallyFilled`, `OrderFilled`, `OrderSizeReduced`, `OrderCancelled`, `OrderRejected`, `OrderErrored` +- TWAP lifecycle: `TwapOrderPlaced`, `TwapOrderTriggered`, `TwapOrderCompleted`, `TwapOrderCancelled`, `TwapOrderErrored` +- Account events: `AccountDeposit`, `AccountWithdrawal` +- TP/SL events: `TpSlSet`, `TpHit`, `SlHit`, `TpCancelled`, `SlCancelled` + +--- + +## 8. SDK Subscription Interface + +### 8.1 Subscribe Method + +The SDK SHALL provide a `subscribe` method on the WebSocket client: + +```python +def subscribe( + topic: str, + model: type[BaseModel], + on_data: Callable[[BaseModel], None] | Callable[[BaseModel], Awaitable[None]] +) -> Callable[[], None]: # Returns unsubscribe function +``` + +**Behavior:** +1. If not connected, SHALL auto-connect to the WebSocket server +2. SHALL send a subscribe message to the server +3. SHALL parse incoming messages for the topic using the specified Pydantic model +4. SHALL invoke the `on_data` callback for each received message +5. SHALL support both sync and async callbacks +6. SHALL return a callable that unsubscribes when invoked + +### 8.2 Unsubscribe Behavior + +When the unsubscribe function is called: +1. SHALL send an unsubscribe message to the server +2. SHALL remove the callback from the topic's subscriber list +3. If no more subscribers exist for any topic, SHOULD close the connection after a short delay + +### 8.3 Reset Method + +```python +def reset(topic: str) -> None: +``` + +Unsubscribes and re-subscribes to a topic (useful for forcing a fresh snapshot). + +### 8.4 Connection States + +```python +def ready_state() -> int: +``` + +Returns: +- `0` — CONNECTING +- `1` — OPEN +- `2` — CLOSING +- `3` — CLOSED + +### 8.5 Close Method + +```python +def close() -> None: +``` + +Closes the WebSocket connection and removes all subscriptions. + +--- + +## 9. Message Schemas + +### 9.1 Schema Differences: REST vs WebSocket + +The following differences exist between REST and WebSocket DTOs: + +| Field | REST | WebSocket | +|-------|------|-----------| +| `PositionDto.max_allowed_leverage` | absent | present (uint32) | +| `PositionDto.event_uid` | absent | present (u128) | +| `PositionDto.tp/sl prices` | float64 | int64 (chain units) | +| `FundingRateHistory.is_funding_positive` | absent | present (bool) | +| `TradeDto.source` | present | absent | +| `TradeDto.trade_id` | string | integer | +| `TradeDto.is_funding_positive` | absent | present (bool) | +| `BulkOrderDto.cancellation_reason` | present | absent | +| `BulkOrderDto.event_uid` | string (u128) | number | +| `PriceDto.funding_rate_bps` | float64 (raw) | integer (EMA-smoothed) | + +The SDK SHALL define separate Pydantic models for REST and WebSocket response types where the schemas diverge, OR use optional fields where appropriate to handle both. + +### 9.2 BigInt Handling + +Some fields (e.g., `event_uid`) may exceed JavaScript's safe integer range. The SDK SHALL: +1. Parse JSON with a custom deserializer that handles large integers +2. Represent u128 values as Python `int` (which supports arbitrary precision) + +### 9.3 Topic String Format + +All topic strings follow the pattern: `{channel_name}:{parameter}:{optional_parameter}` + +| Channel | Topic Pattern | Parameters | +|---------|--------------|------------| +| All Market Prices | `all_market_prices` | none | +| Market Price | `market_price:{marketAddr}` | market address | +| Market Depth | `depth:{marketAddr}:{aggregation}` | market address, aggregation level | +| Market Trades | `trades:{marketAddr}` | market address | +| Market Candlestick | `market_candlestick:{marketAddr}:{interval}` | market address, interval | +| Account Overview | `account_overview:{userAddr}` | subaccount address | +| User Positions | `account_positions:{userAddr}` | subaccount address | +| User Open Orders | `account_open_orders:{userAddr}` | subaccount address | +| User Trades | `user_trades:{userAddr}` | subaccount address | +| User Funding History | `user_funding_rate_history:{userAddr}` | subaccount address | +| Order Updates | `order_updates:{userAddr}` | subaccount address | +| Bulk Orders | `bulk_orders:{userAddr}` | subaccount address | +| Bulk Order Fills | `bulk_order_fills:{userAddr}` | subaccount address | +| User Active TWAPs | `user_active_twaps:{userAddr}` | subaccount address | +| Notifications | `notifications:{userAddr}` | subaccount address | + +> **Note:** The AsyncAPI spec at docs.decibel.trade uses `user_positions` and `user_open_orders` +> as channel names, but the actual server topics used by the SDK are `account_positions` and +> `account_open_orders`. The SDK's `UserOrderHistoryReader` subscribes to `order_updates` (not +> `user_order_history`) which provides real-time order status change events. diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..3040ce4 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,299 @@ +# Decibel Python SDK Specification + +> **Version:** 1.0.0 +> **Date:** 2026-04-07 +> **Source:** https://docs.decibel.trade (OpenAPI 3.1.0 + AsyncAPI 3.0.0) + +## Table of Contents + +1. [Overview](#1-overview) +2. [Architecture](#2-architecture) +3. [Configuration](#3-configuration) +4. [Authentication](#4-authentication) +5. [Common Structures](#5-common-structures) +6. [Error Handling](#6-error-handling) +7. [Feature Specifications](#7-feature-specifications) +8. [Assumptions & Preferences](#8-assumptions--preferences) + +--- + +## 1. Overview + +The Decibel Python SDK provides a client library for interacting with the Decibel perpetual futures exchange built on the Aptos blockchain. The SDK SHALL support: + +- **REST API** (read-only): Market data, account data, analytics, vaults, referrals +- **WebSocket API** (real-time): Streaming market data and account updates +- **On-Chain Transactions** (write): Order placement, account management, vault operations via Aptos blockchain transactions + +### Terminology + +| Term | Definition | +|------|-----------| +| **SHALL** | Required behavior | +| **SHOULD** | Preferred behavior | +| **COULD** | Optional behavior | +| **NOT** | Negation of SHALL/SHOULD/COULD | + +--- + +## 2. Architecture + +### 2.1 SDK Components + +``` +DecibelSDK +├── DecibelReadDex # REST API + WebSocket subscriptions (async) +│ ├── Reader components # 23 domain-specific readers +│ └── WebSocket client # Real-time subscriptions +├── DecibelWriteDex # On-chain transaction building + submission (async) +├── DecibelWriteDexSync # Synchronous variant of write operations +├── DecibelAdminDex # Protocol admin operations (async) +├── DecibelAdminDexSync # Synchronous variant of admin operations +└── OrderStatusClient # Order status polling +``` + +### 2.2 Component Interaction Diagram + +``` +┌─────────────┐ REST (GET) ┌──────────────────────┐ +│ ReadDex │ ──────────────────> │ Trading HTTP Server │ +│ (readers) │ <────────────────── │ /api/v1/* │ +└─────────────┘ JSON Response └──────────────────────┘ + +┌─────────────┐ WSS ┌──────────────────────┐ +│ ReadDex │ ──────────────────> │ WebSocket Server │ +│ (ws) │ <────────────────── │ /ws │ +└─────────────┘ subscribe/data └──────────────────────┘ + +┌─────────────┐ Aptos Tx ┌──────────────────────┐ +│ WriteDex │ ──────────────────> │ Aptos Fullnode │ +│ │ <────────────────── │ /v1/* │ +└─────────────┘ build/sign/submit └──────────────────────┘ + +┌─────────────┐ REST (POST) ┌──────────────────────┐ +│ WriteDex │ ──────────────────> │ Gas Station │ +│ (fee pay) │ <────────────────── │ /submit │ +└─────────────┘ fee-payer tx └──────────────────────┘ +``` + +--- + +## 3. Configuration + +### 3.1 Network Environments + +The SDK SHALL support the following network configurations: + +| Network | Chain ID | Fullnode URL | Trading HTTP URL | Trading WS URL | +|---------|----------|-------------|------------------|----------------| +| **Mainnet** | 1 | `https://api.mainnet.aptoslabs.com/v1` | `https://api.mainnet.aptoslabs.com/decibel` | `wss://api.mainnet.aptoslabs.com/decibel/ws` | +| **Testnet** | 2 | `https://api.testnet.aptoslabs.com/v1` | `https://api.testnet.aptoslabs.com/decibel` | `wss://api.testnet.aptoslabs.com/decibel/ws` | +| **Custom** | varies | user-provided | user-provided | user-provided | + +### 3.2 DecibelConfig Structure + +```python +class DecibelConfig: + network: Network # MAINNET | TESTNET | CUSTOM + fullnode_url: str # Aptos RPC endpoint + trading_http_url: str # REST API base URL + trading_ws_url: str # WebSocket endpoint + gas_station_url: str | None # Optional fee payer service + gas_station_api_key: str | None + deployment: Deployment # Package addresses + chain_id: int | None + compat_version: CompatVersion +``` + +### 3.3 Deployment Addresses + +Each network has a `Deployment` with: +- `package`: Main smart contract address +- `usdc`: USDC token contract address +- `testc`: Test collateral address (testnet only) +- `perp_engine_global`: Global perp engine address + +--- + +## 4. Authentication + +### 4.1 REST API Authentication + +The REST API SHALL require Bearer token authentication. + +**Required Headers:** + +| Header | Value | Required | +|--------|-------|----------| +| `Authorization` | `Bearer ` | YES | + +The API key SHALL be obtained from the Geomi service (https://geomi.dev). + +### 4.2 WebSocket Authentication + +WebSocket connections SHALL authenticate via the `Sec-Websocket-Protocol` header: + +``` +Sec-Websocket-Protocol: decibel, +``` + +### 4.3 On-Chain Authentication + +On-chain transactions SHALL be signed with an Aptos Ed25519 private key. The SDK SHALL support: +- Direct account signing +- Delegated trading (signing on behalf of another account) +- Fee payer transactions (gas station) + +--- + +## 5. Common Structures + +### 5.1 Pagination + +Paginated endpoints SHALL use the following query parameters: + +```json +{ + "limit": { "type": "integer", "minimum": 0, "maximum": 1000 }, + "offset": { "type": "integer", "minimum": 0, "maximum": 10000 } +} +``` + +Paginated responses SHALL return: + +```json +{ + "items": [ ... ], + "total_count": 42 +} +``` + +### 5.2 Sorting + +Sortable endpoints SHALL accept: + +```json +{ + "sort_key": "string", + "sort_dir": "ASC" | "DESC" | null +} +``` + +### 5.3 History Filtering + +History endpoints SHALL accept timestamp range filters: + +```json +{ + "from": 1634567890000, + "to": 1634654290000 +} +``` + +Timestamps SHALL be in Unix milliseconds (int64). + +### 5.4 Side Filter + +Side filter values: +- `"buy"` — Maps to OpenLong/CloseShort +- `"sell"` — Maps to CloseLong/OpenShort + +### 5.5 Aptos Address Format + +All addresses SHALL be 66-character hex strings: `0x` followed by 64 hex characters. +Example: `0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef` + +### 5.6 Numeric Precision + +- Prices and sizes in API responses: `float64` (JSON `number`) +- On-chain prices/sizes: integer chain units (6 decimal places for USDC) +- Conversion: `chain_units = amount * 10^decimals` + +--- + +## 6. Error Handling + +### 6.1 REST API Error Response Format + +All error responses SHALL follow this structure: + +```json +{ + "status": "failed" | "timeout" | "notFound", + "message": "Human-readable error description" +} +``` + +### 6.2 HTTP Status Codes + +| Code | Condition | +|------|-----------| +| 200 | Successful request | +| 400 | Invalid or missing parameters | +| 401 | Authentication token missing/invalid | +| 403 | Token lacks necessary permissions | +| 404 | Resource doesn't exist | +| 429 | Rate limit exceeded | +| 500 | Server-side error | +| 504 | Query timeout | + +### 6.3 SDK Exception Types + +The SDK SHALL define: + +- `FetchError(status, status_text, response_message)` — HTTP errors from REST API +- `TxnSubmitError` — Transaction submission failed (safe to retry) +- `TxnConfirmError(tx_hash, message)` — Transaction submitted but confirmation failed (may be on-chain; check status before retry) + +### 6.4 Retry Strategy + +- The SDK SHOULD implement exponential backoff for 500/504 responses +- The SDK SHALL NOT automatically retry on 400/401/403/404 errors +- For `TxnSubmitError`, the SDK COULD retry automatically +- For `TxnConfirmError`, the SDK SHALL NOT retry without checking transaction status first + +--- + +## 7. Feature Specifications + +Detailed API specifications are in the following documents: + +- **[SPEC-REST.md](./SPEC-REST.md)** — REST API endpoints, request/response shapes, query parameters +- **[SPEC-WEBSOCKET.md](./SPEC-WEBSOCKET.md)** — WebSocket protocol, channels, message schemas + +--- + +## 8. Assumptions & Preferences + +### 8.1 Language & Runtime +- Python 3.11+ (matches `requires-python = ">=3.11"` in pyproject.toml) +- Async-first design using `asyncio` with synchronous wrappers +- `httpx` for HTTP client (async + sync support) +- `websockets` for WebSocket connections +- `pydantic` for data validation and serialization + +### 8.2 SDK Design Preferences +- The SDK SHALL provide both async and sync interfaces for write operations +- The SDK SHALL use Pydantic BaseModel for all API response types +- Reader components SHALL be lazy-initialized on first access +- WebSocket subscriptions SHALL support both async and sync callbacks +- The SDK SHOULD handle BigInt JSON values (numbers exceeding JS safe integer range) via custom JSON parsing + +### 8.3 Naming Conventions +- Python module names: `snake_case` +- Class names: `PascalCase` +- Method names: `snake_case` +- Private modules/functions: prefixed with `_` +- API field names: match server JSON keys exactly (snake_case) + +### 8.4 Thread Safety +- Async clients are NOT required to be thread-safe (single event loop) +- Sync clients SHOULD be usable from any thread +- WebSocket subscriptions SHALL manage their own connection lifecycle + +### 8.5 Deprecated Endpoints +The SDK SHOULD NOT implement deprecated endpoints. The following are deprecated: +- `/api/v1/user_fund_history` → use `/api/v1/account_fund_history` +- `/api/v1/user_positions` → use `/api/v1/account_positions` +- `/api/v1/user_owned_vaults` → use `/api/v1/account_owned_vaults` +- `/api/v1/user_vault_performance` → use `/api/v1/account_vault_performance` diff --git a/examples/bots/.env.example b/examples/bots/.env.example new file mode 100644 index 0000000..58d3610 --- /dev/null +++ b/examples/bots/.env.example @@ -0,0 +1,25 @@ +# Decibel Bot Configuration +# Copy this file to .env and fill in your values: +# cp .env.example .env + +# Required: Aptos account private key (hex, with 0x prefix) +PRIVATE_KEY="0x..." + +# Required: API key from Geomi (https://geomi.dev) +# Either DECIBEL_API_KEY or APTOS_NODE_API_KEY is accepted +DECIBEL_API_KEY="aptoslabs_..." + +# Network: testnet, mainnet, or netna +NETWORK="testnet" + +# Market to trade (must match a market name on the exchange) +MARKET="ETH/USD" + +# Buy spread: place buy order this % below oracle price +BUY_SPREAD_PCT="1.0" + +# Sell spread: place sell order this % above entry price +SELL_SPREAD_PCT="1.0" + +# Notional order size in USD +ORDER_SIZE_USD="100" diff --git a/examples/bots/buy_low_sell_high_bot.py b/examples/bots/buy_low_sell_high_bot.py new file mode 100644 index 0000000..86add52 --- /dev/null +++ b/examples/bots/buy_low_sell_high_bot.py @@ -0,0 +1,506 @@ +""" +Buy-Low-Sell-High Bot — Decibel Python SDK Example +==================================================== + +A simple trading bot that: + 1. Connects to the Decibel exchange via WebSocket for real-time prices + 2. Places a limit BUY order at a configured spread below the oracle price + 3. Monitors for fills via WebSocket order updates + 4. When the buy fills, places a limit SELL order at a spread above entry + 5. When the sell fills, starts over + +This is an EXAMPLE for educational purposes — NOT production trading software. +It demonstrates the full SDK surface: read (REST + WebSocket) and write (on-chain). + +Setup +----- + +1. Create and fund an Aptos testnet account with APT (for gas) and USDC: + + # Generate account + python -c " + from aptos_sdk.account import Account + a = Account.generate() + print(f'Address: {a.address()}') + print(f'Private key: {a.private_key.hex()}') + " + + # Fund with APT via testnet faucet, then mint USDC: + PRIVATE_KEY=0x... APTOS_NODE_API_KEY=... python -c " + import asyncio + from aptos_sdk.account import Account + from aptos_sdk.ed25519 import PrivateKey + from decibel import TESTNET_CONFIG, BaseSDKOptions, DecibelWriteDex, amount_to_chain_units + from decibel._transaction_builder import InputEntryFunctionData + + async def main(): + acct = Account.load_key(PrivateKey.from_hex('$PRIVATE_KEY').hex()) + w = DecibelWriteDex(TESTNET_CONFIG, acct, opts=BaseSDKOptions( + node_api_key='$APTOS_NODE_API_KEY', no_fee_payer=True)) + + # Create subaccount + await w.create_subaccount() + + # Mint USDC + await w._send_tx(InputEntryFunctionData( + function=f'{TESTNET_CONFIG.deployment.package}::usdc::restricted_mint', + function_arguments=[amount_to_chain_units(1000.0)])) + + # Deposit + await w.deposit(amount_to_chain_units(500.0)) + + asyncio.run(main()) + " + +2. Set environment variables: + + export PRIVATE_KEY="0x..." + export APTOS_NODE_API_KEY="aptoslabs_..." + + # Optional — override defaults: + export MARKET="ETH/USD" # default: ETH/USD + export BUY_SPREAD_PCT="1.0" # default: 1.0 (buy 1% below oracle) + export SELL_SPREAD_PCT="1.0" # default: 1.0 (sell 1% above entry) + export ORDER_SIZE_USD="100" # default: 100 (notional size in USD) + export NETWORK="testnet" # default: testnet + +3. Run: + + uv run python examples/buy_low_sell_high_bot.py + +Architecture +------------ + + ┌─────────────────────────────────────────────────────────┐ + │ Bot Main Loop │ + │ │ + │ ┌─────────────┐ price update ┌────────────────┐ │ + │ │ WS: market │ ────────────────> │ check_and_ │ │ + │ │ _price │ │ place_buy() │ │ + │ └─────────────┘ └───────┬────────┘ │ + │ │ │ + │ ┌─────────────┐ order filled ┌───────▼────────┐ │ + │ │ WS: order │ ────────────────> │ on_order_ │ │ + │ │ _updates │ │ update() │ │ + │ └─────────────┘ └───────┬────────┘ │ + │ │ │ + │ ┌───────▼────────┐ │ + │ │ place_sell() │ │ + │ │ or restart │ │ + │ └────────────────┘ │ + └─────────────────────────────────────────────────────────┘ +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import signal +import sys +from dataclasses import dataclass, field +from enum import Enum, auto + +from aptos_sdk.account import Account +from aptos_sdk.ed25519 import PrivateKey + +from decibel import ( + NAMED_CONFIGS, + BaseSDKOptions, + DecibelWriteDex, + PlaceOrderSuccess, + TimeInForce, + amount_to_chain_units, +) +from decibel._utils import get_primary_subaccount_addr +from decibel.read import DecibelReadDex +from decibel.read._market_prices import MarketPriceWsMessage # noqa: TC001 +from decibel.read._user_order_history import UserOrdersWsMessage # noqa: TC001 + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("bot") + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class BotConfig: + """Bot configuration — loaded from environment variables.""" + + private_key: str + api_key: str + network: str = "testnet" + market: str = "ETH/USD" + buy_spread_pct: float = 1.0 # buy this % below oracle price + sell_spread_pct: float = 1.0 # sell this % above entry price + order_size_usd: float = 100.0 # notional order size in USD + + @classmethod + def from_env(cls) -> BotConfig: + private_key = os.environ.get("PRIVATE_KEY", "") + # Accept either DECIBEL_API_KEY or APTOS_NODE_API_KEY + api_key = os.environ.get("DECIBEL_API_KEY") or os.environ.get("APTOS_NODE_API_KEY", "") + if not private_key or not api_key: + print("Error: PRIVATE_KEY and DECIBEL_API_KEY (or APTOS_NODE_API_KEY) must be set") + print("See docstring at top of this file for setup instructions") + sys.exit(1) + + return cls( + private_key=private_key, + api_key=api_key, + network=os.environ.get("NETWORK", "testnet"), + market=os.environ.get("MARKET", "ETH/USD"), + buy_spread_pct=float(os.environ.get("BUY_SPREAD_PCT", "1.0")), + sell_spread_pct=float(os.environ.get("SELL_SPREAD_PCT", "1.0")), + order_size_usd=float(os.environ.get("ORDER_SIZE_USD", "100")), + ) + + +# --------------------------------------------------------------------------- +# Bot State Machine +# --------------------------------------------------------------------------- + + +class BotPhase(Enum): + WAITING_FOR_PRICE = auto() # no position, waiting for first price to place buy + BUY_PLACED = auto() # buy limit order is open, waiting for fill + SELL_PLACED = auto() # sell limit order is open, waiting for fill + + +@dataclass +class BotState: + phase: BotPhase = BotPhase.WAITING_FOR_PRICE + latest_oracle_price: float = 0.0 + buy_order_tx: str | None = None + buy_entry_price: float = 0.0 + sell_order_tx: str | None = None + trades_completed: int = 0 + total_pnl: float = 0.0 + active_client_order_id: str | None = None + _order_counter: int = field(default=0, repr=False) + + def next_client_order_id(self, side: str) -> str: + self._order_counter += 1 + return f"bot-{side}-{self._order_counter}" + + +# --------------------------------------------------------------------------- +# Bot +# --------------------------------------------------------------------------- + + +class BuyLowSellHighBot: + """Simple buy-low-sell-high bot using Decibel SDK.""" + + def __init__(self, cfg: BotConfig) -> None: + self.cfg = cfg + self.state = BotState() + self._shutdown = asyncio.Event() + self._order_lock = asyncio.Lock() + + # SDK clients — initialized in start() + self._read: DecibelReadDex | None = None + self._write: DecibelWriteDex | None = None + self._market_info: object | None = None # PerpMarket + self._sub_addr: str = "" + + async def start(self) -> None: + """Initialize SDK clients, subscribe to feeds, and run the bot.""" + network_config = NAMED_CONFIGS.get(self.cfg.network) + if network_config is None: + log.error("Unknown network: %s (options: %s)", self.cfg.network, list(NAMED_CONFIGS)) + return + + # --- Initialize clients --- + account = Account.load_key(PrivateKey.from_hex(self.cfg.private_key).hex()) + self._sub_addr = get_primary_subaccount_addr( + str(account.address()), + network_config.compat_version, + network_config.deployment.package, + ) + + self._read = DecibelReadDex( + network_config, + api_key=self.cfg.api_key, + on_ws_error=self._on_ws_error, + ) + self._write = DecibelWriteDex( + network_config, + account, + opts=BaseSDKOptions( + node_api_key=self.cfg.api_key, + skip_simulate=False, + no_fee_payer=True, + time_delta_ms=0, + ), + ) + + # --- Load market info --- + markets = await self._read.markets.get_all() + self._market_info = next((m for m in markets if m.market_name == self.cfg.market), None) + if self._market_info is None: + log.error( + "Market %s not found. Available: %s", + self.cfg.market, + [m.market_name for m in markets], + ) + return + + log.info( + "Market: %s (tick=%s, min_size=%s, px_dec=%d, sz_dec=%d)", + self._market_info.market_name, + self._market_info.tick_size, + self._market_info.min_size, + self._market_info.px_decimals, + self._market_info.sz_decimals, + ) + + # --- Check account --- + overview = await self._read.account_overview.get_by_addr(sub_addr=self._sub_addr) + log.info("Account equity: %.2f USDC", overview.perp_equity_balance) + if overview.perp_equity_balance < self.cfg.order_size_usd: + log.warning( + "Account equity (%.2f) < order size (%.2f). Bot may not be able to trade.", + overview.perp_equity_balance, + self.cfg.order_size_usd, + ) + + # --- Subscribe to WebSocket feeds --- + log.info("Subscribing to price feed and order updates...") + self._read.market_prices.subscribe_by_name(self.cfg.market, self._on_price_update) + self._read.user_order_history.subscribe_by_addr(self._sub_addr, self._on_order_update) + + log.info("Bot started — waiting for price data...") + log.info( + "Config: buy %.1f%% below oracle, sell %.1f%% above entry, size $%.0f", + self.cfg.buy_spread_pct, + self.cfg.sell_spread_pct, + self.cfg.order_size_usd, + ) + + # --- Run until shutdown --- + await self._shutdown.wait() + log.info("Shutting down...") + await self._read.ws.close() + + def stop(self) -> None: + """Signal the bot to shut down gracefully.""" + self._shutdown.set() + + # --- WebSocket callbacks --- + + def _on_price_update(self, msg: MarketPriceWsMessage) -> None: + """Called on each market price update from WebSocket.""" + self.state.latest_oracle_price = msg.price.oracle_px + + if self.state.phase == BotPhase.WAITING_FOR_PRICE: + # Schedule the buy order placement (can't await in a sync callback) + asyncio.get_running_loop().create_task(self._place_buy_order()) + + def _on_order_update(self, msg: UserOrdersWsMessage) -> None: + """Called on each order status change from WebSocket.""" + update = msg.order + order = update.order + status = update.status + + # Only process updates for our active order + if ( + self.state.active_client_order_id + and order.client_order_id != self.state.active_client_order_id + ): + return + + log.info( + "Order update: %s %s (id=%s, client=%s)", + status, + order.order_type, + order.order_id, + order.client_order_id, + ) + + if status == "Filled": + if self.state.phase == BotPhase.BUY_PLACED: + entry = order.price or self.state.latest_oracle_price + self.state.buy_entry_price = entry + log.info("BUY FILLED at %.2f — placing sell order...", entry) + asyncio.get_running_loop().create_task(self._place_sell_order()) + elif self.state.phase == BotPhase.SELL_PLACED: + exit_price = order.price or 0 + pnl = exit_price - self.state.buy_entry_price + self.state.trades_completed += 1 + self.state.total_pnl += pnl + log.info( + "SELL FILLED at %.2f — PnL: %.2f (total: %.2f, trades: %d)", + exit_price, + pnl, + self.state.total_pnl, + self.state.trades_completed, + ) + # Reset — start looking for next buy + self.state.phase = BotPhase.WAITING_FOR_PRICE + log.info("Cycle complete — waiting for next opportunity...") + + elif status in ("Cancelled", "Rejected", "Expired"): + log.warning("Order %s: %s — resetting to wait for price", status, update.details) + self.state.phase = BotPhase.WAITING_FOR_PRICE + + def _on_ws_error(self, error: Exception) -> None: + log.error("WebSocket error: %s", error) + + # --- Helpers --- + + def _round_size_to_lot(self, size_chain_units: int) -> int: + """Round size down to nearest lot_size multiple, enforce min_size.""" + assert self._market_info is not None + lot = int(self._market_info.lot_size) + min_sz = int(self._market_info.min_size) + rounded = (size_chain_units // lot) * lot + return max(rounded, min_sz) + + # --- Order placement --- + + async def _place_buy_order(self) -> None: + """Place a limit buy order at spread below oracle price.""" + async with self._order_lock: + if self.state.phase != BotPhase.WAITING_FOR_PRICE: + return + if self.state.latest_oracle_price <= 0: + return + + # Immediately transition to prevent duplicate orders + self.state.phase = BotPhase.BUY_PLACED + + assert self._write is not None + assert self._market_info is not None + mkt = self._market_info + + # Calculate buy price: oracle - spread% + buy_price_human = self.state.latest_oracle_price * (1 - self.cfg.buy_spread_pct / 100) + buy_price = amount_to_chain_units(buy_price_human, mkt.px_decimals) + + # Calculate size from notional USD, rounded to lot_size + size_human = self.cfg.order_size_usd / self.state.latest_oracle_price + size_raw = amount_to_chain_units(size_human, mkt.sz_decimals) + size = self._round_size_to_lot(size_raw) + + client_id = self.state.next_client_order_id("buy") + self.state.active_client_order_id = client_id + + log.info( + "Placing BUY: price=%.2f (oracle=%.2f, spread=%.1f%%), size=%d, id=%s", + buy_price_human, + self.state.latest_oracle_price, + self.cfg.buy_spread_pct, + size, + client_id, + ) + + try: + result = await self._write.place_order( + market_name=self.cfg.market, + price=buy_price, + size=size, + is_buy=True, + time_in_force=TimeInForce.GoodTillCanceled, + is_reduce_only=False, + client_order_id=client_id, + tick_size=int(mkt.tick_size), + ) + + if isinstance(result, PlaceOrderSuccess): + self.state.buy_order_tx = result.transaction_hash + log.info("BUY order placed — tx=%s", result.transaction_hash) + else: + log.error("BUY order failed: %s", result.error) + self.state.phase = BotPhase.WAITING_FOR_PRICE + + except Exception: + log.exception("Error placing buy order") + self.state.phase = BotPhase.WAITING_FOR_PRICE + + async def _place_sell_order(self) -> None: + """Place a limit sell order at spread above entry price.""" + async with self._order_lock: + if self.state.phase != BotPhase.BUY_PLACED: + return + + # Immediately transition to prevent duplicates + self.state.phase = BotPhase.SELL_PLACED + + assert self._write is not None + assert self._market_info is not None + mkt = self._market_info + + # Calculate sell price: entry + spread% + sell_price_human = self.state.buy_entry_price * (1 + self.cfg.sell_spread_pct / 100) + sell_price = amount_to_chain_units(sell_price_human, mkt.px_decimals) + + # Same size as the buy, rounded to lot_size + size_human = self.cfg.order_size_usd / self.state.buy_entry_price + size_raw = amount_to_chain_units(size_human, mkt.sz_decimals) + size = self._round_size_to_lot(size_raw) + + client_id = self.state.next_client_order_id("sell") + self.state.active_client_order_id = client_id + + log.info( + "Placing SELL: price=%.2f (entry=%.2f, spread=%.1f%%), id=%s", + sell_price_human, + self.state.buy_entry_price, + self.cfg.sell_spread_pct, + client_id, + ) + + try: + result = await self._write.place_order( + market_name=self.cfg.market, + price=sell_price, + size=size, + is_buy=False, + time_in_force=TimeInForce.GoodTillCanceled, + is_reduce_only=True, + client_order_id=client_id, + tick_size=int(mkt.tick_size), + ) + + if isinstance(result, PlaceOrderSuccess): + self.state.sell_order_tx = result.transaction_hash + log.info("SELL order placed — tx=%s", result.transaction_hash) + else: + log.error("SELL order failed: %s", result.error) + self.state.phase = BotPhase.WAITING_FOR_PRICE + + except Exception: + log.exception("Error placing sell order") + self.state.phase = BotPhase.WAITING_FOR_PRICE + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +async def main() -> None: + cfg = BotConfig.from_env() + bot = BuyLowSellHighBot(cfg) + + # Handle Ctrl+C gracefully (not supported on Windows) + try: + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, bot.stop) + except NotImplementedError: + log.warning("Signal handlers not supported on this platform") + + await bot.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/decibel/read/_market_trades.py b/src/decibel/read/_market_trades.py index f3fd4e6..f48b7a1 100644 --- a/src/decibel/read/_market_trades.py +++ b/src/decibel/read/_market_trades.py @@ -21,11 +21,37 @@ class MarketTrade(BaseModel): + """REST trade model — fields match the /api/v1/trades response.""" + + model_config = ConfigDict(populate_by_name=True) + + account: str + market: str + action: str + trade_id: str | int + size: float + price: float + is_profit: bool + realized_pnl_amount: float + realized_funding_amount: float + is_rebate: bool + fee_amount: float + order_id: str + client_order_id: str + source: str + transaction_unix_ms: int + transaction_version: int + + +class _WsTradeItem(BaseModel): + """WS trade model — includes is_funding_positive, lacks source.""" + model_config = ConfigDict(populate_by_name=True) account: str market: str action: str + trade_id: int size: float price: float is_profit: bool @@ -34,6 +60,8 @@ class MarketTrade(BaseModel): realized_funding_amount: float is_rebate: bool fee_amount: float + order_id: str + client_order_id: str transaction_unix_ms: int transaction_version: int @@ -46,7 +74,7 @@ class MarketTradesResponse(BaseModel): class MarketTradeWsMessage(BaseModel): model_config = ConfigDict(populate_by_name=True) - trades: list[MarketTrade] + trades: list[_WsTradeItem] class MarketTradesReader(BaseReader): diff --git a/src/decibel/read/_user_funding_history.py b/src/decibel/read/_user_funding_history.py index f91362f..817fa92 100644 --- a/src/decibel/read/_user_funding_history.py +++ b/src/decibel/read/_user_funding_history.py @@ -26,7 +26,7 @@ class UserFunding(BaseModel): class UserFundingHistoryResponse(BaseModel): items: list[UserFunding] - total_count: int + total_count: int | None = None class UserFundingHistoryReader(BaseReader): diff --git a/src/decibel/read/_user_order_history.py b/src/decibel/read/_user_order_history.py index 03069e0..5084af7 100644 --- a/src/decibel/read/_user_order_history.py +++ b/src/decibel/read/_user_order_history.py @@ -52,7 +52,7 @@ class UserOrders(BaseModel): model_config = ConfigDict(populate_by_name=True) items: list[UserOrder] - total_count: int + total_count: int | None = None class _UserOrderInner(BaseModel): diff --git a/src/decibel/read/_user_trade_history.py b/src/decibel/read/_user_trade_history.py index d7e6781..7141ff0 100644 --- a/src/decibel/read/_user_trade_history.py +++ b/src/decibel/read/_user_trade_history.py @@ -32,7 +32,7 @@ class UserTrade(BaseModel): price: float is_profit: bool realized_pnl_amount: float - is_funding_positive: bool + is_funding_positive: bool | None = None realized_funding_amount: float is_rebate: bool fee_amount: float @@ -42,7 +42,7 @@ class UserTrade(BaseModel): class UserTradesResponse(BaseModel): items: list[UserTrade] - total_count: int + total_count: int | None = None class UserTradesWsMessage(BaseModel): diff --git a/src/decibel/read/_user_twap_history.py b/src/decibel/read/_user_twap_history.py index a44af5b..e00764a 100644 --- a/src/decibel/read/_user_twap_history.py +++ b/src/decibel/read/_user_twap_history.py @@ -13,7 +13,7 @@ class UserTwapHistoryResponse(BaseModel): items: list[UserActiveTwap] - total_count: int + total_count: int | None = None class UserTwapHistoryReader(BaseReader): diff --git a/src/decibel/read/_ws.py b/src/decibel/read/_ws.py index 105e64e..726d56b 100644 --- a/src/decibel/read/_ws.py +++ b/src/decibel/read/_ws.py @@ -58,18 +58,21 @@ def _parse_message(self, data: str) -> tuple[str, dict[str, Any]] | None: except (json.JSONDecodeError, TypeError) as e: raise ValueError(f"Unhandled WebSocket message: failed to parse JSON: {data}") from e - if ( - isinstance(json_data, dict) - and "topic" in json_data - and isinstance(json_data["topic"], str) - ): - # Filter out response messages (they have a "success" field; data payloads do not) - if "success" in json_data: - return None - topic: str = json_data["topic"] - json_dict = cast("dict[str, Any]", json_data) + if not isinstance(json_data, dict): + raise ValueError(f"Unhandled WebSocket message: expected dict, got: {data}") + + json_dict = cast("dict[str, Any]", json_data) + + # Subscribe/unsubscribe response messages ({"success": true/false, "message": "..."}) + # These don't have a topic field and are not data messages — silently ignore them. + if "success" in json_dict: + return None + + if "topic" in json_dict and isinstance(json_dict["topic"], str): + topic: str = json_dict["topic"] rest: dict[str, Any] = {k: v for k, v in json_dict.items() if k != "topic"} return (topic, rest) + raise ValueError(f"Unhandled WebSocket message: missing topic field: {data}") async def _open(self) -> None: diff --git a/tests/api_resources/conftest.py b/tests/api_resources/conftest.py new file mode 100644 index 0000000..7e199f6 --- /dev/null +++ b/tests/api_resources/conftest.py @@ -0,0 +1,94 @@ +"""Shared fixtures for API resource behavioral tests. + +These tests verify the SDK's REST API readers produce correct HTTP requests +and parse responses according to the specification in docs/SPEC-REST.md. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock + +import httpx +import pytest + +from decibel._constants import TESTNET_CONFIG, DecibelConfig +from decibel.read._base import ReaderDeps +from decibel.read._ws import DecibelWsSubscription + + +@dataclass +class CapturedRequest: + """Captures the details of an HTTP request for assertion.""" + + method: str + url: str + params: dict[str, str] | None + headers: dict[str, str] + + +class MockTransport(httpx.AsyncBaseTransport): + """Mock transport that captures requests and returns canned responses.""" + + def __init__(self) -> None: + self.captured_requests: list[CapturedRequest] = [] + self._responses: list[httpx.Response] = [] + + def set_response(self, json_data: Any, status_code: int = 200) -> None: + """Set the next response to return.""" + import json + + self._responses.append( + httpx.Response( + status_code=status_code, + content=json.dumps(json_data).encode(), + headers={"content-type": "application/json"}, + ) + ) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.captured_requests.append( + CapturedRequest( + method=request.method, + url=str(request.url), + params=dict(request.url.params) if request.url.params else None, + headers=dict(request.headers), + ) + ) + if self._responses: + return self._responses.pop(0) + return httpx.Response(200, content=b"[]", headers={"content-type": "application/json"}) + + +@pytest.fixture +def testnet_config() -> DecibelConfig: + """Provide testnet configuration.""" + return TESTNET_CONFIG + + +@pytest.fixture +def mock_transport() -> MockTransport: + """Provide a mock transport for capturing HTTP requests.""" + return MockTransport() + + +@pytest.fixture +def mock_ws() -> DecibelWsSubscription: + """Provide a mock WebSocket subscription.""" + ws = AsyncMock(spec=DecibelWsSubscription) + return ws + + +@pytest.fixture +def reader_deps( + testnet_config: DecibelConfig, + mock_ws: DecibelWsSubscription, +) -> ReaderDeps: + """Provide reader dependencies with mocked WS and API key.""" + return ReaderDeps( + config=testnet_config, + ws=mock_ws, + aptos=AsyncMock(), + api_key="test-api-key-123", + ) diff --git a/tests/api_resources/test_rest_spec_compliance.py b/tests/api_resources/test_rest_spec_compliance.py new file mode 100644 index 0000000..e5effec --- /dev/null +++ b/tests/api_resources/test_rest_spec_compliance.py @@ -0,0 +1,586 @@ +"""Behavioral tests verifying the SDK matches the REST API specification. + +These tests mock HTTP transport to verify: +1. Readers call the correct endpoint URLs (SPEC-REST.md) +2. Readers send the correct query parameters +3. Authentication headers are included/excluded correctly +4. Response JSON is correctly parsed into Pydantic models +5. Required fields are enforced (missing required fields → error) +6. Nullable fields accept None properly +7. Wrong types are rejected +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import httpx +import pytest +from pydantic import ValidationError + +from decibel._utils import FetchError, get_request +from decibel.read._account_overview import AccountOverview +from decibel.read._candlesticks import Candlestick +from decibel.read._market_prices import MarketPrice, MarketPricesReader +from decibel.read._markets import PerpMarket +from decibel.read._user_open_orders import UserOpenOrder +from decibel.read._user_positions import UserPosition + +from .conftest import MockTransport + +if TYPE_CHECKING: + from decibel.read._base import ReaderDeps + +# --------------------------------------------------------------------------- +# SPEC Section 2.1 — GET /api/v1/prices +# Tests the READER, not just the model. +# --------------------------------------------------------------------------- + + +class TestMarketPricesReader: + """Verify MarketPricesReader calls correct endpoints with correct params.""" + + SAMPLE_PRICE: dict[str, Any] = { + "market": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "oracle_px": 50125.75, + "mark_px": 50120.5, + "mid_px": 50122.25, + "funding_rate_bps": 5.0, + "is_funding_positive": True, + "transaction_unix_ms": 1699564800000, + "open_interest": 125000.5, + } + + @pytest.fixture + def mock_reader( + self, reader_deps: ReaderDeps, mock_transport: MockTransport + ) -> tuple[MarketPricesReader, MockTransport]: + mock_transport.set_response([self.SAMPLE_PRICE]) + + reader = MarketPricesReader(reader_deps) + + async def patched_get(model: type, url: str, *, params: dict | None = None) -> tuple: + async with httpx.AsyncClient(transport=mock_transport) as client: + return await get_request( + model=model, + url=url, + params=params, + api_key="test-key", + client=client, + ) + + reader.get_request = patched_get # type: ignore[assignment] + return reader, mock_transport + + async def test_get_all_calls_prices_endpoint( + self, mock_reader: tuple[MarketPricesReader, MockTransport] + ) -> None: + """Reader.get_all() SHALL call GET /api/v1/prices.""" + reader, transport = mock_reader + prices = await reader.get_all() + + assert len(transport.captured_requests) == 1 + req = transport.captured_requests[0] + assert req.method == "GET" + assert "/api/v1/prices" in req.url + # Verify returned data is parsed + assert len(prices) == 1 + assert prices[0].oracle_px == 50125.75 + + +# --------------------------------------------------------------------------- +# SPEC Section 4 — Authentication +# --------------------------------------------------------------------------- + + +class TestAuthentication: + """SPEC.md Section 4.1: REST API Authentication headers.""" + + async def test_bearer_token_included_when_key_set(self) -> None: + """SHALL include Authorization: Bearer header.""" + transport = MockTransport() + transport.set_response([]) + + async with httpx.AsyncClient(transport=transport) as client: + from decibel.read._market_prices import _MarketPriceList + + await get_request( + model=_MarketPriceList, + url="https://test/api/v1/prices", + api_key="my-api-key-xyz", + client=client, + ) + + req = transport.captured_requests[0] + assert req.headers["authorization"] == "Bearer my-api-key-xyz" + + async def test_no_auth_header_when_key_is_none(self) -> None: + """SHALL NOT include Authorization header when api_key is None.""" + transport = MockTransport() + transport.set_response([]) + + async with httpx.AsyncClient(transport=transport) as client: + from decibel.read._market_prices import _MarketPriceList + + await get_request( + model=_MarketPriceList, + url="https://test/api/v1/prices", + api_key=None, + client=client, + ) + + req = transport.captured_requests[0] + assert "authorization" not in req.headers + + +# --------------------------------------------------------------------------- +# SPEC Section 6 — Error Handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + """SPEC.md Section 6: Error response parsing and FetchError.""" + + async def _fetch_and_expect_error(self, body: dict, status_code: int) -> FetchError: + """Helper: make a request that returns an error, return the FetchError.""" + transport = MockTransport() + transport.set_response(body, status_code=status_code) + + async with httpx.AsyncClient(transport=transport) as client: + from decibel.read._market_prices import _MarketPriceList + + with pytest.raises(FetchError) as exc_info: + await get_request( + model=_MarketPriceList, + url="https://test/fail", + api_key="k", + client=client, + ) + + return exc_info.value + + async def test_400_raises_fetch_error(self) -> None: + """SHALL raise FetchError for 400 Bad Request.""" + err = await self._fetch_and_expect_error( + {"status": "failed", "message": "Invalid parameters"}, 400 + ) + assert err.status == 400 + assert err.status_text == "failed" + assert err.response_message == "Invalid parameters" + + async def test_404_raises_fetch_error_with_not_found(self) -> None: + """SHALL parse notFound status from error body.""" + err = await self._fetch_and_expect_error( + {"status": "notFound", "message": "Market not found"}, 404 + ) + assert err.status == 404 + assert err.status_text == "notFound" + + async def test_500_raises_fetch_error(self) -> None: + """SHALL raise FetchError for 500 Internal Server Error.""" + err = await self._fetch_and_expect_error( + {"status": "failed", "message": "Server error"}, 500 + ) + assert err.status == 500 + + +# --------------------------------------------------------------------------- +# Model validation: PriceDto — positive AND negative +# --------------------------------------------------------------------------- + + +class TestPriceDtoValidation: + """PriceDto SHALL enforce required fields and reject bad types.""" + + def test_valid_price_parses(self) -> None: + """Valid PriceDto with all required fields SHALL parse.""" + price = MarketPrice.model_validate( + { + "market": "0x" + "a" * 64, + "oracle_px": 100.0, + "mark_px": 99.0, + "mid_px": 99.5, + "funding_rate_bps": 1.0, + "is_funding_positive": True, + "transaction_unix_ms": 1000, + "open_interest": 50.0, + } + ) + assert price.oracle_px == 100.0 + + def test_missing_required_field_raises(self) -> None: + """Missing 'market' field SHALL raise ValidationError.""" + with pytest.raises(ValidationError): + MarketPrice.model_validate( + { + # "market" is missing + "oracle_px": 100.0, + "mark_px": 99.0, + "mid_px": 99.5, + "funding_rate_bps": 1.0, + "is_funding_positive": True, + "transaction_unix_ms": 1000, + "open_interest": 50.0, + } + ) + + def test_wrong_type_for_oracle_px_raises(self) -> None: + """Non-numeric oracle_px SHALL raise ValidationError.""" + with pytest.raises(ValidationError): + MarketPrice.model_validate( + { + "market": "0x" + "a" * 64, + "oracle_px": "not_a_number", + "mark_px": 99.0, + "mid_px": 99.5, + "funding_rate_bps": 1.0, + "is_funding_positive": True, + "transaction_unix_ms": 1000, + "open_interest": 50.0, + } + ) + + +# --------------------------------------------------------------------------- +# Model validation: MarketDto — positive AND negative +# --------------------------------------------------------------------------- + + +class TestMarketDtoValidation: + """MarketDto SHALL enforce required fields and mode enum.""" + + def test_valid_market_parses(self) -> None: + market = PerpMarket.model_validate( + { + "market_addr": "0x" + "a" * 64, + "market_name": "BTC-PERP", + "sz_decimals": 4, + "max_leverage": 50, + "tick_size": 100, + "min_size": 1000, + "lot_size": 100, + "max_open_interest": 1000000.0, + "px_decimals": 1, + "mode": "Open", + } + ) + assert market.market_name == "BTC-PERP" + assert market.mode.value == "Open" + + def test_mode_reduce_only_parses(self) -> None: + """ReduceOnly mode SHALL be accepted.""" + market = PerpMarket.model_validate( + { + "market_addr": "0x" + "b" * 64, + "market_name": "ETH-PERP", + "sz_decimals": 8, + "max_leverage": 20, + "tick_size": 10, + "min_size": 100, + "lot_size": 10, + "max_open_interest": 500000.0, + "px_decimals": 2, + "mode": "ReduceOnly", + } + ) + assert market.mode.value == "ReduceOnly" + + def test_missing_market_name_raises(self) -> None: + """Missing required field SHALL raise ValidationError.""" + with pytest.raises(ValidationError): + PerpMarket.model_validate( + { + "market_addr": "0x" + "a" * 64, + # "market_name" missing + "sz_decimals": 4, + "max_leverage": 50, + "tick_size": 100, + "min_size": 1000, + "lot_size": 100, + "max_open_interest": 1000000.0, + "px_decimals": 1, + "mode": "Open", + } + ) + + +# --------------------------------------------------------------------------- +# Model validation: CandlestickDto — alias mapping +# --------------------------------------------------------------------------- + + +class TestCandlestickDtoValidation: + """CandlestickDto SHALL map single-letter JSON keys to descriptive field names.""" + + def test_alias_mapping(self) -> None: + """JSON keys t/T/o/h/l/c/v/i SHALL map to descriptive Python attrs.""" + candle = Candlestick.model_validate( + { + "t": 1000, + "T": 2000, + "o": 10.0, + "h": 12.0, + "l": 9.0, + "c": 11.0, + "v": 500.0, + "i": "1h", + } + ) + assert candle.time_start == 1000 + assert candle.time_end == 2000 + assert candle.open_price == 10.0 + assert candle.high == 12.0 + assert candle.low == 9.0 + assert candle.close == 11.0 + assert candle.volume == 500.0 + assert candle.interval == "1h" + + def test_missing_alias_field_raises(self) -> None: + """Missing 'o' (open) alias SHALL raise ValidationError.""" + with pytest.raises(ValidationError): + Candlestick.model_validate( + { + "t": 1000, + "T": 2000, + # "o" missing + "h": 12.0, + "l": 9.0, + "c": 11.0, + "v": 500.0, + "i": "1h", + } + ) + + +# --------------------------------------------------------------------------- +# Model validation: AccountOverviewDto — nullable vs required +# --------------------------------------------------------------------------- + + +class TestAccountOverviewValidation: + """AccountOverviewDto SHALL enforce required fields and accept nulls for optional ones.""" + + MINIMAL_VALID: dict[str, Any] = { + "perp_equity_balance": 100.0, + "unrealized_pnl": 10.0, + "unrealized_funding_cost": -5.0, + "cross_margin_ratio": 0.01, + "maintenance_margin": 50.0, + "cross_account_leverage_ratio": None, # nullable + "total_margin": 80.0, + "usdc_cross_withdrawable_balance": 70.0, + "usdc_isolated_withdrawable_balance": 0.0, + "cross_account_position": 0.0, + "volume": None, + "all_time_return": None, + "pnl_90d": None, + "sharpe_ratio": None, + "max_drawdown": None, + "weekly_win_rate_12w": None, + "average_cash_position": None, + "average_leverage": None, + "realized_pnl": None, + "liquidation_fees_paid": None, + "liquidation_losses": None, + } + + def test_minimal_valid_overview_parses(self) -> None: + overview = AccountOverview.model_validate(self.MINIMAL_VALID) + assert overview.perp_equity_balance == 100.0 + assert overview.volume is None + + def test_missing_perp_equity_balance_raises(self) -> None: + """perp_equity_balance is required — omitting it SHALL fail.""" + data = {**self.MINIMAL_VALID} + del data["perp_equity_balance"] + with pytest.raises(ValidationError): + AccountOverview.model_validate(data) + + def test_null_for_required_field_raises(self) -> None: + """Setting perp_equity_balance=None SHALL fail (it's not nullable).""" + data = {**self.MINIMAL_VALID, "perp_equity_balance": None} + with pytest.raises(ValidationError): + AccountOverview.model_validate(data) + + +# --------------------------------------------------------------------------- +# Model validation: PositionDto — TP/SL nullable contract +# --------------------------------------------------------------------------- + + +class TestPositionDtoValidation: + """PositionDto SHALL enforce required fields and allow null TP/SL.""" + + VALID_POSITION: dict[str, Any] = { + "market": "0x" + "a" * 64, + "user": "0x" + "b" * 64, + "size": 2.5, + "user_leverage": 10, + "entry_price": 49800.0, + "is_isolated": False, + "is_deleted": False, + "unrealized_funding": -25.5, + "estimated_liquidation_price": 45000.0, + "transaction_version": 123, + "has_fixed_sized_tpsls": False, + "tp_order_id": None, + "tp_trigger_price": None, + "tp_limit_price": None, + "sl_order_id": None, + "sl_trigger_price": None, + "sl_limit_price": None, + } + + def test_valid_position_parses(self) -> None: + pos = UserPosition.model_validate(self.VALID_POSITION) + assert pos.size == 2.5 + assert pos.tp_order_id is None + + def test_position_with_tp_sl_set(self) -> None: + """Positions with TP/SL SHALL parse correctly.""" + data = { + **self.VALID_POSITION, + "tp_order_id": "tp1", + "tp_trigger_price": 55000.0, + "sl_order_id": "sl1", + "sl_trigger_price": 40000.0, + } + pos = UserPosition.model_validate(data) + assert pos.tp_order_id == "tp1" + assert pos.sl_trigger_price == 40000.0 + + def test_missing_size_raises(self) -> None: + """'size' is required — omitting it SHALL fail.""" + data = {**self.VALID_POSITION} + del data["size"] + with pytest.raises(ValidationError): + UserPosition.model_validate(data) + + +# --------------------------------------------------------------------------- +# Model validation: OrderDto — required fields +# --------------------------------------------------------------------------- + + +class TestOrderDtoValidation: + """OrderDto (UserOpenOrder) SHALL enforce required fields.""" + + VALID_ORDER: dict[str, Any] = { + "parent": "0x" + "0" * 64, + "market": "0x" + "a" * 64, + "client_order_id": "c1", + "order_id": "45678", + "is_buy": True, + "is_tpsl": False, + "details": "", + "transaction_version": 12345678, + "unix_ms": 1699564800000, + "tp_trigger_price": None, + "tp_limit_price": None, + "sl_trigger_price": None, + "sl_limit_price": None, + "orig_size": 1.5, + "remaining_size": 1.5, + "size_delta": None, + "price": 50000.5, + } + + def test_valid_order_parses(self) -> None: + order = UserOpenOrder.model_validate(self.VALID_ORDER) + assert order.order_id == "45678" + assert order.is_buy is True + + def test_missing_order_id_raises(self) -> None: + data = {**self.VALID_ORDER} + del data["order_id"] + with pytest.raises(ValidationError): + UserOpenOrder.model_validate(data) + + def test_missing_is_buy_raises(self) -> None: + data = {**self.VALID_ORDER} + del data["is_buy"] + with pytest.raises(ValidationError): + UserOpenOrder.model_validate(data) + + +# --------------------------------------------------------------------------- +# Model validation: TwapDto — status literal enforcement +# --------------------------------------------------------------------------- + + +class TestTwapDtoValidation: + """TwapDto SHALL enforce status Literal and required fields.""" + + VALID_TWAP: dict[str, Any] = { + "market": "0x" + "a" * 64, + "is_buy": True, + "order_id": "78901", + "client_order_id": "twap_123", + "is_reduce_only": False, + "start_unix_ms": 1699564800000, + "frequency_s": 300, + "duration_s": 3600, + "orig_size": 100.0, + "remaining_size": 75.0, + "status": "Activated", + "transaction_unix_ms": 1699564800000, + "transaction_version": 12345679, + } + + def test_valid_twap_parses(self) -> None: + from decibel.read._user_active_twaps import UserActiveTwap + + twap = UserActiveTwap.model_validate(self.VALID_TWAP) + assert twap.frequency_s == 300 + assert twap.status == "Activated" + + def test_invalid_status_raises(self) -> None: + """Status must be a valid TwapStatus literal — bogus values SHALL fail.""" + from decibel.read._user_active_twaps import UserActiveTwap + + data = {**self.VALID_TWAP, "status": "InvalidStatus"} + with pytest.raises(ValidationError): + UserActiveTwap.model_validate(data) + + def test_missing_frequency_raises(self) -> None: + from decibel.read._user_active_twaps import UserActiveTwap + + data = {**self.VALID_TWAP} + del data["frequency_s"] + with pytest.raises(ValidationError): + UserActiveTwap.model_validate(data) + + +# --------------------------------------------------------------------------- +# Utility function tests +# --------------------------------------------------------------------------- + + +class TestUtilityFunctions: + """Verify chain unit conversion utilities match spec Section 5.6.""" + + def test_amount_to_chain_units(self) -> None: + from decibel._utils import amount_to_chain_units + + assert amount_to_chain_units(1.0) == 1_000_000 + assert amount_to_chain_units(0.5) == 500_000 + assert amount_to_chain_units(100.123456) == 100_123_456 + + def test_chain_units_to_amount(self) -> None: + from decibel._utils import chain_units_to_amount + + assert chain_units_to_amount(1_000_000) == 1.0 + assert chain_units_to_amount(500_000) == 0.5 + + def test_bigint_reviver_converts_bigint(self) -> None: + from decibel._utils import bigint_reviver + + result = bigint_reviver({"$bigint": "340282366920938463463374607431768211455"}) + assert result == 340282366920938463463374607431768211455 + assert isinstance(result, int) + + def test_bigint_reviver_passes_through_normal_dicts(self) -> None: + from decibel._utils import bigint_reviver + + obj = {"key": "value", "num": 42} + assert bigint_reviver(obj) == obj diff --git a/tests/api_resources/test_testnet_integration.py b/tests/api_resources/test_testnet_integration.py new file mode 100644 index 0000000..d11e437 --- /dev/null +++ b/tests/api_resources/test_testnet_integration.py @@ -0,0 +1,658 @@ +"""Integration tests that run against the live Decibel testnet API. + +These tests verify that the SDK correctly parses real API responses +and that the spec matches actual server behavior. + +Run with: + DECIBEL_API_KEY= uv run pytest tests/api_resources/test_testnet_integration.py -v + +Skip with: + uv run pytest tests/api_resources/test_testnet_integration.py -v (auto-skips without key) +""" + +from __future__ import annotations + +import asyncio +import os +import time + +import pytest + +from decibel._constants import TESTNET_CONFIG +from decibel.read import DecibelReadDex + +# --------------------------------------------------------------------------- +# Skip entire module if no API key +# --------------------------------------------------------------------------- + +DECIBEL_API_KEY = os.environ.get("DECIBEL_API_KEY") + +pytestmark = pytest.mark.skipif( + not DECIBEL_API_KEY, + reason="DECIBEL_API_KEY env var not set — skipping testnet integration tests", +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def read() -> DecibelReadDex: + """Shared read client for all tests in this module.""" + return DecibelReadDex(TESTNET_CONFIG, api_key=DECIBEL_API_KEY) + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 2.2: GET /api/v1/markets +# --------------------------------------------------------------------------- + + +class TestMarketsIntegration: + """Verify /api/v1/markets against live testnet.""" + + async def test_get_all_markets_returns_list(self, read: DecibelReadDex) -> None: + """SHALL return a non-empty list of PerpMarket objects.""" + markets = await read.markets.get_all() + assert isinstance(markets, list) + assert len(markets) > 0 + + async def test_market_has_required_fields(self, read: DecibelReadDex) -> None: + """Each market SHALL have all required MarketDto fields per spec.""" + markets = await read.markets.get_all() + market = markets[0] + + # Required fields per SPEC-REST.md Section 11.6 + assert isinstance(market.market_addr, str) + assert market.market_addr.startswith("0x") + assert isinstance(market.market_name, str) + assert len(market.market_name) > 0 + assert isinstance(market.sz_decimals, int) + assert market.sz_decimals >= 0 + assert isinstance(market.px_decimals, int) + assert market.px_decimals >= 0 + assert isinstance(market.max_leverage, (int, float)) + assert market.max_leverage > 0 + assert isinstance(market.tick_size, (int, float)) + assert market.tick_size > 0 + assert isinstance(market.min_size, (int, float)) + assert market.min_size > 0 + assert isinstance(market.lot_size, (int, float)) + assert market.lot_size > 0 + assert isinstance(market.max_open_interest, float) + + async def test_market_mode_is_valid(self, read: DecibelReadDex) -> None: + """Market mode SHALL be one of the valid enum values.""" + from decibel.read._markets import MarketMode + + markets = await read.markets.get_all() + valid_modes = {m.value for m in MarketMode} + for market in markets: + assert market.mode.value in valid_modes, ( + f"Market {market.market_name} has unexpected mode: {market.mode}" + ) + + async def test_market_addresses_are_unique(self, read: DecibelReadDex) -> None: + """Each market SHALL have a unique address.""" + markets = await read.markets.get_all() + addrs = [m.market_addr for m in markets] + assert len(addrs) == len(set(addrs)), "Duplicate market addresses found" + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 2.1: GET /api/v1/prices +# --------------------------------------------------------------------------- + + +class TestPricesIntegration: + """Verify /api/v1/prices against live testnet.""" + + async def test_get_all_prices(self, read: DecibelReadDex) -> None: + """SHALL return prices for all markets.""" + prices = await read.market_prices.get_all() + assert isinstance(prices, list) + assert len(prices) > 0 + + async def test_price_has_required_fields(self, read: DecibelReadDex) -> None: + """Each price SHALL have all required PriceDto fields per spec.""" + prices = await read.market_prices.get_all() + price = prices[0] + + # Required fields per SPEC-REST.md Section 11.1 + assert isinstance(price.market, str) + assert price.market.startswith("0x") + assert isinstance(price.oracle_px, float) + assert price.oracle_px > 0 + assert isinstance(price.mark_px, float) + assert isinstance(price.mid_px, float) + assert isinstance(price.funding_rate_bps, float) + assert isinstance(price.is_funding_positive, bool) + assert isinstance(price.transaction_unix_ms, int) + assert price.transaction_unix_ms > 0 + assert isinstance(price.open_interest, float) + assert price.open_interest >= 0 + + async def test_price_count_matches_markets(self, read: DecibelReadDex) -> None: + """Number of prices SHOULD match number of markets.""" + markets = await read.markets.get_all() + prices = await read.market_prices.get_all() + assert len(prices) == len(markets), f"Expected {len(markets)} prices, got {len(prices)}" + + async def test_price_markets_match_market_list(self, read: DecibelReadDex) -> None: + """Price market addresses SHALL be from the known market list.""" + markets = await read.markets.get_all() + market_addrs = {m.market_addr for m in markets} + prices = await read.market_prices.get_all() + for price in prices: + assert price.market in market_addrs, f"Price for unknown market: {price.market}" + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 2.3: GET /api/v1/candlesticks +# --------------------------------------------------------------------------- + + +class TestCandlesticksIntegration: + """Verify /api/v1/candlesticks against live testnet.""" + + async def test_get_candlesticks(self, read: DecibelReadDex) -> None: + """SHALL return candlestick data for a valid market and time range.""" + from decibel.read._candlesticks import CandlestickInterval + + markets = await read.markets.get_all() + now_ms = int(time.time() * 1000) + candles = await read.candlesticks.get_by_name( + market_name=markets[0].market_name, + interval=CandlestickInterval.ONE_DAY, + start_time=now_ms - 86400000 * 30, # 30 days ago + end_time=now_ms, + ) + # May be empty if no recent trades, but should not error + assert isinstance(candles, list) + + async def test_candlestick_fields(self, read: DecibelReadDex) -> None: + """Each candlestick SHALL have OHLCV fields per spec.""" + from decibel.read._candlesticks import CandlestickInterval + + markets = await read.markets.get_all() + now_ms = int(time.time() * 1000) + + # Try all markets until we find one with candles + candles = [] + for market in markets: + candles = await read.candlesticks.get_by_name( + market_name=market.market_name, + interval=CandlestickInterval.ONE_DAY, + start_time=now_ms - 86400000 * 90, + end_time=now_ms, + ) + if candles: + break + + if not candles: + pytest.skip("No candlestick data available on testnet") + + candle = candles[0] + assert isinstance(candle.time_start, int) # alias: t + assert isinstance(candle.time_end, int) # alias: T + assert candle.time_end > candle.time_start + assert isinstance(candle.open_price, float) # alias: o + assert isinstance(candle.high, float) # alias: h + assert isinstance(candle.low, float) # alias: l + assert isinstance(candle.close, float) # alias: c + assert isinstance(candle.volume, float) # alias: v + assert isinstance(candle.interval, str) # alias: i + assert candle.high >= candle.low + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 2.5: GET /api/v1/asset_contexts +# --------------------------------------------------------------------------- + + +class TestAssetContextsIntegration: + """Verify /api/v1/asset_contexts against live testnet.""" + + async def test_get_all_contexts(self, read: DecibelReadDex) -> None: + """SHALL return market contexts with 24h stats.""" + contexts = await read.market_contexts.get_all() + assert isinstance(contexts, list) + assert len(contexts) > 0 + + async def test_context_has_required_fields(self, read: DecibelReadDex) -> None: + """Each context SHALL have required AssetContextDto fields.""" + contexts = await read.market_contexts.get_all() + ctx = contexts[0] + + # market field is the market name (e.g., "ETH/USD"), not address + assert isinstance(ctx.market, str) + assert len(ctx.market) > 0 + assert isinstance(ctx.volume_24h, float) + assert isinstance(ctx.open_interest, float) + assert isinstance(ctx.previous_day_price, float) + assert isinstance(ctx.price_change_pct_24h, float) + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 2.4: GET /api/v1/trades +# --------------------------------------------------------------------------- + + +class TestTradesIntegration: + """Verify /api/v1/trades against live testnet.""" + + async def test_get_market_trades(self, read: DecibelReadDex) -> None: + """SHALL return a list of trades for a market.""" + markets = await read.markets.get_all() + trades = await read.market_trades.get_by_name( + market_name=markets[0].market_name, + limit=5, + ) + assert isinstance(trades, list) + + async def test_trade_has_required_fields(self, read: DecibelReadDex) -> None: + """Each trade SHALL have all TradeDto fields per spec.""" + markets = await read.markets.get_all() + + # Try markets until we find one with trades + trades: list = [] + for market in markets: + trades = await read.market_trades.get_by_name(market_name=market.market_name, limit=2) + if trades: + break + + if not trades: + pytest.skip("No trades available on testnet") + + trade = trades[0] + assert isinstance(trade.account, str) + assert trade.account.startswith("0x") + assert isinstance(trade.market, str) + assert isinstance(trade.action, str) + assert isinstance(trade.trade_id, (str, int)) + assert isinstance(trade.size, float) + assert trade.size > 0 + assert isinstance(trade.price, float) + assert trade.price > 0 + assert isinstance(trade.is_profit, bool) + assert isinstance(trade.realized_pnl_amount, float) + assert isinstance(trade.fee_amount, float) + assert isinstance(trade.order_id, str) + assert isinstance(trade.transaction_unix_ms, int) + assert isinstance(trade.transaction_version, int) + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 8.1: GET /api/v1/leaderboard +# --------------------------------------------------------------------------- + + +class TestLeaderboardIntegration: + """Verify /api/v1/leaderboard against live testnet.""" + + async def test_get_leaderboard(self, read: DecibelReadDex) -> None: + """SHALL return paginated leaderboard with total_count.""" + result = await read.leaderboard.get_leaderboard(limit=5, offset=0) + assert hasattr(result, "items") + assert hasattr(result, "total_count") + assert result.total_count > 0 + + async def test_leaderboard_entry_fields(self, read: DecibelReadDex) -> None: + """Each entry SHALL have rank, account, account_value, realized_pnl, roi, volume.""" + result = await read.leaderboard.get_leaderboard(limit=3, offset=0) + assert len(result.items) > 0 + + entry = result.items[0] + assert isinstance(entry.rank, int) + assert entry.rank >= 0 + assert isinstance(entry.account, str) + assert entry.account.startswith("0x") + assert isinstance(entry.account_value, float) + assert isinstance(entry.realized_pnl, float) + assert isinstance(entry.roi, float) + assert isinstance(entry.volume, float) + + async def test_leaderboard_pagination(self, read: DecibelReadDex) -> None: + """Pagination SHALL work: offset=0 limit=2 then offset=2 limit=2.""" + page1 = await read.leaderboard.get_leaderboard(limit=2, offset=0) + page2 = await read.leaderboard.get_leaderboard(limit=2, offset=2) + + if page1.total_count < 4: + pytest.skip(f"Not enough leaderboard entries ({page1.total_count}) to test pagination") + + p1_accounts = {e.account for e in page1.items} + p2_accounts = {e.account for e in page2.items} + assert p1_accounts != p2_accounts, "Pagination returned same results for different offsets" + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 7.1: GET /api/v1/vaults +# --------------------------------------------------------------------------- + + +class TestVaultsIntegration: + """Verify /api/v1/vaults against live testnet.""" + + async def test_get_public_vaults(self, read: DecibelReadDex) -> None: + """SHALL return paginated vault listing.""" + result = await read.vaults.get_vaults(limit=5, offset=0) + assert hasattr(result, "items") + assert hasattr(result, "total_count") + + async def test_vault_has_required_fields(self, read: DecibelReadDex) -> None: + """Each vault SHALL have core fields per spec.""" + result = await read.vaults.get_vaults(limit=2, offset=0) + if not result.items: + pytest.skip("No vaults on testnet") + + vault = result.items[0] + assert isinstance(vault.address, str) + assert vault.address.startswith("0x") + assert isinstance(vault.name, str) + assert isinstance(vault.status, str) + + +# --------------------------------------------------------------------------- +# SPEC-REST Section 2.1: GET /api/v1/prices (single market) +# --------------------------------------------------------------------------- + + +class TestSingleMarketPriceIntegration: + """Verify fetching a single market's price by name.""" + + async def test_get_price_by_name(self, read: DecibelReadDex) -> None: + """SHALL return price for a specific market by name.""" + markets = await read.markets.get_all() + prices = await read.market_prices.get_by_name(markets[0].market_name) + assert isinstance(prices, list) + assert len(prices) >= 1 + assert prices[0].market == markets[0].market_addr + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/points/trading/account +# --------------------------------------------------------------------------- + + +class TestTradingPointsIntegration: + """Verify /api/v1/points/trading/account against live testnet.""" + + async def test_get_trading_points(self, read: DecibelReadDex) -> None: + """SHALL return trading points for a known account.""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + manager = vaults.items[0].manager + points = await read.trading_points.get_by_owner(owner_addr=manager) + assert isinstance(points.owner, str) + assert isinstance(points.total_points, float) + + +# --------------------------------------------------------------------------- +# On-chain view functions +# --------------------------------------------------------------------------- + + +class TestOnChainViewFunctions: + """Verify on-chain view calls via Aptos fullnode.""" + + async def test_list_market_addresses(self, read: DecibelReadDex) -> None: + """SHALL return list of market addresses from on-chain.""" + addrs = await read.markets.list_market_addresses() + assert isinstance(addrs, list) + assert len(addrs) > 0 + assert addrs[0].startswith("0x") + + async def test_market_name_by_address(self, read: DecibelReadDex) -> None: + """SHALL resolve a market address to its name.""" + # Use a fresh read client to avoid connection pool reuse issues + fresh = DecibelReadDex(TESTNET_CONFIG, api_key=DECIBEL_API_KEY) + addrs = await fresh.markets.list_market_addresses() + name = await fresh.markets.market_name_by_address(addrs[0]) + assert isinstance(name, str) + assert len(name) > 0 + # Should be a human-readable name like "ETH/USD" + assert "/" in name or "-" in name + + async def test_get_market_config_by_name(self, read: DecibelReadDex) -> None: + """markets.get_by_name() currently fails due to wrong resource type. + + The SDK looks for PerpMarketConfig but the on-chain resource is + PerpMarketConfiguration. This test documents the known bug — + get_by_name returns None and logs an error instead of crashing. + """ + markets = await read.markets.get_all() + config = await read.markets.get_by_name(markets[0].market_name) + # Known bug: returns None because resource type doesn't match + # When fixed, this assertion should change to `assert config is not None` + assert config is None + + async def test_vault_share_price(self, read: DecibelReadDex) -> None: + """SHALL return share price for an active vault.""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + vault_addr = vaults.items[0].address + price = await read.vaults.get_vault_share_price(vault_address=vault_addr) + assert isinstance(price, float) + assert price > 0 + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/portfolio_chart +# --------------------------------------------------------------------------- + + +class TestPortfolioChartIntegration: + """Verify /api/v1/portfolio_chart against live testnet.""" + + async def test_get_portfolio_chart(self, read: DecibelReadDex) -> None: + """SHALL return chart data (may be empty for new accounts).""" + from decibel._utils import get_primary_subaccount_addr + + # Use the vault manager address from vaults as a known funded account + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults to get a known subaccount") + + manager = vaults.items[0].manager + sub_addr = get_primary_subaccount_addr( + manager, TESTNET_CONFIG.compat_version, TESTNET_CONFIG.deployment.package + ) + + chart = await read.portfolio_chart.get_by_addr( + sub_addr=sub_addr, time_range="30d", data_type="pnl" + ) + assert isinstance(chart, list) + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/funding_rate_history +# --------------------------------------------------------------------------- + + +class TestFundingHistoryIntegration: + """Verify /api/v1/funding_rate_history against live testnet.""" + + async def test_get_funding_history(self, read: DecibelReadDex) -> None: + """SHALL return funding history (may be empty).""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + from decibel._utils import get_primary_subaccount_addr + + manager = vaults.items[0].manager + sub_addr = get_primary_subaccount_addr( + manager, TESTNET_CONFIG.compat_version, TESTNET_CONFIG.deployment.package + ) + + result = await read.user_funding_history.get_by_addr(sub_addr=sub_addr) + assert isinstance(result.items, list) + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/twap_history +# --------------------------------------------------------------------------- + + +class TestTwapHistoryIntegration: + """Verify /api/v1/twap_history against live testnet.""" + + async def test_get_twap_history(self, read: DecibelReadDex) -> None: + """SHALL return TWAP history (may be empty).""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + from decibel._utils import get_primary_subaccount_addr + + manager = vaults.items[0].manager + sub_addr = get_primary_subaccount_addr( + manager, TESTNET_CONFIG.compat_version, TESTNET_CONFIG.deployment.package + ) + + result = await read.user_twap_history.get_by_addr(sub_addr=sub_addr) + assert isinstance(result.items, list) + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/bulk_orders +# --------------------------------------------------------------------------- + + +class TestBulkOrdersIntegration: + """Verify /api/v1/bulk_orders against live testnet.""" + + async def test_get_bulk_orders(self, read: DecibelReadDex) -> None: + """SHALL return bulk orders (may be empty).""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + from decibel._utils import get_primary_subaccount_addr + + manager = vaults.items[0].manager + sub_addr = get_primary_subaccount_addr( + manager, TESTNET_CONFIG.compat_version, TESTNET_CONFIG.deployment.package + ) + + orders = await read.user_bulk_orders.get_by_addr(sub_addr=sub_addr) + assert isinstance(orders, list) + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/account_owned_vaults +# --------------------------------------------------------------------------- + + +class TestUserOwnedVaultsIntegration: + """Verify /api/v1/account_owned_vaults against live testnet.""" + + async def test_get_user_owned_vaults(self, read: DecibelReadDex) -> None: + """SHALL return owned vaults for a vault manager.""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + manager = vaults.items[0].manager + result = await read.vaults.get_user_owned_vaults(owner_addr=manager) + assert hasattr(result, "items") + assert result.total_count >= 1 + assert result.items[0].vault_address.startswith("0x") + + +# --------------------------------------------------------------------------- +# SPEC-REST: GET /api/v1/account_vault_performance +# --------------------------------------------------------------------------- + + +class TestVaultPerformanceIntegration: + """Verify /api/v1/account_vault_performance against live testnet.""" + + async def test_get_vault_performance(self, read: DecibelReadDex) -> None: + """SHALL return vault performance for a depositor.""" + vaults = await read.vaults.get_vaults(limit=1, offset=0) + if not vaults.items: + pytest.skip("No vaults on testnet") + + manager = vaults.items[0].manager + performances = await read.vaults.get_user_performances_on_vaults(owner_addr=manager) + assert isinstance(performances, list) + + +# --------------------------------------------------------------------------- +# WebSocket integration — quick smoke test +# --------------------------------------------------------------------------- + + +class TestWebSocketIntegration: + """Verify WebSocket connection and subscription against live testnet.""" + + async def test_subscribe_all_market_prices(self, read: DecibelReadDex) -> None: + """SHALL receive at least one all_market_prices message within 15s.""" + received: list = [] + event = asyncio.Event() + + def on_data(msg: object) -> None: + received.append(msg) + event.set() + + unsub = read.market_prices.subscribe_all(on_data) + + try: + # Wait up to 15 seconds for first message + await asyncio.wait_for(event.wait(), timeout=15.0) + except TimeoutError: + pytest.skip("No WebSocket message received within 15s (testnet may be quiet)") + finally: + unsub() + await read.ws.close() + + assert len(received) >= 1 + msg = received[0] + # Type check: should be AllMarketPricesWsMessage + from decibel.read._market_prices import AllMarketPricesWsMessage + + assert isinstance(msg, AllMarketPricesWsMessage) + assert isinstance(msg.prices, list) + assert len(msg.prices) > 0, "Expected at least one price in WS message" + price = msg.prices[0] + assert isinstance(price.market, str) + assert isinstance(price.oracle_px, float) + assert price.oracle_px > 0 + + async def test_subscribe_market_price_single(self, read: DecibelReadDex) -> None: + """SHALL receive price updates for a single market.""" + markets = await read.markets.get_all() + if not markets: + pytest.skip("No markets on testnet") + + received: list = [] + event = asyncio.Event() + + def on_data(msg: object) -> None: + received.append(msg) + event.set() + + unsub = read.market_prices.subscribe_by_address(markets[0].market_addr, on_data) + + try: + await asyncio.wait_for(event.wait(), timeout=15.0) + except TimeoutError: + pytest.skip("No WebSocket message received within 15s") + finally: + unsub() + await read.ws.close() + + assert len(received) >= 1 + msg = received[0] + from decibel.read._market_prices import MarketPriceWsMessage + + assert isinstance(msg, MarketPriceWsMessage) + assert msg.price.market == markets[0].market_addr + assert isinstance(msg.price.oracle_px, float) + assert msg.price.oracle_px > 0 diff --git a/tests/api_resources/test_testnet_write_integration.py b/tests/api_resources/test_testnet_write_integration.py new file mode 100644 index 0000000..6256c5c --- /dev/null +++ b/tests/api_resources/test_testnet_write_integration.py @@ -0,0 +1,279 @@ +"""Integration tests for the write SDK against live Decibel testnet. + +These tests exercise the full transaction lifecycle: + build -> simulate -> sign -> submit -> confirm + +Requires two env vars: + DECIBEL_API_KEY - API key for testnet + DECIBEL_PRIVATE_KEY - Private key of a funded testnet account + +Run with: + DECIBEL_API_KEY= DECIBEL_PRIVATE_KEY= \ + uv run pytest tests/api_resources/test_testnet_write_integration.py -v + +The account MUST have: + - Testnet APT for gas + - Testnet USDC (minted via restricted_mint) + - An existing subaccount with deposited USDC +""" + +from __future__ import annotations + +import asyncio +import os + +import pytest + +from decibel._constants import TESTNET_CONFIG +from decibel._exceptions import TxnConfirmError + +# --------------------------------------------------------------------------- +# Skip if credentials not available +# --------------------------------------------------------------------------- + +DECIBEL_API_KEY = os.environ.get("DECIBEL_API_KEY") +DECIBEL_PRIVATE_KEY = os.environ.get("DECIBEL_PRIVATE_KEY") + +pytestmark = pytest.mark.skipif( + not DECIBEL_API_KEY or not DECIBEL_PRIVATE_KEY, + reason="DECIBEL_API_KEY and DECIBEL_PRIVATE_KEY env vars required", +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def account(): + from aptos_sdk.account import Account + from aptos_sdk.ed25519 import PrivateKey + + return Account.load_key(PrivateKey.from_hex(DECIBEL_PRIVATE_KEY).hex()) + + +@pytest.fixture(scope="module") +def write_client(account): + from decibel import BaseSDKOptions, DecibelWriteDex + + return DecibelWriteDex( + TESTNET_CONFIG, + account, + opts=BaseSDKOptions( + node_api_key=DECIBEL_API_KEY, + skip_simulate=False, + no_fee_payer=True, + time_delta_ms=0, + ), + ) + + +@pytest.fixture(scope="module") +def read_client(): + from decibel.read import DecibelReadDex + + return DecibelReadDex(TESTNET_CONFIG, api_key=DECIBEL_API_KEY) + + +@pytest.fixture(scope="module") +def subaccount_addr(account): + """Get the primary subaccount address.""" + from decibel._utils import get_primary_subaccount_addr + + return get_primary_subaccount_addr( + str(account.address()), + TESTNET_CONFIG.compat_version, + TESTNET_CONFIG.deployment.package, + ) + + +@pytest.fixture(scope="module") +def eth_market(read_client): + """Get ETH market info for order tests.""" + markets = asyncio.run(read_client.markets.get_all()) + eth = next((m for m in markets if "ETH" in m.market_name), None) + if eth is None: + pytest.skip("No ETH market found on testnet") + return eth + + +# --------------------------------------------------------------------------- +# Mint USDC +# --------------------------------------------------------------------------- + + +class TestMintUSDC: + """Test minting testnet USDC via restricted_mint.""" + + async def test_restricted_mint(self, write_client) -> None: + """SHALL mint testnet USDC (or skip if daily limit reached).""" + from decibel._transaction_builder import InputEntryFunctionData + from decibel._utils import amount_to_chain_units + + payload = InputEntryFunctionData( + function=f"{TESTNET_CONFIG.deployment.package}::usdc::restricted_mint", + function_arguments=[amount_to_chain_units(100.0)], + ) + try: + result = await write_client._send_tx(payload) + assert result.get("vm_status") == "Executed successfully" + assert "hash" in result + except TxnConfirmError as e: + if "MINT_ACCOUNT_LIMIT_EXCEEDED" in str(e): + pytest.skip("Daily mint limit exceeded") + raise + + +# --------------------------------------------------------------------------- +# Subaccount management +# --------------------------------------------------------------------------- + + +class TestSubaccountManagement: + """Test subaccount creation and querying.""" + + async def test_subaccount_exists(self, read_client, account) -> None: + """Account SHALL have at least one subaccount after setup.""" + subs = await read_client.user_subaccounts.get_by_addr(owner_addr=str(account.address())) + assert len(subs) >= 1 + assert subs[0].subaccount_address.startswith("0x") + + async def test_primary_subaccount_address_matches( + self, read_client, account, subaccount_addr + ) -> None: + """Computed primary subaccount address SHALL match on-chain.""" + subs = await read_client.user_subaccounts.get_by_addr(owner_addr=str(account.address())) + sub_addrs = [s.subaccount_address for s in subs] + assert subaccount_addr in sub_addrs + + +# --------------------------------------------------------------------------- +# Deposit / Withdraw +# --------------------------------------------------------------------------- + + +class TestDepositWithdraw: + """Test deposit and withdrawal flows.""" + + async def test_deposit(self, write_client) -> None: + """SHALL deposit USDC into the primary subaccount.""" + from decibel._utils import amount_to_chain_units + + result = await write_client.deposit(amount_to_chain_units(50.0)) + assert result.get("vm_status") == "Executed successfully" + assert "hash" in result + + async def test_account_overview_after_deposit(self, read_client, subaccount_addr) -> None: + """Account overview SHALL reflect deposited funds.""" + overview = await read_client.account_overview.get_by_addr(sub_addr=subaccount_addr) + assert overview.perp_equity_balance >= 0 + assert overview.total_margin >= 0 + + async def test_withdraw(self, write_client) -> None: + """SHALL withdraw a small amount of USDC.""" + from decibel._utils import amount_to_chain_units + + result = await write_client.withdraw(amount_to_chain_units(10.0)) + assert result.get("vm_status") == "Executed successfully" + assert "hash" in result + + +# --------------------------------------------------------------------------- +# Order lifecycle: place -> query -> cancel +# --------------------------------------------------------------------------- + + +class TestOrderLifecycle: + """Test the full order lifecycle: place, query, cancel.""" + + async def test_place_limit_order(self, write_client, eth_market) -> None: + """SHALL place a limit buy order far below market price.""" + from decibel import PlaceOrderSuccess, TimeInForce + from decibel._utils import amount_to_chain_units + + low_price = amount_to_chain_units(500.0, eth_market.px_decimals) + size = int(eth_market.min_size) + + result = await write_client.place_order( + market_name=eth_market.market_name, + price=low_price, + size=size, + is_buy=True, + time_in_force=TimeInForce.GoodTillCanceled, + is_reduce_only=False, + client_order_id="test-integ-limit-001", + tick_size=int(eth_market.tick_size), + ) + + assert isinstance(result, PlaceOrderSuccess) + assert result.transaction_hash.startswith("0x") + + async def test_query_open_orders(self, read_client, subaccount_addr) -> None: + """SHALL query open orders without error.""" + result = await read_client.user_open_orders.get_by_addr(sub_addr=subaccount_addr) + assert result.total_count >= 0 + + async def test_cancel_all_open_orders( + self, write_client, read_client, eth_market, subaccount_addr + ) -> None: + """SHALL cancel any remaining open orders for cleanup.""" + result = await read_client.user_open_orders.get_by_addr(sub_addr=subaccount_addr) + + for order in result.items: + if order.market == eth_market.market_addr: + try: + cancel_tx = await write_client.cancel_order( + market_name=eth_market.market_name, + order_id=int(order.order_id), + ) + vm = cancel_tx.get("vm_status") + assert vm == "Executed successfully" + except (TxnConfirmError, ValueError): + pass # Order may have already been cancelled + + +# --------------------------------------------------------------------------- +# Read endpoints that require a funded account +# --------------------------------------------------------------------------- + + +class TestAuthenticatedReadEndpoints: + """Test read endpoints that need a real account.""" + + async def test_user_positions(self, read_client, subaccount_addr) -> None: + """SHALL return positions list (may be empty).""" + positions = await read_client.user_positions.get_by_addr(sub_addr=subaccount_addr) + assert isinstance(positions, list) + + async def test_user_order_history(self, read_client, subaccount_addr) -> None: + """SHALL return order history.""" + result = await read_client.user_order_history.get_by_addr(sub_addr=subaccount_addr) + assert isinstance(result.items, list) + + async def test_user_trade_history(self, read_client, subaccount_addr) -> None: + """SHALL return trade history.""" + result = await read_client.user_trade_history.get_by_addr(sub_addr=subaccount_addr) + assert isinstance(result.items, list) + + async def test_user_active_twaps(self, read_client, subaccount_addr) -> None: + """SHALL return active TWAPs (likely empty).""" + twaps = await read_client.user_active_twaps.get_by_addr(sub_addr=subaccount_addr) + assert isinstance(twaps, list) + + async def test_user_fund_history(self, read_client, subaccount_addr) -> None: + """SHALL return fund history (deposits/withdrawals).""" + result = await read_client.user_fund_history.get_by_addr(sub_addr=subaccount_addr) + assert isinstance(result.funds, list) + assert result.total >= 1 + + async def test_user_subaccounts(self, read_client, account) -> None: + """SHALL return subaccount list.""" + subs = await read_client.user_subaccounts.get_by_addr(owner_addr=str(account.address())) + assert len(subs) >= 1 + assert subs[0].subaccount_address.startswith("0x") + + async def test_delegations(self, read_client, subaccount_addr) -> None: + """SHALL return delegations (may be empty).""" + delegations = await read_client.delegations.get_all(sub_addr=subaccount_addr) + assert isinstance(delegations, list) diff --git a/tests/api_resources/test_websocket_spec_compliance.py b/tests/api_resources/test_websocket_spec_compliance.py new file mode 100644 index 0000000..f7c1ed4 --- /dev/null +++ b/tests/api_resources/test_websocket_spec_compliance.py @@ -0,0 +1,493 @@ +"""Behavioral tests verifying the SDK matches the WebSocket API specification. + +These tests verify: +1. Subscribe/unsubscribe message JSON format (SPEC-WEBSOCKET Section 2) +2. Actual reader topic construction matches spec (Section 9.3) +3. Data message parsing into Pydantic models (Sections 3-7) +4. Negative cases — bad data is rejected +5. Connection protocol details (Section 1) +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from decibel._constants import TESTNET_CONFIG +from decibel._utils import get_market_addr +from decibel.read._base import ReaderDeps +from decibel.read._ws import DecibelWsSubscription + +# --------------------------------------------------------------------------- +# SPEC Section 1 — Connection Protocol +# --------------------------------------------------------------------------- + + +class TestConnectionProtocol: + """SPEC-WEBSOCKET.md Section 1: Connection Protocol.""" + + def test_testnet_ws_url_uses_wss(self) -> None: + """Server URL SHALL use wss:// protocol.""" + assert TESTNET_CONFIG.trading_ws_url.startswith("wss://") + + def test_testnet_ws_url_ends_with_ws_path(self) -> None: + """Server path SHALL end with /ws.""" + assert TESTNET_CONFIG.trading_ws_url.endswith("/ws") + + +# --------------------------------------------------------------------------- +# SPEC Section 2 — Subscribe/Unsubscribe Messages +# --------------------------------------------------------------------------- + + +class TestSubscriptionMessages: + """SPEC-WEBSOCKET.md Section 2: Subscription Protocol message format.""" + + @pytest.fixture + def ws(self) -> DecibelWsSubscription: + return DecibelWsSubscription(TESTNET_CONFIG, api_key="test-key") + + def test_subscribe_message_has_method_and_topic(self, ws: DecibelWsSubscription) -> None: + """Subscribe SHALL produce {"method": "subscribe", "topic": "..."}.""" + msg = json.loads(ws._get_subscribe_message("all_market_prices")) + assert msg["method"] == "subscribe" + assert msg["topic"] == "all_market_prices" + assert set(msg.keys()) == {"method", "topic"} + + def test_unsubscribe_message_has_method_and_topic(self, ws: DecibelWsSubscription) -> None: + """Unsubscribe SHALL produce {"method": "unsubscribe", "topic": "..."}.""" + msg = json.loads(ws._get_unsubscribe_message("all_market_prices")) + assert msg["method"] == "unsubscribe" + assert msg["topic"] == "all_market_prices" + + def test_subscribe_preserves_parameterized_topic(self, ws: DecibelWsSubscription) -> None: + """Topic string with parameters SHALL be preserved verbatim.""" + topic = "user_open_orders:0x1234abcd" + msg = json.loads(ws._get_subscribe_message(topic)) + assert msg["topic"] == topic + + +# --------------------------------------------------------------------------- +# SPEC Section 9.3 — Topic strings constructed by actual readers +# --------------------------------------------------------------------------- + + +class TestReaderTopicConstruction: + """Verify actual readers construct correct topic strings per spec Section 9.3.""" + + @pytest.fixture + def mock_ws(self) -> MagicMock: + """Mock WS that captures subscribe calls.""" + ws = MagicMock(spec=DecibelWsSubscription) + ws.subscribe = MagicMock(return_value=lambda: None) + return ws + + @pytest.fixture + def deps(self, mock_ws: MagicMock) -> ReaderDeps: + return ReaderDeps( + config=TESTNET_CONFIG, + ws=mock_ws, + aptos=AsyncMock(), + api_key="k", + ) + + def test_market_price_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """MarketPricesReader SHALL subscribe to 'market_price:{addr}'.""" + from decibel.read._market_prices import MarketPricesReader + + reader = MarketPricesReader(deps) + reader.subscribe_by_address("0xmarket123", lambda _: None) + + mock_ws.subscribe.assert_called_once() + topic = mock_ws.subscribe.call_args[0][0] + assert topic == "market_price:0xmarket123" + + def test_all_market_prices_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """subscribe_all SHALL use topic 'all_market_prices' (no parameters).""" + from decibel.read._market_prices import MarketPricesReader + + reader = MarketPricesReader(deps) + reader.subscribe_all(lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + assert topic == "all_market_prices" + + def test_market_depth_subscribe_topic_with_aggregation( + self, deps: ReaderDeps, mock_ws: MagicMock + ) -> None: + """MarketDepthReader SHALL subscribe to 'depth:{addr}:{aggregation}'.""" + from decibel.read._market_depth import MarketDepthReader + + reader = MarketDepthReader(deps) + reader.subscribe_by_name("BTC-PERP", 10, lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + expected_addr = get_market_addr("BTC-PERP", TESTNET_CONFIG.deployment.perp_engine_global) + assert topic == f"depth:{expected_addr}:10" + + def test_user_positions_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """UserPositionsReader SHALL subscribe to 'account_positions:{addr}'.""" + from decibel.read._user_positions import UserPositionsReader + + reader = UserPositionsReader(deps) + reader.subscribe_by_addr("0xuser456", lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + assert topic == "account_positions:0xuser456" + + def test_order_updates_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """UserOrderHistoryReader SHALL subscribe to 'order_updates:{addr}'.""" + from decibel.read._user_order_history import UserOrderHistoryReader + + reader = UserOrderHistoryReader(deps) + reader.subscribe_by_addr("0xuser789", lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + assert topic == "order_updates:0xuser789" + + def test_notifications_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """UserNotificationsReader SHALL subscribe to 'notifications:{addr}'.""" + from decibel.read._user_notifications import UserNotificationsReader + + reader = UserNotificationsReader(deps) + reader.subscribe_by_addr("0xnotif", lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + assert topic == "notifications:0xnotif" + + def test_user_active_twaps_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """UserActiveTwapsReader SHALL subscribe to 'user_active_twaps:{addr}'.""" + from decibel.read._user_active_twaps import UserActiveTwapsReader + + reader = UserActiveTwapsReader(deps) + reader.subscribe_by_addr("0xtwap", lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + assert topic == "user_active_twaps:0xtwap" + + def test_candlestick_subscribe_topic(self, deps: ReaderDeps, mock_ws: MagicMock) -> None: + """CandlesticksReader SHALL subscribe to 'market_candlestick:{addr}:{interval}'.""" + from decibel.read._candlesticks import CandlestickInterval, CandlesticksReader + + reader = CandlesticksReader(deps) + reader.subscribe_by_name("ETH-PERP", CandlestickInterval.ONE_HOUR, lambda _: None) + + topic = mock_ws.subscribe.call_args[0][0] + expected_addr = get_market_addr("ETH-PERP", TESTNET_CONFIG.deployment.perp_engine_global) + assert topic == f"market_candlestick:{expected_addr}:1h" + + +# --------------------------------------------------------------------------- +# SPEC Section 3 — Market data WS message parsing +# --------------------------------------------------------------------------- + + +class TestMarketDataMessages: + """Verify WS market data messages parse correctly.""" + + def test_all_market_prices_message(self) -> None: + """AllMarketPricesWsMessage SHALL parse prices array.""" + from decibel.read._market_prices import AllMarketPricesWsMessage + + msg = AllMarketPricesWsMessage.model_validate( + { + "prices": [ + { + "market": "0x" + "a" * 64, + "oracle_px": 100.0, + "mark_px": 99.0, + "mid_px": 99.5, + "funding_rate_bps": 1.0, + "is_funding_positive": True, + "transaction_unix_ms": 1000, + "open_interest": 50.0, + }, + ] + } + ) + assert len(msg.prices) == 1 + assert msg.prices[0].oracle_px == 100.0 + + def test_all_market_prices_empty_array(self) -> None: + """Empty prices array SHALL be valid.""" + from decibel.read._market_prices import AllMarketPricesWsMessage + + msg = AllMarketPricesWsMessage.model_validate({"prices": []}) + assert msg.prices == [] + + def test_all_market_prices_missing_prices_field_raises(self) -> None: + """Missing 'prices' key SHALL raise ValidationError.""" + from decibel.read._market_prices import AllMarketPricesWsMessage + + with pytest.raises(ValidationError): + AllMarketPricesWsMessage.model_validate({"wrong_key": []}) + + def test_market_depth_message(self) -> None: + """MarketDepth SHALL parse bids/asks arrays of {price, size}.""" + from decibel.read._market_depth import MarketDepth + + depth = MarketDepth.model_validate( + { + "market": "0x" + "a" * 64, + "unix_ms": 1000, + "bids": [{"price": 100.0, "size": 10.0}], + "asks": [{"price": 101.0, "size": 5.0}], + } + ) + assert depth.bids[0].price == 100.0 + assert depth.asks[0].size == 5.0 + + def test_market_depth_empty_book(self) -> None: + """Empty bids/asks SHALL be valid (thin market).""" + from decibel.read._market_depth import MarketDepth + + depth = MarketDepth.model_validate( + { + "market": "0x" + "a" * 64, + "unix_ms": 1000, + "bids": [], + "asks": [], + } + ) + assert depth.bids == [] + assert depth.asks == [] + + +# --------------------------------------------------------------------------- +# SPEC Section 4 — Account WS message parsing +# --------------------------------------------------------------------------- + + +class TestAccountMessages: + """Verify WS account data messages parse correctly.""" + + def test_user_positions_message(self) -> None: + """UserPositionsWsMessage SHALL parse positions array.""" + from decibel.read._user_positions import UserPositionsWsMessage + + msg = UserPositionsWsMessage.model_validate( + { + "positions": [ + { + "market": "0x" + "a" * 64, + "user": "0x" + "b" * 64, + "size": 2.5, + "user_leverage": 10, + "entry_price": 100.0, + "is_isolated": False, + "is_deleted": False, + "unrealized_funding": -1.0, + "estimated_liquidation_price": 50.0, + "transaction_version": 1, + "has_fixed_sized_tpsls": False, + "tp_order_id": None, + "tp_trigger_price": None, + "tp_limit_price": None, + "sl_order_id": None, + "sl_trigger_price": None, + "sl_limit_price": None, + } + ] + } + ) + assert len(msg.positions) == 1 + assert msg.positions[0].size == 2.5 + + def test_user_open_orders_message(self) -> None: + """UserOpenOrdersWsMessage SHALL parse orders array.""" + from decibel.read._user_open_orders import UserOpenOrdersWsMessage + + msg = UserOpenOrdersWsMessage.model_validate( + { + "orders": [ + { + "parent": "0x" + "0" * 64, + "market": "0x" + "a" * 64, + "order_id": "123", + "client_order_id": "c1", + "is_buy": True, + "is_tpsl": False, + "details": "", + "transaction_version": 1, + "unix_ms": 1000, + "tp_trigger_price": None, + "tp_limit_price": None, + "sl_trigger_price": None, + "sl_limit_price": None, + "orig_size": 1.0, + "remaining_size": 1.0, + "size_delta": None, + "price": 100.0, + } + ] + } + ) + assert msg.orders[0].order_id == "123" + + def test_order_update_nested_structure(self) -> None: + """OrderUpdate WS message SHALL have nested {status, details, order} structure.""" + from decibel.read._user_order_history import UserOrdersWsMessage + + msg = UserOrdersWsMessage.model_validate( + { + "order": { + "status": "Filled", + "details": "", + "order": { + "parent": "0x" + "0" * 64, + "market": "0x" + "a" * 64, + "client_order_id": "c1", + "order_id": "456", + "status": "Filled", + "order_type": "Market", + "trigger_condition": "None", + "order_direction": "Close Short", + "orig_size": 2.0, + "remaining_size": 0.0, + "size_delta": None, + "price": 100.0, + "is_buy": False, + "is_reduce_only": False, + "is_tpsl": False, + "details": "", + "tp_order_id": None, + "tp_trigger_price": None, + "tp_limit_price": None, + "sl_order_id": None, + "sl_trigger_price": None, + "sl_limit_price": None, + "transaction_version": 1, + "unix_ms": 1000, + }, + } + } + ) + assert msg.order.status == "Filled" + assert msg.order.order.remaining_size == 0.0 + + +# --------------------------------------------------------------------------- +# SPEC Section 7.1 — NotificationType enum completeness +# --------------------------------------------------------------------------- + + +class TestNotificationTypes: + """SPEC-WEBSOCKET.md Section 7.1: Notification types.""" + + def test_all_spec_notification_types_exist_in_enum(self) -> None: + """SDK NotificationType enum SHALL contain all values from the spec.""" + from decibel.read._user_notifications import NotificationType + + spec_types = [ + "MarketOrderPlaced", + "LimitOrderPlaced", + "StopMarketOrderPlaced", + "StopMarketOrderTriggered", + "StopLimitOrderPlaced", + "StopLimitOrderTriggered", + "OrderPartiallyFilled", + "OrderFilled", + "OrderSizeReduced", + "OrderCancelled", + "OrderRejected", + "OrderErrored", + "TwapOrderPlaced", + "TwapOrderTriggered", + "TwapOrderCompleted", + "TwapOrderCancelled", + "TwapOrderErrored", + "AccountDeposit", + "AccountWithdrawal", + "TpSlSet", + "TpHit", + "SlHit", + "TpCancelled", + "SlCancelled", + ] + enum_values = {e.value for e in NotificationType} + for expected in spec_types: + assert expected in enum_values, f"NotificationType missing: {expected}" + + +# --------------------------------------------------------------------------- +# SPEC Section 9.2 — BigInt and JSON parsing +# --------------------------------------------------------------------------- + + +class TestWsMessageParsing: + """Verify _parse_message handles data messages and rejects non-data ones.""" + + def test_data_message_extracts_topic(self) -> None: + """Messages with 'topic' field SHALL return (topic, data) tuple.""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + raw = json.dumps({"topic": "all_market_prices", "prices": []}) + result = ws._parse_message(raw) + assert result is not None + topic, data = result + assert topic == "all_market_prices" + assert "prices" in data + + def test_subscribe_response_returns_none(self) -> None: + """Subscribe responses (success field, no topic) SHALL return None.""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + raw = json.dumps({"success": True, "message": "Subscribed"}) + assert ws._parse_message(raw) is None + + def test_unsubscribe_response_returns_none(self) -> None: + """Unsubscribe responses SHALL also return None.""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + raw = json.dumps({"success": True, "message": "Unsubscribed"}) + assert ws._parse_message(raw) is None + + def test_failed_subscribe_response_returns_none(self) -> None: + """Failed subscribe responses SHALL return None (not raise).""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + raw = json.dumps({"success": False, "message": "Unknown topic"}) + assert ws._parse_message(raw) is None + + def test_unknown_message_without_topic_raises(self) -> None: + """Non-subscribe messages without 'topic' SHALL raise ValueError.""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + raw = json.dumps({"some_random_key": 42}) + with pytest.raises(ValueError, match="missing topic field"): + ws._parse_message(raw) + + def test_invalid_json_raises(self) -> None: + """Malformed JSON SHALL raise ValueError.""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + with pytest.raises(ValueError, match="failed to parse JSON"): + ws._parse_message("not valid json{{{") + + def test_bigint_in_message(self) -> None: + """Messages with $bigint values SHALL parse to Python int.""" + ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test") + raw = json.dumps( + { + "topic": "test_topic", + "event_uid": {"$bigint": "999999999999999999999"}, + } + ) + result = ws._parse_message(raw) + assert result is not None + _, data = result + assert data["event_uid"] == 999999999999999999999 + + +# --------------------------------------------------------------------------- +# SPEC: Market depth aggregation levels +# --------------------------------------------------------------------------- + + +class TestMarketDepthAggregation: + """Verify MarketDepthReader exposes correct aggregation levels.""" + + def test_get_aggregation_sizes_returns_spec_values(self) -> None: + """get_aggregation_sizes SHALL return (1, 2, 5, 10, 100, 1000).""" + from decibel.read._market_depth import MarketDepthReader + + reader = MarketDepthReader.__new__(MarketDepthReader) + sizes = reader.get_aggregation_sizes() + assert sizes == (1, 2, 5, 10, 100, 1000) diff --git a/tests/test_pure_logic.py b/tests/test_pure_logic.py new file mode 100644 index 0000000..cf7565a --- /dev/null +++ b/tests/test_pure_logic.py @@ -0,0 +1,624 @@ +"""Unit tests for pure-logic and utility modules in the Decibel SDK.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +import httpx +import pytest +from pydantic import BaseModel, ValidationError + +from decibel._exceptions import TxnConfirmError, TxnSubmitError +from decibel._gas_price_manager import _build_auth_headers +from decibel._order_status import OrderStatusClient +from decibel._pagination import construct_known_query_params +from decibel._utils import ( + FetchError, + extract_vault_address_from_create_tx, + get_request_sync, + get_trading_competition_subaccount_addr, + get_vault_share_address, + patch_request_sync, + post_request_sync, + prettify_validation_error, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _DummyModel(BaseModel): + value: str + + +@dataclass +class _SyncCapturedRequest: + method: str + url: str + params: dict[str, str] | None + headers: dict[str, str] + + +class SyncMockTransport(httpx.BaseTransport): + """Synchronous mock transport mirroring the async MockTransport.""" + + def __init__(self) -> None: + self.captured_requests: list[_SyncCapturedRequest] = [] + self._responses: list[httpx.Response] = [] + + def set_response(self, json_data: Any, status_code: int = 200) -> None: + self._responses.append( + httpx.Response( + status_code=status_code, + content=json.dumps(json_data).encode(), + headers={"content-type": "application/json"}, + ) + ) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self.captured_requests.append( + _SyncCapturedRequest( + method=request.method, + url=str(request.url), + params=(dict(request.url.params) if request.url.params else None), + headers=dict(request.headers), + ) + ) + if self._responses: + return self._responses.pop(0) + return httpx.Response( + 200, + content=b"[]", + headers={"content-type": "application/json"}, + ) + + +# =================================================================== +# 1. _pagination.construct_known_query_params +# =================================================================== + + +class TestConstructKnownQueryParams: + def test_all_params_set(self) -> None: + result = construct_known_query_params( + { + "limit": 10, + "offset": 5, + "search_term": "hello", + "sort_key": "name", + "sort_dir": "ASC", + } + ) + assert result == { + "limit": "10", + "offset": "5", + "search_term": "hello", + "sort_key": "name", + "sort_dir": "ASC", + } + + def test_some_params_set(self) -> None: + result = construct_known_query_params({"limit": 20, "sort_key": "price"}) + assert result == {"limit": "20", "sort_key": "price"} + + def test_no_params(self) -> None: + result = construct_known_query_params({}) + assert result == {} + + def test_none_values_skipped(self) -> None: + result = construct_known_query_params({"limit": 5, "sort_dir": None}) + assert result == {"limit": "5"} + assert "sort_dir" not in result + + def test_empty_string_skipped(self) -> None: + result = construct_known_query_params({"search_term": " "}) + assert result == {} + + def test_whitespace_only_skipped(self) -> None: + result = construct_known_query_params({"search_term": "\t\n"}) + assert result == {} + + def test_non_empty_string_kept(self) -> None: + result = construct_known_query_params({"search_term": " hi "}) + assert result == {"search_term": " hi "} + + +# =================================================================== +# 2. _order_status static helpers +# =================================================================== + + +class TestParseOrderStatusType: + def test_acknowledged(self) -> None: + assert OrderStatusClient.parse_order_status_type("Acknowledged") == "Acknowledged" + + def test_acknowledged_case_insensitive(self) -> None: + assert OrderStatusClient.parse_order_status_type("ACKNOWLEDGED") == "Acknowledged" + + def test_filled(self) -> None: + assert OrderStatusClient.parse_order_status_type("Filled") == "Filled" + + def test_cancelled(self) -> None: + assert OrderStatusClient.parse_order_status_type("Cancelled") == "Cancelled" + + def test_rejected(self) -> None: + assert OrderStatusClient.parse_order_status_type("Rejected") == "Rejected" + + def test_unknown_string(self) -> None: + assert OrderStatusClient.parse_order_status_type("Pending") == "Unknown" + + def test_none_returns_unknown(self) -> None: + assert OrderStatusClient.parse_order_status_type(None) == "Unknown" + + def test_empty_string_returns_unknown(self) -> None: + assert OrderStatusClient.parse_order_status_type("") == "Unknown" + + def test_substring_match(self) -> None: + assert OrderStatusClient.parse_order_status_type("order_filled_fully") == "Filled" + + +class TestIsSuccessStatus: + def test_filled_is_success(self) -> None: + assert OrderStatusClient.is_success_status("Filled") is True + + def test_cancelled_not_success(self) -> None: + assert OrderStatusClient.is_success_status("Cancelled") is False + + def test_none_not_success(self) -> None: + assert OrderStatusClient.is_success_status(None) is False + + +class TestIsFailureStatus: + def test_cancelled_is_failure(self) -> None: + assert OrderStatusClient.is_failure_status("Cancelled") is True + + def test_rejected_is_failure(self) -> None: + assert OrderStatusClient.is_failure_status("Rejected") is True + + def test_filled_not_failure(self) -> None: + assert OrderStatusClient.is_failure_status("Filled") is False + + def test_none_not_failure(self) -> None: + assert OrderStatusClient.is_failure_status(None) is False + + +class TestIsFinalStatus: + def test_filled_is_final(self) -> None: + assert OrderStatusClient.is_final_status("Filled") is True + + def test_cancelled_is_final(self) -> None: + assert OrderStatusClient.is_final_status("Cancelled") is True + + def test_rejected_is_final(self) -> None: + assert OrderStatusClient.is_final_status("Rejected") is True + + def test_acknowledged_not_final(self) -> None: + assert OrderStatusClient.is_final_status("Acknowledged") is False + + def test_none_not_final(self) -> None: + assert OrderStatusClient.is_final_status(None) is False + + +# =================================================================== +# 3. _exceptions.TxnSubmitError +# =================================================================== + + +class TestTxnSubmitError: + def test_basic_instantiation(self) -> None: + err = TxnSubmitError("connection refused") + assert str(err) == "connection refused" + assert err.original_exception is None + + def test_with_original_exception(self) -> None: + cause = TimeoutError("timed out") + err = TxnSubmitError("submit failed", cause) + assert err.original_exception is cause + assert "submit failed" in str(err) + + def test_inherits_exception(self) -> None: + err = TxnSubmitError("oops") + assert isinstance(err, Exception) + + def test_with_none_original(self) -> None: + err = TxnSubmitError("msg", None) + assert err.original_exception is None + + +class TestTxnConfirmError: + def test_basic_instantiation(self) -> None: + err = TxnConfirmError("0xabc", "timeout") + assert err.tx_hash == "0xabc" + assert "0xabc" in str(err) + assert "timeout" in str(err) + + +# =================================================================== +# 4. _utils pure functions +# =================================================================== + + +class TestPrettifyValidationError: + def test_single_field_error(self) -> None: + class _M(BaseModel): + x: int + + try: + _M.model_validate({"x": "not_an_int"}) + except ValidationError as e: + result = prettify_validation_error(e) + assert "Validation error:" in result + assert "x" in result + + def test_missing_field(self) -> None: + class _M(BaseModel): + a: str + b: int + + try: + _M.model_validate({}) + except ValidationError as e: + result = prettify_validation_error(e) + assert "a" in result + assert "b" in result + + def test_root_level_error(self) -> None: + """Errors with empty loc should show 'root'.""" + + class _M(BaseModel): + x: int + + try: + _M.model_validate("not_a_dict") + except ValidationError as e: + result = prettify_validation_error(e) + assert "root" in result + + +class TestGetTradingCompetitionSubaccountAddr: + def test_returns_deterministic_address(self) -> None: + addr = "0x1" + result = get_trading_competition_subaccount_addr(addr) + assert isinstance(result, str) + assert result.startswith("0x") + # Deterministic: same input -> same output + assert get_trading_competition_subaccount_addr(addr) == result + + def test_different_addresses_differ(self) -> None: + a = get_trading_competition_subaccount_addr("0x1") + b = get_trading_competition_subaccount_addr("0x2") + assert a != b + + +class TestGetVaultShareAddress: + _VAULT_A = "0x" + "a" * 64 + _VAULT_B = "0x" + "b" * 64 + + def test_returns_deterministic_address(self) -> None: + result = get_vault_share_address(self._VAULT_A) + assert isinstance(result, str) + assert result.startswith("0x") + assert get_vault_share_address(self._VAULT_A) == result + + def test_different_vaults_differ(self) -> None: + a = get_vault_share_address(self._VAULT_A) + b = get_vault_share_address(self._VAULT_B) + assert a != b + + +class TestExtractVaultAddressFromCreateTx: + def test_string_vault_address(self) -> None: + tx: dict[str, Any] = { + "events": [ + { + "type": "0x1::vault::VaultCreatedEvent", + "data": {"vault": "0xdeadbeef"}, + } + ] + } + assert extract_vault_address_from_create_tx(tx) == "0xdeadbeef" + + def test_dict_vault_with_inner(self) -> None: + tx: dict[str, Any] = { + "events": [ + { + "type": "0x1::vault::VaultCreatedEvent", + "data": {"vault": {"inner": "0xcafe"}}, + } + ] + } + assert extract_vault_address_from_create_tx(tx) == "0xcafe" + + def test_missing_events_raises(self) -> None: + with pytest.raises(ValueError, match="Unable to extract"): + extract_vault_address_from_create_tx({}) + + def test_no_matching_event_raises(self) -> None: + tx: dict[str, Any] = {"events": [{"type": "0x1::other::Event", "data": {}}]} + with pytest.raises(ValueError, match="Unable to extract"): + extract_vault_address_from_create_tx(tx) + + def test_events_not_list_raises(self) -> None: + with pytest.raises(ValueError, match="Unable to extract"): + extract_vault_address_from_create_tx({"events": "not_a_list"}) + + +class TestFetchError: + def test_json_response_data(self) -> None: + data = json.dumps({"status": "error", "message": "not found"}) + err = FetchError(data, 404, "Not Found") + assert err.status == 404 + assert err.status_text == "error" + assert err.response_message == "not found" + assert "404" in str(err) + + def test_non_json_response_data(self) -> None: + err = FetchError("plain text body", 500, "Server Error") + assert err.status == 500 + assert err.status_text == "Server Error" + assert err.response_message == "plain text body" + + def test_json_without_status_and_message(self) -> None: + data = json.dumps({"foo": "bar"}) + err = FetchError(data, 400, "Bad Request") + assert err.status_text == "Bad Request" + assert err.response_message == data + + def test_empty_status_text(self) -> None: + data = json.dumps({"status": "err", "message": "oops"}) + err = FetchError(data, 422, "") + assert err.status_text == "err" + + +# =================================================================== +# 4b. Sync request wrappers (get/post/patch) +# =================================================================== + + +class TestSyncRequestWrappers: + def test_get_request_sync_success(self) -> None: + transport = SyncMockTransport() + transport.set_response({"value": "ok"}) + client = httpx.Client(transport=transport) + + data, status, status_text = get_request_sync( + _DummyModel, + "http://test/api", + params={"q": "1"}, + client=client, + ) + assert isinstance(data, _DummyModel) + assert data.value == "ok" + assert status == 200 + req = transport.captured_requests[0] + assert req.method == "GET" + assert "q=1" in req.url + + def test_get_request_sync_with_api_key(self) -> None: + transport = SyncMockTransport() + transport.set_response({"value": "ok"}) + client = httpx.Client(transport=transport) + + get_request_sync( + _DummyModel, + "http://test/api", + api_key="secret", + client=client, + ) + req = transport.captured_requests[0] + assert req.headers.get("authorization") == "Bearer secret" + + def test_post_request_sync_success(self) -> None: + transport = SyncMockTransport() + transport.set_response({"value": "created"}) + client = httpx.Client(transport=transport) + + data, status, _ = post_request_sync( + _DummyModel, + "http://test/api", + body={"key": "val"}, + client=client, + ) + assert data.value == "created" + assert status == 200 + req = transport.captured_requests[0] + assert req.method == "POST" + assert "application/json" in req.headers.get("content-type", "") + + def test_patch_request_sync_success(self) -> None: + transport = SyncMockTransport() + transport.set_response({"value": "patched"}) + client = httpx.Client(transport=transport) + + data, _, _ = patch_request_sync( + _DummyModel, + "http://test/api", + body={"key": "val"}, + client=client, + ) + assert data.value == "patched" + req = transport.captured_requests[0] + assert req.method == "PATCH" + + def test_get_request_sync_http_error(self) -> None: + transport = SyncMockTransport() + transport.set_response( + {"status": "error", "message": "bad"}, + status_code=400, + ) + client = httpx.Client(transport=transport) + + with pytest.raises(FetchError) as exc_info: + get_request_sync( + _DummyModel, + "http://test/api", + client=client, + ) + assert exc_info.value.status == 400 + + def test_post_request_sync_validation_error(self) -> None: + transport = SyncMockTransport() + # Return data that does not match _DummyModel + transport.set_response({"wrong_field": 123}) + client = httpx.Client(transport=transport) + + with pytest.raises(ValueError, match="Validation error"): + post_request_sync( + _DummyModel, + "http://test/api", + body={}, + client=client, + ) + + +# =================================================================== +# 5. _gas_price_manager._build_auth_headers +# =================================================================== + + +class TestBuildAuthHeaders: + def test_with_api_key(self) -> None: + headers = _build_auth_headers("my-key") + assert headers == {"x-api-key": "my-key"} + + def test_without_api_key(self) -> None: + assert _build_auth_headers(None) == {} + + def test_empty_string_api_key(self) -> None: + assert _build_auth_headers("") == {} + + +# --------------------------------------------------------------------------- +# _base.py — _wait_for_transaction retry on transient network errors +# --------------------------------------------------------------------------- + + +class TestWaitForTransactionRetry: + """Verify _wait_for_transaction retries on ConnectTimeout/ReadTimeout/ConnectError.""" + + @pytest.fixture + def base_sdk(self): + """Create a minimal BaseSDK instance for testing.""" + from unittest.mock import AsyncMock + + from aptos_sdk.account import Account + + from decibel._base import BaseSDK, BaseSDKOptions + from decibel._constants import TESTNET_CONFIG + + account = Account.generate() + sdk = BaseSDK( + TESTNET_CONFIG, + account, + opts=BaseSDKOptions(no_fee_payer=True), + ) + # Patch sleep to avoid real delays + sdk._async_sleep = AsyncMock() # type: ignore[assignment] + return sdk + + async def test_retries_on_connect_timeout_then_succeeds(self, base_sdk) -> None: + """SHALL retry on ConnectTimeout and succeed when tx confirms.""" + from unittest.mock import AsyncMock, patch + + call_count = 0 + + async def mock_get(url, headers=None): + nonlocal call_count + call_count += 1 + if call_count <= 2: + raise httpx.ConnectTimeout("connection timed out") + # Third call succeeds + resp = httpx.Response( + 200, + json={"success": True, "hash": "0xabc", "type": "user_transaction"}, + ) + return resp + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await base_sdk._wait_for_transaction("0xabc") + + assert result["success"] is True + assert call_count == 3 + + async def test_retries_on_read_timeout(self, base_sdk) -> None: + """SHALL retry on ReadTimeout.""" + from unittest.mock import AsyncMock, patch + + call_count = 0 + + async def mock_get(url, headers=None): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.ReadTimeout("read timed out") + resp = httpx.Response( + 200, + json={"success": True, "hash": "0xabc", "type": "user_transaction"}, + ) + return resp + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await base_sdk._wait_for_transaction("0xabc") + + assert result["success"] is True + assert call_count == 2 + + async def test_retries_on_connect_error(self, base_sdk) -> None: + """SHALL retry on ConnectError.""" + from unittest.mock import AsyncMock, patch + + call_count = 0 + + async def mock_get(url, headers=None): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.ConnectError("connection refused") + resp = httpx.Response( + 200, + json={"success": True, "hash": "0xabc", "type": "user_transaction"}, + ) + return resp + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await base_sdk._wait_for_transaction("0xabc") + + assert result["success"] is True + + async def test_timeout_after_retries_exhausted(self, base_sdk) -> None: + """SHALL raise TxnConfirmError if timeout exceeded during retries.""" + from unittest.mock import AsyncMock, patch + + from decibel._exceptions import TxnConfirmError + + async def mock_get(url, headers=None): + raise httpx.ConnectTimeout("always fails") + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with ( + patch("httpx.AsyncClient", return_value=mock_client), + pytest.raises(TxnConfirmError, match="did not confirm"), + ): + await base_sdk._wait_for_transaction("0xabc", txn_confirm_timeout=0.01) diff --git a/tests/test_ws_and_order_status.py b/tests/test_ws_and_order_status.py new file mode 100644 index 0000000..0bb8f1d --- /dev/null +++ b/tests/test_ws_and_order_status.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from decibel._constants import ( + CompatVersion, + DecibelConfig, + Deployment, + Network, +) +from decibel._gas_price_manager import ( + GasPriceManager, + GasPriceManagerOptions, + GasPriceManagerSync, + _build_auth_headers, +) +from decibel._order_status import OrderStatus, OrderStatusClient +from decibel.read._ws import DecibelWsSubscription + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_DEPLOYMENT = Deployment( + package="0xabc", + usdc="0xusdc", + testc="0xtestc", + perp_engine_global="0xperp", +) + + +@pytest.fixture() +def config() -> DecibelConfig: + return DecibelConfig( + network=Network.TESTNET, + fullnode_url="https://fullnode.example.com", + trading_http_url="https://trading.example.com", + trading_ws_url="wss://ws.example.com", + gas_station_url=None, + gas_station_api_key=None, + deployment=_DEPLOYMENT, + chain_id=1, + compat_version=CompatVersion.V0_4, + ) + + +SAMPLE_ORDER_STATUS = { + "parent": "0xparent", + "market": "0xmarket", + "order_id": "order-1", + "status": "filled", + "orig_size": 100.0, + "remaining_size": 0.0, + "size_delta": 100.0, + "price": 50.5, + "is_buy": True, + "details": "ok", + "transaction_version": 42, + "unix_ms": 1700000000000, +} + +# =================================================================== +# WebSocket (DecibelWsSubscription) tests +# =================================================================== + + +class TestWsSubscribe: + """subscribe() stores callbacks, returns unsubscribe, auto-opens.""" + + async def test_subscribe_stores_callback_and_returns_unsubscribe( + self, config: DecibelConfig + ) -> None: + ws = DecibelWsSubscription(config) + callback = MagicMock() + + with patch.object(ws, "_open", new_callable=AsyncMock): + unsub = ws.subscribe("topic.a", MagicMock, callback) + + assert "topic.a" in ws._subscriptions + assert len(ws._subscriptions["topic.a"]) == 1 + assert callable(unsub) + + async def test_subscribe_auto_opens_on_first_subscribe(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + assert ws._ws is None + + with patch.object(ws, "_open", new_callable=AsyncMock) as mock_open: + ws.subscribe("topic.a", MagicMock, MagicMock()) + # _open is scheduled via asyncio.create_task; yield + # to let the task execute. + await asyncio.sleep(0) + + mock_open.assert_awaited_once() + + async def test_subscribe_cancels_close_timer(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + timer_task = MagicMock() + ws._close_timer_task = timer_task + + with patch.object(ws, "_open", new_callable=AsyncMock): + ws.subscribe("topic.b", MagicMock, MagicMock()) + + timer_task.cancel.assert_called_once() + assert ws._close_timer_task is None + + async def test_subscribe_sends_sub_msg_when_ws_open(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = AsyncMock() + ws._ws = mock_conn + + ws.subscribe("topic.new", MagicMock, MagicMock()) + + # The subscribe message is sent via asyncio.create_task + await asyncio.sleep(0) + mock_conn.send.assert_awaited() + + +class TestWsUnsubscribe: + """_unsubscribe_listener / _unsubscribe_topic cleanup.""" + + async def test_unsubscribe_removes_listener(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + callback = MagicMock() + + with patch.object(ws, "_open", new_callable=AsyncMock): + unsub = ws.subscribe("t", MagicMock, callback) + + assert len(ws._subscriptions["t"]) == 1 + with patch.object(ws, "_delayed_close", new_callable=AsyncMock): + unsub() + assert "t" not in ws._subscriptions + + async def test_unsubscribe_keeps_other_listeners(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + + with patch.object(ws, "_open", new_callable=AsyncMock): + unsub1 = ws.subscribe("t", MagicMock, MagicMock()) + _ = ws.subscribe("t", MagicMock, MagicMock()) + + assert len(ws._subscriptions["t"]) == 2 + unsub1() + assert len(ws._subscriptions["t"]) == 1 + + async def test_unsubscribe_topic_sends_unsub_message(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = AsyncMock() + ws._ws = mock_conn + + with patch.object(ws, "_open", new_callable=AsyncMock): + unsub = ws.subscribe("t", MagicMock, MagicMock()) + + with patch.object(ws, "_delayed_close", new_callable=AsyncMock): + unsub() + # unsubscribe message sent via create_task + await asyncio.sleep(0) + mock_conn.send.assert_awaited() + + +class TestWsReset: + """reset() and _reset_topic().""" + + async def test_reset_sends_unsub_then_sub(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = AsyncMock() + ws._ws = mock_conn + ws._subscriptions["t"] = {MagicMock()} + + ws.reset("t") + # Let the task run + await asyncio.sleep(0) + + calls = mock_conn.send.await_args_list + assert len(calls) == 2 + assert "unsubscribe" in calls[0].args[0] + assert "subscribe" in calls[1].args[0] + + async def test_reset_noop_for_unknown_topic(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = AsyncMock() + ws._ws = mock_conn + ws.reset("unknown") + await asyncio.sleep(0) + mock_conn.send.assert_not_awaited() + + +class TestWsReadyState: + """ready_state() returns correct integers.""" + + def test_closed_when_no_ws(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + assert ws.ready_state() == 3 + + def test_open_state(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = MagicMock() + mock_conn.state.name = "OPEN" + ws._ws = mock_conn + assert ws.ready_state() == 1 + + def test_closing_state(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = MagicMock() + mock_conn.state.name = "CLOSING" + ws._ws = mock_conn + assert ws.ready_state() == 2 + + def test_connecting_state(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + mock_conn = MagicMock() + mock_conn.state.name = "CONNECTING" + ws._ws = mock_conn + assert ws.ready_state() == 0 + + +class TestWsScheduleReconnect: + """_schedule_reconnect exponential backoff.""" + + async def test_backoff_increases(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + ws._subscriptions["t"] = {MagicMock()} + + with ( + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + patch.object(ws, "_open", new_callable=AsyncMock), + ): + ws._reconnect_attempts = 0 + await ws._schedule_reconnect() + # 1.5^0 = 1.0 + mock_sleep.assert_awaited_once_with(1.0) + + with ( + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + patch.object(ws, "_open", new_callable=AsyncMock), + ): + # attempts was incremented to 1 by the first call + await ws._schedule_reconnect() + # 1.5^1 = 1.5 + mock_sleep.assert_awaited_once_with(1.5) + + async def test_backoff_capped_at_60(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + ws._subscriptions["t"] = {MagicMock()} + ws._reconnect_attempts = 100 + + with ( + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + patch.object(ws, "_open", new_callable=AsyncMock), + ): + await ws._schedule_reconnect() + mock_sleep.assert_awaited_once_with(60.0) + + async def test_no_reconnect_without_subscriptions(self, config: DecibelConfig) -> None: + ws = DecibelWsSubscription(config) + + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await ws._schedule_reconnect() + mock_sleep.assert_not_awaited() + + +# =================================================================== +# OrderStatusClient tests +# =================================================================== + + +class TestParseOrderStatusType: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("acknowledged", "Acknowledged"), + ("ACKNOWLEDGED", "Acknowledged"), + ("order_acknowledged", "Acknowledged"), + ("filled", "Filled"), + ("FILLED", "Filled"), + ("partially_filled", "Filled"), + ("cancelled", "Cancelled"), + ("Cancelled", "Cancelled"), + ("rejected", "Rejected"), + ("REJECTED", "Rejected"), + ("pending", "Unknown"), + ("", "Unknown"), + (None, "Unknown"), + ], + ) + def test_parse(self, raw: str | None, expected: str) -> None: + assert OrderStatusClient.parse_order_status_type(raw) == expected + + +class TestOrderStatusHelpers: + @pytest.mark.parametrize( + ("status", "expected"), + [ + ("filled", True), + ("cancelled", False), + ("rejected", False), + ("acknowledged", False), + (None, False), + ], + ) + def test_is_success(self, status: str | None, expected: bool) -> None: + assert OrderStatusClient.is_success_status(status) is expected + + @pytest.mark.parametrize( + ("status", "expected"), + [ + ("filled", False), + ("cancelled", True), + ("rejected", True), + ("acknowledged", False), + (None, False), + ], + ) + def test_is_failure(self, status: str | None, expected: bool) -> None: + assert OrderStatusClient.is_failure_status(status) is expected + + @pytest.mark.parametrize( + ("status", "expected"), + [ + ("filled", True), + ("cancelled", True), + ("rejected", True), + ("acknowledged", False), + (None, False), + ], + ) + def test_is_final(self, status: str | None, expected: bool) -> None: + assert OrderStatusClient.is_final_status(status) is expected + + +class TestGetOrderStatus: + async def test_async_returns_parsed_order(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.json.return_value = SAMPLE_ORDER_STATUS + + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client.get.return_value = mock_response + + client = OrderStatusClient(config) + result = await client.get_order_status("order-1", "0xmarket", "0xuser", client=mock_client) + + assert isinstance(result, OrderStatus) + assert result.order_id == "order-1" + assert result.status == "filled" + assert result.price == 50.5 + + async def test_async_returns_none_on_404(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.is_success = False + + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client.get.return_value = mock_response + + client = OrderStatusClient(config) + result = await client.get_order_status("order-1", "0xmarket", "0xuser", client=mock_client) + assert result is None + + async def test_async_returns_none_on_error(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.is_success = False + mock_response.text = "Internal Server Error" + mock_response.reason_phrase = "Internal Server Error" + + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client.get.return_value = mock_response + + client = OrderStatusClient(config) + result = await client.get_order_status("order-1", "0xmarket", "0xuser", client=mock_client) + # FetchError is caught and logged, returns None + assert result is None + + def test_sync_returns_parsed_order(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_response.json.return_value = SAMPLE_ORDER_STATUS + + mock_client = MagicMock(spec=httpx.Client) + mock_client.get.return_value = mock_response + + client = OrderStatusClient(config) + result = client.get_order_status_sync("order-1", "0xmarket", "0xuser", client=mock_client) + + assert isinstance(result, OrderStatus) + assert result.order_id == "order-1" + + def test_sync_returns_none_on_404(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.is_success = False + + mock_client = MagicMock(spec=httpx.Client) + mock_client.get.return_value = mock_response + + client = OrderStatusClient(config) + result = client.get_order_status_sync("order-1", "0xmarket", "0xuser", client=mock_client) + assert result is None + + def test_sync_returns_none_on_error(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.is_success = False + mock_response.text = "Internal Server Error" + mock_response.reason_phrase = "Internal Server Error" + + mock_client = MagicMock(spec=httpx.Client) + mock_client.get.return_value = mock_response + + client = OrderStatusClient(config) + result = client.get_order_status_sync("order-1", "0xmarket", "0xuser", client=mock_client) + assert result is None + + +# =================================================================== +# GasPriceManager tests +# =================================================================== + + +class TestBuildAuthHeaders: + def test_with_api_key(self) -> None: + assert _build_auth_headers("my-key") == {"x-api-key": "my-key"} + + def test_without_api_key(self) -> None: + assert _build_auth_headers(None) == {} + + def test_with_empty_string(self) -> None: + assert _build_auth_headers("") == {} + + +class TestGasPriceManagerAsync: + async def test_get_gas_price_returns_none_initially(self, config: DecibelConfig) -> None: + mgr = GasPriceManager(config) + assert mgr.get_gas_price() is None + + async def test_fetch_gas_price_estimation(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = {"gas_estimate": 100} + + with patch("decibel._gas_price_manager.httpx.AsyncClient") as mock_cls: + ctx = AsyncMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__aenter__ = AsyncMock(return_value=ctx) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mgr = GasPriceManager(config) + result = await mgr.fetch_gas_price_estimation() + + # default multiplier is 2.0 + assert result == 200 + + async def test_fetch_gas_price_estimation_custom_multiplier( + self, config: DecibelConfig + ) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = {"gas_estimate": 100} + + opts = GasPriceManagerOptions(multiplier=3.0) + + with patch("decibel._gas_price_manager.httpx.AsyncClient") as mock_cls: + ctx = AsyncMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__aenter__ = AsyncMock(return_value=ctx) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mgr = GasPriceManager(config, opts) + result = await mgr.fetch_gas_price_estimation() + + assert result == 300 + + async def test_fetch_gas_price_estimation_failure(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.status_code = 500 + mock_response.text = "error" + + with patch("decibel._gas_price_manager.httpx.AsyncClient") as mock_cls: + ctx = AsyncMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__aenter__ = AsyncMock(return_value=ctx) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mgr = GasPriceManager(config) + with pytest.raises(ValueError, match="Failed to fetch"): + await mgr.fetch_gas_price_estimation() + + async def test_fetch_and_set_gas_price(self, config: DecibelConfig) -> None: + mgr = GasPriceManager(config) + + with patch.object( + mgr, + "fetch_gas_price_estimation", + new_callable=AsyncMock, + return_value=500, + ): + result = await mgr.fetch_and_set_gas_price() + + assert result == 500 + assert mgr.get_gas_price() == 500 + + async def test_fetch_and_set_raises_on_zero(self, config: DecibelConfig) -> None: + mgr = GasPriceManager(config) + + with ( + patch.object( + mgr, + "fetch_gas_price_estimation", + new_callable=AsyncMock, + return_value=0, + ), + pytest.raises(ValueError, match="no gas estimate"), + ): + await mgr.fetch_and_set_gas_price() + + async def test_fetch_gas_price_with_api_key(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = {"gas_estimate": 50} + + opts = GasPriceManagerOptions(node_api_key="secret-key") + + with patch("decibel._gas_price_manager.httpx.AsyncClient") as mock_cls: + ctx = AsyncMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__aenter__ = AsyncMock(return_value=ctx) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mgr = GasPriceManager(config, opts) + result = await mgr.fetch_gas_price_estimation() + + assert result == 100 + ctx.get.assert_awaited_once_with( + f"{config.fullnode_url}/estimate_gas_price", + headers={"x-api-key": "secret-key"}, + ) + + +class TestGasPriceManagerSync: + def test_get_gas_price_returns_none_initially(self, config: DecibelConfig) -> None: + mgr = GasPriceManagerSync(config) + assert mgr.get_gas_price() is None + + def test_fetch_gas_price_estimation(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = {"gas_estimate": 100} + + with patch("decibel._gas_price_manager.httpx.Client") as mock_cls: + ctx = MagicMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__enter__ = MagicMock(return_value=ctx) + mock_cls.return_value.__exit__ = MagicMock(return_value=False) + + mgr = GasPriceManagerSync(config) + result = mgr.fetch_gas_price_estimation() + + assert result == 200 + + def test_fetch_gas_price_estimation_failure(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.is_success = False + mock_response.status_code = 500 + mock_response.text = "error" + + with patch("decibel._gas_price_manager.httpx.Client") as mock_cls: + ctx = MagicMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__enter__ = MagicMock(return_value=ctx) + mock_cls.return_value.__exit__ = MagicMock(return_value=False) + + mgr = GasPriceManagerSync(config) + with pytest.raises(ValueError, match="Failed to fetch"): + mgr.fetch_gas_price_estimation() + + def test_fetch_and_set_gas_price(self, config: DecibelConfig) -> None: + mgr = GasPriceManagerSync(config) + + with patch.object( + mgr, + "fetch_gas_price_estimation", + return_value=500, + ): + result = mgr.fetch_and_set_gas_price() + + assert result == 500 + assert mgr.get_gas_price() == 500 + + def test_fetch_and_set_raises_on_zero(self, config: DecibelConfig) -> None: + mgr = GasPriceManagerSync(config) + + with ( + patch.object( + mgr, + "fetch_gas_price_estimation", + return_value=0, + ), + pytest.raises(ValueError, match="no gas estimate"), + ): + mgr.fetch_and_set_gas_price() + + def test_fetch_gas_price_with_api_key(self, config: DecibelConfig) -> None: + mock_response = MagicMock() + mock_response.is_success = True + mock_response.json.return_value = {"gas_estimate": 50} + + opts = GasPriceManagerOptions(node_api_key="secret-key") + + with patch("decibel._gas_price_manager.httpx.Client") as mock_cls: + ctx = MagicMock() + ctx.get.return_value = mock_response + mock_cls.return_value.__enter__ = MagicMock(return_value=ctx) + mock_cls.return_value.__exit__ = MagicMock(return_value=False) + + mgr = GasPriceManagerSync(config, opts) + result = mgr.fetch_gas_price_estimation() + + assert result == 100 + ctx.get.assert_called_once_with( + f"{config.fullnode_url}/estimate_gas_price", + headers={"x-api-key": "secret-key"}, + )