test_okx_client.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import base64
  2. import hashlib
  3. import hmac
  4. from dataclasses import dataclass
  5. from urllib.parse import urlencode, urlparse
  6. import pytest
  7. from okx_codex_trader.config import Config
  8. from okx_codex_trader.models import InstrumentMeta, TradeSignal
  9. from okx_codex_trader.okx_client import OkxClient, build_contract_size
  10. @dataclass
  11. class DummyResponse:
  12. payload: dict[str, object]
  13. status_code: int = 200
  14. json_error: Exception | None = None
  15. def json(self) -> dict[str, object]:
  16. if self.json_error is not None:
  17. raise self.json_error
  18. return self.payload
  19. @dataclass
  20. class RecordedRequest:
  21. method: str
  22. url: str
  23. headers: dict[str, str]
  24. params: dict[str, object] | None
  25. json_body: dict[str, object] | None
  26. class DummySession:
  27. def __init__(self, responses: list[DummyResponse | Exception] | None = None):
  28. self._responses = list(responses or [])
  29. self.last_request: RecordedRequest | None = None
  30. self.request_paths: list[str] = []
  31. self.request_bodies: list[dict[str, object] | None] = []
  32. @property
  33. def last_json_body(self) -> dict[str, object] | None:
  34. return self.last_request.json_body if self.last_request else None
  35. def request(
  36. self,
  37. method: str,
  38. url: str,
  39. *,
  40. headers: dict[str, str] | None = None,
  41. params: dict[str, object] | None = None,
  42. json: dict[str, object] | None = None,
  43. ) -> DummyResponse:
  44. self.last_request = RecordedRequest(
  45. method=method,
  46. url=url,
  47. headers=headers or {},
  48. params=params,
  49. json_body=json,
  50. )
  51. self.request_paths.append(urlparse(url).path)
  52. self.request_bodies.append(json)
  53. if self._responses:
  54. response = self._responses.pop(0)
  55. if isinstance(response, Exception):
  56. raise response
  57. return response
  58. return candles_response()
  59. def sample_config() -> Config:
  60. return Config(api_key="key", api_secret="secret", api_passphrase="passphrase")
  61. def candles_response() -> DummyResponse:
  62. return DummyResponse(
  63. {
  64. "code": "0",
  65. "msg": "",
  66. "data": [
  67. ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"],
  68. ],
  69. }
  70. )
  71. def descending_candles_response() -> DummyResponse:
  72. return DummyResponse(
  73. {
  74. "code": "0",
  75. "msg": "",
  76. "data": [
  77. ["1710000001000", "25100", "25200", "25000", "25150", "110", "1100", "1100", "1"],
  78. ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"],
  79. ],
  80. }
  81. )
  82. def instrument_response() -> DummyResponse:
  83. return DummyResponse(
  84. {
  85. "code": "0",
  86. "msg": "",
  87. "data": [
  88. {
  89. "instId": "BTC-USDT-SWAP",
  90. "instType": "SWAP",
  91. "ctVal": "0.001",
  92. "lotSz": "1",
  93. "minSz": "1",
  94. }
  95. ],
  96. }
  97. )
  98. def large_min_size_instrument_response() -> DummyResponse:
  99. return DummyResponse(
  100. {
  101. "code": "0",
  102. "msg": "",
  103. "data": [
  104. {
  105. "instId": "BTC-USDT-SWAP",
  106. "instType": "SWAP",
  107. "ctVal": "0.01",
  108. "lotSz": "1",
  109. "minSz": "100",
  110. }
  111. ],
  112. }
  113. )
  114. def ticker_response(last: str) -> DummyResponse:
  115. return DummyResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "last": last}]})
  116. def account_config_response(pos_mode: str) -> DummyResponse:
  117. return DummyResponse({"code": "0", "msg": "", "data": [{"posMode": pos_mode}]})
  118. def leverage_response() -> DummyResponse:
  119. return DummyResponse({"code": "0", "msg": "", "data": [{"lever": "2"}]})
  120. def place_order_response() -> DummyResponse:
  121. return DummyResponse({"code": "0", "msg": "", "data": [{"ordId": "123"}]})
  122. def place_order_response_without_order_id() -> DummyResponse:
  123. return DummyResponse({"code": "0", "msg": "", "data": [{}]})
  124. def error_response(code: str, msg: str) -> DummyResponse:
  125. return DummyResponse({"code": code, "msg": msg, "data": []})
  126. def positions_response() -> DummyResponse:
  127. return DummyResponse(
  128. {
  129. "code": "0",
  130. "msg": "",
  131. "data": [
  132. {
  133. "instId": "BTC-USDT-SWAP",
  134. "posSide": "long",
  135. "pos": "8",
  136. "avgPx": "25000",
  137. }
  138. ],
  139. }
  140. )
  141. def positions_with_zero_size_response() -> DummyResponse:
  142. return DummyResponse(
  143. {
  144. "code": "0",
  145. "msg": "",
  146. "data": [
  147. {
  148. "instId": "BTC-USDT-SWAP",
  149. "posSide": "long",
  150. "pos": "0",
  151. "avgPx": "25000",
  152. },
  153. {
  154. "instId": "BTC-USDT-SWAP",
  155. "posSide": "short",
  156. "pos": "3",
  157. "avgPx": "24900",
  158. },
  159. ],
  160. }
  161. )
  162. def positions_with_zero_size_malformed_avg_price_response() -> DummyResponse:
  163. return DummyResponse(
  164. {
  165. "code": "0",
  166. "msg": "",
  167. "data": [
  168. {
  169. "instId": "BTC-USDT-SWAP",
  170. "posSide": "long",
  171. "pos": "0",
  172. "avgPx": "bad",
  173. },
  174. {
  175. "instId": "BTC-USDT-SWAP",
  176. "posSide": "short",
  177. "pos": "3",
  178. "avgPx": "24900",
  179. },
  180. ],
  181. }
  182. )
  183. def market_long_signal() -> TradeSignal:
  184. return TradeSignal(
  185. action="long",
  186. confidence=0.9,
  187. leverage=2,
  188. entry_price=None,
  189. take_profit_price=26000.0,
  190. stop_loss_price=24000.0,
  191. reason="trend",
  192. )
  193. def limit_short_signal() -> TradeSignal:
  194. return TradeSignal(
  195. action="short",
  196. confidence=0.8,
  197. leverage=2,
  198. entry_price=25000.0,
  199. take_profit_price=24000.0,
  200. stop_loss_price=25500.0,
  201. reason="mean reversion",
  202. )
  203. def flat_signal() -> TradeSignal:
  204. return TradeSignal(
  205. action="flat",
  206. confidence=0.7,
  207. leverage=2,
  208. entry_price=None,
  209. take_profit_price=None,
  210. stop_loss_price=None,
  211. reason="exit",
  212. )
  213. def test_signed_demo_request_attaches_headers():
  214. session = DummySession()
  215. client = OkxClient(config=sample_config(), session=session)
  216. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  217. request = session.last_request
  218. assert request is not None
  219. assert request.headers["x-simulated-trading"] == "1"
  220. assert request.headers["OK-ACCESS-KEY"] == "key"
  221. assert request.headers["OK-ACCESS-PASSPHRASE"] == "passphrase"
  222. timestamp = request.headers["OK-ACCESS-TIMESTAMP"]
  223. path = urlparse(request.url).path
  224. query = urlencode(request.params or {})
  225. path_with_query = path if not query else f"{path}?{query}"
  226. expected_signature = base64.b64encode(
  227. hmac.new(
  228. b"secret",
  229. f"{timestamp}{request.method}{path_with_query}".encode(),
  230. hashlib.sha256,
  231. ).digest()
  232. ).decode()
  233. assert request.headers["OK-ACCESS-SIGN"] == expected_signature
  234. def test_get_candles_returns_chronological_ascending_order():
  235. session = DummySession([descending_candles_response()])
  236. client = OkxClient(config=sample_config(), session=session)
  237. candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  238. assert [candle.ts for candle in candles] == [1710000000000, 1710000001000]
  239. def test_build_contract_size_rounds_down_to_lot_size():
  240. metadata = InstrumentMeta(ct_val=0.01, lot_sz=0.1, min_sz=0.1)
  241. assert build_contract_size(notional=251, price=25_000, metadata=metadata) == 1.0
  242. def test_build_contract_size_fails_below_min_size():
  243. metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=5)
  244. with pytest.raises(ValueError):
  245. build_contract_size(notional=250, price=25_100, metadata=metadata)
  246. def test_market_order_fetches_latest_price_before_sizing():
  247. session = DummySession(
  248. [
  249. instrument_response(),
  250. ticker_response(last="25000"),
  251. account_config_response(pos_mode="long_short_mode"),
  252. leverage_response(),
  253. place_order_response(),
  254. ]
  255. )
  256. client = OkxClient(config=sample_config(), session=session)
  257. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  258. assert session.request_paths == [
  259. "/api/v5/public/instruments",
  260. "/api/v5/market/ticker",
  261. "/api/v5/account/config",
  262. "/api/v5/account/set-leverage",
  263. "/api/v5/trade/order",
  264. ]
  265. def test_place_demo_order_fails_when_not_hedge_mode():
  266. session = DummySession(
  267. [
  268. instrument_response(),
  269. ticker_response(last="25000"),
  270. account_config_response(pos_mode="net_mode"),
  271. ]
  272. )
  273. client = OkxClient(config=sample_config(), session=session)
  274. with pytest.raises(ValueError):
  275. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  276. def test_place_demo_order_validates_size_before_setting_leverage():
  277. session = DummySession(
  278. [
  279. large_min_size_instrument_response(),
  280. ticker_response(last="25000"),
  281. account_config_response(pos_mode="long_short_mode"),
  282. ]
  283. )
  284. client = OkxClient(config=sample_config(), session=session)
  285. with pytest.raises(ValueError, match="contract size below minimum"):
  286. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  287. assert session.request_paths == [
  288. "/api/v5/public/instruments",
  289. "/api/v5/market/ticker",
  290. "/api/v5/account/config",
  291. ]
  292. def test_limit_short_order_uses_sell_and_short_pos_side():
  293. session = DummySession(
  294. [
  295. instrument_response(),
  296. account_config_response(pos_mode="long_short_mode"),
  297. leverage_response(),
  298. place_order_response(),
  299. ]
  300. )
  301. client = OkxClient(config=sample_config(), session=session)
  302. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  303. order_request = session.last_json_body
  304. assert order_request is not None
  305. assert order_request["ordType"] == "limit"
  306. assert order_request["side"] == "sell"
  307. assert order_request["posSide"] == "short"
  308. assert order_request["px"] == "25000"
  309. assert session.request_bodies[2]["lever"] == "2"
  310. assert session.request_bodies[2]["mgnMode"] == "isolated"
  311. def test_flat_signal_returns_noop_without_order_submission():
  312. session = DummySession([])
  313. client = OkxClient(config=sample_config(), session=session)
  314. result = client.place_demo_order(symbol="BTC-USDT-SWAP", signal=flat_signal(), margin_usdt=100)
  315. assert result.status == "noop"
  316. assert session.request_paths == []
  317. def test_place_demo_order_sends_computed_sz_and_ignores_tp_sl_fields():
  318. session = DummySession(
  319. [
  320. instrument_response(),
  321. ticker_response(last="25000"),
  322. account_config_response(pos_mode="long_short_mode"),
  323. leverage_response(),
  324. place_order_response(),
  325. ]
  326. )
  327. client = OkxClient(config=sample_config(), session=session)
  328. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  329. order_request = session.last_json_body
  330. assert order_request is not None
  331. assert order_request["sz"] == "8"
  332. assert "tpTriggerPx" not in order_request
  333. assert "slTriggerPx" not in order_request
  334. def test_okx_error_payload_raises_value_error():
  335. session = DummySession([error_response(code="51000", msg="parameter error")])
  336. client = OkxClient(config=sample_config(), session=session)
  337. with pytest.raises(ValueError):
  338. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  339. def test_transport_failure_raises_stable_value_error():
  340. session = DummySession([RuntimeError("socket closed")])
  341. client = OkxClient(config=sample_config(), session=session)
  342. with pytest.raises(ValueError, match="okx transport error"):
  343. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  344. def test_invalid_json_raises_stable_value_error():
  345. session = DummySession([DummyResponse({}, json_error=ValueError("bad json"))])
  346. client = OkxClient(config=sample_config(), session=session)
  347. with pytest.raises(ValueError, match="okx response payload is invalid"):
  348. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  349. def test_empty_positions_data_returns_empty_list():
  350. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": []})])
  351. client = OkxClient(config=sample_config(), session=session)
  352. assert client.get_positions(symbol="BTC-USDT-SWAP") == []
  353. def test_malformed_numeric_field_raises_stable_value_error():
  354. session = DummySession(
  355. [
  356. DummyResponse(
  357. {
  358. "code": "0",
  359. "msg": "",
  360. "data": [
  361. {
  362. "instId": "BTC-USDT-SWAP",
  363. "posSide": "long",
  364. "pos": "bad",
  365. "avgPx": "25000",
  366. }
  367. ],
  368. }
  369. )
  370. ]
  371. )
  372. client = OkxClient(config=sample_config(), session=session)
  373. with pytest.raises(ValueError, match="okx response payload is invalid"):
  374. client.get_positions(symbol="BTC-USDT-SWAP")
  375. def test_non_list_okx_data_raises_stable_value_error():
  376. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": {}})])
  377. client = OkxClient(config=sample_config(), session=session)
  378. with pytest.raises(ValueError, match="okx response payload is invalid"):
  379. client.get_positions(symbol="BTC-USDT-SWAP")
  380. def test_place_demo_order_raises_when_order_id_is_missing():
  381. session = DummySession(
  382. [
  383. instrument_response(),
  384. ticker_response(last="25000"),
  385. account_config_response(pos_mode="long_short_mode"),
  386. leverage_response(),
  387. place_order_response_without_order_id(),
  388. ]
  389. )
  390. client = OkxClient(config=sample_config(), session=session)
  391. with pytest.raises(ValueError, match="okx response payload is invalid"):
  392. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  393. def test_place_demo_order_rejects_invalid_leverage_before_okx():
  394. session = DummySession([])
  395. signal = TradeSignal(
  396. action="long",
  397. confidence=0.9,
  398. leverage=4,
  399. entry_price=None,
  400. take_profit_price=None,
  401. stop_loss_price=None,
  402. reason="x",
  403. )
  404. client = OkxClient(config=sample_config(), session=session)
  405. with pytest.raises(ValueError, match="leverage is invalid"):
  406. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  407. assert session.request_paths == []
  408. def test_place_demo_order_rejects_unknown_action_before_okx():
  409. session = DummySession([])
  410. signal = TradeSignal(
  411. action="hold",
  412. confidence=0.9,
  413. leverage=2,
  414. entry_price=None,
  415. take_profit_price=None,
  416. stop_loss_price=None,
  417. reason="x",
  418. )
  419. client = OkxClient(config=sample_config(), session=session)
  420. with pytest.raises(ValueError, match="action is invalid"):
  421. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  422. assert session.request_paths == []
  423. def test_get_positions_returns_normalized_positions():
  424. session = DummySession([positions_response()])
  425. client = OkxClient(config=sample_config(), session=session)
  426. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  427. assert positions[0].symbol == "BTC-USDT-SWAP"
  428. assert positions[0].pos_side == "long"
  429. assert positions[0].size == 8.0
  430. assert positions[0].avg_price == 25000.0
  431. def test_get_positions_filters_zero_size_rows():
  432. session = DummySession([positions_with_zero_size_response()])
  433. client = OkxClient(config=sample_config(), session=session)
  434. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  435. assert len(positions) == 1
  436. assert positions[0].pos_side == "short"
  437. assert positions[0].size == 3.0
  438. def test_get_positions_ignores_malformed_fields_on_zero_size_rows():
  439. session = DummySession([positions_with_zero_size_malformed_avg_price_response()])
  440. client = OkxClient(config=sample_config(), session=session)
  441. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  442. assert len(positions) == 1
  443. assert positions[0].pos_side == "short"
  444. assert positions[0].avg_price == 24900.0