refactor(V2): get_historical & get_indicators are common-only on /mcp
Consolidate the cross-cutting data tools onto the unified interface and remove their per-exchange duplicates. - /mcp/tools/get_historical: single call, `exchange` now optional. Set → that venue's candles (native symbol); omitted → cross-exchange consensus (canonical symbol, median OHLC + div_pct). Folds in the former /mcp-cross consensus, whose router is deleted. - /mcp/tools/get_indicators: new common tool computing sma/rsi/atr/macd/adx over the single-or-consensus series. Computation centralized in common/indicators.py::compute_indicators (was duplicated per exchange). - Remove get_historical from /mcp-deribit and /mcp-hyperliquid; remove get_technical_indicators (deribit) and get_indicators (hyperliquid), including the now-dead client methods, Req schemas and tool wrappers. The client get_historical METHODS stay (used by the unified dispatch). Tests: rewrite cross tests into test_unified (instruments, historical single + consensus + failures, indicators single + consensus); drop the old CrossClient test; tighten app-boot surface assertions (/mcp owns the data tools; no /mcp-cross, no per-exchange historical/indicators). Docs: API_REFERENCE / README / CLAUDE updated; smoke run_unified.sh now covers consensus + indicators. 324 passed, ruff clean. Verified live against Deribit/Hyperliquid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,17 @@ def test_app_boots_and_health_responds(monkeypatch):
|
||||
assert any(p.startswith("/mcp-hyperliquid/") for p in paths)
|
||||
assert any(p.startswith("/mcp-macro/") for p in paths)
|
||||
assert any(p.startswith("/mcp-sentiment/") for p in paths)
|
||||
# Unified common interface
|
||||
assert any(p.startswith("/mcp/tools/") for p in paths)
|
||||
# Unified common interface owns the cross-cutting data tools
|
||||
assert "/mcp/tools/get_instruments" in paths
|
||||
assert "/mcp/tools/get_historical" in paths
|
||||
assert "/mcp/tools/get_indicators" in paths
|
||||
# get_historical / get_indicators are common-only, not per-exchange
|
||||
assert "/mcp-deribit/tools/get_historical" not in paths
|
||||
assert "/mcp-deribit/tools/get_technical_indicators" not in paths
|
||||
assert "/mcp-hyperliquid/tools/get_historical" not in paths
|
||||
assert "/mcp-hyperliquid/tools/get_indicators" not in paths
|
||||
# The standalone consensus router has been folded into /mcp
|
||||
assert not any(p.startswith("/mcp-cross/") for p in paths)
|
||||
# Bybit and Alpaca have been retired from the API surface
|
||||
assert not any(p.startswith("/mcp-bybit/") for p in paths)
|
||||
assert not any(p.startswith("/mcp-alpaca/") for p in paths)
|
||||
|
||||
@@ -15,7 +15,9 @@ testnet venga instradato (deribit `get_ticker BTC-PERPETUAL`).
|
||||
Verifica end-to-end contro Deribit/Hyperliquid (endpoint pubblici):
|
||||
- `get_instruments exchange=deribit` → `fees` e `history_start` popolati live
|
||||
- `get_instruments` fan-out → contribuiscono sia deribit sia hyperliquid
|
||||
- `get_historical` deribit + hyperliquid → candele non vuote, schema uniforme
|
||||
- `get_historical` singolo (deribit, hyperliquid) → candele non vuote
|
||||
- `get_historical` consenso (senza `exchange`) → `sources_used` multipli + `div_pct`
|
||||
- `get_indicators` singolo + consenso → indicatori calcolati
|
||||
- `get_historical exchange=bybit` → `422` (rifiutato a livello schema)
|
||||
|
||||
Variabili di ambiente:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test interfaccia comune /mcp contro un server vivo + upstream reali.
|
||||
# get_instruments e get_historical di Deribit/Hyperliquid sono pubblici, ma il
|
||||
# bearer (testnet o mainnet) e X-Bot-Tag sono comunque richiesti dall'auth.
|
||||
# get_instruments/get_historical/get_indicators di Deribit/Hyperliquid sono
|
||||
# pubblici, ma bearer (testnet o mainnet) e X-Bot-Tag sono comunque richiesti.
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-9000}"
|
||||
@@ -40,19 +40,31 @@ post() {
|
||||
|
||||
echo "==> get_instruments exchange=deribit (fees + history_start live)"
|
||||
post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"future"}' 200 \
|
||||
"assert d['instruments'], 'no instruments'; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; print(' ', i['symbol'], i['fees'], i['history_start'])"
|
||||
"assert d['instruments']; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; print(' ', i['symbol'], i['fees'], i['history_start'])"
|
||||
|
||||
echo "==> get_instruments fan-out (deribit + hyperliquid)"
|
||||
post "/mcp/tools/get_instruments" '{}' 200 \
|
||||
"exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs), '| failed:', d['failed_sources'])"
|
||||
"exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs))"
|
||||
|
||||
echo "==> get_historical deribit BTC-PERPETUAL 1h"
|
||||
echo "==> get_historical SINGLE deribit BTC-PERPETUAL 1h"
|
||||
post "/mcp/tools/get_historical" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||
"assert d['exchange']=='deribit'; assert d['candles'], 'no candles'; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))"
|
||||
"assert d['exchange']=='deribit'; assert d['candles']; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))"
|
||||
|
||||
echo "==> get_historical hyperliquid BTC 1h"
|
||||
echo "==> get_historical SINGLE hyperliquid BTC 1h"
|
||||
post "/mcp/tools/get_historical" "{\"exchange\":\"hyperliquid\",\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||
"assert d['exchange']=='hyperliquid'; assert d['candles'], 'no candles'; print(' candles:', len(d['candles']))"
|
||||
"assert d['exchange']=='hyperliquid'; assert d['candles']; print(' candles:', len(d['candles']))"
|
||||
|
||||
echo "==> get_historical CONSENSUS (no exchange) BTC 1h"
|
||||
post "/mcp/tools/get_historical" "{\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||
"assert d['exchange'] is None; assert {'deribit','hyperliquid'} <= set(d['sources_used']), d['sources_used']; assert 'div_pct' in d['candles'][0]; print(' sources:', d['sources_used'], '| candles:', len(d['candles']))"
|
||||
|
||||
echo "==> get_indicators SINGLE deribit BTC-PERPETUAL"
|
||||
post "/mcp/tools/get_indicators" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"indicators\":[\"rsi\",\"atr\",\"macd\"],\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||
"assert set(d['indicators']) == {'rsi','atr','macd'}; print(' rsi:', d['indicators']['rsi'], '| candles_used:', d['candles_used'])"
|
||||
|
||||
echo "==> get_indicators CONSENSUS BTC"
|
||||
post "/mcp/tools/get_indicators" "{\"instrument\":\"BTC\",\"indicators\":\"rsi,atr\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||
"assert d['exchange'] is None; assert 'rsi' in d['indicators']; print(' sources:', d['sources_used'])"
|
||||
|
||||
echo "==> get_historical exchange non supportato → 422"
|
||||
post "/mcp/tools/get_historical" "{\"exchange\":\"bybit\",\"instrument\":\"BTCUSDT\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 422
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.cross.client import CrossClient
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class _Fake:
|
||||
def __init__(self, candles: list[dict[str, Any]] | None = None,
|
||||
*, raises: Exception | None = None):
|
||||
self._candles = candles or []
|
||||
self._raises = raises
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
self.calls.append(kwargs)
|
||||
return {"candles": list(self._candles)}
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
def __init__(self, clients: dict[str, _Fake]):
|
||||
self._clients = clients
|
||||
|
||||
async def get(self, exchange: str, env: str) -> _Fake:
|
||||
if exchange not in self._clients:
|
||||
raise KeyError(exchange)
|
||||
return self._clients[exchange]
|
||||
|
||||
|
||||
def _c(ts: int, close: float = 100.0) -> dict[str, Any]:
|
||||
return {"timestamp": ts, "open": close, "high": close, "low": close,
|
||||
"close": close, "volume": 1.0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crypto_two_sources_aggregates():
|
||||
fakes = {
|
||||
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
|
||||
"deribit": _Fake([_c(1, 100), _c(2, 200)]),
|
||||
}
|
||||
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
|
||||
out = await cc.get_historical(
|
||||
symbol="BTC", asset_class="crypto", interval="1h",
|
||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
||||
)
|
||||
assert out["symbol"] == "BTC"
|
||||
assert out["asset_class"] == "crypto"
|
||||
assert len(out["candles"]) == 2
|
||||
assert out["candles"][0]["sources"] == 2
|
||||
assert out["candles"][0]["div_pct"] == 0.0
|
||||
assert set(out["sources_used"]) == {"hyperliquid", "deribit"}
|
||||
assert out["failed_sources"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crypto_partial_failure_returns_partial_with_warning():
|
||||
fakes = {
|
||||
"hyperliquid": _Fake([_c(1, 100)]),
|
||||
"deribit": _Fake(raises=RuntimeError("upstream down")),
|
||||
}
|
||||
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
|
||||
out = await cc.get_historical(
|
||||
symbol="BTC", asset_class="crypto", interval="1h",
|
||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
||||
)
|
||||
assert out["candles"][0]["sources"] == 1
|
||||
assert set(out["sources_used"]) == {"hyperliquid"}
|
||||
assert len(out["failed_sources"]) == 1
|
||||
assert out["failed_sources"][0]["exchange"] == "deribit"
|
||||
assert "upstream down" in out["failed_sources"][0]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_sources_fail_raises_502():
|
||||
fakes = {
|
||||
"hyperliquid": _Fake(raises=RuntimeError("b")),
|
||||
"deribit": _Fake(raises=RuntimeError("c")),
|
||||
}
|
||||
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cc.get_historical(
|
||||
symbol="BTC", asset_class="crypto", interval="1h",
|
||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
||||
)
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_symbol_raises_400():
|
||||
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cc.get_historical(
|
||||
symbol="NONEXISTENT", asset_class="crypto", interval="1h",
|
||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_interval_raises_400():
|
||||
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cc.get_historical(
|
||||
symbol="BTC", asset_class="crypto", interval="3h",
|
||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -10,28 +10,24 @@ from cerbero_mcp.exchanges.cross.instruments import (
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _c(ts: int, close: float = 100.0) -> dict[str, Any]:
|
||||
return {"timestamp": ts, "open": close, "high": close, "low": close,
|
||||
"close": close, "volume": 1.0}
|
||||
|
||||
|
||||
# ── Normalizers ──────────────────────────────────────────────────────
|
||||
|
||||
def test_normalize_deribit_populates_fees_and_history_start():
|
||||
rows = [{
|
||||
"name": "BTC-PERPETUAL",
|
||||
"kind": "future",
|
||||
"tick_size": 0.5,
|
||||
"maker_commission": 0.0,
|
||||
"taker_commission": 0.0005,
|
||||
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
|
||||
"maker_commission": 0.0, "taker_commission": 0.0005,
|
||||
"creation_timestamp": 1534377600000, # 2018-08-16
|
||||
"strike": None,
|
||||
"expiry": None,
|
||||
"option_type": None,
|
||||
"min_trade_amount": 10,
|
||||
"open_interest": 123.0,
|
||||
}]
|
||||
out = normalize_deribit(rows)
|
||||
assert len(out) == 1
|
||||
inst = out[0]
|
||||
inst = normalize_deribit(rows)[0]
|
||||
assert inst["exchange"] == "deribit"
|
||||
assert inst["symbol"] == "BTC-PERPETUAL"
|
||||
assert inst["asset_class"] == "crypto"
|
||||
assert inst["type"] == "perpetual"
|
||||
assert inst["fees"] == {"maker": 0.0, "taker": 0.0005}
|
||||
assert inst["history_start"] == "2018-08-16"
|
||||
@@ -39,41 +35,39 @@ def test_normalize_deribit_populates_fees_and_history_start():
|
||||
assert inst["native"]["open_interest"] == 123.0
|
||||
|
||||
|
||||
def test_normalize_deribit_dated_future_is_future_not_perpetual():
|
||||
out = normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])
|
||||
assert out[0]["type"] == "future"
|
||||
def test_normalize_deribit_dated_future_is_future():
|
||||
assert normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])[0]["type"] == "future"
|
||||
|
||||
|
||||
def test_normalize_deribit_option_type():
|
||||
out = normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])
|
||||
assert out[0]["type"] == "option"
|
||||
def test_normalize_deribit_option():
|
||||
assert normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])[0]["type"] == "option"
|
||||
|
||||
|
||||
def test_normalize_deribit_fees_none_when_absent():
|
||||
out = normalize_deribit([{"name": "X", "kind": "future"}])
|
||||
assert out[0]["fees"] is None
|
||||
assert out[0]["history_start"] is None
|
||||
inst = normalize_deribit([{"name": "X", "kind": "future"}])[0]
|
||||
assert inst["fees"] is None and inst["history_start"] is None
|
||||
|
||||
|
||||
def test_normalize_hyperliquid_leaves_fees_and_history_none():
|
||||
rows = [{
|
||||
"asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001,
|
||||
"open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50,
|
||||
}]
|
||||
out = normalize_hyperliquid(rows)
|
||||
assert out[0]["exchange"] == "hyperliquid"
|
||||
assert out[0]["symbol"] == "BTC"
|
||||
assert out[0]["type"] == "perpetual"
|
||||
assert out[0]["fees"] is None
|
||||
assert out[0]["history_start"] is None
|
||||
assert out[0]["native"]["max_leverage"] == 50
|
||||
rows = [{"asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001,
|
||||
"open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50}]
|
||||
inst = normalize_hyperliquid(rows)[0]
|
||||
assert inst["exchange"] == "hyperliquid"
|
||||
assert inst["symbol"] == "BTC"
|
||||
assert inst["type"] == "perpetual"
|
||||
assert inst["fees"] is None and inst["history_start"] is None
|
||||
assert inst["native"]["max_leverage"] == 50
|
||||
|
||||
|
||||
# ── UnifiedClient ────────────────────────────────────────────────────
|
||||
# ── Fakes ────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeDeribit:
|
||||
def __init__(self, candles: list[dict[str, Any]] | None = None,
|
||||
*, raises: Exception | None = None):
|
||||
self._candles = candles or [_c(1, 100), _c(2, 200)]
|
||||
self._raises = raises
|
||||
|
||||
async def get_instruments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.last_call = kwargs
|
||||
return {"instruments": [{
|
||||
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
|
||||
"maker_commission": 0.0, "taker_commission": 0.0005,
|
||||
@@ -81,18 +75,26 @@ class _FakeDeribit:
|
||||
}]}
|
||||
|
||||
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
self.hist_call = kwargs
|
||||
return {"candles": [{"timestamp": 1, "open": 1, "high": 1,
|
||||
"low": 1, "close": 1, "volume": 1}]}
|
||||
return {"candles": list(self._candles)}
|
||||
|
||||
|
||||
class _FakeHyperliquid:
|
||||
def __init__(self, candles: list[dict[str, Any]] | None = None,
|
||||
*, raises: Exception | None = None):
|
||||
self._candles = candles or [_c(1, 100), _c(2, 200)]
|
||||
self._raises = raises
|
||||
|
||||
async def get_markets(self) -> list[dict[str, Any]]:
|
||||
return [{"asset": "BTC", "mark_price": 1.0, "funding_rate": 0.0,
|
||||
"open_interest": 1.0, "volume_24h": 1.0, "max_leverage": 50}]
|
||||
|
||||
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
||||
return {"candles": []}
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
return {"candles": list(self._candles)}
|
||||
|
||||
|
||||
class _FakeRegistry:
|
||||
@@ -105,47 +107,45 @@ class _FakeRegistry:
|
||||
return self._clients[exchange]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instruments_fans_out_all_venues():
|
||||
uc = UnifiedClient(
|
||||
def _both() -> UnifiedClient:
|
||||
return UnifiedClient(
|
||||
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
|
||||
env="mainnet",
|
||||
)
|
||||
out = await uc.get_instruments()
|
||||
venues = {i["exchange"] for i in out["instruments"]}
|
||||
assert venues == {"deribit", "hyperliquid"}
|
||||
|
||||
|
||||
# ── get_instruments ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instruments_fans_out_all_venues():
|
||||
out = await _both().get_instruments()
|
||||
assert {i["exchange"] for i in out["instruments"]} == {"deribit", "hyperliquid"}
|
||||
assert out["failed_sources"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instruments_single_exchange_filter():
|
||||
uc = UnifiedClient(
|
||||
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
|
||||
env="mainnet",
|
||||
)
|
||||
out = await uc.get_instruments(exchange="deribit")
|
||||
out = await _both().get_instruments(exchange="deribit")
|
||||
assert {i["exchange"] for i in out["instruments"]} == {"deribit"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instruments_unsupported_exchange_raises_400():
|
||||
uc = UnifiedClient(_FakeRegistry({}), env="mainnet")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await uc.get_instruments(exchange="bybit")
|
||||
await UnifiedClient(_FakeRegistry({}), env="mainnet").get_instruments(exchange="bybit")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instruments_partial_failure_reports_failed():
|
||||
uc = UnifiedClient(
|
||||
_FakeRegistry({"deribit": _FakeDeribit()}), # hyperliquid missing → KeyError
|
||||
env="mainnet",
|
||||
)
|
||||
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
|
||||
out = await uc.get_instruments()
|
||||
assert {i["exchange"] for i in out["instruments"]} == {"deribit"}
|
||||
assert [f["exchange"] for f in out["failed_sources"]] == ["hyperliquid"]
|
||||
|
||||
|
||||
# ── get_historical: single exchange ──────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_single_exchange():
|
||||
fake = _FakeDeribit()
|
||||
@@ -156,16 +156,15 @@ async def test_get_historical_single_exchange():
|
||||
)
|
||||
assert out["exchange"] == "deribit"
|
||||
assert out["instrument"] == "BTC-PERPETUAL"
|
||||
assert out["interval"] == "1h"
|
||||
assert len(out["candles"]) == 1
|
||||
assert out["sources_used"] == ["deribit"]
|
||||
assert len(out["candles"]) == 2
|
||||
assert fake.hist_call["instrument"] == "BTC-PERPETUAL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_unsupported_exchange_raises_400():
|
||||
uc = UnifiedClient(_FakeRegistry({}), env="mainnet")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await uc.get_historical(
|
||||
await UnifiedClient(_FakeRegistry({}), env="mainnet").get_historical(
|
||||
exchange="bybit", instrument="BTCUSDT", interval="1h",
|
||||
start_date="2026-05-09", end_date="2026-05-10",
|
||||
)
|
||||
@@ -174,10 +173,92 @@ async def test_get_historical_unsupported_exchange_raises_400():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_unsupported_interval_raises_400():
|
||||
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await uc.get_historical(
|
||||
await _both().get_historical(
|
||||
exchange="deribit", instrument="BTC-PERPETUAL", interval="3h",
|
||||
start_date="2026-05-09", end_date="2026-05-10",
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── get_historical: consensus (exchange omitted) ─────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_consensus_merges_sources():
|
||||
out = await _both().get_historical(
|
||||
instrument="BTC", interval="1h",
|
||||
start_date="2026-05-09", end_date="2026-05-10",
|
||||
)
|
||||
assert out["exchange"] is None
|
||||
assert out["instrument"] == "BTC"
|
||||
assert set(out["sources_used"]) == {"deribit", "hyperliquid"}
|
||||
assert out["candles"][0]["sources"] == 2
|
||||
assert out["failed_sources"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_consensus_partial_failure():
|
||||
uc = UnifiedClient(
|
||||
_FakeRegistry({"deribit": _FakeDeribit(raises=RuntimeError("down")),
|
||||
"hyperliquid": _FakeHyperliquid()}),
|
||||
env="mainnet",
|
||||
)
|
||||
out = await uc.get_historical(instrument="BTC", interval="1h",
|
||||
start_date="2026-05-09", end_date="2026-05-10")
|
||||
assert out["sources_used"] == ["hyperliquid"]
|
||||
assert [f["exchange"] for f in out["failed_sources"]] == ["deribit"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_consensus_all_fail_raises_502():
|
||||
uc = UnifiedClient(
|
||||
_FakeRegistry({"deribit": _FakeDeribit(raises=RuntimeError("a")),
|
||||
"hyperliquid": _FakeHyperliquid(raises=RuntimeError("b"))}),
|
||||
env="mainnet",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await uc.get_historical(instrument="BTC", interval="1h",
|
||||
start_date="2026-05-09", end_date="2026-05-10")
|
||||
assert exc.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical_consensus_unsupported_symbol_raises_400():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _both().get_historical(instrument="NOPE", interval="1h",
|
||||
start_date="2026-05-09", end_date="2026-05-10")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── get_indicators ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_indicators_single_exchange():
|
||||
fake = _FakeDeribit(candles=[_c(i, 100 + i) for i in range(1, 40)])
|
||||
uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet")
|
||||
out = await uc.get_indicators(
|
||||
indicators=["rsi", "sma", "unknownX"], exchange="deribit",
|
||||
instrument="BTC-PERPETUAL", interval="1h",
|
||||
start_date="2026-05-01", end_date="2026-05-10",
|
||||
)
|
||||
assert out["exchange"] == "deribit"
|
||||
assert out["candles_used"] == 39
|
||||
assert set(out["indicators"].keys()) == {"rsi", "sma", "unknownx"}
|
||||
assert out["indicators"]["unknownx"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_indicators_consensus():
|
||||
candles = [_c(i, 100 + i) for i in range(1, 40)]
|
||||
uc = UnifiedClient(
|
||||
_FakeRegistry({"deribit": _FakeDeribit(candles=candles),
|
||||
"hyperliquid": _FakeHyperliquid(candles=candles)}),
|
||||
env="mainnet",
|
||||
)
|
||||
out = await uc.get_indicators(
|
||||
indicators=["rsi"], instrument="BTC", interval="1h",
|
||||
start_date="2026-05-01", end_date="2026-05-10",
|
||||
)
|
||||
assert out["exchange"] is None
|
||||
assert set(out["sources_used"]) == {"deribit", "hyperliquid"}
|
||||
assert "rsi" in out["indicators"]
|
||||
|
||||
Reference in New Issue
Block a user