220 lines
9.8 KiB
Python
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()
|