Browse Source

Harden ETH micro dry-run execution bounds

lxy 1 month ago
parent
commit
7d89e8ce0e

+ 5 - 5
docs/live-eth-nextgen-micro-deployment.md

@@ -52,8 +52,8 @@ Do not add live order submission to this service until all of these are true:
 - Persistent virtual nextgen and micro state is maintained.
 - Target position is expressed as one net ETH position with units `0`, `0.5`, or `1.0`.
 - Close/reduce-only order support is implemented and tested.
-- The daemon is idempotent by completed candle timestamp and client order id.
-- A hard `max_margin_usdt` cap is enforced.
+- The executor is idempotent by completed candle timestamp and client order id.
+- Hard `max_new_margin_usdt` and `max_total_margin_usdt` caps are enforced.
 
 ## Execution design
 
@@ -75,9 +75,9 @@ The strategy logic is moving out of ad hoc scripts into package modules:
 
 - `okx_codex_trader.eth_nextgen_micro` builds the ETH nextgen+micro signal payload.
 - `okx_codex_trader.live_execution` maintains runtime strategy state, converts nextgen virtual legs into one net target position, normalizes OKX positions into strategy units, and builds a pure delta plan.
-- `okx_codex_trader.live_execution.render_market_order_bodies` converts a tested delta plan into OKX market order bodies with deterministic client order ids and a hard new-margin cap.
+- `okx_codex_trader.live_execution.render_market_order_bodies` converts a tested delta plan into OKX market order bodies with deterministic client order ids, a hard new-margin cap, and a hard target-total-margin cap.
 - `okx_codex_trader.okx_client.submit_market_order_body` can submit a prebuilt market order body, but no deployed service or CLI calls it yet.
-- `scripts/run_eth_nextgen_micro_observer.py` remains read-only. It writes runtime state and target-position diagnostics, but it still submits no orders.
+- `scripts/run_eth_nextgen_micro_observer.py` remains read-only. It writes observer-only runtime state and target-position diagnostics, but it still submits no orders.
 - `scripts/run_eth_nextgen_micro_executor.py` builds a dry-run execution snapshot. It reads the strategy payload and OKX account state, renders order bodies only when current and target positions are known, and always reports `orders_submitted: 0`.
 
 Live order submission is still intentionally unreachable from the daemon. The next required boundary is an executor command or service that calls the submit method, records client order ids, and verifies fills.
@@ -85,5 +85,5 @@ Live order submission is still intentionally unreachable from the daemon. The ne
 Dry-run executor example:
 
 ```bash
-python scripts/run_eth_nextgen_micro_executor.py --margin-per-unit-usdt 5 --max-new-margin-usdt 5
+python scripts/run_eth_nextgen_micro_executor.py --margin-per-unit-usdt 100 --max-new-margin-usdt 100 --max-total-margin-usdt 200
 ```

+ 4 - 1
okx_codex_trader/live_execution.py

@@ -185,10 +185,13 @@ def render_market_order_bodies(
     leverage: int,
     margin_per_unit_usdt: float,
     max_new_margin_usdt: float,
+    max_total_margin_usdt: float,
     client_order_id_prefix: str,
 ) -> tuple[RenderedOrder, ...]:
-    if leverage <= 0 or margin_per_unit_usdt <= 0.0 or max_new_margin_usdt < 0.0:
+    if leverage <= 0 or margin_per_unit_usdt <= 0.0 or max_new_margin_usdt < 0.0 or max_total_margin_usdt < 0.0:
         raise ValueError("order rendering inputs are invalid")
+    if plan.target.known and plan.target.unit * margin_per_unit_usdt > max_total_margin_usdt:
+        raise ValueError("target margin exceeds max_total_margin_usdt")
     rendered: list[RenderedOrder] = []
     new_margin = 0.0
     index = 1

+ 23 - 6
scripts/run_eth_nextgen_micro_executor.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import argparse
 import json
+import os
 import sys
 from dataclasses import asdict
 from datetime import UTC, datetime
@@ -61,10 +62,13 @@ def account_current_position(margin_per_unit_usdt: float) -> tuple[TargetPositio
         return unknown_current_position(str(exc)), {"account_error": str(exc)}
 
 
-def build_snapshot(*, state_dir: Path, margin_per_unit_usdt: float, max_new_margin_usdt: float) -> dict[str, object]:
+EXECUTOR_STATE_FILENAME = "executor-runtime-state.json"
+
+
+def build_snapshot(*, state_dir: Path, margin_per_unit_usdt: float, max_new_margin_usdt: float, max_total_margin_usdt: float) -> dict[str, object]:
     state_dir.mkdir(parents=True, exist_ok=True)
     payload = eth_nextgen_micro.build_payload()
-    previous_state = load_runtime_state(state_dir / "runtime-state.json")
+    previous_state = load_runtime_state(state_dir / EXECUTOR_STATE_FILENAME)
     next_state, target = target_from_signal(payload, previous_state)
     current, account = account_current_position(margin_per_unit_usdt)
     plan = plan_position_delta(current, target)
@@ -78,6 +82,7 @@ def build_snapshot(*, state_dir: Path, margin_per_unit_usdt: float, max_new_marg
             leverage=LEVERAGE,
             margin_per_unit_usdt=margin_per_unit_usdt,
             max_new_margin_usdt=max_new_margin_usdt,
+            max_total_margin_usdt=max_total_margin_usdt,
             client_order_id_prefix=f"ethnm-{target_candle_ts(payload)}",
         )
     return {
@@ -100,12 +105,22 @@ def build_snapshot(*, state_dir: Path, margin_per_unit_usdt: float, max_new_marg
         "risk_limits": {
             "submit_enabled": False,
             "max_new_margin_usdt": max_new_margin_usdt,
+            "max_total_margin_usdt": max_total_margin_usdt,
             "margin_per_unit_usdt": margin_per_unit_usdt,
             "state_write_required_before_live": True,
         },
     }
 
 
+def risk_arg(value: float | None, env_name: str) -> float:
+    if value is not None:
+        return value
+    raw = os.environ.get(env_name)
+    if raw is None or raw == "":
+        raise ValueError(f"{env_name} is required")
+    return float(raw)
+
+
 def target_candle_ts(payload: dict[str, object]) -> int:
     if payload["decision"]["active_engine"] == "nextgen":
         return int(payload["nextgen"]["data"]["decision_candle_ts"])
@@ -122,14 +137,16 @@ def client_metadata(account: dict[str, object]):
 def main() -> int:
     parser = argparse.ArgumentParser(description="Build ETH nextgen+micro live execution dry-run snapshot.")
     parser.add_argument("--state-dir", type=Path, default=STATE_DIR)
-    parser.add_argument("--margin-per-unit-usdt", type=float, required=True)
-    parser.add_argument("--max-new-margin-usdt", type=float, required=True)
+    parser.add_argument("--margin-per-unit-usdt", type=float)
+    parser.add_argument("--max-new-margin-usdt", type=float)
+    parser.add_argument("--max-total-margin-usdt", type=float)
     args = parser.parse_args()
 
     snapshot = build_snapshot(
         state_dir=args.state_dir,
-        margin_per_unit_usdt=args.margin_per_unit_usdt,
-        max_new_margin_usdt=args.max_new_margin_usdt,
+        margin_per_unit_usdt=risk_arg(args.margin_per_unit_usdt, "ETH_NEXTGEN_MARGIN_PER_UNIT_USDT"),
+        max_new_margin_usdt=risk_arg(args.max_new_margin_usdt, "ETH_NEXTGEN_MAX_NEW_MARGIN_USDT"),
+        max_total_margin_usdt=risk_arg(args.max_total_margin_usdt, "ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT"),
     )
     print(json.dumps(snapshot, indent=2, sort_keys=True))
     return 0

+ 1 - 1
scripts/run_eth_nextgen_micro_observer.py

@@ -87,7 +87,7 @@ def run_once(state_dir: Path) -> dict[str, object]:
     signal_payload = intent.build_payload()
     intent.JSON_REPORT.write_text(json.dumps(signal_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
     intent.MARKDOWN_REPORT.write_text(intent.markdown_report(signal_payload), encoding="utf-8")
-    runtime_state_path = state_dir / "runtime-state.json"
+    runtime_state_path = state_dir / "observer-runtime-state.json"
     previous_state = load_runtime_state(runtime_state_path)
     next_state, target_position = target_from_signal(signal_payload, previous_state)
     save_runtime_state(runtime_state_path, next_state)

+ 19 - 2
tests/test_eth_nextgen_micro_executor.py

@@ -41,7 +41,7 @@ def test_executor_snapshot_does_not_render_orders_when_current_position_is_unkno
         lambda _: (TargetPosition(side="flat", unit=0.0, known=False, reason="no credentials"), {"account_error": "no credentials"}),
     )
 
-    snapshot = executor.build_snapshot(state_dir=tmp_path, margin_per_unit_usdt=1000.0, max_new_margin_usdt=500.0)
+    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)
 
     assert snapshot["orders_submitted"] == 0
     assert snapshot["target_position"]["known"] is True
@@ -61,7 +61,7 @@ def test_executor_snapshot_renders_order_body_when_positions_are_known(monkeypat
         ),
     )
 
-    snapshot = executor.build_snapshot(state_dir=tmp_path, margin_per_unit_usdt=1000.0, max_new_margin_usdt=500.0)
+    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)
 
     assert snapshot["orders_submitted"] == 0
     assert snapshot["rendered_orders"] == [
@@ -79,3 +79,20 @@ def test_executor_snapshot_renders_order_body_when_positions_are_known(monkeypat
             },
         }
     ]
+
+
+def test_executor_uses_executor_state_file(monkeypatch, tmp_path):
+    monkeypatch.setattr(executor.eth_nextgen_micro, "build_payload", payload_with_nextgen_long)
+    monkeypatch.setattr(
+        executor,
+        "account_current_position",
+        lambda _: (
+            TargetPosition(side="flat", unit=0.0, known=True, reason="flat"),
+            {"mark_price": 3000.0, "instrument_meta": {"ct_val": 0.1, "lot_sz": 1.0, "min_sz": 1.0}},
+        ),
+    )
+    (tmp_path / "runtime-state.json").write_text('{"last_candle_ts":1000,"micro_side":null,"nextgen_active_legs":[]}\n', encoding="utf-8")
+
+    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)
+
+    assert snapshot["target_position"]["unit"] == 0.5

+ 23 - 0
tests/test_live_execution.py

@@ -130,6 +130,7 @@ def test_render_market_order_bodies_builds_open_order_body():
         leverage=3,
         margin_per_unit_usdt=1000.0,
         max_new_margin_usdt=500.0,
+        max_total_margin_usdt=1000.0,
         client_order_id_prefix="eth-1000",
     )
 
@@ -160,6 +161,7 @@ def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
         leverage=3,
         margin_per_unit_usdt=1000.0,
         max_new_margin_usdt=500.0,
+        max_total_margin_usdt=1000.0,
         client_order_id_prefix="eth-2000",
     )
 
@@ -201,5 +203,26 @@ def test_render_market_order_bodies_enforces_new_margin_cap():
             leverage=3,
             margin_per_unit_usdt=1000.0,
             max_new_margin_usdt=500.0,
+            max_total_margin_usdt=1000.0,
             client_order_id_prefix="eth-3000",
         )
+
+
+def test_render_market_order_bodies_enforces_total_margin_cap():
+    plan = plan_position_delta(
+        TargetPosition(side="long", unit=0.5, known=True, reason="current"),
+        TargetPosition(side="long", unit=1.0, known=True, reason="target"),
+    )
+
+    with pytest.raises(ValueError, match="target margin exceeds max_total_margin_usdt"):
+        render_market_order_bodies(
+            plan=plan,
+            symbol="ETH-USDT-SWAP",
+            mark_price=3000.0,
+            metadata=InstrumentMeta(ct_val=0.1, lot_sz=1.0, min_sz=1.0),
+            leverage=3,
+            margin_per_unit_usdt=1000.0,
+            max_new_margin_usdt=500.0,
+            max_total_margin_usdt=500.0,
+            client_order_id_prefix="eth-4000",
+        )