test_eth_nextgen_micro_executor.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import importlib.util
  2. import sys
  3. from pathlib import Path
  4. import pytest
  5. from okx_codex_trader.models import OrderResult
  6. from okx_codex_trader.live_execution import TargetPosition
  7. def load_executor():
  8. path = Path(__file__).resolve().parents[1] / "scripts" / "run_eth_nextgen_micro_executor.py"
  9. spec = importlib.util.spec_from_file_location("run_eth_nextgen_micro_executor", path)
  10. assert spec is not None
  11. module = importlib.util.module_from_spec(spec)
  12. assert spec.loader is not None
  13. sys.modules[spec.name] = module
  14. spec.loader.exec_module(module)
  15. return module
  16. executor = load_executor()
  17. def payload_with_nextgen_long():
  18. return {
  19. "decision": {"active_engine": "nextgen", "selected_signal": "long"},
  20. "execution_intent": {"entry_signal": "long", "entry_unit": 0.5, "target_position_known": False, "target_position": None},
  21. "nextgen": {
  22. "data": {"decision_candle_ts": 1000},
  23. "legs": [
  24. {"leg_id": "a", "suggested_weight": 0.5, "signal": True, "exit_signal": False},
  25. {"leg_id": "b", "suggested_weight": 0.5, "signal": False, "exit_signal": False},
  26. ],
  27. },
  28. }
  29. def test_executor_snapshot_does_not_render_orders_when_current_position_is_unknown(monkeypatch, tmp_path):
  30. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  31. monkeypatch.setattr(
  32. executor,
  33. "account_current_position",
  34. lambda _: (TargetPosition(side="flat", unit=0.0, known=False, reason="no credentials"), {"account_error": "no credentials"}),
  35. )
  36. snapshot = executor.build_snapshot(state_dir=tmp_path, margin_per_unit_usdt=1000.0, max_new_margin_usdt=500.0, max_total_margin_usdt=1000.0)
  37. assert snapshot["orders_submitted"] == 0
  38. assert snapshot["target_position"]["known"] is True
  39. assert snapshot["target_position"]["unit"] == 0.5
  40. assert snapshot["current_position"]["known"] is False
  41. assert snapshot["rendered_orders"] == []
  42. def test_executor_snapshot_renders_order_body_when_positions_are_known(monkeypatch, tmp_path):
  43. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  44. monkeypatch.setattr(
  45. executor,
  46. "account_current_position",
  47. lambda _: (
  48. TargetPosition(side="flat", unit=0.0, known=True, reason="flat"),
  49. {"mark_price": 3000.0, "instrument_meta": {"ct_val": 0.1, "lot_sz": 1.0, "min_sz": 1.0}},
  50. ),
  51. )
  52. snapshot = executor.build_snapshot(state_dir=tmp_path, margin_per_unit_usdt=1000.0, max_new_margin_usdt=500.0, max_total_margin_usdt=1000.0)
  53. assert snapshot["orders_submitted"] == 0
  54. assert snapshot["rendered_orders"] == [
  55. {
  56. "action": "open",
  57. "margin_usdt": 500.0,
  58. "body": {
  59. "instId": "ETH-USDT-SWAP",
  60. "tdMode": "isolated",
  61. "side": "buy",
  62. "posSide": "long",
  63. "ordType": "market",
  64. "sz": "5",
  65. "clOrdId": "ethnm-1000-1-open",
  66. },
  67. }
  68. ]
  69. def test_executor_uses_executor_state_file(monkeypatch, tmp_path):
  70. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  71. monkeypatch.setattr(
  72. executor,
  73. "account_current_position",
  74. lambda _: (
  75. TargetPosition(side="flat", unit=0.0, known=True, reason="flat"),
  76. {"mark_price": 3000.0, "instrument_meta": {"ct_val": 0.1, "lot_sz": 1.0, "min_sz": 1.0}},
  77. ),
  78. )
  79. (tmp_path / "runtime-state.json").write_text('{"last_candle_ts":1000,"micro_side":null,"nextgen_active_legs":[]}\n', encoding="utf-8")
  80. snapshot = executor.build_snapshot(state_dir=tmp_path, margin_per_unit_usdt=1000.0, max_new_margin_usdt=500.0, max_total_margin_usdt=1000.0)
  81. assert snapshot["target_position"]["unit"] == 0.5
  82. class FakeClient:
  83. def __init__(self, *, fail_submit=False, positions=None):
  84. self.fail_submit = fail_submit
  85. self.positions = positions or []
  86. self.submitted = []
  87. self.leverage = []
  88. def get_positions(self, symbol):
  89. return self.positions
  90. def get_instrument_meta(self, symbol):
  91. from okx_codex_trader.models import InstrumentMeta
  92. return InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0)
  93. def get_last_price(self, symbol):
  94. return 3000.0
  95. def ensure_hedge_mode(self):
  96. return None
  97. def set_leverage(self, *, symbol, leverage, pos_side):
  98. self.leverage.append((symbol, leverage, pos_side))
  99. def submit_market_order_body(self, body):
  100. if self.fail_submit:
  101. raise ValueError("submit failed")
  102. self.submitted.append(body)
  103. return OrderResult(
  104. status="placed",
  105. order_id="1",
  106. symbol=body["instId"],
  107. side=body["side"],
  108. pos_side=body["posSide"],
  109. order_type=body["ordType"],
  110. size=float(body["sz"]),
  111. )
  112. def test_live_submit_requires_both_flags(monkeypatch, tmp_path):
  113. monkeypatch.setenv("ETH_NEXTGEN_MARGIN_PER_UNIT_USDT", "100")
  114. monkeypatch.setenv("ETH_NEXTGEN_MAX_NEW_MARGIN_USDT", "100")
  115. monkeypatch.setenv("ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT", "200")
  116. monkeypatch.setattr(sys, "argv", ["executor", "--state-dir", str(tmp_path), "--submit-live"])
  117. with pytest.raises(ValueError, match="must be used together"):
  118. executor.main()
  119. def test_dry_run_does_not_write_runtime_state(monkeypatch, tmp_path):
  120. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  121. monkeypatch.setattr(
  122. executor,
  123. "account_current_position",
  124. lambda _: (
  125. TargetPosition(side="flat", unit=0.0, known=True, reason="flat"),
  126. {"mark_price": 3000.0, "instrument_meta": {"ct_val": 0.1, "lot_sz": 1.0, "min_sz": 1.0}},
  127. ),
  128. )
  129. executor.build_snapshot(state_dir=tmp_path, margin_per_unit_usdt=1000.0, max_new_margin_usdt=500.0, max_total_margin_usdt=1000.0)
  130. assert not (tmp_path / executor.EXECUTOR_STATE_FILENAME).exists()
  131. def test_live_noop_writes_state_and_submits_zero_orders(monkeypatch, tmp_path):
  132. from okx_codex_trader.models import Position
  133. client = FakeClient(positions=[Position(symbol="ETH-USDT-SWAP", pos_side="long", size=1.0, avg_price=3000.0)])
  134. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  135. monkeypatch.setattr(executor, "OkxClient", lambda _: client)
  136. monkeypatch.setattr(executor, "load_config", lambda: object())
  137. (tmp_path / executor.EXECUTOR_STATE_FILENAME).write_text(
  138. '{"last_candle_ts":1000,"micro_side":null,"nextgen_active_legs":["a"]}\n',
  139. encoding="utf-8",
  140. )
  141. snapshot = executor.execute_live_once(
  142. state_dir=tmp_path,
  143. margin_per_unit_usdt=200.0,
  144. max_new_margin_usdt=100.0,
  145. max_total_margin_usdt=200.0,
  146. )
  147. assert snapshot["orders_submitted"] == 0
  148. assert client.submitted == []
  149. assert (tmp_path / executor.EXECUTOR_STATE_FILENAME).exists()
  150. def test_live_submit_calls_okx_and_writes_state_after_success(monkeypatch, tmp_path):
  151. client = FakeClient()
  152. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  153. monkeypatch.setattr(executor, "OkxClient", lambda _: client)
  154. monkeypatch.setattr(executor, "load_config", lambda: object())
  155. snapshot = executor.execute_live_once(
  156. state_dir=tmp_path,
  157. margin_per_unit_usdt=200.0,
  158. max_new_margin_usdt=100.0,
  159. max_total_margin_usdt=200.0,
  160. )
  161. assert snapshot["orders_submitted"] == 1
  162. assert client.submitted[0]["clOrdId"] == "ethnm-1000-1-open"
  163. assert client.leverage == [("ETH-USDT-SWAP", 3, "long")]
  164. assert (tmp_path / executor.EXECUTOR_STATE_FILENAME).exists()
  165. assert (tmp_path / executor.EXECUTOR_EVENTS_FILENAME).exists()
  166. def test_live_submit_failure_does_not_write_runtime_state(monkeypatch, tmp_path):
  167. client = FakeClient(fail_submit=True)
  168. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  169. monkeypatch.setattr(executor, "OkxClient", lambda _: client)
  170. monkeypatch.setattr(executor, "load_config", lambda: object())
  171. with pytest.raises(ValueError, match="submit failed"):
  172. executor.execute_live_once(
  173. state_dir=tmp_path,
  174. margin_per_unit_usdt=200.0,
  175. max_new_margin_usdt=100.0,
  176. max_total_margin_usdt=200.0,
  177. )
  178. assert not (tmp_path / executor.EXECUTOR_STATE_FILENAME).exists()
  179. assert (tmp_path / executor.EXECUTOR_EVENTS_FILENAME).exists()
  180. def test_live_unknown_position_does_not_write_runtime_state(monkeypatch, tmp_path):
  181. client = FakeClient()
  182. monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
  183. monkeypatch.setattr(executor, "OkxClient", lambda _: client)
  184. monkeypatch.setattr(executor, "load_config", lambda: object())
  185. monkeypatch.setattr(
  186. executor,
  187. "account_current_position_with_client",
  188. lambda _client, _margin: (
  189. TargetPosition(side="flat", unit=0.0, known=False, reason="unknown"),
  190. {"account_error": "unknown"},
  191. ),
  192. )
  193. with pytest.raises(ValueError, match="must both be known"):
  194. executor.execute_live_once(
  195. state_dir=tmp_path,
  196. margin_per_unit_usdt=200.0,
  197. max_new_margin_usdt=100.0,
  198. max_total_margin_usdt=200.0,
  199. )
  200. assert not (tmp_path / executor.EXECUTOR_STATE_FILENAME).exists()
  201. assert (tmp_path / executor.EXECUTOR_EVENTS_FILENAME).exists()