Skip to content

Add API specification and behavioral test suite#8

Open
gregnazario wants to merge 12 commits intomainfrom
make-python-spec
Open

Add API specification and behavioral test suite#8
gregnazario wants to merge 12 commits intomainfrom
make-python-spec

Conversation

@gregnazario
Copy link
Copy Markdown
Collaborator

Summary

  • Specification documents (docs/SPEC.md, SPEC-REST.md, SPEC-WEBSOCKET.md) documenting the full REST and WebSocket API contracts derived from the official OpenAPI 3.1.0 and AsyncAPI 3.0.0 specs at https://docs.decibel.trade
  • Spec compliance tests (unit, no network) verifying Pydantic model validation, auth headers, error handling, reader topic construction, and negative cases (missing required fields, wrong types, invalid enums)
  • Testnet integration tests verifying the SDK parses real API responses correctly — REST endpoints (markets, prices, candlesticks, asset contexts, trades, leaderboard, vaults) and WebSocket subscriptions (all_market_prices, single market price)
  • SDK bug fix: MarketTrade.is_funding_positive made optional (REST API omits it; it's WebSocket-only per spec Section 9.1)
  • SDK enhancement: Added missing MarketTrade fields (trade_id, order_id, client_order_id, source) that the REST API returns

Test plan

  • uv run pytest tests/ -k "not testnet" — 101 unit/spec tests pass (no network needed)
  • DECIBEL_API_KEY=<key> uv run pytest tests/api_resources/test_testnet_integration.py -v — 21 integration tests pass against live testnet
  • Integration tests auto-skip cleanly without DECIBEL_API_KEY
  • uv run ruff check — all lint checks pass

Copilot AI review requested due to automatic review settings April 7, 2026 23:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds formal API specification documents and a new test suite (unit spec-compliance + optional live testnet integration) to validate the SDK’s REST/WebSocket contracts, alongside a small MarketTrade model update to match observed server payloads.

Changes:

  • Added REST/WebSocket/API-wide specification documents under docs/.
  • Added REST + WebSocket spec-compliance behavioral tests (mocked/unit) and optional live testnet integration tests.
  • Updated MarketTrade model fields to better align with REST/WS payload differences.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/api_resources/conftest.py Adds shared fixtures + a mock httpx transport for request/response assertions.
tests/api_resources/test_rest_spec_compliance.py Unit tests for REST reader request shapes, auth header behavior, error parsing, and Pydantic validation negative cases.
tests/api_resources/test_websocket_spec_compliance.py Unit tests for WS subscribe/unsubscribe message format, topic construction, WS payload model parsing, and bigint parsing.
tests/api_resources/test_testnet_integration.py Live testnet integration tests (auto-skipped without DECIBEL_API_KEY) for REST endpoints + WS subscription smoke tests.
src/decibel/read/_market_trades.py Extends MarketTrade fields and relaxes some fields to be optional to accommodate REST/WS differences.
docs/SPEC.md Adds overall SDK/API contract specification and cross-links to REST/WS specs.
docs/SPEC-REST.md Adds detailed REST endpoint + schema specification derived from OpenAPI.
docs/SPEC-WEBSOCKET.md Adds detailed WebSocket protocol/topic/schema specification derived from AsyncAPI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +352 to +357
"is_buy": False,
"is_reduce_only": False,
"is_tpsl": False,
"details": "",
"tp_order_id": None,
"tp_trigger_price": None,
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WebSocket spec includes subscribe/unsubscribe response messages like {"success": true, "message": "Subscribed ..."} that do not contain a "topic" field (see docs/SPEC-WEBSOCKET.md §2.2). This test currently asserts those messages should raise, which would encode spec-noncompliant behavior and encourages treating normal server responses as errors. Update the expectation to accept/ignore these non-topic response messages (and adjust DecibelWsSubscription._parse_message accordingly).

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +52
@pytest.fixture(scope="module")
def first_market_addr(read: DecibelReadDex) -> str:
"""Get a valid market address from testnet for parameterized tests."""
markets = asyncio.get_event_loop().run_until_complete(read.markets.get_all())
assert len(markets) > 0, "Testnet should have at least one market"
return markets[0].market_addr
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture calls asyncio.get_event_loop().run_until_complete(), which is fragile under pytest-asyncio (asyncio_mode=auto) and can break when an event loop is already running (or create loop mismatch warnings). Prefer making this an async fixture and awaiting read.markets.get_all() directly.

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +184
now_ms = int(time.time() * 1000)
candles = await read.candlesticks.get_by_name(
# Use market name from first market
market_name=(await read.markets.get_all())[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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first_market_addr is injected into this test but never used; the test fetches markets again and uses markets[0] instead. This adds an extra live-network call and makes failures harder to diagnose. Either remove the unused fixture parameter or use it to supply the market address/name for the request.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +133

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 <KEY> 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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description and docs/SPEC-REST.md state auth header behavior is validated and that Origin is a required header, but these tests only assert Authorization and never check Origin. Either (a) add Origin header support in get_request/_base_request_async and assert it here, or (b) update the spec docs to reflect what the SDK actually sends.

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +274
### 8.1 Language & Runtime
- Python 3.10+ (for union type syntax and modern typing)
- 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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec says Python 3.10+ here, but pyproject.toml sets requires-python = ">=3.11" (and ruff/pyright target 3.11). Please update this section to avoid publishing a minimum version that the package doesn't actually support.

Copilot uses AI. Check for mistakes.
Comment on lines 26 to 41
account: str
market: str
action: str
trade_id: str | int | None = None
size: float
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
order_id: str | None = None
client_order_id: str | None = None
source: str | None = None
transaction_unix_ms: int
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several fields that appear to be required in both REST and WS trade payloads (e.g., trade_id, order_id, client_order_id) are declared optional with default None. This weakens validation and conflicts with the new spec docs that treat these as required fields (docs/SPEC-REST.md §11.4; docs/SPEC-WEBSOCKET.md §3.4). Prefer keeping always-present fields required (no default), and if REST/WS differ, use separate Pydantic models (or a discriminated wrapper) so each surface remains strict without leaking None into the public API.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 8, 2026 00:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

class UserTradesResponse(BaseModel):
items: list[UserTrade]
total_count: int
total_count: int = 0
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total_count is defined with a default (= 0), which makes the field optional during validation. For REST trade_history responses this can silently accept malformed/incomplete server payloads and return an incorrect total_count (0) to callers. Prefer keeping total_count required (no default) or making it explicitly optional (int | None) and handling the fallback in the reader layer when appropriate.

Suggested change
total_count: int = 0
total_count: int

Copilot uses AI. Check for mistakes.

items: list[UserOrder]
total_count: int
total_count: int = 0
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total_count is currently defaulted to 0, which makes it optional during model validation. Since UserOrderHistoryReader.get_by_addr() is parsing a paginated REST response, this risks masking missing/invalid total_count values and returning incorrect metadata to SDK consumers. Consider keeping total_count required (no default) or making it optional and computing a fallback outside the Pydantic model only when the API truly omits it.

Suggested change
total_count: int = 0
total_count: int

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +98
@pytest.fixture(scope="module")
def eth_market(read_client):
"""Get ETH market info for order tests."""
markets = asyncio.get_event_loop().run_until_complete(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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture uses asyncio.get_event_loop().run_until_complete(...) to call async SDK methods. Under Python 3.12+ (and with pytest-asyncio managing loops) this can raise RuntimeError when no loop is set, or interfere with the test event loop. Prefer converting the fixture to async and await read_client.markets.get_all() (or otherwise avoid run_until_complete).

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 8, 2026 02:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 13 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

class UserTwapHistoryResponse(BaseModel):
items: list[UserActiveTwap]
total_count: int
total_count: int = 0
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total_count defaulting to 0 can silently change the meaning of responses when the server omits the field (e.g., items may be non-empty but total_count becomes 0). Consider making this field int | None = None (unknown) or keeping it required and handling the server omission at the request/parsing layer so pagination logic can’t misinterpret a missing count as an empty result set.

Suggested change
total_count: int = 0
total_count: int | None = None

Copilot uses AI. Check for mistakes.
Comment on lines 42 to 46

class UserTradesResponse(BaseModel):
items: list[UserTrade]
total_count: int
total_count: int = 0

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern as other paginated responses: defaulting total_count to 0 conflates “missing/unknown total” with “zero total”. This can break pagination logic downstream. Prefer int | None = None (and document it) or enforce presence of total_count and fail fast when the API response is malformed.

Copilot uses AI. Check for mistakes.

items: list[UserOrder]
total_count: int
total_count: int = 0
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting total_count to 0 risks misrepresenting responses when the field is omitted (unknown vs zero). If this is intended to tolerate missing server fields, consider using total_count: int | None = None and/or adding a validator that sets it to len(items) only when that’s the desired fallback.

Suggested change
total_count: int = 0
total_count: int | None = None

Copilot uses AI. Check for mistakes.
class UserFundingHistoryResponse(BaseModel):
items: list[UserFunding]
total_count: int
total_count: int = 0
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total_count: int = 0 has ambiguous semantics for a paginated response: missing/unknown total gets treated as zero. Consider changing it to int | None = None (unknown) or ensuring the SDK always receives total_count so callers relying on it don’t silently get incorrect pagination info.

Suggested change
total_count: int = 0
total_count: int | None = None

Copilot uses AI. Check for mistakes.
Comment on lines +431 to +439
assert "prices" in data

def test_non_topic_message_raises(self) -> None:
"""Messages without 'topic' field SHALL raise ValueError."""
ws = DecibelWsSubscription(TESTNET_CONFIG, api_key="test")
raw = json.dumps({"success": True, "message": "Subscribed"})
with pytest.raises(ValueError, match="missing topic field"):
ws._parse_message(raw)

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that any message without a topic must raise. However, SPEC-WEBSOCKET.md in this PR defines subscribe/unsubscribe responses as {success, message} without topic. To keep the spec, docs, and tests consistent (and to avoid treating normal ACK frames as errors), consider updating the expected behavior here to treat {success, message} frames as non-data messages that are ignored (e.g., return None) rather than raising.

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +159
with patch.object(ws, "_open", new_callable=AsyncMock):
unsub = ws.subscribe("t", MagicMock, MagicMock())

unsub()
# unsubscribe message sent via create_task
await asyncio.sleep(0)
mock_conn.send.assert_awaited()

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unsubscribe will schedule _delayed_close() since it removes the last topic, which can leave a pending task (0.5s sleep) at test teardown. To avoid leaked tasks, consider cancelling/awaiting ws._close_timer_task (or patching _delayed_close/asyncio.sleep) before the test exits.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +130
**Required Headers:**

| Header | Value | Required |
|--------|-------|----------|
| `Authorization` | `Bearer <API_KEY>` | YES |
| `Origin` | Application origin URL (e.g., `https://app.decibel.trade`) | YES |

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec here marks Origin as a required REST header, but the SDK’s request helpers (decibel._utils._base_request_async/_base_request_sync) only add Authorization and (for PATCH/POST) Content-Type. Either update the spec to clarify Origin is browser/CORS-specific (not required for this SDK), or implement adding a configurable Origin header in the SDK so the docs and implementation match.

Suggested change
**Required Headers:**
| Header | Value | Required |
|--------|-------|----------|
| `Authorization` | `Bearer <API_KEY>` | YES |
| `Origin` | Application origin URL (e.g., `https://app.decibel.trade`) | YES |
**Headers:**
| Header | Value | Required |
|--------|-------|----------|
| `Authorization` | `Bearer <API_KEY>` | YES |
| `Origin` | Application origin URL (e.g., `https://app.decibel.trade`) | Browser/CORS-specific |
The `Origin` header is not required for requests made by this Python SDK. It is a browser-managed header used in CORS scenarios and may be sent automatically by browser-based clients.

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +108
### 2.2 Subscribe Response (Success)

```json
{
"success": true,
"message": "Subscribed to user_open_orders:0x1234..."
}
```
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subscribe/unsubscribe response examples omit topic, but the rest of the spec and SDK parsing logic typically use topic to distinguish data frames. If ACK frames truly have no topic (as shown), the SDK/tests should treat {success, message} frames as ignorable control messages. Consider clarifying this section (e.g., explicitly stating ACK frames have no topic and must be ignored by clients) so it’s unambiguous for implementers.

Copilot uses AI. Check for mistakes.
Comment on lines +398 to +450
### 4.2 User Positions

**Topic:** `user_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:** `user_open_orders:{userAddr}`

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Topic names here (user_positions:{userAddr}, user_open_orders:{userAddr}, etc.) don’t match the current SDK reader topics (e.g., UserPositionsReader.subscribe_by_addr() uses account_positions:{sub_addr} in src/decibel/read/_user_positions.py). Either update this spec to reflect the actual server/SKD topic strings, or update the SDK (and compliance tests) so the documented contract and implementation agree.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 8, 2026 13:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +398 to +406
### 4.2 User Positions

**Topic:** `user_positions:{userAddr}`

**Message Schema:**
```json
{
"topic": "user_positions:0x1234...",
"positions": [
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK’s UserPositionsReader.subscribe_by_addr() uses the topic account_positions:{sub_addr} (see src/decibel/read/_user_positions.py:83), but this spec section documents user_positions:{userAddr}. Please update the documented topic/message examples (or update the SDK) so the spec and implementation match.

Copilot uses AI. Check for mistakes.
Comment on lines +447 to +455
### 4.3 User Open Orders

**Topic:** `user_open_orders:{userAddr}`

**Message Schema:**
```json
{
"topic": "user_open_orders:0x1234...",
"orders": [
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK’s UserOpenOrdersReader.subscribe_by_addr() uses the topic account_open_orders:{sub_addr} (see src/decibel/read/_user_open_orders.py:90), but this spec section documents user_open_orders:{userAddr}. Please align the spec with the implemented topic (or vice versa).

Copilot uses AI. Check for mistakes.
Comment on lines +906 to +913
| Market Candlestick | `market_candlestick:{marketAddr}:{interval}` | market address, interval |
| Account Overview | `account_overview:{userAddr}` | subaccount address |
| User Positions | `user_positions:{userAddr}` | subaccount address |
| User Open Orders | `user_open_orders:{userAddr}` | subaccount address |
| User Order History | `user_order_history:{userAddr}` | subaccount address |
| User Trade History | `user_trade_history:{userAddr}` | subaccount address |
| User Trades | `user_trades:{userAddr}` | subaccount address |
| User Funding History | `user_funding_rate_history:{userAddr}` | subaccount address |
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Topic String Format table, the documented patterns for User Positions and User Open Orders (user_positions:*, user_open_orders:*) don’t match the SDK’s current subscription topics (account_positions:*, account_open_orders:*). This table should be updated to reflect the actual topic names used by the SDK (or the SDK updated to match the spec).

Copilot uses AI. Check for mistakes.
class UserTwapHistoryResponse(BaseModel):
items: list[UserActiveTwap]
total_count: int
total_count: int | None = None
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total_count was changed from required to optional here. This diverges from the spec docs in this PR that describe paginated responses as always including total_count. Either document the omission explicitly (so callers handle None) or keep the field required to catch API regressions.

Suggested change
total_count: int | None = None
total_count: int

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +152
def from_env(cls) -> BotConfig:
private_key = os.environ.get("PRIVATE_KEY", "")
api_key = os.environ.get("APTOS_NODE_API_KEY", "")
if not private_key or not api_key:
print("Error: PRIVATE_KEY and APTOS_NODE_API_KEY must be set")
print("See docstring at top of this file for setup instructions")
sys.exit(1)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example reads the API key from APTOS_NODE_API_KEY and passes it as DecibelReadDex(..., api_key=...), but the rest of the repo (including the integration tests) refers to this as DECIBEL_API_KEY. If these are different credentials (Decibel trading API key vs Aptos node API key), the example should accept them separately to avoid confusing users and misconfiguring auth headers.

Copilot uses AI. Check for mistakes.
Comment on lines +301 to +305
if self.state.phase == BotPhase.WAITING_FOR_PRICE:
# Schedule the buy order placement (can't await in a sync callback)
asyncio.get_event_loop().call_soon(
lambda: asyncio.ensure_future(self._place_buy_order())
)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using asyncio.get_event_loop() + asyncio.ensure_future() inside callbacks can be brittle and may emit deprecation warnings on newer Python versions. Since these callbacks should already be running on the event loop, prefer asyncio.get_running_loop().create_task(...) (or asyncio.create_task(...)) to schedule _place_buy_order() / _place_sell_order().

Copilot uses AI. Check for mistakes.
Comment on lines +482 to +483
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, bot.stop)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loop.add_signal_handler(...) is not supported on some platforms (notably Windows) and will raise NotImplementedError. Since this is an example intended to be runnable, consider guarding this with a try/except (or platform check) so the script still works without signal handlers.

Suggested change
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, bot.stop)
try:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, bot.stop)
except NotImplementedError:
log.warning("Asyncio signal handlers are not supported on this platform; continuing without them")

Copilot uses AI. Check for mistakes.
Comment on lines +494 to +513
### 4.4 User Order History

**Topic:** `user_order_history:{userAddr}`

**Message Schema:**
```json
{
"topic": "user_order_history:0x1234...",
"orders": [ ... ]
}
```

**Payload Type:** `UserOrderHistoryResponse` (same shape as UserOpenOrdersResponse)

---

### 4.5 User Trade History

**Topic:** `user_trade_history:{userAddr}`

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK does not subscribe to user_order_history:{addr} or user_trade_history:{addr} topics (order updates use order_updates:{addr} and trades use user_trades:{addr} in the readers). Either update these documented WebSocket topics to the ones the SDK actually uses, or implement the missing topics so the spec and behavior are consistent.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 8, 2026 15:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

gas_station_url=None,
gas_station_api_key=None,
deployment=_DEPLOYMENT,
chain_id=1,
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The testnet config fixture sets chain_id=1, but Network.TESTNET uses chain id 2 (see src/decibel/_constants.py:132-141). Using the wrong chain id in shared fixtures can hide bugs in any code paths that rely on config.chain_id.

Suggested fix: set chain_id=2 in this fixture (or reuse TESTNET_CONFIG as the base and override only the URLs you need).

Suggested change
chain_id=1,
chain_id=2,

Copilot uses AI. Check for mistakes.

3. Run:

uv run python examples/buy_low_sell_high_bot.py
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring run command uses the wrong path. This file lives under examples/bots/, but the instructions say uv run python examples/buy_low_sell_high_bot.py, which will fail.

Suggested fix: update the command to uv run python examples/bots/buy_low_sell_high_bot.py.

Suggested change
uv run python examples/buy_low_sell_high_bot.py
uv run python examples/bots/buy_low_sell_high_bot.py

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +169
Paginated responses SHALL return:

```json
{
"items": [ ... ],
"total_count": 42
}
```
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section states that paginated responses SHALL always include total_count, but several SDK response models in this PR were relaxed to total_count: int | None = None (e.g. src/decibel/read/_user_order_history.py, _user_trade_history.py, _user_funding_history.py, _user_twap_history.py).

Suggested fix: either (a) keep total_count required in the SDK models and treat missing total_count as a server/spec violation, or (b) update the spec docs here (and in SPEC-REST) to document that total_count may be omitted/null for specific endpoints.

Copilot uses AI. Check for mistakes.
Comment on lines +1192 to +1202
### 11.8 PaginatedResponse\<T\>

```json
{
"items": [ T, ... ],
"total_count": 42
}
```

All paginated responses SHALL include `items` (array) and `total_count` (int32).

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PaginatedResponse<T> is documented here as always including a non-null total_count, but some SDK paginated response models were changed in this PR to allow missing total_count (set to int | None = None).

Suggested fix: align the spec with actual SDK/server behavior by either making total_count optional in this schema (and noting which endpoints are affected), or keep it required and update the SDK to fail validation when it’s missing.

Copilot uses AI. Check for mistakes.
Comment on lines +400 to +405
**Topic:** `user_positions:{userAddr}`

**Message Schema:**
```json
{
"topic": "user_positions:0x1234...",
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WebSocket topic names documented here (e.g. user_positions:{userAddr} / user_open_orders:{userAddr}) don’t match the SDK’s actual subscription topics (e.g. account_positions:{sub_addr} and account_open_orders:{sub_addr} in src/decibel/read/_user_positions.py:83 and _user_open_orders.py:90).

Suggested fix: update this section’s topic strings to match the SDK/server (or, if the spec is authoritative, update the SDK topic construction + compliance tests accordingly).

Suggested change
**Topic:** `user_positions:{userAddr}`
**Message Schema:**
```json
{
"topic": "user_positions:0x1234...",
**Topic:** `account_positions:{sub_addr}`
**Message Schema:**
```json
{
"topic": "account_positions:0x1234...",

Copilot uses AI. Check for mistakes.
Comment on lines +906 to +914
| Market Candlestick | `market_candlestick:{marketAddr}:{interval}` | market address, interval |
| Account Overview | `account_overview:{userAddr}` | subaccount address |
| User Positions | `user_positions:{userAddr}` | subaccount address |
| User Open Orders | `user_open_orders:{userAddr}` | subaccount address |
| User Order History | `user_order_history:{userAddr}` | subaccount address |
| User Trade History | `user_trade_history:{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 |
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The topic string table lists user_positions, user_open_orders, and user_order_history, but the SDK currently uses account_positions, account_open_orders, and does not construct a user_order_history:{addr} topic (order updates use order_updates:{addr} instead).

Suggested fix: reconcile this table with the SDK’s actual topics (or update the SDK/readers + tests if the spec is intended to be authoritative).

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 78
@@ -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]

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MarketTradeWsMessage is part of the public API (decibel.read exports it), but its trades field is typed as list[_WsTradeItem] where _WsTradeItem is a private, non-exported class. This makes it awkward for SDK consumers to type-annotate WS trade callbacks without importing a private symbol.

Suggested fix: rename _WsTradeItem to a public model (e.g. MarketTradeWsItem) and export it (or type trades as list[MarketTrade] if you intentionally want one unified public type).

Copilot uses AI. Check for mistakes.
- Add docs/SPEC.md, SPEC-REST.md, SPEC-WEBSOCKET.md documenting all REST
  and WebSocket API contracts from the official OpenAPI/AsyncAPI specs
- Add spec compliance tests (unit) verifying model validation, auth headers,
  error handling, reader topic construction, and negative cases
- Add testnet integration tests verifying SDK against live API (auto-skip
  without DECIBEL_API_KEY env var)
- Fix MarketTrade.is_funding_positive: make optional (REST API omits it,
  WebSocket-only field per spec)
- Add missing MarketTrade fields: trade_id, order_id, client_order_id, source
- Add CLAUDE.md with pre-commit checklist and project conventions
- Run ruff format on test files to pass CI format check
- Add test_testnet_write_integration.py covering: mint, subaccount,
  deposit/withdraw, order place/query/cancel, and authenticated read
  endpoints (positions, orders, trades, funds, delegations)
- Fix UserTradesResponse.total_count: default to 0 (API omits it)
- Fix UserOrders.total_count: default to 0 (API omits it)
- Fix UserTrade.is_funding_positive: make optional (REST omits it)
124 new unit tests covering:
- _pagination: construct_known_query_params (97%)
- _order_status: status parsing and helper methods (95%)
- _exceptions: TxnSubmitError/TxnConfirmError init (100%)
- _utils: prettify_validation_error, sync request wrappers,
  address generators, FetchError edge cases (88%)
- _gas_price_manager: auth headers, fetch/set gas price,
  async and sync variants (66%)
- read/_ws: subscribe/unsubscribe, ready_state, reset,
  reconnect scheduling (59%)

Total coverage: 53% -> 70% (with integration tests)
New integration tests for previously untested endpoints:
- market_prices.get_by_name (single market)
- portfolio_chart.get_by_addr
- user_funding_history.get_by_addr
- user_twap_history.get_by_addr
- user_bulk_orders.get_by_addr
- vaults.get_user_owned_vaults
- vaults.get_user_performances_on_vaults

SDK fixes found by live testing:
- UserFundingHistoryResponse.total_count: default to 0 (API omits)
- UserTwapHistoryResponse.total_count: default to 0 (API omits)

All 28 read integration tests + 16 write integration tests pass
against live testnet with zero JSON mismatches.
New integration tests:
- markets.list_market_addresses() — on-chain view
- markets.market_name_by_address() — on-chain view
- markets.get_by_name() — documents known bug (wrong resource type)
- vaults.get_vault_share_price() — on-chain view
- trading_points.get_by_owner() — REST endpoint

SDK fixes:
- UserFundingHistoryResponse.total_count: default to 0 (API omits)
- UserTwapHistoryResponse.total_count: default to 0 (API omits)

Every SDK reader method is now tested against live testnet:
  26 REST endpoints + 4 on-chain views + 2 WebSocket channels = 33 tests
1. Fix _parse_message to return None for subscribe/unsubscribe responses
   instead of raising ValueError (spec §2.2 compliance)
2. Remove fragile get_event_loop().run_until_complete() from fixtures,
   use asyncio.run() instead
3. Remove unused first_market_addr fixture and parameter
4. Update SPEC docs: remove Origin as required header (SDK doesn't send
   it), fix Python version 3.10+ -> 3.11+
5. Split MarketTrade into strict REST model (trade_id, order_id,
   client_order_id, source all required) and separate _WsTradeItem
   (has is_funding_positive, no source)
6. Change total_count from default=0 to int|None on UserOrders,
   UserTradesResponse, UserFundingHistoryResponse, UserTwapHistoryResponse
   to avoid silently masking missing API data
End-to-end trading bot demonstrating the full SDK surface:
- REST API: market info, account overview
- WebSocket: real-time price feed, order update stream
- Write SDK: place_order, cancel lifecycle
- State machine: WAITING -> BUY_PLACED -> SELL_PLACED -> repeat

Configurable via env vars: MARKET, BUY_SPREAD_PCT, SELL_SPREAD_PCT,
ORDER_SIZE_USD, NETWORK. Includes setup instructions in docstring.
- Add asyncio.Lock to prevent race condition where multiple price ticks
  fire concurrent buy order placements
- Transition phase inside the lock BEFORE the async order call
- Round order size down to nearest lot_size multiple (fixes
  ESIZE_NOT_RESPECTING_LOT_SIZE on-chain error)
- Remove debug print of private key
- Move buy_low_sell_high_bot.py into examples/bots/
- Add .env.example with all config vars documented (no real creds)
Cover the new ConnectTimeout/ReadTimeout/ConnectError retry paths
added in main (PR #9). Tests verify:
- Retries on each error type then succeeds
- Times out with TxnConfirmError after retries exhausted
Spec docs:
- Fix WS topic names to match SDK: user_positions -> account_positions,
  user_open_orders -> account_open_orders
- Remove user_order_history/user_trade_history WS sections (SDK uses
  order_updates and user_trades topics instead)
- Add note clarifying ACK frames have no topic field
- Add note explaining AsyncAPI vs actual server topic divergence

Bot example:
- Replace asyncio.get_event_loop() with get_running_loop().create_task()
- Guard add_signal_handler with try/except NotImplementedError for Windows
- Accept DECIBEL_API_KEY in addition to APTOS_NODE_API_KEY
- Update .env.example to use DECIBEL_API_KEY

Tests:
- Patch _delayed_close in WS unsubscribe tests to prevent leaked tasks
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `account` | string | Yes | Subaccount address |
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section documents account as the query parameter for /api/v1/open_orders, but the current SDK uses user (see src/decibel/read/_user_open_orders.py:69). Please align the spec with the SDK (or update the SDK to match the OpenAPI contract) so users and the spec compliance tests don’t diverge.

Suggested change
| `account` | string | Yes | Subaccount address |
| `user` | string | Yes | Subaccount address |

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +403
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `account` | string | Yes | Subaccount address |
| `limit` | int32 | Yes | Max results |
| `offset` | int32 | Yes | Pagination offset |
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section documents account as the query parameter for /api/v1/order_history, but the SDK currently sends user (see src/decibel/read/_user_order_history.py:80). Please reconcile the spec vs implementation and consider adding a compliance test to lock the expected param name.

Copilot uses AI. Check for mistakes.
Comment on lines +447 to +452
**WebSocket TP/SL price fields** are `int64` (chain units), unlike REST which uses `float64`.

---

### 4.3 User Open Orders

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec says the open orders topic is user_open_orders:{userAddr}, but the SDK currently subscribes with account_open_orders:{sub_addr} (see src/decibel/read/_user_open_orders.py:90). Please either update the spec to match the implementation or fix the SDK topic string so it matches the AsyncAPI contract.

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +80
# SPEC Section 9.3 — Topic strings constructed by actual readers
# ---------------------------------------------------------------------------


class TestReaderTopicConstruction:
"""Verify actual readers construct correct topic strings per spec Section 9.3."""

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The topic-construction suite doesn’t currently cover UserOpenOrdersReader.subscribe_by_addr(). Given the spec in this PR uses user_open_orders:{addr} while the SDK uses account_open_orders:{addr} (src/decibel/read/_user_open_orders.py:90), please add an explicit test here to catch topic drift and force alignment with the spec.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +11
# Required: API key from Geomi (https://geomi.dev)
# Either DECIBEL_API_KEY or APTOS_NODE_API_KEY is accepted
DECIBEL_API_KEY="aptoslabs_..."

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name APTOS_NODE_API_KEY suggests an Aptos fullnode API key, but the comment says it’s the Decibel/Geomi API key. Since the SDK uses different keys for trading REST/WS auth vs fullnode rate limits, consider splitting this into two env vars (e.g., DECIBEL_API_KEY for DecibelReadDex(api_key=...) and APTOS_NODE_API_KEY for BaseSDKOptions(node_api_key=...)) to avoid misconfiguration.

Suggested change
# Required: API key from Geomi (https://geomi.dev)
# Either DECIBEL_API_KEY or APTOS_NODE_API_KEY is accepted
DECIBEL_API_KEY="aptoslabs_..."
# Required: Decibel/Geomi trading API key (https://geomi.dev)
DECIBEL_API_KEY="aptoslabs_..."
# Optional: Aptos fullnode API key for node rate limits, if your node provider requires one
APTOS_NODE_API_KEY=""

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +157
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,
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BotConfig.from_env() reads APTOS_NODE_API_KEY into api_key, and later uses it both for DecibelReadDex(api_key=...) (trading REST/WS auth) and BaseSDKOptions(node_api_key=...) (Aptos fullnode header). These are not necessarily the same credential; consider using separate config fields / env vars to avoid accidentally sending the wrong key to either service.

Suggested change
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,
decibel_api_key: str
aptos_node_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
@property
def api_key(self) -> str:
"""Backward-compatible alias for the Decibel trading API key."""
return self.decibel_api_key
@classmethod
def from_env(cls) -> BotConfig:
private_key = os.environ.get("PRIVATE_KEY", "")
decibel_api_key = os.environ.get("DECIBEL_API_KEY", "")
aptos_node_api_key = os.environ.get("APTOS_NODE_API_KEY", "")
if not private_key or not decibel_api_key:
print("Error: PRIVATE_KEY and DECIBEL_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,
decibel_api_key=decibel_api_key,
aptos_node_api_key=aptos_node_api_key,

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +8
Requires two env vars:
DECIBEL_API_KEY - API key for testnet
DECIBEL_PRIVATE_KEY - Private key of a funded testnet account
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This integration test treats DECIBEL_API_KEY as both the trading API key (used for DecibelReadDex(api_key=...)) and the Aptos fullnode API key (passed as BaseSDKOptions(node_api_key=...)). If those are distinct credentials in practice, consider requiring a separate env var for the node API key (or leaving node_api_key unset) and update the module docstring accordingly to prevent false-negative setup failures.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +27
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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suite claims to verify that readers send correct query parameters, but it currently doesn’t include reader-level tests for /api/v1/open_orders and /api/v1/order_history (only the UserOpenOrder/UserPosition models are referenced). Adding transport-based tests for those readers would help catch spec/implementation drift (e.g., whether the query key is account vs user).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants