Files
ai-exchange/tests/unit/test_okx_sandbox_preflight_service.py
T
2026-05-18 14:11:06 +08:00

220 lines
9.8 KiB
Python

import os
import unittest
from io import BytesIO
from datetime import datetime, timezone
from urllib.error import HTTPError
from unittest.mock import patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.config.settings import settings
from src.services.broker.okx_sandbox_preflight_service import (
OKXSandboxPreflightService,
build_okx_client_order_id,
normalize_okx_order_error,
)
class OKXSandboxPreflightServiceTests(unittest.TestCase):
def test_build_summary_waits_for_sandbox_credentials_without_live_submit(self) -> None:
service = OKXSandboxPreflightService(api_key="", api_secret="", api_passphrase="")
with patch.object(settings, "live_trading_enabled", False):
payload = service.build_summary(
instrument_id=1,
timeframe="1m",
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "waiting_for_sandbox_credentials")
self.assertFalse(payload["dry_run_ready"])
self.assertFalse(payload["sandbox_credentials_present"])
self.assertEqual(payload["sandbox_header"], {"x-simulated-trading": "1"})
self.assertEqual(payload["next_gate"], "okx_sandbox_credentials")
self.assertEqual(payload["execution_boundary"], "real_live_still_blocked")
self.assertIn("okx_sandbox_credentials_present", payload["failed_criteria"])
self.assertEqual(payload["signed_request_previews"][0]["operation"], "submit_order")
self.assertFalse(payload["signed_request_previews"][0]["signed"])
def test_build_summary_builds_signed_dry_run_request_previews(self) -> None:
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
with patch.object(settings, "live_trading_enabled", False):
payload = service.build_summary(
instrument_id=2,
timeframe="5m",
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "sandbox_preflight_ready")
self.assertTrue(payload["dry_run_ready"])
self.assertTrue(payload["submit_dry_run_ready"])
self.assertTrue(payload["cancel_dry_run_ready"])
self.assertTrue(payload["query_order_dry_run_ready"])
self.assertTrue(payload["query_fills_dry_run_ready"])
self.assertEqual(payload["okx_inst_id"], "ETH-USDT-SWAP")
self.assertEqual(payload["next_gate"], "sandbox_submit_cancel_reconcile_smoke")
operations = {item["operation"]: item for item in payload["signed_request_previews"]}
self.assertIn("OK-ACCESS-SIGN", operations["submit_order"]["header_names"])
self.assertIn("x-simulated-trading", operations["cancel_order"]["header_names"])
self.assertTrue(operations["query_order"]["request_path"].startswith("/api/v5/trade/order?"))
self.assertEqual(payload["bracket_semantics"]["schema_version"], "bracket-v1")
self.assertIn("insufficient_balance", payload["error_normalization"])
def test_live_switch_enabled_keeps_preflight_in_operator_review(self) -> None:
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
with patch.object(settings, "live_trading_enabled", True):
payload = service.build_summary(
instrument_id=1,
timeframe="1m",
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "live_switch_enabled_requires_operator_review")
self.assertIn("okx_live_submit_still_disabled", payload["failed_criteria"])
self.assertEqual(payload["execution_boundary"], "real_live_still_blocked")
def test_client_order_id_is_bounded_and_deterministic(self) -> None:
value = build_okx_client_order_id(
instrument_id=12,
timeframe="15m",
observed_ts=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
)
self.assertEqual(value, "AI1215M20260517010203")
self.assertLessEqual(len(value), 32)
def test_normalizes_okx_order_errors(self) -> None:
self.assertEqual(
normalize_okx_order_error(transport_error="connection refused")["category"],
"network_error",
)
self.assertEqual(
normalize_okx_order_error(payload={"code": "51008", "msg": "Insufficient balance"})["category"],
"insufficient_balance",
)
self.assertEqual(
normalize_okx_order_error(payload={"code": "51001", "msg": "Parameter error"})["category"],
"parameter_error",
)
self.assertEqual(
normalize_okx_order_error(payload={"code": "50120", "msg": "This API key doesn't have permission to use this function"})["category"],
"permission_error",
)
self.assertEqual(
normalize_okx_order_error(payload={"code": "51100", "msg": "Rejected"})["category"],
"exchange_rejected",
)
def test_isolated_smoke_requires_explicit_execute_flag(self) -> None:
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
calls = []
def opener(*args, **kwargs):
calls.append((args, kwargs))
raise AssertionError("opener should not be called when execute is false")
with patch.object(settings, "live_trading_enabled", False):
payload = service.run_isolated_smoke(
instrument_id=1,
timeframe="1m",
execute=False,
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
opener=opener,
)
self.assertEqual(payload["status"], "blocked")
self.assertEqual(payload["blockers"], ["okx_sandbox_smoke_execute_not_enabled"])
self.assertEqual(payload["next_gate"], "enable_explicit_sandbox_smoke_execute")
self.assertEqual(payload["steps"], [])
self.assertEqual(calls, [])
def test_isolated_smoke_passes_submit_query_cancel_fills_sequence(self) -> None:
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
seen_operations = []
def opener(request, timeout_seconds):
seen_operations.append((request.get_method(), request.selector, timeout_seconds))
if request.selector.startswith("/api/v5/trade/order?"):
return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "ordId": "123", "state": "live"}]})
if request.selector == "/api/v5/trade/order":
return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"ordId": "123", "clOrdId": "AI0101M20260517010203", "sCode": "0"}]})
if request.selector == "/api/v5/trade/cancel-order":
return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"ordId": "123", "clOrdId": "AI0101M20260517010203", "sCode": "0"}]})
if request.selector.startswith("/api/v5/trade/fills?"):
return _FakeHTTPResponse({"code": "0", "msg": "", "data": []})
raise AssertionError(f"unexpected request {request.get_method()} {request.selector}")
with patch.object(settings, "live_trading_enabled", False):
payload = service.run_isolated_smoke(
instrument_id=1,
timeframe="1m",
execute=True,
order_price="1",
order_size="1",
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
timeout_seconds=7,
opener=opener,
)
self.assertEqual(payload["status"], "passed")
self.assertEqual([item["operation"] for item in payload["steps"]], [
"submit_order",
"query_order_after_submit",
"cancel_order",
"query_order_after_cancel",
"query_fills",
])
self.assertEqual([item[2] for item in seen_operations], [7, 7, 7, 7, 7])
self.assertTrue(all(item["simulated_trading_header"] == "1" for item in payload["steps"]))
self.assertEqual(payload["execution_boundary"], "real_live_still_blocked")
self.assertEqual(payload["reconcile_check"]["status"], "ready")
def test_isolated_smoke_normalizes_http_error(self) -> None:
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
def opener(request, timeout_seconds):
raise HTTPError(
url=request.full_url,
code=400,
msg="Bad Request",
hdrs=None,
fp=BytesIO(b'{"code":"51001","msg":"Parameter error"}'),
)
with patch.object(settings, "live_trading_enabled", False):
payload = service.run_isolated_smoke(
instrument_id=1,
timeframe="1m",
execute=True,
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
opener=opener,
)
self.assertEqual(payload["status"], "blocked")
self.assertEqual(payload["blockers"], ["okx_sandbox_submit_failed"])
self.assertEqual(payload["steps"][0]["http_status"], 400)
self.assertEqual(payload["steps"][0]["normalized_error"]["category"], "parameter_error")
class _FakeHTTPResponse:
def __init__(self, payload: dict, status: int = 200) -> None:
self.payload = payload
self.status = status
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
def read(self) -> bytes:
import json
return json.dumps(self.payload).encode("utf-8")
if __name__ == "__main__":
unittest.main()