test_live_execution.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import pytest
  2. from okx_codex_trader.live_execution import (
  3. RuntimeState,
  4. TargetPosition,
  5. current_position_from_okx,
  6. plan_position_delta,
  7. render_market_order_bodies,
  8. market_client_order_id,
  9. target_from_signal,
  10. )
  11. from okx_codex_trader.models import InstrumentMeta, Position
  12. def nextgen_payload(*, candle_ts=1000, first_signal=False, first_exit=False, second_signal=False, second_exit=False):
  13. return {
  14. "decision": {"active_engine": "nextgen"},
  15. "nextgen": {
  16. "decision": {"decision_candle_ts": candle_ts},
  17. "legs": [
  18. {"leg_id": "a", "suggested_weight": 0.5, "signal": first_signal, "exit_signal": first_exit},
  19. {"leg_id": "b", "suggested_weight": 0.5, "signal": second_signal, "exit_signal": second_exit},
  20. ],
  21. },
  22. }
  23. def test_nextgen_target_opens_one_virtual_leg():
  24. state, target = target_from_signal(nextgen_payload(first_signal=True), RuntimeState(None, (), None))
  25. assert state.nextgen_active_legs == ("a",)
  26. assert target == TargetPosition(side="long", unit=0.5, known=True, reason="active nextgen virtual legs net to one long ETH target")
  27. def test_nextgen_target_nets_two_virtual_legs_to_one_unit():
  28. state, target = target_from_signal(nextgen_payload(first_signal=True, second_signal=True), RuntimeState(None, (), None))
  29. assert state.nextgen_active_legs == ("a", "b")
  30. assert target.side == "long"
  31. assert target.unit == 1.0
  32. def test_nextgen_exit_removes_only_active_leg():
  33. previous = RuntimeState(last_candle_ts=1000, nextgen_active_legs=("a", "b"), micro_side=None)
  34. state, target = target_from_signal(nextgen_payload(candle_ts=2000, first_exit=True), previous)
  35. assert state.nextgen_active_legs == ("b",)
  36. assert target.side == "long"
  37. assert target.unit == 0.5
  38. def test_repeated_candle_is_idempotent():
  39. previous = RuntimeState(last_candle_ts=1000, nextgen_active_legs=("a",), micro_side=None)
  40. state, target = target_from_signal(nextgen_payload(candle_ts=1000, first_signal=True, second_signal=True), previous)
  41. assert state == previous
  42. assert target.side == "long"
  43. assert target.unit == 0.5
  44. def test_micro_target_is_blocked_until_exit_state_exists():
  45. payload = {"decision": {"active_engine": "micro"}, "micro": {"decision_candle_ts": 1000}}
  46. _, target = target_from_signal(payload, RuntimeState(None, (), None))
  47. assert target.known is False
  48. assert target.unit == 0.0
  49. def test_position_delta_plans_reduce_only_close_before_reverse():
  50. current = TargetPosition(side="long", unit=1.0, known=True, reason="current")
  51. target = TargetPosition(side="short", unit=0.5, known=True, reason="target")
  52. plan = plan_position_delta(current, target)
  53. assert [(action.action, action.side, action.unit, action.reduce_only) for action in plan.actions] == [
  54. ("close", "long", 1.0, True),
  55. ("reverse", "short", 0.5, False),
  56. ]
  57. def test_position_delta_does_not_plan_when_current_position_is_unknown():
  58. current = TargetPosition(side="flat", unit=0.0, known=False, reason="unknown")
  59. target = TargetPosition(side="long", unit=0.5, known=True, reason="target")
  60. plan = plan_position_delta(current, target)
  61. assert plan.actions == ()
  62. def test_current_position_from_okx_normalizes_contracts_to_strategy_units():
  63. current = current_position_from_okx(
  64. positions=[Position(symbol="ETH-USDT-SWAP", pos_side="long", size=10.0, avg_price=3000.0)],
  65. mark_price=3000.0,
  66. metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
  67. leverage=3,
  68. margin_per_unit_usdt=1000.0,
  69. )
  70. assert current.side == "long"
  71. assert current.unit == pytest.approx(1.0)
  72. assert current.known is True
  73. def test_current_position_from_okx_blocks_when_both_hedge_sides_are_open():
  74. current = current_position_from_okx(
  75. positions=[
  76. Position(symbol="ETH-USDT-SWAP", pos_side="long", size=1.0, avg_price=3000.0),
  77. Position(symbol="ETH-USDT-SWAP", pos_side="short", size=1.0, avg_price=3000.0),
  78. ],
  79. mark_price=3000.0,
  80. metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
  81. leverage=3,
  82. margin_per_unit_usdt=1000.0,
  83. )
  84. assert current.known is False
  85. def test_render_market_order_bodies_builds_open_order_body():
  86. plan = plan_position_delta(
  87. TargetPosition(side="flat", unit=0.0, known=True, reason="current"),
  88. TargetPosition(side="long", unit=0.5, known=True, reason="target"),
  89. )
  90. orders = render_market_order_bodies(
  91. plan=plan,
  92. symbol="ETH-USDT-SWAP",
  93. mark_price=3000.0,
  94. metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
  95. leverage=3,
  96. margin_per_unit_usdt=1000.0,
  97. max_new_margin_usdt=500.0,
  98. max_total_margin_usdt=1000.0,
  99. client_order_id_prefix="eth-1000",
  100. stop_loss_pct=0.01,
  101. take_profit_pct=0.03,
  102. )
  103. assert len(orders) == 1
  104. assert orders[0].margin_usdt == 500.0
  105. assert orders[0].body == {
  106. "instId": "ETH-USDT-SWAP",
  107. "tdMode": "isolated",
  108. "side": "buy",
  109. "posSide": "long",
  110. "ordType": "market",
  111. "sz": "5",
  112. "clOrdId": "eth10001open",
  113. "attachAlgoOrds": [{"slTriggerPx": "2970", "slOrdPx": "-1", "tpTriggerPx": "3090", "tpOrdPx": "-1"}],
  114. }
  115. def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
  116. plan = plan_position_delta(
  117. TargetPosition(side="long", unit=1.0, known=True, reason="current", contracts=10.0),
  118. TargetPosition(side="short", unit=0.5, known=True, reason="target"),
  119. )
  120. orders = render_market_order_bodies(
  121. plan=plan,
  122. symbol="ETH-USDT-SWAP",
  123. mark_price=3000.0,
  124. metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
  125. leverage=3,
  126. margin_per_unit_usdt=1000.0,
  127. max_new_margin_usdt=500.0,
  128. max_total_margin_usdt=1000.0,
  129. client_order_id_prefix="eth-2000",
  130. stop_loss_pct=0.01,
  131. take_profit_pct=0.03,
  132. )
  133. assert [order.body for order in orders] == [
  134. {
  135. "instId": "ETH-USDT-SWAP",
  136. "tdMode": "isolated",
  137. "side": "sell",
  138. "posSide": "long",
  139. "ordType": "market",
  140. "sz": "10",
  141. "clOrdId": "eth20001close",
  142. "reduceOnly": "true",
  143. },
  144. {
  145. "instId": "ETH-USDT-SWAP",
  146. "tdMode": "isolated",
  147. "side": "sell",
  148. "posSide": "short",
  149. "ordType": "market",
  150. "sz": "5",
  151. "clOrdId": "eth20002reverse",
  152. "attachAlgoOrds": [{"slTriggerPx": "3030", "slOrdPx": "-1", "tpTriggerPx": "2910", "tpOrdPx": "-1"}],
  153. },
  154. ]
  155. def test_render_market_order_bodies_closes_actual_contract_size():
  156. plan = plan_position_delta(
  157. TargetPosition(side="short", unit=1.0024642, known=True, reason="current", contracts=1.38),
  158. TargetPosition(side="flat", unit=0.0, known=True, reason="target"),
  159. )
  160. orders = render_market_order_bodies(
  161. plan=plan,
  162. symbol="ETH-USDT-SWAP",
  163. mark_price=2179.27,
  164. metadata=InstrumentMeta(ct_val=0.1, lot_sz=0.01, min_sz=0.01),
  165. leverage=3,
  166. margin_per_unit_usdt=100.0,
  167. max_new_margin_usdt=100.0,
  168. max_total_margin_usdt=200.0,
  169. client_order_id_prefix="bbsq-1778988600000",
  170. )
  171. assert len(orders) == 1
  172. assert orders[0].body["sz"] == "1.38"
  173. assert orders[0].body["reduceOnly"] == "true"
  174. def test_market_client_order_id_removes_unsupported_characters_and_caps_length():
  175. assert market_client_order_id("bbsq-1778508900000", 1, "open") == "bbsq17785089000001open"
  176. assert len(market_client_order_id("x" * 40, 1, "open")) == 32
  177. def test_render_market_order_bodies_enforces_new_margin_cap():
  178. plan = plan_position_delta(
  179. TargetPosition(side="flat", unit=0.0, known=True, reason="current"),
  180. TargetPosition(side="long", unit=1.0, known=True, reason="target"),
  181. )
  182. with pytest.raises(ValueError, match="new margin exceeds max_new_margin_usdt"):
  183. render_market_order_bodies(
  184. plan=plan,
  185. symbol="ETH-USDT-SWAP",
  186. mark_price=3000.0,
  187. metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
  188. leverage=3,
  189. margin_per_unit_usdt=1000.0,
  190. max_new_margin_usdt=500.0,
  191. max_total_margin_usdt=1000.0,
  192. client_order_id_prefix="eth-3000",
  193. )
  194. def test_render_market_order_bodies_enforces_total_margin_cap():
  195. plan = plan_position_delta(
  196. TargetPosition(side="long", unit=0.5, known=True, reason="current"),
  197. TargetPosition(side="long", unit=1.0, known=True, reason="target"),
  198. )
  199. with pytest.raises(ValueError, match="target margin exceeds max_total_margin_usdt"):
  200. render_market_order_bodies(
  201. plan=plan,
  202. symbol="ETH-USDT-SWAP",
  203. mark_price=3000.0,
  204. metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
  205. leverage=3,
  206. margin_per_unit_usdt=1000.0,
  207. max_new_margin_usdt=500.0,
  208. max_total_margin_usdt=500.0,
  209. client_order_id_prefix="eth-4000",
  210. )