Files
root c94312d79f feat(V2): shared Candle validator + uniform 'candles' response key
Introduce common/candles.py with a Pydantic Candle model enforcing OHLC
consistency (high≥max, low≤min), non-negative volume and positive
timestamp. validate_candles() coerces upstream rows, sorts by timestamp
and raises HTTPException(502) on malformed data — surfacing upstream
data corruption as a retryable envelope instead of silently returning
nonsense.

Wired into all five exchange historical endpoints (Bybit, Hyperliquid,
Deribit, Alpaca, IBKR). BREAKING: Alpaca get_bars and IBKR get_bars now
return 'candles' (was 'bars') to align with the other exchanges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:19:20 +00:00

418 lines
12 KiB
Python

"""Test AlpacaClient httpx-based (V2.0.0).
Mockano gli endpoint REST tramite pytest-httpx. Coprono account/positions,
ordini (place/cancel/limit-error), close position, clock, asset class
invalida → ValueError.
"""
from __future__ import annotations
import re
import pytest
from cerbero_mcp.exchanges.alpaca.client import AlpacaClient
from pytest_httpx import HTTPXMock
PAPER = "https://paper-api.alpaca.markets"
DATA = "https://data.alpaca.markets"
@pytest.mark.asyncio
async def test_init_paper_mode(client: AlpacaClient):
assert client.paper is True
assert client.base_url is None
assert client._trading_base == PAPER
@pytest.mark.asyncio
async def test_init_live_mode(client_live: AlpacaClient):
assert client_live.paper is False
assert client_live._trading_base == "https://api.alpaca.markets"
@pytest.mark.asyncio
async def test_init_base_url_override():
c = AlpacaClient(
api_key="k",
secret_key="s",
paper=True,
base_url="https://alpaca-custom.example.com",
)
try:
assert c.base_url == "https://alpaca-custom.example.com"
assert c._trading_base == "https://alpaca-custom.example.com"
# Data endpoint NON viene overridato
assert c._data_base == DATA
finally:
await c.aclose()
@pytest.mark.asyncio
async def test_get_account(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=f"{PAPER}/v2/account",
json={"id": "abc", "equity": "100000.00", "buying_power": "200000.00"},
)
result = await client.get_account()
assert result["id"] == "abc"
assert result["equity"] == "100000.00"
@pytest.mark.asyncio
async def test_get_account_sends_auth_headers(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(url=f"{PAPER}/v2/account", json={"id": "x"})
await client.get_account()
req = httpx_mock.get_requests()[0]
assert req.headers["APCA-API-KEY-ID"] == "test_key"
assert req.headers["APCA-API-SECRET-KEY"] == "test_secret"
assert req.headers["Accept"] == "application/json"
@pytest.mark.asyncio
async def test_get_positions_returns_list(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
url=f"{PAPER}/v2/positions",
json=[{"symbol": "AAPL", "qty": "10", "side": "long"}],
)
result = await client.get_positions()
assert len(result) == 1
assert result[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_place_market_order_stocks(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
method="POST",
url=f"{PAPER}/v2/orders",
json={"id": "o123", "symbol": "AAPL", "status": "accepted"},
)
result = await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="market", asset_class="stocks"
)
assert result["id"] == "o123"
# body POST corretto
req = httpx_mock.get_requests()[0]
import json as _j
body = _j.loads(req.content)
assert body["symbol"] == "AAPL"
assert body["side"] == "buy"
assert body["type"] == "market"
assert body["qty"] == "1"
assert body["time_in_force"] == "day"
@pytest.mark.asyncio
async def test_place_limit_order_requires_price(client: AlpacaClient):
with pytest.raises(ValueError, match="limit_price"):
await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="limit"
)
@pytest.mark.asyncio
async def test_place_stop_order_requires_price(client: AlpacaClient):
with pytest.raises(ValueError, match="stop_price"):
await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="stop"
)
@pytest.mark.asyncio
async def test_place_unsupported_order_type(client: AlpacaClient):
with pytest.raises(ValueError, match="unsupported order_type"):
await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="trailing_stop"
)
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client: AlpacaClient):
# 204 No Content su success
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/orders/o1",
status_code=204,
)
result = await client.cancel_order("o1")
assert result == {"order_id": "o1", "canceled": True}
@pytest.mark.asyncio
async def test_cancel_all_orders(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/orders",
json=[
{"id": "a", "status": 200},
{"id": "b", "status": 200},
],
)
result = await client.cancel_all_orders()
assert len(result) == 2
@pytest.mark.asyncio
async def test_close_position_no_options(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/positions/AAPL",
json={"id": "close-1", "symbol": "AAPL"},
)
result = await client.close_position("AAPL")
assert result["id"] == "close-1"
@pytest.mark.asyncio
async def test_close_position_with_qty(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/positions/AAPL?qty=5.0",
json={"id": "close-2"},
)
result = await client.close_position("AAPL", qty=5.0)
assert result["id"] == "close-2"
@pytest.mark.asyncio
async def test_close_all_positions(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/positions?cancel_orders=true",
json=[{"symbol": "AAPL", "status": 200}],
)
result = await client.close_all_positions(cancel_orders=True)
assert len(result) == 1
assert result[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_get_clock(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=f"{PAPER}/v2/clock",
json={"is_open": True, "next_close": "2026-04-21T20:00:00Z"},
)
result = await client.get_clock()
assert result["is_open"] is True
@pytest.mark.asyncio
async def test_invalid_asset_class(client: AlpacaClient):
with pytest.raises(ValueError, match="invalid asset_class"):
await client.get_ticker("AAPL", asset_class="forex")
@pytest.mark.asyncio
async def test_get_ticker_stocks(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=f"{DATA}/v2/stocks/AAPL/trades/latest",
json={
"symbol": "AAPL",
"trade": {"p": 175.50, "s": 100, "t": "2026-04-18T15:30:00Z"},
},
)
httpx_mock.add_response(
url=f"{DATA}/v2/stocks/AAPL/quotes/latest",
json={
"symbol": "AAPL",
"quote": {
"bp": 175.40,
"ap": 175.55,
"bs": 50,
"as": 25,
"t": "2026-04-18T15:30:00Z",
},
},
)
result = await client.get_ticker("AAPL", asset_class="stocks")
assert result["asset_class"] == "stocks"
assert result["last_price"] == 175.50
assert result["bid"] == 175.40
assert result["ask"] == 175.55
@pytest.mark.asyncio
async def test_get_bars_stocks(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{DATA}/v2/stocks/bars\?.*"),
json={
"bars": {
"AAPL": [
{
"t": "2026-04-17T00:00:00Z",
"o": 170.0,
"h": 176.0,
"l": 169.5,
"c": 175.0,
"v": 1000000,
}
]
}
},
)
result = await client.get_bars(
symbol="AAPL",
asset_class="stocks",
interval="1d",
start="2026-04-17T00:00:00",
end="2026-04-18T00:00:00",
limit=10,
)
assert result["symbol"] == "AAPL"
assert result["interval"] == "1d"
assert len(result["candles"]) == 1
assert result["candles"][0]["close"] == 175.0
@pytest.mark.asyncio
async def test_get_bars_unsupported_timeframe(client: AlpacaClient):
with pytest.raises(ValueError, match="unsupported timeframe"):
await client.get_bars(
symbol="AAPL",
asset_class="stocks",
interval="3min",
)
@pytest.mark.asyncio
async def test_get_bars_invalid_asset_class(client: AlpacaClient):
with pytest.raises(ValueError, match="invalid asset_class"):
await client.get_bars(symbol="AAPL", asset_class="forex")
@pytest.mark.asyncio
async def test_get_assets(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/assets\?.*"),
json=[
{"symbol": "AAPL", "tradable": True, "class": "us_equity"},
{"symbol": "GOOG", "tradable": True, "class": "us_equity"},
],
)
result = await client.get_assets(asset_class="stocks", status="active")
assert len(result) == 2
assert result[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_get_assets_invalid_class(client: AlpacaClient):
with pytest.raises(ValueError, match="invalid asset_class"):
await client.get_assets(asset_class="forex")
@pytest.mark.asyncio
async def test_get_open_orders(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/orders\?.*"),
json=[{"id": "o1", "status": "open", "symbol": "AAPL"}],
)
result = await client.get_open_orders(limit=10)
assert len(result) == 1
assert result[0]["id"] == "o1"
@pytest.mark.asyncio
async def test_amend_order(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
method="PATCH",
url=f"{PAPER}/v2/orders/o1",
json={"id": "o1", "qty": "5", "limit_price": "180.0"},
)
result = await client.amend_order(
"o1", qty=5, limit_price=180.0, tif="gtc"
)
assert result["id"] == "o1"
req = httpx_mock.get_requests()[0]
import json as _j
body = _j.loads(req.content)
assert body["qty"] == "5"
assert body["limit_price"] == "180.0"
assert body["time_in_force"] == "gtc"
@pytest.mark.asyncio
async def test_get_calendar(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/calendar.*"),
json=[{"date": "2026-04-20", "open": "09:30", "close": "16:00"}],
)
result = await client.get_calendar(start="2026-04-20", end="2026-04-20")
assert len(result) == 1
assert result[0]["date"] == "2026-04-20"
@pytest.mark.asyncio
async def test_get_calendar_no_filters(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
url=f"{PAPER}/v2/calendar",
json=[{"date": "2026-04-20"}],
)
result = await client.get_calendar()
assert len(result) == 1
@pytest.mark.asyncio
async def test_get_snapshot(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{DATA}/v2/stocks/snapshots\?.*"),
json={
"AAPL": {
"latestTrade": {"p": 175.0},
"latestQuote": {"bp": 174.9, "ap": 175.1},
}
},
)
result = await client.get_snapshot("AAPL")
assert result["latestTrade"]["p"] == 175.0
@pytest.mark.asyncio
async def test_get_option_chain(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{DATA}/v1beta1/options/snapshots/AAPL.*"),
json={
"snapshots": {
"AAPL250620C00200000": {
"latestQuote": {"bp": 1.20, "ap": 1.30}
}
}
},
)
result = await client.get_option_chain("AAPL", expiry="2026-06-20")
assert result["underlying"] == "AAPL"
assert result["expiry"] == "2026-06-20"
assert "AAPL250620C00200000" in result["contracts"]
@pytest.mark.asyncio
async def test_get_activities(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/account/activities.*"),
json=[
{"id": "1", "activity_type": "FILL"},
{"id": "2", "activity_type": "TRANS"},
],
)
result = await client.get_activities(limit=10)
assert len(result) == 2
@pytest.mark.asyncio
async def test_aclose_idempotent(client: AlpacaClient):
await client.aclose()
await client.aclose() # nessun raise