Files
ai-exchange/tests/unit/test_broker_read_model_service.py
T
2026-05-14 08:20:02 +08:00

207 lines
8.5 KiB
Python

from datetime import datetime, timezone
import os
import unittest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.services.broker import (
ALERT_UNKNOWN_OPEN_POSITION,
ALERT_UNKNOWN_PENDING_ORDER,
BROKER_STATE_OK,
BROKER_STATE_UNAVAILABLE,
BrokerReadModelService,
)
class BrokerReadModelServiceTests(unittest.TestCase):
def test_fetch_snapshot_parses_account_positions_orders_and_fills(self) -> None:
service = BrokerReadModelService()
snapshot = service.fetch_snapshot(
instrument_id=1,
now=datetime(2026, 5, 7, 1, 0, tzinfo=timezone.utc),
account_payload={
"code": "0",
"data": [{
"totalEq": "1000",
"upl": "20",
"details": [{"availBal": "600", "cashBal": "700"}],
}],
},
positions_payload={
"code": "0",
"data": [{"instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "2", "avgPx": "100", "markPx": "101", "upl": "5"}],
},
orders_payload={
"code": "0",
"data": [{"ordId": "o-1", "instId": "BTC-USDT-SWAP", "side": "buy", "state": "live", "sz": "2", "accFillSz": "0", "px": "100"}],
},
fills_payload={
"code": "0",
"data": [{"fillId": "f-1", "ordId": "o-1", "instId": "BTC-USDT-SWAP", "side": "buy", "fillSz": "1", "fillPx": "100", "fillTime": "1715043600000"}],
},
known_execution_refs={"broker_order_ids": [], "open_execution_count": 0},
)
self.assertEqual(snapshot.status, BROKER_STATE_OK)
self.assertEqual(snapshot.account["equity"], 1000.0)
self.assertEqual(len(snapshot.positions), 1)
self.assertEqual(len(snapshot.orders), 1)
self.assertEqual(len(snapshot.fills), 1)
self.assertIn(ALERT_UNKNOWN_OPEN_POSITION, snapshot.alerts)
self.assertIn(ALERT_UNKNOWN_PENDING_ORDER, snapshot.alerts)
def test_build_account_risk_state_uses_broker_positions_and_daily_pnl(self) -> None:
service = BrokerReadModelService()
snapshot = service.fetch_snapshot(
instrument_id=1,
account_payload={"code": "0", "data": [{"totalEq": "1000", "upl": "-50", "details": [{"availBal": "600", "cashBal": "700"}]}]},
positions_payload={"code": "0", "data": [{"instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "2", "avgPx": "100", "markPx": "101", "upl": "5"}]},
orders_payload={"code": "0", "data": []},
fills_payload={"code": "0", "data": []},
known_execution_refs={"broker_order_ids": [], "open_execution_count": 1},
)
state = service.build_account_risk_state(snapshot, instrument_id=1, side="long")
self.assertEqual(state.open_positions, 1)
self.assertEqual(state.same_side_open_positions, 1)
self.assertAlmostEqual(state.daily_pnl_pct, -0.05)
def test_fetch_snapshot_returns_unavailable_when_credentials_or_request_fail(self) -> None:
from unittest.mock import patch
with patch("src.services.broker.broker_read_model_service.settings.okx_api_key", ""), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_secret", ""
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_passphrase", ""
):
service = BrokerReadModelService()
snapshot = service.fetch_snapshot(instrument_id=1)
self.assertEqual(snapshot.status, BROKER_STATE_UNAVAILABLE)
self.assertTrue(snapshot.error)
self.assertIn("okx_read_credentials_missing", snapshot.error)
def test_fetch_snapshot_surfaces_http_status_payload_message(self) -> None:
from unittest.mock import patch
from urllib.error import HTTPError
from io import BytesIO
class _FailingOpener:
def open(self, request, timeout=None):
raise HTTPError(
url=request.full_url,
code=401,
msg="Unauthorized",
hdrs=None,
fp=BytesIO(b'{\"code\":\"50113\",\"msg\":\"Invalid signature\"}'),
)
with patch("src.services.broker.broker_read_model_service._build_proxy_opener", return_value=_FailingOpener()), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_key", "k"
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_secret", "s"
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_passphrase", "p"
):
service = BrokerReadModelService()
snapshot = service.fetch_snapshot(instrument_id=1)
self.assertEqual(snapshot.status, BROKER_STATE_UNAVAILABLE)
self.assertIn("okx_read_http_401:50113:Invalid signature", snapshot.error)
def test_private_get_retries_transport_errors(self) -> None:
from unittest.mock import patch
from urllib.error import URLError
class _Response:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return None
def read(self):
return b'{"code":"0","data":[]}'
class _FlakyOpener:
def __init__(self):
self.calls = 0
def open(self, request, timeout=None):
self.calls += 1
if self.calls == 1:
raise URLError("temporary handshake timeout")
return _Response()
opener = _FlakyOpener()
with patch("src.services.broker.broker_read_model_service._build_proxy_opener", return_value=opener), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_key", "k"
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_secret", "s"
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_passphrase", "p"
), patch(
"src.services.broker.broker_read_model_service.settings.broker_read_retries", 2
), patch(
"src.services.broker.broker_read_model_service.settings.broker_read_retry_backoff_seconds", 0
):
service = BrokerReadModelService(proxy_url="http://127.0.0.1:10808")
payload = service._private_get("/api/v5/account/balance")
self.assertEqual(payload["code"], "0")
self.assertEqual(opener.calls, 2)
def test_private_get_falls_back_to_direct_when_proxy_transport_fails(self) -> None:
from unittest.mock import patch
from urllib.error import URLError
class _Response:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return None
def read(self):
return b'{"code":"0","data":[]}'
class _FailingProxyOpener:
def __init__(self):
self.calls = 0
def open(self, request, timeout=None):
self.calls += 1
raise URLError("proxy refused")
proxy_opener = _FailingProxyOpener()
direct_calls = []
def _direct_open(request, timeout=None):
direct_calls.append((request.full_url, timeout))
return _Response()
with patch("src.services.broker.broker_read_model_service._build_proxy_opener", return_value=proxy_opener), patch(
"src.services.broker.broker_read_model_service.urlopen", side_effect=_direct_open
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_key", "k"
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_secret", "s"
), patch(
"src.services.broker.broker_read_model_service.settings.okx_api_passphrase", "p"
), patch(
"src.services.broker.broker_read_model_service.settings.broker_read_retries", 1
), patch(
"src.services.broker.broker_read_model_service.settings.broker_read_retry_backoff_seconds", 0
):
service = BrokerReadModelService(proxy_url="http://127.0.0.1:10808")
payload = service._private_get("/api/v5/account/balance")
self.assertEqual(payload["code"], "0")
self.assertEqual(proxy_opener.calls, 1)
self.assertEqual(len(direct_calls), 1)
if __name__ == "__main__":
unittest.main()