run_eth_nextgen_micro_executor.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import os
  5. import sys
  6. from dataclasses import asdict
  7. from datetime import UTC, datetime
  8. from pathlib import Path
  9. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  10. from okx_codex_trader import eth_nextgen_micro
  11. from okx_codex_trader.config import load_config
  12. from okx_codex_trader.live_execution import (
  13. TargetPosition,
  14. current_position_from_okx,
  15. load_runtime_state,
  16. plan_position_delta,
  17. render_market_order_bodies,
  18. save_runtime_state,
  19. target_from_signal,
  20. )
  21. from okx_codex_trader.okx_client import OkxClient
  22. ROOT = Path(__file__).resolve().parents[1]
  23. STATE_DIR = ROOT / "var" / "eth-nextgen-micro"
  24. SYMBOL = "ETH-USDT-SWAP"
  25. LEVERAGE = 3
  26. def now_iso() -> str:
  27. return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
  28. def unknown_current_position(reason: str) -> TargetPosition:
  29. return TargetPosition(side="flat", unit=0.0, known=False, reason=reason)
  30. def account_current_position_with_client(client: OkxClient, margin_per_unit_usdt: float) -> tuple[TargetPosition, dict[str, object]]:
  31. try:
  32. positions = client.get_positions(SYMBOL)
  33. metadata = client.get_instrument_meta(SYMBOL)
  34. mark_price = client.get_last_price(SYMBOL)
  35. current = current_position_from_okx(
  36. positions=positions,
  37. mark_price=mark_price,
  38. metadata=metadata,
  39. leverage=LEVERAGE,
  40. margin_per_unit_usdt=margin_per_unit_usdt,
  41. )
  42. return current, {
  43. "positions": [asdict(position) for position in positions],
  44. "instrument_meta": asdict(metadata),
  45. "mark_price": mark_price,
  46. }
  47. except ValueError as exc:
  48. return unknown_current_position(str(exc)), {"account_error": str(exc)}
  49. def account_current_position(margin_per_unit_usdt: float) -> tuple[TargetPosition, dict[str, object]]:
  50. try:
  51. client = OkxClient(load_config())
  52. except ValueError as exc:
  53. return unknown_current_position(str(exc)), {"account_error": str(exc)}
  54. return account_current_position_with_client(client, margin_per_unit_usdt)
  55. EXECUTOR_STATE_FILENAME = "executor-runtime-state.json"
  56. EXECUTOR_EVENTS_FILENAME = "executor-events.jsonl"
  57. def build_snapshot(*, state_dir: Path, margin_per_unit_usdt: float, max_new_margin_usdt: float, max_total_margin_usdt: float) -> dict[str, object]:
  58. state_dir.mkdir(parents=True, exist_ok=True)
  59. payload = eth_nextgen_micro.build_payload()
  60. previous_state = load_runtime_state(state_dir / EXECUTOR_STATE_FILENAME)
  61. next_state, target = target_from_signal(payload, previous_state)
  62. current, account = account_current_position(margin_per_unit_usdt)
  63. plan = plan_position_delta(current, target)
  64. orders = ()
  65. if current.known and target.known:
  66. orders = render_market_order_bodies(
  67. plan=plan,
  68. symbol=SYMBOL,
  69. mark_price=float(account["mark_price"]),
  70. metadata=client_metadata(account),
  71. leverage=LEVERAGE,
  72. margin_per_unit_usdt=margin_per_unit_usdt,
  73. max_new_margin_usdt=max_new_margin_usdt,
  74. max_total_margin_usdt=max_total_margin_usdt,
  75. client_order_id_prefix=f"ethnm-{target_candle_ts(payload)}",
  76. )
  77. return {
  78. "created_at": now_iso(),
  79. "mode": "dry_run_executor",
  80. "orders_submitted": 0,
  81. "signal": payload["decision"],
  82. "execution_intent": payload["execution_intent"],
  83. "previous_state": asdict(previous_state),
  84. "next_state": asdict(next_state),
  85. "current_position": asdict(current),
  86. "target_position": asdict(target),
  87. "execution_plan": {
  88. "current": asdict(plan.current),
  89. "target": asdict(plan.target),
  90. "actions": [asdict(action) for action in plan.actions],
  91. },
  92. "rendered_orders": [asdict(order) for order in orders],
  93. "account": account,
  94. "risk_limits": {
  95. "submit_enabled": False,
  96. "max_new_margin_usdt": max_new_margin_usdt,
  97. "max_total_margin_usdt": max_total_margin_usdt,
  98. "margin_per_unit_usdt": margin_per_unit_usdt,
  99. "state_write_required_before_live": True,
  100. },
  101. }
  102. def build_execution_snapshot(
  103. *,
  104. client: OkxClient,
  105. state_dir: Path,
  106. margin_per_unit_usdt: float,
  107. max_new_margin_usdt: float,
  108. max_total_margin_usdt: float,
  109. mode: str,
  110. ) -> tuple[dict[str, object], object]:
  111. state_dir.mkdir(parents=True, exist_ok=True)
  112. payload = eth_nextgen_micro.build_payload()
  113. previous_state = load_runtime_state(state_dir / EXECUTOR_STATE_FILENAME)
  114. next_state, target = target_from_signal(payload, previous_state)
  115. current, account = account_current_position_with_client(client, margin_per_unit_usdt)
  116. plan = plan_position_delta(current, target)
  117. orders = ()
  118. if current.known and target.known:
  119. orders = render_market_order_bodies(
  120. plan=plan,
  121. symbol=SYMBOL,
  122. mark_price=float(account["mark_price"]),
  123. metadata=client_metadata(account),
  124. leverage=LEVERAGE,
  125. margin_per_unit_usdt=margin_per_unit_usdt,
  126. max_new_margin_usdt=max_new_margin_usdt,
  127. max_total_margin_usdt=max_total_margin_usdt,
  128. client_order_id_prefix=f"ethnm-{target_candle_ts(payload)}",
  129. )
  130. snapshot = {
  131. "created_at": now_iso(),
  132. "mode": mode,
  133. "orders_submitted": 0,
  134. "signal": payload["decision"],
  135. "execution_intent": payload["execution_intent"],
  136. "previous_state": asdict(previous_state),
  137. "next_state": asdict(next_state),
  138. "current_position": asdict(current),
  139. "target_position": asdict(target),
  140. "execution_plan": {
  141. "current": asdict(plan.current),
  142. "target": asdict(plan.target),
  143. "actions": [asdict(action) for action in plan.actions],
  144. },
  145. "rendered_orders": [asdict(order) for order in orders],
  146. "account": account,
  147. "risk_limits": {
  148. "submit_enabled": mode == "live_executor",
  149. "max_new_margin_usdt": max_new_margin_usdt,
  150. "max_total_margin_usdt": max_total_margin_usdt,
  151. "margin_per_unit_usdt": margin_per_unit_usdt,
  152. },
  153. }
  154. return snapshot, next_state
  155. def append_event(state_dir: Path, snapshot: dict[str, object]) -> None:
  156. state_dir.mkdir(parents=True, exist_ok=True)
  157. with (state_dir / EXECUTOR_EVENTS_FILENAME).open("a", encoding="utf-8") as handle:
  158. handle.write(json.dumps(snapshot, sort_keys=True) + "\n")
  159. def execute_live_once(
  160. *,
  161. state_dir: Path,
  162. margin_per_unit_usdt: float,
  163. max_new_margin_usdt: float,
  164. max_total_margin_usdt: float,
  165. ) -> dict[str, object]:
  166. client = OkxClient(load_config())
  167. snapshot, next_state = build_execution_snapshot(
  168. client=client,
  169. state_dir=state_dir,
  170. margin_per_unit_usdt=margin_per_unit_usdt,
  171. max_new_margin_usdt=max_new_margin_usdt,
  172. max_total_margin_usdt=max_total_margin_usdt,
  173. mode="live_executor",
  174. )
  175. if not snapshot["current_position"]["known"] or not snapshot["target_position"]["known"]:
  176. snapshot["execution_error"] = "current and target positions must both be known"
  177. append_event(state_dir, snapshot)
  178. raise ValueError("current and target positions must both be known")
  179. client.ensure_hedge_mode()
  180. submitted = []
  181. try:
  182. for rendered in snapshot["rendered_orders"]:
  183. body = rendered["body"]
  184. client.set_leverage(symbol=SYMBOL, leverage=LEVERAGE, pos_side=body["posSide"])
  185. result = client.submit_market_order_body(body)
  186. submitted.append(asdict(result))
  187. except ValueError as exc:
  188. snapshot["orders_submitted"] = len(submitted)
  189. snapshot["submitted_orders"] = submitted
  190. snapshot["execution_error"] = str(exc)
  191. append_event(state_dir, snapshot)
  192. raise
  193. snapshot["orders_submitted"] = len(submitted)
  194. snapshot["submitted_orders"] = submitted
  195. save_runtime_state(state_dir / EXECUTOR_STATE_FILENAME, next_state)
  196. append_event(state_dir, snapshot)
  197. return snapshot
  198. def risk_arg(value: float | None, env_name: str) -> float:
  199. if value is not None:
  200. return value
  201. raw = os.environ.get(env_name)
  202. if raw is None or raw == "":
  203. raise ValueError(f"{env_name} is required")
  204. return float(raw)
  205. def target_candle_ts(payload: dict[str, object]) -> int:
  206. if payload["decision"]["active_engine"] == "nextgen":
  207. return int(payload["nextgen"]["data"]["decision_candle_ts"])
  208. return int(payload["micro"]["decision_candle_ts"])
  209. def client_metadata(account: dict[str, object]):
  210. from okx_codex_trader.models import InstrumentMeta
  211. meta = account["instrument_meta"]
  212. return InstrumentMeta(ct_val=float(meta["ct_val"]), lot_sz=float(meta["lot_sz"]), min_sz=float(meta["min_sz"]))
  213. def main() -> int:
  214. parser = argparse.ArgumentParser(description="Build ETH nextgen+micro live execution dry-run snapshot.")
  215. parser.add_argument("--state-dir", type=Path, default=STATE_DIR)
  216. parser.add_argument("--margin-per-unit-usdt", type=float)
  217. parser.add_argument("--max-new-margin-usdt", type=float)
  218. parser.add_argument("--max-total-margin-usdt", type=float)
  219. parser.add_argument("--submit-live", action="store_true")
  220. parser.add_argument("--confirm-live", action="store_true")
  221. args = parser.parse_args()
  222. margin_per_unit_usdt = risk_arg(args.margin_per_unit_usdt, "ETH_NEXTGEN_MARGIN_PER_UNIT_USDT")
  223. max_new_margin_usdt = risk_arg(args.max_new_margin_usdt, "ETH_NEXTGEN_MAX_NEW_MARGIN_USDT")
  224. max_total_margin_usdt = risk_arg(args.max_total_margin_usdt, "ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT")
  225. if args.submit_live != args.confirm_live:
  226. raise ValueError("--submit-live and --confirm-live must be used together")
  227. if args.submit_live:
  228. snapshot = execute_live_once(
  229. state_dir=args.state_dir,
  230. margin_per_unit_usdt=margin_per_unit_usdt,
  231. max_new_margin_usdt=max_new_margin_usdt,
  232. max_total_margin_usdt=max_total_margin_usdt,
  233. )
  234. else:
  235. snapshot = build_snapshot(
  236. state_dir=args.state_dir,
  237. margin_per_unit_usdt=margin_per_unit_usdt,
  238. max_new_margin_usdt=max_new_margin_usdt,
  239. max_total_margin_usdt=max_total_margin_usdt,
  240. )
  241. print(json.dumps(snapshot, indent=2, sort_keys=True))
  242. return 0
  243. if __name__ == "__main__":
  244. raise SystemExit(main())