207 lines
8.5 KiB
Python
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()
|