test_okx_client.py 20 KB

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