eb5f0148ad
end_date as a bare date now covers the whole UTC day (23:59:59.999) instead of stopping at midnight, so end_date=today returns intraday up to the last closed candle. Dates are parsed as explicit UTC and inline timestamps (YYYY-MM-DDTHH:MM:SS) are honored for precise windows. Wide ranges are paged in <=5000-candle windows so Deribit's per-call cap can no longer silently drop start_date and the oldest candles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
473 lines
16 KiB
Python
473 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
from cerbero_mcp.exchanges.deribit.client import DeribitClient
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return DeribitClient(client_id="cid", client_secret="csec", testnet=True)
|
|
|
|
|
|
AUTH_RESP = {"result": {"access_token": "tok", "expires_in": 3600}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_ticker(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
# public endpoint — no auth needed
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/ticker"),
|
|
json={
|
|
"result": {
|
|
"mark_price": 50000,
|
|
"last_price": 49900,
|
|
"best_bid_price": 49950,
|
|
"best_ask_price": 50050,
|
|
"instrument_name": "BTC-PERPETUAL",
|
|
"stats": {"volume": 1234.5},
|
|
"open_interest": 9999,
|
|
"greeks": None,
|
|
"mark_iv": None,
|
|
}
|
|
},
|
|
)
|
|
result = await client.get_ticker("BTC-PERPETUAL")
|
|
assert result["mark_price"] == 50000
|
|
assert result["bid"] == 49950
|
|
assert result["ask"] == 50050
|
|
# CER-003: perpetual returns conceptual greeks, not None
|
|
assert result["greeks"] == {"delta": 1.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0}
|
|
# CER-007: testnet flag present
|
|
assert result["testnet"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_ticker_option_preserves_greeks(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
real_greeks = {"delta": 0.42, "gamma": 0.001, "vega": 0.05, "theta": -0.02, "rho": 0.003}
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/ticker"),
|
|
json={
|
|
"result": {
|
|
"mark_price": 2500,
|
|
"last_price": 2500,
|
|
"best_bid_price": 2490,
|
|
"best_ask_price": 2510,
|
|
"instrument_name": "BTC-30APR26-75000-C",
|
|
"stats": {"volume": 5.0},
|
|
"open_interest": 100,
|
|
"greeks": real_greeks,
|
|
"mark_iv": 62.5,
|
|
}
|
|
},
|
|
)
|
|
result = await client.get_ticker("BTC-30APR26-75000-C")
|
|
assert result["greeks"] == real_greeks
|
|
assert result["mark_iv"] == 62.5
|
|
|
|
|
|
def test_is_testnet(client: DeribitClient):
|
|
info = client.is_testnet()
|
|
assert info["testnet"] is True
|
|
assert "test.deribit.com" in info["base_url"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_instruments_pagination_and_filter(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
items = []
|
|
for i, exp_ms in enumerate([1700000000000, 1776000000000, 1800000000000]):
|
|
items.append({
|
|
"instrument_name": f"BTC-inst-{i}",
|
|
"strike": 50000 + i * 10000,
|
|
"expiration_timestamp": exp_ms,
|
|
"option_type": "call",
|
|
"tick_size": 0.5,
|
|
"min_trade_amount": 0.1,
|
|
# CER-008: public/get_instruments non include OI in produzione
|
|
})
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_instruments"),
|
|
json={"result": items},
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_book_summary_by_currency"),
|
|
json={"result": [
|
|
{"instrument_name": "BTC-inst-0", "open_interest": 100.0},
|
|
{"instrument_name": "BTC-inst-1", "open_interest": 200.0},
|
|
{"instrument_name": "BTC-inst-2", "open_interest": 300.0},
|
|
]},
|
|
)
|
|
result = await client.get_instruments(
|
|
"BTC", kind="option", strike_min=55000, limit=1, offset=0
|
|
)
|
|
assert result["total"] == 2
|
|
assert len(result["instruments"]) == 1
|
|
assert result["has_more"] is True
|
|
assert result["testnet"] is True
|
|
assert result["instruments"][0]["strike"] >= 55000
|
|
# CER-008: OI merge da book_summary
|
|
assert result["instruments"][0]["open_interest"] in (200.0, 300.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_instruments_min_oi_filter(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
"""CER-008: min_open_interest filtra server-side usando book_summary."""
|
|
items = [
|
|
{"instrument_name": "BTC-low-OI", "strike": 60000, "expiration_timestamp": 1800000000000,
|
|
"option_type": "call", "tick_size": 0.5, "min_trade_amount": 0.1},
|
|
{"instrument_name": "BTC-high-OI", "strike": 60000, "expiration_timestamp": 1800000000000,
|
|
"option_type": "call", "tick_size": 0.5, "min_trade_amount": 0.1},
|
|
]
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_instruments"),
|
|
json={"result": items},
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_book_summary_by_currency"),
|
|
json={"result": [
|
|
{"instrument_name": "BTC-low-OI", "open_interest": 5.0},
|
|
{"instrument_name": "BTC-high-OI", "open_interest": 500.0},
|
|
]},
|
|
)
|
|
result = await client.get_instruments("BTC", kind="option", min_open_interest=100)
|
|
assert result["total"] == 1
|
|
assert result["instruments"][0]["name"] == "BTC-high-OI"
|
|
assert result["instruments"][0]["open_interest"] == 500.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_account_summary(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json=AUTH_RESP,
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/get_account_summary"),
|
|
json={"result": {"equity": 1000.0, "balance": 900.0, "currency": "USDC",
|
|
"margin_balance": 800.0, "available_funds": 700.0,
|
|
"unrealized_pnl": 50.0, "total_pnl": 100.0}},
|
|
)
|
|
result = await client.get_account_summary("USDC")
|
|
assert result["equity"] == 1000.0
|
|
assert result["balance"] == 900.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upstream_5xx_raises_clean_http_error(
|
|
httpx_mock: HTTPXMock, client: DeribitClient
|
|
):
|
|
"""Upstream Deribit 5xx (non-JSON body) → HTTPException 502, non JSONDecodeError."""
|
|
from fastapi import HTTPException
|
|
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"),
|
|
status_code=502,
|
|
text="<html>Bad Gateway</html>",
|
|
)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await client.get_historical(
|
|
instrument="BTC-PERPETUAL",
|
|
start_date="2026-05-09T00:00:00",
|
|
end_date="2026-05-10T00:00:00",
|
|
resolution="1h",
|
|
)
|
|
assert exc_info.value.status_code == 502
|
|
assert "Deribit upstream" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_historical_end_date_is_day_inclusive(
|
|
httpx_mock: HTTPXMock, client: DeribitClient
|
|
):
|
|
"""Bare end_date covers the whole UTC day (23:59:59.999), not just midnight,
|
|
so 'end_date = today' returns intraday up to now instead of stopping at 00:00."""
|
|
import datetime as dt
|
|
|
|
start = dt.datetime(2026, 5, 20, tzinfo=dt.UTC)
|
|
start_ms = int(start.timestamp() * 1000)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"),
|
|
json={
|
|
"result": {
|
|
"ticks": [start_ms, start_ms + 3_600_000],
|
|
"open": [100.0, 100.5],
|
|
"high": [101.0, 101.5],
|
|
"low": [99.0, 99.5],
|
|
"close": [100.5, 101.0],
|
|
"volume": [10.0, 11.0],
|
|
}
|
|
},
|
|
)
|
|
result = await client.get_historical(
|
|
instrument="BTC-PERPETUAL",
|
|
start_date="2026-05-20",
|
|
end_date="2026-05-28",
|
|
resolution="1h",
|
|
)
|
|
assert len(result["candles"]) == 2
|
|
|
|
expected_start = start_ms
|
|
expected_end = int(
|
|
dt.datetime(2026, 5, 28, 23, 59, 59, 999_000, tzinfo=dt.UTC).timestamp()
|
|
* 1000
|
|
)
|
|
params = httpx_mock.get_requests()[0].url.params
|
|
assert int(params["start_timestamp"]) == expected_start
|
|
assert int(params["end_timestamp"]) == expected_end
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_historical_explicit_time_is_honored(
|
|
httpx_mock: HTTPXMock, client: DeribitClient
|
|
):
|
|
"""An end_date carrying an explicit time is passed through as-is, not bumped
|
|
to end-of-day — clients can request precise intraday windows."""
|
|
import datetime as dt
|
|
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"),
|
|
json={"result": {"ticks": [], "open": [], "high": [], "low": [],
|
|
"close": [], "volume": []}},
|
|
)
|
|
await client.get_historical(
|
|
instrument="BTC-PERPETUAL",
|
|
start_date="2026-05-28T09:00:00",
|
|
end_date="2026-05-28T14:00:00",
|
|
resolution="1h",
|
|
)
|
|
params = httpx_mock.get_requests()[0].url.params
|
|
expected_end = int(
|
|
dt.datetime(2026, 5, 28, 14, 0, 0, tzinfo=dt.UTC).timestamp() * 1000
|
|
)
|
|
assert int(params["end_timestamp"]) == expected_end
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_historical_paginates_beyond_cap(
|
|
httpx_mock: HTTPXMock, client: DeribitClient
|
|
):
|
|
"""A range wider than Deribit's ~5000-candle cap is paged window-by-window so
|
|
start_date is honored instead of being silently dropped."""
|
|
import datetime as dt
|
|
|
|
import httpx
|
|
|
|
interval = 3_600_000 # 1h
|
|
|
|
def chart_callback(request: httpx.Request) -> httpx.Response:
|
|
params = request.url.params
|
|
start = int(params["start_timestamp"])
|
|
end = int(params["end_timestamp"])
|
|
ticks = list(range(start, end + 1, interval))
|
|
# Mimic Deribit truncating to the most recent slice if a single call
|
|
# ever exceeds the cap (it shouldn't, because windows are sized to fit).
|
|
ticks = ticks[-5000:]
|
|
n = len(ticks)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"result": {
|
|
"ticks": ticks,
|
|
"open": [100.0] * n,
|
|
"high": [101.0] * n,
|
|
"low": [99.0] * n,
|
|
"close": [100.5] * n,
|
|
"volume": [10.0] * n,
|
|
}
|
|
},
|
|
)
|
|
|
|
httpx_mock.add_callback(
|
|
chart_callback,
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"),
|
|
is_reusable=True,
|
|
)
|
|
|
|
result = await client.get_historical(
|
|
instrument="BTC-PERPETUAL",
|
|
start_date="2025-01-01",
|
|
end_date="2025-12-30",
|
|
resolution="1h",
|
|
)
|
|
candles = result["candles"]
|
|
# ~363 days * 24h ≈ 8712 candles → more than one ~5000 window.
|
|
assert len(candles) > 5000
|
|
assert len(httpx_mock.get_requests()) >= 2
|
|
|
|
expected_start = int(
|
|
dt.datetime(2025, 1, 1, tzinfo=dt.UTC).timestamp() * 1000
|
|
)
|
|
expected_end_bound = int(
|
|
dt.datetime(2025, 12, 30, 23, 59, 59, 999_000, tzinfo=dt.UTC).timestamp()
|
|
* 1000
|
|
)
|
|
assert candles[0]["timestamp"] == expected_start
|
|
assert candles[-1]["timestamp"] <= expected_end_bound
|
|
# No duplicate timestamps across window boundaries.
|
|
tss = [c["timestamp"] for c in candles]
|
|
assert len(tss) == len(set(tss))
|
|
assert tss == sorted(tss)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_private_call_with_bad_auth_returns_error_envelope(
|
|
httpx_mock: HTTPXMock, client: DeribitClient
|
|
):
|
|
"""Auth fallita (creds errate / scope mancante) → error envelope, non KeyError."""
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json={"error": {"code": 13004, "message": "invalid_credentials"}},
|
|
is_reusable=True,
|
|
)
|
|
summary = await client.get_account_summary("USDC")
|
|
assert summary["equity"] is None
|
|
assert summary["balance"] is None
|
|
assert "invalid_credentials" in summary["error"]
|
|
positions = await client.get_positions("USDC")
|
|
assert positions == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_order(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json=AUTH_RESP,
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/buy"),
|
|
json={"result": {"order": {"order_id": "abc", "amount": 10, "order_state": "open"}, "trades": []}},
|
|
)
|
|
result = await client.place_order(
|
|
instrument_name="BTC-PERPETUAL",
|
|
side="buy",
|
|
amount=10,
|
|
type="limit",
|
|
price=50000,
|
|
)
|
|
assert result["order"]["order_id"] == "abc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_positions(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json=AUTH_RESP,
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/get_positions"),
|
|
json={"result": [
|
|
{
|
|
"instrument_name": "BTC-PERPETUAL",
|
|
"size": 100.0,
|
|
"average_price": 48000.0,
|
|
"mark_price": 50000.0,
|
|
"floating_profit_loss": 200.0,
|
|
"realized_profit_loss": 50.0,
|
|
"leverage": 10,
|
|
}
|
|
]},
|
|
)
|
|
result = await client.get_positions("USDC")
|
|
assert len(result) == 1
|
|
assert result[0]["instrument"] == "BTC-PERPETUAL"
|
|
assert result[0]["direction"] == "long"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_dvol(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_volatility_index_data"),
|
|
json={
|
|
"result": {
|
|
"data": [
|
|
[1700000000000, 55.0, 58.0, 54.0, 57.0],
|
|
[1700086400000, 57.0, 60.0, 56.0, 59.5],
|
|
],
|
|
"continuation": None,
|
|
}
|
|
},
|
|
)
|
|
result = await client.get_dvol("btc", "2024-01-01", "2024-01-02", "1D")
|
|
assert result["currency"] == "BTC"
|
|
assert result["latest"] == 59.5
|
|
assert len(result["candles"]) == 2
|
|
assert result["candles"][0]["close"] == 57.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_combo_order(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json=AUTH_RESP,
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/create_combo"),
|
|
json={
|
|
"result": {
|
|
"id": "BTC-COMBO-1",
|
|
"instrument_name": "BTC-COMBO-1",
|
|
"state": "active",
|
|
}
|
|
},
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/buy"),
|
|
json={
|
|
"result": {
|
|
"order": {"order_id": "ord-1", "order_state": "open"},
|
|
"trades": [],
|
|
}
|
|
},
|
|
)
|
|
legs = [
|
|
{"instrument_name": "BTC-30APR26-75000-C", "direction": "buy", "ratio": 1},
|
|
{"instrument_name": "BTC-30APR26-80000-C", "direction": "sell", "ratio": 1},
|
|
]
|
|
result = await client.place_combo_order(
|
|
legs=legs,
|
|
side="buy",
|
|
amount=1,
|
|
type="limit",
|
|
price=0.05,
|
|
label="vert-spread",
|
|
)
|
|
assert result["combo_instrument"] == "BTC-COMBO-1"
|
|
assert result["order"]["order_id"] == "ord-1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_combo_order_create_combo_error(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json=AUTH_RESP,
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/create_combo"),
|
|
json={"error": {"code": -32602, "message": "Invalid leg"}},
|
|
)
|
|
result = await client.place_combo_order(
|
|
legs=[{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}],
|
|
side="buy",
|
|
amount=1,
|
|
type="market",
|
|
)
|
|
assert result["state"] == "error"
|
|
assert "Invalid leg" in str(result.get("error"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_order(httpx_mock: HTTPXMock, client: DeribitClient):
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
|
|
json=AUTH_RESP,
|
|
)
|
|
httpx_mock.add_response(
|
|
url=re.compile(r"https://test\.deribit\.com/api/v2/private/cancel"),
|
|
json={"result": {"order_id": "abc123", "order_state": "cancelled"}},
|
|
)
|
|
result = await client.cancel_order("abc123")
|
|
assert result["order_id"] == "abc123"
|
|
assert result["state"] == "cancelled"
|