Pārlūkot izejas kodu

research: add eth execution implementation plans

lxy 1 mēnesi atpakaļ
vecāks
revīzija
238fa4a579

+ 194 - 0
freqtrade/user_data/strategies/EthFocusedInformativeDry.py

@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+import pandas as pd
+from freqtrade.persistence import Trade
+from freqtrade.strategy import IStrategy
+
+
+class EthFocusedInformativeDry(IStrategy):
+    INTERFACE_VERSION = 3
+
+    timeframe = "5m"
+    can_short = False
+    startup_candle_count = 480
+    process_only_new_candles = True
+
+    minimal_roi = {"0": 100.0}
+    stoploss = -0.02
+    use_exit_signal = True
+    exit_profit_only = False
+    ignore_roi_if_entry_signal = False
+
+    eth_rsi_trend_sma = 120
+    eth_rsi_length = 2
+    eth_rsi_threshold = 3.0
+    eth_exit_rsi = 55.0
+    btc_trend_sma = 480
+    btc_momentum_lookback = 240
+    btc_min_momentum = 0.0
+
+    lead_lookback_15m = 8
+    lead_lookback_5m = 16
+    btc_return_threshold_15m = 0.018
+    btc_return_threshold_5m = 0.012
+    lag_gap = 0.006
+    lead_lag_max_hold_bars = 8
+    lead_lag_stop_loss = -0.006
+    lead_lag_take_profit = 0.018
+
+    rsi_filter_leverage = 3.0
+    lead_lag_leverage = 3.0
+
+    def informative_pairs(self) -> list[tuple[str, str]]:
+        return [
+            ("BTC/USDT:USDT", "5m"),
+            ("BTC/USDT:USDT", "15m"),
+            ("ETH/USDT:USDT", "15m"),
+        ]
+
+    def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe["eth_return_5m"] = dataframe["close"].pct_change(self.lead_lookback_5m)
+
+        if self.dp:
+            btc_5m = self.dp.get_pair_dataframe(pair="BTC/USDT:USDT", timeframe="5m")
+            btc_5m["btc_return"] = btc_5m["close"].pct_change(self.lead_lookback_5m)
+            dataframe = self._merge_informative(dataframe, btc_5m, "btc", "5m")
+
+            btc_15m = self.dp.get_pair_dataframe(pair="BTC/USDT:USDT", timeframe="15m")
+            btc_15m["btc_trend"] = btc_15m["close"].rolling(self.btc_trend_sma).mean()
+            btc_15m["btc_momentum"] = btc_15m["close"].pct_change(self.btc_momentum_lookback)
+            btc_15m["btc_return"] = btc_15m["close"].pct_change(self.lead_lookback_15m)
+            dataframe = self._merge_informative(dataframe, btc_15m, "btc", "15m")
+
+            eth_15m = self.dp.get_pair_dataframe(pair=metadata["pair"], timeframe="15m")
+            eth_15m["eth_trend"] = eth_15m["close"].rolling(self.eth_rsi_trend_sma).mean()
+            eth_15m["eth_rsi2"] = self._rsi(eth_15m["close"], self.eth_rsi_length)
+            eth_15m["eth_return"] = eth_15m["close"].pct_change(self.lead_lookback_15m)
+            dataframe = self._merge_informative(dataframe, eth_15m, "eth", "15m")
+
+        return dataframe
+
+    def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        rsi_filter = (
+            (dataframe["eth_close_15m"] > dataframe["eth_trend_15m"])
+            & (dataframe["eth_rsi2_15m"] <= self.eth_rsi_threshold)
+            & (dataframe["btc_close_15m"] > dataframe["btc_trend_15m"])
+            & (dataframe["btc_momentum_15m"] >= self.btc_min_momentum)
+        )
+        lead_lag_15m = (
+            (dataframe["btc_return_15m"] >= self.btc_return_threshold_15m)
+            & ((dataframe["btc_return_15m"] - dataframe["eth_return_15m"]) >= self.lag_gap)
+        )
+        lead_lag_5m = (
+            (dataframe["btc_return_5m"] >= self.btc_return_threshold_5m)
+            & ((dataframe["btc_return_5m"] - dataframe["eth_return_5m"]) >= self.lag_gap)
+        )
+
+        dataframe.loc[rsi_filter, ["enter_long", "enter_tag"]] = (1, "eth_btc_rsi_filter_15m")
+        dataframe.loc[lead_lag_15m, ["enter_long", "enter_tag"]] = (1, "btc_lead_eth_lag_15m")
+        dataframe.loc[lead_lag_5m, ["enter_long", "enter_tag"]] = (1, "btc_lead_eth_lag_5m")
+        return dataframe
+
+    def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe.loc[
+            (dataframe["eth_rsi2_15m"] >= self.eth_exit_rsi)
+            | (dataframe["btc_close_15m"] <= dataframe["btc_trend_15m"]),
+            ["exit_long", "exit_tag"],
+        ] = (1, "rsi_or_btc_trend_exit")
+        return dataframe
+
+    def custom_exit(
+        self,
+        pair: str,
+        trade: Trade,
+        current_time: datetime,
+        current_rate: float,
+        current_profit: float,
+        **kwargs,
+    ) -> str | None:
+        if trade.enter_tag not in {"btc_lead_eth_lag_15m", "btc_lead_eth_lag_5m"}:
+            return None
+        held_bars = int((current_time - trade.open_date_utc).total_seconds() // (5 * 60))
+        if current_profit <= self.lead_lag_stop_loss:
+            return "lead_lag_stop"
+        if current_profit >= self.lead_lag_take_profit:
+            return "lead_lag_take_profit"
+        if held_bars >= self.lead_lag_max_hold_bars:
+            return "lead_lag_max_hold"
+        return None
+
+    def leverage(
+        self,
+        pair: str,
+        current_time: datetime,
+        current_rate: float,
+        proposed_leverage: float,
+        max_leverage: float,
+        entry_tag: str | None,
+        side: str,
+        **kwargs,
+    ) -> float:
+        if entry_tag in {"btc_lead_eth_lag_15m", "btc_lead_eth_lag_5m"}:
+            return min(self.lead_lag_leverage, max_leverage)
+        return min(self.rsi_filter_leverage, max_leverage)
+
+    @staticmethod
+    def _merge_informative(
+        dataframe: pd.DataFrame,
+        informative: pd.DataFrame,
+        prefix: str,
+        timeframe: str,
+    ) -> pd.DataFrame:
+        minutes = {"5m": 5, "15m": 15}[timeframe]
+        informative = informative.copy()
+        informative["merge_date"] = informative["date"] + pd.to_timedelta(minutes, unit="m")
+        columns = ["merge_date", "open", "high", "low", "close", "volume"]
+        columns += [column for column in informative.columns if column.startswith(f"{prefix}_")]
+        informative = informative[columns].rename(
+            columns={
+                column: f"{prefix}_{column}_{timeframe}"
+                for column in columns
+                if column != "merge_date" and not column.startswith(f"{prefix}_")
+            }
+        )
+        informative = informative.rename(
+            columns={
+                column: f"{column}_{timeframe}"
+                for column in informative.columns
+                if column.startswith(f"{prefix}_") and not column.endswith(f"_{timeframe}")
+            }
+        )
+        merged = pd.merge_asof(
+            dataframe.sort_values("date"),
+            informative.sort_values("merge_date"),
+            left_on="date",
+            right_on="merge_date",
+            direction="backward",
+        ).ffill()
+        return merged.drop(columns=[column for column in merged.columns if column.startswith("merge_date")])
+
+    @staticmethod
+    def _rsi(close: pd.Series, length: int) -> pd.Series:
+        deltas = close.diff()
+        gains = deltas.clip(lower=0.0)
+        losses = -deltas.clip(upper=0.0)
+        values = [float("nan")] * len(close)
+        if len(close) <= length:
+            return pd.Series(values, index=close.index)
+
+        average_gain = float(gains.iloc[1 : length + 1].mean())
+        average_loss = float(losses.iloc[1 : length + 1].mean())
+        for index in range(length, len(close)):
+            if index > length:
+                average_gain = ((average_gain * (length - 1)) + float(gains.iloc[index])) / length
+                average_loss = ((average_loss * (length - 1)) + float(losses.iloc[index])) / length
+            if pd.isna(average_gain) or pd.isna(average_loss):
+                continue
+            if average_loss == 0.0:
+                values[index] = 100.0 if average_gain > 0.0 else 50.0
+                continue
+            relative_strength = average_gain / average_loss
+            values[index] = 100.0 - (100.0 / (1.0 + relative_strength))
+        return pd.Series(values, index=close.index)

+ 14 - 0
reports/eth-exploration/README.md

@@ -185,3 +185,17 @@ Reports:
 - `reports/eth-exploration/eth-focused-portfolio-live-readiness.json`
 
 Core conclusion: freqtrade is usable as a no-maker-dependent comparison path for the ETH/BTC RSI filter and BTC lead-lag legs, but it does not save time for TWAP execution because maker-fill lifecycle fidelity still needs custom order/fill tracking. Live readiness is not satisfied yet: post_only entry payloads, batch order intent, cancel intent/client support, dedicated state, position protection, scheduler, and append-only logging are still missing.
+
+## Execution implementation plans
+
+Scripts:
+- `scripts/implement_okx_readonly_order_support_plan.py`
+- `scripts/build_freqtrade_eth_informative_skeleton.py`
+
+Reports:
+- `reports/eth-exploration/okx-order-support-plan.md`
+- `reports/eth-exploration/okx-order-support-plan.json`
+- `reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.md`
+- `reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.json`
+
+Core conclusion: OKX 实盘支持缺口的最小实现计划已生成;Freqtrade no-maker-dependent 对照骨架已生成。

+ 54 - 0
reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.json

@@ -0,0 +1,54 @@
+{
+  "backtesting_command": "rtk freqtrade backtesting --config freqtrade/config-okx-futures.json --userdir freqtrade/user_data --strategy EthFocusedInformativeDry --timeframe 5m --pairs ETH/USDT:USDT --timerange 20230101-",
+  "data_export_commands": [
+    "rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 5m",
+    "rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 5m",
+    "rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 15m",
+    "rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 15m"
+  ],
+  "generated_at": "2026-04-29T18:32:30Z",
+  "json_report": "reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.json",
+  "legs": [
+    {
+      "entry": "ETH close > ETH SMA120, ETH RSI2 <= 3, BTC close > BTC SMA480, BTC momentum240 >= 0",
+      "exit": "ETH RSI2 >= 55 or BTC close <= BTC SMA480",
+      "leverage": 3.0,
+      "tag": "eth_btc_rsi_filter_15m",
+      "timeframe": "15m"
+    },
+    {
+      "entry": "BTC return8 >= 0.018 and BTC return8 - ETH return8 >= 0.006",
+      "exit": "stop -0.006, take profit 0.018, or max 8 base 5m bars",
+      "leverage": 3.0,
+      "tag": "btc_lead_eth_lag_15m",
+      "timeframe": "15m"
+    },
+    {
+      "entry": "BTC return16 >= 0.012 and BTC return16 - ETH return16 >= 0.006",
+      "exit": "stop -0.006, take profit 0.018, or max 8 base 5m bars",
+      "leverage": 3.0,
+      "tag": "btc_lead_eth_lag_5m",
+      "timeframe": "5m"
+    }
+  ],
+  "markdown_report": "reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.md",
+  "mode": "backtest_comparison_skeleton_only",
+  "notes": [
+    "The skeleton is for backtest comparison only and does not modify config.",
+    "The strategy models signal legs with entry tags on one ETH futures pair; it is not a multi-position portfolio allocator.",
+    "The maker-dependent ETH robust TWAP leg is intentionally excluded."
+  ],
+  "scope": {
+    "base_pair": "ETH/USDT:USDT",
+    "base_timeframe": "5m",
+    "config_changed": false,
+    "existing_strategy_changed": false,
+    "informative_pairs": [
+      "BTC/USDT:USDT 5m",
+      "BTC/USDT:USDT 15m",
+      "ETH/USDT:USDT 15m"
+    ],
+    "real_trading": false
+  },
+  "strategy": "freqtrade/user_data/strategies/EthFocusedInformativeDry.py"
+}

+ 50 - 0
reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.md

@@ -0,0 +1,50 @@
+# Freqtrade ETH informative skeleton
+
+Purpose: backtest comparison only. No live or dry-run trading command was executed, and no config file was changed.
+
+## Generated files
+
+- Strategy: `freqtrade/user_data/strategies/EthFocusedInformativeDry.py`
+- JSON report: `reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.json`
+- Markdown report: `reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.md`
+
+## Strategy mapping
+
+| Entry tag | Timeframe | Entry | Exit |
+| --- | --- | --- | --- |
+| `eth_btc_rsi_filter_15m` | `15m` | ETH close > ETH SMA120, ETH RSI2 <= 3, BTC close > BTC SMA480, BTC momentum240 >= 0 | ETH RSI2 >= 55 or BTC close <= BTC SMA480 |
+| `btc_lead_eth_lag_15m` | `15m` | BTC return8 >= 0.018 and BTC return8 - ETH return8 >= 0.006 | stop -0.006, take profit 0.018, or max 8 base 5m bars |
+| `btc_lead_eth_lag_5m` | `5m` | BTC return16 >= 0.012 and BTC return16 - ETH return16 >= 0.006 | stop -0.006, take profit 0.018, or max 8 base 5m bars |
+
+## Data export
+
+Export cached OKX candles into Freqtrade JSON futures files before backtesting:
+
+```bash
+rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 5m
+```
+```bash
+rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 5m
+```
+```bash
+rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 15m
+```
+```bash
+rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 15m
+```
+
+## Backtesting
+
+Run this only as a backtest comparison against exported data:
+
+```bash
+rtk freqtrade backtesting --config freqtrade/config-okx-futures.json --userdir freqtrade/user_data --strategy EthFocusedInformativeDry --timeframe 5m --pairs ETH/USDT:USDT --timerange 20230101-
+```
+
+This uses the existing config path but does not require editing it. The `--pairs ETH/USDT:USDT` argument keeps the run focused on the ETH base pair while BTC is used only as informative data.
+
+## Boundaries
+
+- The skeleton is for backtest comparison only and does not modify config.
+- The strategy models signal legs with entry tags on one ETH futures pair; it is not a multi-position portfolio allocator.
+- The maker-dependent ETH robust TWAP leg is intentionally excluded.

+ 206 - 0
reports/eth-exploration/okx-order-support-plan.json

@@ -0,0 +1,206 @@
+{
+  "report": "okx-order-support-plan",
+  "scope": "static implementation plan only; no OKX request, no order, no cancel",
+  "readiness_gap": "ETH-focused quasi-live portfolio needs a full order lifecycle before any submit-capable runner is valid.",
+  "source_files_checked": [
+    {
+      "path": "okx_codex_trader/okx_client.py",
+      "facts": [
+        "Signed _request already supports GET/POST with params/json_body.",
+        "place_order is a single-order submitter.",
+        "place_order emits ordType=market or ordType=limit only.",
+        "No batch-orders, orders-pending, cancel-order, order status, or fills method exists.",
+        "get_positions reads exchange positions but is not tied to portfolio-owned state."
+      ]
+    },
+    {
+      "path": "tests/test_okx_client.py",
+      "facts": [
+        "DummySession records request path/body and can support endpoint-level client tests.",
+        "Existing tests cover request signing, live/demo header, contract sizing, leverage validation, single order payload, and position parsing.",
+        "No tests cover post_only payloads, batch payloads, order list/cancel/status/fills, or state persistence."
+      ]
+    },
+    {
+      "path": "okx_codex_trader/cli.py",
+      "facts": [
+        "okx-account is read-only and returns balance plus positions.",
+        "okx-order is submit-capable and calls place_order.",
+        "No read-only ETH order-intent/state command exists.",
+        "No quasi-live state path or append-only lifecycle log is wired."
+      ]
+    }
+  ],
+  "official_okx_api_surface": [
+    {
+      "capability": "single post-only limit order payload",
+      "endpoint": "POST /api/v5/trade/order",
+      "boundary": "submit-capable only after read-only payload rendering is tested",
+      "required_fields": [
+        "instId",
+        "tdMode",
+        "side",
+        "posSide",
+        "ordType",
+        "px",
+        "sz",
+        "clOrdId"
+      ],
+      "fixed_eth_values": {
+        "instId": "ETH-USDT-SWAP",
+        "tdMode": "isolated",
+        "side": "buy",
+        "posSide": "long",
+        "ordType": "post_only"
+      }
+    },
+    {
+      "capability": "batch entry orders",
+      "endpoint": "POST /api/v5/trade/batch-orders",
+      "boundary": "submit-capable; read-only stage renders the exact array only",
+      "required_shape": "list of three post_only entry payloads with deterministic clOrdId values"
+    },
+    {
+      "capability": "list open orders",
+      "endpoint": "GET /api/v5/trade/orders-pending",
+      "boundary": "read-only",
+      "minimum_params": {
+        "instType": "SWAP",
+        "instId": "ETH-USDT-SWAP"
+      }
+    },
+    {
+      "capability": "cancel open order",
+      "endpoint": "POST /api/v5/trade/cancel-order",
+      "boundary": "submit-capable; read-only stage renders cancel intents only",
+      "required_fields": [
+        "instId",
+        "ordId or clOrdId"
+      ]
+    },
+    {
+      "capability": "cancel open orders in batch",
+      "endpoint": "POST /api/v5/trade/cancel-batch-orders",
+      "boundary": "submit-capable; read-only stage renders cancel intent array only",
+      "required_shape": "list of tracked open order ids for ETH strategy-owned orders"
+    },
+    {
+      "capability": "order status",
+      "endpoint": "GET /api/v5/trade/order",
+      "boundary": "read-only",
+      "minimum_params": {
+        "instId": "ETH-USDT-SWAP",
+        "ordId_or_clOrdId": "tracked order identity"
+      }
+    },
+    {
+      "capability": "recent fills",
+      "endpoint": "GET /api/v5/trade/fills",
+      "boundary": "read-only",
+      "minimum_params": {
+        "instType": "SWAP",
+        "instId": "ETH-USDT-SWAP"
+      }
+    }
+  ],
+  "state_persistence": {
+    "boundary": "local read/write only; not an OKX API call",
+    "minimum_file": "state/eth_robust_twap_15m_live.json",
+    "fields": [
+      "strategy_id",
+      "symbol",
+      "bar",
+      "td_mode",
+      "pos_side",
+      "leverage",
+      "last_confirmed_candle_ts",
+      "last_signal_candle_ts",
+      "orders",
+      "fills",
+      "position",
+      "planned_margin_usdt",
+      "max_margin_usdt",
+      "updated_at"
+    ],
+    "event_log": "append-only JSONL for signal, order-intent, cancel-intent, order-status, fills, and state-transition events"
+  },
+  "minimum_patch_plan_by_file": [
+    {
+      "file": "okx_codex_trader/okx_client.py",
+      "patch_plan": [
+        "Add small dataclasses or typed dicts only if needed by return values: OkxOrder, OkxFill, OkxOrderAck.",
+        "Add render_post_only_limit_order_payload(...) as a non-submitting function using existing _format_number and build_contract_size.",
+        "Add render_eth_entry_batch_payloads(...) that returns exactly three post_only payloads for offsets 0.003, 0.006, 0.009.",
+        "Add list_open_orders(symbol) calling GET /api/v5/trade/orders-pending with instType=SWAP and instId=symbol.",
+        "Add get_order(symbol, ord_id=None, client_order_id=None) calling GET /api/v5/trade/order.",
+        "Add list_fills(symbol) calling GET /api/v5/trade/fills with instType=SWAP and instId=symbol.",
+        "Add cancel_order(symbol, ord_id=None, client_order_id=None) and cancel_batch_orders(symbol, order_ids) only when moving past read-only intent.",
+        "Do not change existing place_order semantics for the first patch; it is not suitable for the ETH portfolio lifecycle."
+      ]
+    },
+    {
+      "file": "okx_codex_trader/cli.py",
+      "patch_plan": [
+        "Add read-only eth-robust-twap-order-intent command that prints signal, three entry payloads, expiry, and state transition preview.",
+        "Add read-only eth-robust-twap-open-orders command that lists current OKX pending orders for ETH-USDT-SWAP.",
+        "Add read-only eth-robust-twap-reconcile command that reads state, queries open orders/status/fills, and writes a new local state snapshot plus JSONL audit event.",
+        "Keep okx-order unchanged and do not route portfolio intents through it.",
+        "Only after read-only commands pass tests, add explicit submit commands for batch entry and cancellation."
+      ]
+    },
+    {
+      "file": "tests/test_okx_client.py",
+      "patch_plan": [
+        "Add tests for post_only payload shape: ETH-USDT-SWAP, isolated, buy, long, post_only, px, sz, deterministic clOrdId.",
+        "Add tests for batch payload shape: exactly three orders, offsets 0.003/0.006/0.009, each level sized independently and rounded to lotSz.",
+        "Add tests for GET /api/v5/trade/orders-pending params and normalized open-order state values.",
+        "Add tests for POST /api/v5/trade/cancel-order and /cancel-batch-orders request bodies before enabling submit commands.",
+        "Add tests for GET /api/v5/trade/order by ordId and by clOrdId.",
+        "Add tests for GET /api/v5/trade/fills parsing fillSz, fillPx, tradeId, ordId, clOrdId, fee, and fillTime.",
+        "Add tests that malformed OKX lifecycle payloads raise the existing stable invalid-payload error."
+      ]
+    },
+    {
+      "file": "new okx_codex_trader/eth_robust_twap_state.py",
+      "patch_plan": [
+        "Add only the dedicated state schema and load/save functions needed by the ETH quasi-live lifecycle.",
+        "Persist exchange order ids, client order ids, fills, position, last confirmed candle, active state, and audit cursor.",
+        "Reject state whose symbol/bar/strategy_id does not match the command arguments."
+      ]
+    },
+    {
+      "file": "new tests/test_eth_robust_twap_state.py",
+      "patch_plan": [
+        "Add load empty state test.",
+        "Add save/read roundtrip for one signal with three open orders and one partial fill.",
+        "Add state mismatch rejection tests for symbol, bar, and strategy_id."
+      ]
+    }
+  ],
+  "readonly_boundary": [
+    "Render post_only single and batch order payloads without calling POST /api/v5/trade/order or /batch-orders.",
+    "Read account balance, positions, pending orders, order details, and fills.",
+    "Write local state snapshots and append-only JSONL audit events.",
+    "Render cancel intents without calling cancel endpoints."
+  ],
+  "submit_capable_boundary": [
+    "POST /api/v5/trade/order with ordType=post_only.",
+    "POST /api/v5/trade/batch-orders.",
+    "POST /api/v5/trade/cancel-order.",
+    "POST /api/v5/trade/cancel-batch-orders.",
+    "Any reduce-only market close path."
+  ],
+  "test_checklist": [
+    "Existing tests: rtk .venv/bin/pytest tests/test_okx_client.py",
+    "New client tests for render-only post_only payloads make no DummySession requests.",
+    "New client tests for list_open_orders/get_order/list_fills assert method/path/params and parsed output.",
+    "New cancel tests assert request body shape but stay behind commands that are not wired in the read-only phase.",
+    "New CLI tests assert read-only order-intent/reconcile commands do not call submit/cancel endpoints.",
+    "New state tests assert deterministic state roundtrip and event append order."
+  ],
+  "sources": [
+    "https://www.okx.com/docs-v5/en/",
+    "reports/eth-exploration/eth-focused-portfolio-live-readiness.md",
+    "reports/eth-exploration/eth-robust-twap-live-plan.md"
+  ]
+}

+ 111 - 0
reports/eth-exploration/okx-order-support-plan.md

@@ -0,0 +1,111 @@
+# OKX order support minimum implementation plan
+
+Static implementation plan only. No OKX request, order, or cancel was made.
+
+## Goal
+
+ETH-focused quasi-live portfolio needs a full order lifecycle before any submit-capable runner is valid.
+
+Minimum direct path: first add read-only payload/state/reconciliation support, then add submit-capable order and cancel calls behind explicit commands.
+
+## Current code facts
+
+### `okx_codex_trader/okx_client.py`
+- Signed _request already supports GET/POST with params/json_body.
+- place_order is a single-order submitter.
+- place_order emits ordType=market or ordType=limit only.
+- No batch-orders, orders-pending, cancel-order, order status, or fills method exists.
+- get_positions reads exchange positions but is not tied to portfolio-owned state.
+
+### `tests/test_okx_client.py`
+- DummySession records request path/body and can support endpoint-level client tests.
+- Existing tests cover request signing, live/demo header, contract sizing, leverage validation, single order payload, and position parsing.
+- No tests cover post_only payloads, batch payloads, order list/cancel/status/fills, or state persistence.
+
+### `okx_codex_trader/cli.py`
+- okx-account is read-only and returns balance plus positions.
+- okx-order is submit-capable and calls place_order.
+- No read-only ETH order-intent/state command exists.
+- No quasi-live state path or append-only lifecycle log is wired.
+
+## Required OKX API surface
+
+| Capability | Endpoint | Boundary |
+| --- | --- | --- |
+| single post-only limit order payload | `POST /api/v5/trade/order` | submit-capable only after read-only payload rendering is tested |
+| batch entry orders | `POST /api/v5/trade/batch-orders` | submit-capable; read-only stage renders the exact array only |
+| list open orders | `GET /api/v5/trade/orders-pending` | read-only |
+| cancel open order | `POST /api/v5/trade/cancel-order` | submit-capable; read-only stage renders cancel intents only |
+| cancel open orders in batch | `POST /api/v5/trade/cancel-batch-orders` | submit-capable; read-only stage renders cancel intent array only |
+| order status | `GET /api/v5/trade/order` | read-only |
+| recent fills | `GET /api/v5/trade/fills` | read-only |
+
+State persistence is local state, not an OKX API call. Minimum file: `state/eth_robust_twap_15m_live.json`.
+
+## Read-only boundary
+
+- Render post_only single and batch order payloads without calling POST /api/v5/trade/order or /batch-orders.
+- Read account balance, positions, pending orders, order details, and fills.
+- Write local state snapshots and append-only JSONL audit events.
+- Render cancel intents without calling cancel endpoints.
+
+## Submit-capable boundary
+
+- POST /api/v5/trade/order with ordType=post_only.
+- POST /api/v5/trade/batch-orders.
+- POST /api/v5/trade/cancel-order.
+- POST /api/v5/trade/cancel-batch-orders.
+- Any reduce-only market close path.
+
+## Minimum patch plan by file
+
+### `okx_codex_trader/okx_client.py`
+- Add small dataclasses or typed dicts only if needed by return values: OkxOrder, OkxFill, OkxOrderAck.
+- Add render_post_only_limit_order_payload(...) as a non-submitting function using existing _format_number and build_contract_size.
+- Add render_eth_entry_batch_payloads(...) that returns exactly three post_only payloads for offsets 0.003, 0.006, 0.009.
+- Add list_open_orders(symbol) calling GET /api/v5/trade/orders-pending with instType=SWAP and instId=symbol.
+- Add get_order(symbol, ord_id=None, client_order_id=None) calling GET /api/v5/trade/order.
+- Add list_fills(symbol) calling GET /api/v5/trade/fills with instType=SWAP and instId=symbol.
+- Add cancel_order(symbol, ord_id=None, client_order_id=None) and cancel_batch_orders(symbol, order_ids) only when moving past read-only intent.
+- Do not change existing place_order semantics for the first patch; it is not suitable for the ETH portfolio lifecycle.
+
+### `okx_codex_trader/cli.py`
+- Add read-only eth-robust-twap-order-intent command that prints signal, three entry payloads, expiry, and state transition preview.
+- Add read-only eth-robust-twap-open-orders command that lists current OKX pending orders for ETH-USDT-SWAP.
+- Add read-only eth-robust-twap-reconcile command that reads state, queries open orders/status/fills, and writes a new local state snapshot plus JSONL audit event.
+- Keep okx-order unchanged and do not route portfolio intents through it.
+- Only after read-only commands pass tests, add explicit submit commands for batch entry and cancellation.
+
+### `tests/test_okx_client.py`
+- Add tests for post_only payload shape: ETH-USDT-SWAP, isolated, buy, long, post_only, px, sz, deterministic clOrdId.
+- Add tests for batch payload shape: exactly three orders, offsets 0.003/0.006/0.009, each level sized independently and rounded to lotSz.
+- Add tests for GET /api/v5/trade/orders-pending params and normalized open-order state values.
+- Add tests for POST /api/v5/trade/cancel-order and /cancel-batch-orders request bodies before enabling submit commands.
+- Add tests for GET /api/v5/trade/order by ordId and by clOrdId.
+- Add tests for GET /api/v5/trade/fills parsing fillSz, fillPx, tradeId, ordId, clOrdId, fee, and fillTime.
+- Add tests that malformed OKX lifecycle payloads raise the existing stable invalid-payload error.
+
+### `new okx_codex_trader/eth_robust_twap_state.py`
+- Add only the dedicated state schema and load/save functions needed by the ETH quasi-live lifecycle.
+- Persist exchange order ids, client order ids, fills, position, last confirmed candle, active state, and audit cursor.
+- Reject state whose symbol/bar/strategy_id does not match the command arguments.
+
+### `new tests/test_eth_robust_twap_state.py`
+- Add load empty state test.
+- Add save/read roundtrip for one signal with three open orders and one partial fill.
+- Add state mismatch rejection tests for symbol, bar, and strategy_id.
+
+## Test checklist
+
+- Existing tests: rtk .venv/bin/pytest tests/test_okx_client.py
+- New client tests for render-only post_only payloads make no DummySession requests.
+- New client tests for list_open_orders/get_order/list_fills assert method/path/params and parsed output.
+- New cancel tests assert request body shape but stay behind commands that are not wired in the read-only phase.
+- New CLI tests assert read-only order-intent/reconcile commands do not call submit/cancel endpoints.
+- New state tests assert deterministic state roundtrip and event append order.
+
+## Sources
+
+- OKX API v5 docs: https://www.okx.com/docs-v5/en/
+- Live readiness report: `reports/eth-exploration/eth-focused-portfolio-live-readiness.md`
+- ETH live execution plan: `reports/eth-exploration/eth-robust-twap-live-plan.md`

+ 341 - 0
scripts/build_freqtrade_eth_informative_skeleton.py

@@ -0,0 +1,341 @@
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+STRATEGY_PATH = ROOT / "freqtrade" / "user_data" / "strategies" / "EthFocusedInformativeDry.py"
+REPORT_DIR = ROOT / "reports" / "eth-exploration"
+
+
+STRATEGY_SOURCE = '''from __future__ import annotations
+
+from datetime import datetime
+
+import pandas as pd
+from freqtrade.persistence import Trade
+from freqtrade.strategy import IStrategy
+
+
+class EthFocusedInformativeDry(IStrategy):
+    INTERFACE_VERSION = 3
+
+    timeframe = "5m"
+    can_short = False
+    startup_candle_count = 480
+    process_only_new_candles = True
+
+    minimal_roi = {"0": 100.0}
+    stoploss = -0.02
+    use_exit_signal = True
+    exit_profit_only = False
+    ignore_roi_if_entry_signal = False
+
+    eth_rsi_trend_sma = 120
+    eth_rsi_length = 2
+    eth_rsi_threshold = 3.0
+    eth_exit_rsi = 55.0
+    btc_trend_sma = 480
+    btc_momentum_lookback = 240
+    btc_min_momentum = 0.0
+
+    lead_lookback_15m = 8
+    lead_lookback_5m = 16
+    btc_return_threshold_15m = 0.018
+    btc_return_threshold_5m = 0.012
+    lag_gap = 0.006
+    lead_lag_max_hold_bars = 8
+    lead_lag_stop_loss = -0.006
+    lead_lag_take_profit = 0.018
+
+    rsi_filter_leverage = 3.0
+    lead_lag_leverage = 3.0
+
+    def informative_pairs(self) -> list[tuple[str, str]]:
+        return [
+            ("BTC/USDT:USDT", "5m"),
+            ("BTC/USDT:USDT", "15m"),
+            ("ETH/USDT:USDT", "15m"),
+        ]
+
+    def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe["eth_return_5m"] = dataframe["close"].pct_change(self.lead_lookback_5m)
+
+        if self.dp:
+            btc_5m = self.dp.get_pair_dataframe(pair="BTC/USDT:USDT", timeframe="5m")
+            btc_5m["btc_return"] = btc_5m["close"].pct_change(self.lead_lookback_5m)
+            dataframe = self._merge_informative(dataframe, btc_5m, "btc", "5m")
+
+            btc_15m = self.dp.get_pair_dataframe(pair="BTC/USDT:USDT", timeframe="15m")
+            btc_15m["btc_trend"] = btc_15m["close"].rolling(self.btc_trend_sma).mean()
+            btc_15m["btc_momentum"] = btc_15m["close"].pct_change(self.btc_momentum_lookback)
+            btc_15m["btc_return"] = btc_15m["close"].pct_change(self.lead_lookback_15m)
+            dataframe = self._merge_informative(dataframe, btc_15m, "btc", "15m")
+
+            eth_15m = self.dp.get_pair_dataframe(pair=metadata["pair"], timeframe="15m")
+            eth_15m["eth_trend"] = eth_15m["close"].rolling(self.eth_rsi_trend_sma).mean()
+            eth_15m["eth_rsi2"] = self._rsi(eth_15m["close"], self.eth_rsi_length)
+            eth_15m["eth_return"] = eth_15m["close"].pct_change(self.lead_lookback_15m)
+            dataframe = self._merge_informative(dataframe, eth_15m, "eth", "15m")
+
+        return dataframe
+
+    def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        rsi_filter = (
+            (dataframe["eth_close_15m"] > dataframe["eth_trend_15m"])
+            & (dataframe["eth_rsi2_15m"] <= self.eth_rsi_threshold)
+            & (dataframe["btc_close_15m"] > dataframe["btc_trend_15m"])
+            & (dataframe["btc_momentum_15m"] >= self.btc_min_momentum)
+        )
+        lead_lag_15m = (
+            (dataframe["btc_return_15m"] >= self.btc_return_threshold_15m)
+            & ((dataframe["btc_return_15m"] - dataframe["eth_return_15m"]) >= self.lag_gap)
+        )
+        lead_lag_5m = (
+            (dataframe["btc_return_5m"] >= self.btc_return_threshold_5m)
+            & ((dataframe["btc_return_5m"] - dataframe["eth_return_5m"]) >= self.lag_gap)
+        )
+
+        dataframe.loc[rsi_filter, ["enter_long", "enter_tag"]] = (1, "eth_btc_rsi_filter_15m")
+        dataframe.loc[lead_lag_15m, ["enter_long", "enter_tag"]] = (1, "btc_lead_eth_lag_15m")
+        dataframe.loc[lead_lag_5m, ["enter_long", "enter_tag"]] = (1, "btc_lead_eth_lag_5m")
+        return dataframe
+
+    def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe.loc[
+            (dataframe["eth_rsi2_15m"] >= self.eth_exit_rsi)
+            | (dataframe["btc_close_15m"] <= dataframe["btc_trend_15m"]),
+            ["exit_long", "exit_tag"],
+        ] = (1, "rsi_or_btc_trend_exit")
+        return dataframe
+
+    def custom_exit(
+        self,
+        pair: str,
+        trade: Trade,
+        current_time: datetime,
+        current_rate: float,
+        current_profit: float,
+        **kwargs,
+    ) -> str | None:
+        if trade.enter_tag not in {"btc_lead_eth_lag_15m", "btc_lead_eth_lag_5m"}:
+            return None
+        held_bars = int((current_time - trade.open_date_utc).total_seconds() // (5 * 60))
+        if current_profit <= self.lead_lag_stop_loss:
+            return "lead_lag_stop"
+        if current_profit >= self.lead_lag_take_profit:
+            return "lead_lag_take_profit"
+        if held_bars >= self.lead_lag_max_hold_bars:
+            return "lead_lag_max_hold"
+        return None
+
+    def leverage(
+        self,
+        pair: str,
+        current_time: datetime,
+        current_rate: float,
+        proposed_leverage: float,
+        max_leverage: float,
+        entry_tag: str | None,
+        side: str,
+        **kwargs,
+    ) -> float:
+        if entry_tag in {"btc_lead_eth_lag_15m", "btc_lead_eth_lag_5m"}:
+            return min(self.lead_lag_leverage, max_leverage)
+        return min(self.rsi_filter_leverage, max_leverage)
+
+    @staticmethod
+    def _merge_informative(
+        dataframe: pd.DataFrame,
+        informative: pd.DataFrame,
+        prefix: str,
+        timeframe: str,
+    ) -> pd.DataFrame:
+        minutes = {"5m": 5, "15m": 15}[timeframe]
+        informative = informative.copy()
+        informative["merge_date"] = informative["date"] + pd.to_timedelta(minutes, unit="m")
+        columns = ["merge_date", "open", "high", "low", "close", "volume"]
+        columns += [column for column in informative.columns if column.startswith(f"{prefix}_")]
+        informative = informative[columns].rename(
+            columns={
+                column: f"{prefix}_{column}_{timeframe}"
+                for column in columns
+                if column != "merge_date" and not column.startswith(f"{prefix}_")
+            }
+        )
+        informative = informative.rename(
+            columns={
+                column: f"{column}_{timeframe}"
+                for column in informative.columns
+                if column.startswith(f"{prefix}_") and not column.endswith(f"_{timeframe}")
+            }
+        )
+        merged = pd.merge_asof(
+            dataframe.sort_values("date"),
+            informative.sort_values("merge_date"),
+            left_on="date",
+            right_on="merge_date",
+            direction="backward",
+        ).ffill()
+        return merged.drop(columns=[column for column in merged.columns if column.startswith("merge_date")])
+
+    @staticmethod
+    def _rsi(close: pd.Series, length: int) -> pd.Series:
+        deltas = close.diff()
+        gains = deltas.clip(lower=0.0)
+        losses = -deltas.clip(upper=0.0)
+        values = [float("nan")] * len(close)
+        if len(close) <= length:
+            return pd.Series(values, index=close.index)
+
+        average_gain = float(gains.iloc[1 : length + 1].mean())
+        average_loss = float(losses.iloc[1 : length + 1].mean())
+        for index in range(length, len(close)):
+            if index > length:
+                average_gain = ((average_gain * (length - 1)) + float(gains.iloc[index])) / length
+                average_loss = ((average_loss * (length - 1)) + float(losses.iloc[index])) / length
+            if pd.isna(average_gain) or pd.isna(average_loss):
+                continue
+            if average_loss == 0.0:
+                values[index] = 100.0 if average_gain > 0.0 else 50.0
+                continue
+            relative_strength = average_gain / average_loss
+            values[index] = 100.0 - (100.0 / (1.0 + relative_strength))
+        return pd.Series(values, index=close.index)
+'''
+
+
+def build_payload(generated_at: str) -> dict[str, object]:
+    return {
+        "generated_at": generated_at,
+        "mode": "backtest_comparison_skeleton_only",
+        "strategy": str(STRATEGY_PATH.relative_to(ROOT)),
+        "scope": {
+            "real_trading": False,
+            "config_changed": False,
+            "existing_strategy_changed": False,
+            "base_pair": "ETH/USDT:USDT",
+            "informative_pairs": ["BTC/USDT:USDT 5m", "BTC/USDT:USDT 15m", "ETH/USDT:USDT 15m"],
+            "base_timeframe": "5m",
+        },
+        "legs": [
+            {
+                "tag": "eth_btc_rsi_filter_15m",
+                "timeframe": "15m",
+                "entry": "ETH close > ETH SMA120, ETH RSI2 <= 3, BTC close > BTC SMA480, BTC momentum240 >= 0",
+                "exit": "ETH RSI2 >= 55 or BTC close <= BTC SMA480",
+                "leverage": 3.0,
+            },
+            {
+                "tag": "btc_lead_eth_lag_15m",
+                "timeframe": "15m",
+                "entry": "BTC return8 >= 0.018 and BTC return8 - ETH return8 >= 0.006",
+                "exit": "stop -0.006, take profit 0.018, or max 8 base 5m bars",
+                "leverage": 3.0,
+            },
+            {
+                "tag": "btc_lead_eth_lag_5m",
+                "timeframe": "5m",
+                "entry": "BTC return16 >= 0.012 and BTC return16 - ETH return16 >= 0.006",
+                "exit": "stop -0.006, take profit 0.018, or max 8 base 5m bars",
+                "leverage": 3.0,
+            },
+        ],
+        "data_export_commands": [
+            "rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 5m",
+            "rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 5m",
+            "rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 15m",
+            "rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 15m",
+        ],
+        "backtesting_command": (
+            "rtk freqtrade backtesting --config freqtrade/config-okx-futures.json "
+            "--userdir freqtrade/user_data --strategy EthFocusedInformativeDry "
+            "--timeframe 5m --pairs ETH/USDT:USDT --timerange 20230101-"
+        ),
+        "notes": [
+            "The skeleton is for backtest comparison only and does not modify config.",
+            "The strategy models signal legs with entry tags on one ETH futures pair; it is not a multi-position portfolio allocator.",
+            "The maker-dependent ETH robust TWAP leg is intentionally excluded.",
+        ],
+    }
+
+
+def build_markdown(payload: dict[str, object]) -> str:
+    lines = [
+        "# Freqtrade ETH informative skeleton",
+        "",
+        "Purpose: backtest comparison only. No live or dry-run trading command was executed, and no config file was changed.",
+        "",
+        "## Generated files",
+        "",
+        f"- Strategy: `{payload['strategy']}`",
+        f"- JSON report: `{payload['json_report']}`",
+        f"- Markdown report: `{payload['markdown_report']}`",
+        "",
+        "## Strategy mapping",
+        "",
+        "| Entry tag | Timeframe | Entry | Exit |",
+        "| --- | --- | --- | --- |",
+    ]
+    for leg in payload["legs"]:
+        lines.append(f"| `{leg['tag']}` | `{leg['timeframe']}` | {leg['entry']} | {leg['exit']} |")
+
+    lines.extend(
+        [
+            "",
+            "## Data export",
+            "",
+            "Export cached OKX candles into Freqtrade JSON futures files before backtesting:",
+            "",
+        ]
+    )
+    for command in payload["data_export_commands"]:
+        lines.append(f"```bash\n{command}\n```")
+
+    lines.extend(
+        [
+            "",
+            "## Backtesting",
+            "",
+            "Run this only as a backtest comparison against exported data:",
+            "",
+            f"```bash\n{payload['backtesting_command']}\n```",
+            "",
+            "This uses the existing config path but does not require editing it. The `--pairs ETH/USDT:USDT` argument keeps the run focused on the ETH base pair while BTC is used only as informative data.",
+            "",
+            "## Boundaries",
+            "",
+        ]
+    )
+    for note in payload["notes"]:
+        lines.append(f"- {note}")
+    lines.append("")
+    return "\n".join(lines)
+
+
+def main() -> int:
+    generated_at = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
+    stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
+    json_path = REPORT_DIR / f"freqtrade-eth-skeleton-{stamp}.json"
+    md_path = REPORT_DIR / f"freqtrade-eth-skeleton-{stamp}.md"
+
+    REPORT_DIR.mkdir(parents=True, exist_ok=True)
+    STRATEGY_PATH.parent.mkdir(parents=True, exist_ok=True)
+    STRATEGY_PATH.write_text(STRATEGY_SOURCE, encoding="utf-8")
+
+    payload = build_payload(generated_at)
+    payload["json_report"] = str(json_path.relative_to(ROOT))
+    payload["markdown_report"] = str(md_path.relative_to(ROOT))
+    json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    md_path.write_text(build_markdown(payload), encoding="utf-8")
+
+    print(STRATEGY_PATH.relative_to(ROOT))
+    print(json_path.relative_to(ROOT))
+    print(md_path.relative_to(ROOT))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 276 - 0
scripts/implement_okx_readonly_order_support_plan.py

@@ -0,0 +1,276 @@
+import json
+from pathlib import Path
+
+
+REPORT_DIR = Path("reports/eth-exploration")
+JSON_PATH = REPORT_DIR / "okx-order-support-plan.json"
+MD_PATH = REPORT_DIR / "okx-order-support-plan.md"
+
+
+PLAN = {
+    "report": "okx-order-support-plan",
+    "scope": "static implementation plan only; no OKX request, no order, no cancel",
+    "readiness_gap": "ETH-focused quasi-live portfolio needs a full order lifecycle before any submit-capable runner is valid.",
+    "source_files_checked": [
+        {
+            "path": "okx_codex_trader/okx_client.py",
+            "facts": [
+                "Signed _request already supports GET/POST with params/json_body.",
+                "place_order is a single-order submitter.",
+                "place_order emits ordType=market or ordType=limit only.",
+                "No batch-orders, orders-pending, cancel-order, order status, or fills method exists.",
+                "get_positions reads exchange positions but is not tied to portfolio-owned state.",
+            ],
+        },
+        {
+            "path": "tests/test_okx_client.py",
+            "facts": [
+                "DummySession records request path/body and can support endpoint-level client tests.",
+                "Existing tests cover request signing, live/demo header, contract sizing, leverage validation, single order payload, and position parsing.",
+                "No tests cover post_only payloads, batch payloads, order list/cancel/status/fills, or state persistence.",
+            ],
+        },
+        {
+            "path": "okx_codex_trader/cli.py",
+            "facts": [
+                "okx-account is read-only and returns balance plus positions.",
+                "okx-order is submit-capable and calls place_order.",
+                "No read-only ETH order-intent/state command exists.",
+                "No quasi-live state path or append-only lifecycle log is wired.",
+            ],
+        },
+    ],
+    "official_okx_api_surface": [
+        {
+            "capability": "single post-only limit order payload",
+            "endpoint": "POST /api/v5/trade/order",
+            "boundary": "submit-capable only after read-only payload rendering is tested",
+            "required_fields": ["instId", "tdMode", "side", "posSide", "ordType", "px", "sz", "clOrdId"],
+            "fixed_eth_values": {
+                "instId": "ETH-USDT-SWAP",
+                "tdMode": "isolated",
+                "side": "buy",
+                "posSide": "long",
+                "ordType": "post_only",
+            },
+        },
+        {
+            "capability": "batch entry orders",
+            "endpoint": "POST /api/v5/trade/batch-orders",
+            "boundary": "submit-capable; read-only stage renders the exact array only",
+            "required_shape": "list of three post_only entry payloads with deterministic clOrdId values",
+        },
+        {
+            "capability": "list open orders",
+            "endpoint": "GET /api/v5/trade/orders-pending",
+            "boundary": "read-only",
+            "minimum_params": {"instType": "SWAP", "instId": "ETH-USDT-SWAP"},
+        },
+        {
+            "capability": "cancel open order",
+            "endpoint": "POST /api/v5/trade/cancel-order",
+            "boundary": "submit-capable; read-only stage renders cancel intents only",
+            "required_fields": ["instId", "ordId or clOrdId"],
+        },
+        {
+            "capability": "cancel open orders in batch",
+            "endpoint": "POST /api/v5/trade/cancel-batch-orders",
+            "boundary": "submit-capable; read-only stage renders cancel intent array only",
+            "required_shape": "list of tracked open order ids for ETH strategy-owned orders",
+        },
+        {
+            "capability": "order status",
+            "endpoint": "GET /api/v5/trade/order",
+            "boundary": "read-only",
+            "minimum_params": {"instId": "ETH-USDT-SWAP", "ordId_or_clOrdId": "tracked order identity"},
+        },
+        {
+            "capability": "recent fills",
+            "endpoint": "GET /api/v5/trade/fills",
+            "boundary": "read-only",
+            "minimum_params": {"instType": "SWAP", "instId": "ETH-USDT-SWAP"},
+        },
+    ],
+    "state_persistence": {
+        "boundary": "local read/write only; not an OKX API call",
+        "minimum_file": "state/eth_robust_twap_15m_live.json",
+        "fields": [
+            "strategy_id",
+            "symbol",
+            "bar",
+            "td_mode",
+            "pos_side",
+            "leverage",
+            "last_confirmed_candle_ts",
+            "last_signal_candle_ts",
+            "orders",
+            "fills",
+            "position",
+            "planned_margin_usdt",
+            "max_margin_usdt",
+            "updated_at",
+        ],
+        "event_log": "append-only JSONL for signal, order-intent, cancel-intent, order-status, fills, and state-transition events",
+    },
+    "minimum_patch_plan_by_file": [
+        {
+            "file": "okx_codex_trader/okx_client.py",
+            "patch_plan": [
+                "Add small dataclasses or typed dicts only if needed by return values: OkxOrder, OkxFill, OkxOrderAck.",
+                "Add render_post_only_limit_order_payload(...) as a non-submitting function using existing _format_number and build_contract_size.",
+                "Add render_eth_entry_batch_payloads(...) that returns exactly three post_only payloads for offsets 0.003, 0.006, 0.009.",
+                "Add list_open_orders(symbol) calling GET /api/v5/trade/orders-pending with instType=SWAP and instId=symbol.",
+                "Add get_order(symbol, ord_id=None, client_order_id=None) calling GET /api/v5/trade/order.",
+                "Add list_fills(symbol) calling GET /api/v5/trade/fills with instType=SWAP and instId=symbol.",
+                "Add cancel_order(symbol, ord_id=None, client_order_id=None) and cancel_batch_orders(symbol, order_ids) only when moving past read-only intent.",
+                "Do not change existing place_order semantics for the first patch; it is not suitable for the ETH portfolio lifecycle.",
+            ],
+        },
+        {
+            "file": "okx_codex_trader/cli.py",
+            "patch_plan": [
+                "Add read-only eth-robust-twap-order-intent command that prints signal, three entry payloads, expiry, and state transition preview.",
+                "Add read-only eth-robust-twap-open-orders command that lists current OKX pending orders for ETH-USDT-SWAP.",
+                "Add read-only eth-robust-twap-reconcile command that reads state, queries open orders/status/fills, and writes a new local state snapshot plus JSONL audit event.",
+                "Keep okx-order unchanged and do not route portfolio intents through it.",
+                "Only after read-only commands pass tests, add explicit submit commands for batch entry and cancellation.",
+            ],
+        },
+        {
+            "file": "tests/test_okx_client.py",
+            "patch_plan": [
+                "Add tests for post_only payload shape: ETH-USDT-SWAP, isolated, buy, long, post_only, px, sz, deterministic clOrdId.",
+                "Add tests for batch payload shape: exactly three orders, offsets 0.003/0.006/0.009, each level sized independently and rounded to lotSz.",
+                "Add tests for GET /api/v5/trade/orders-pending params and normalized open-order state values.",
+                "Add tests for POST /api/v5/trade/cancel-order and /cancel-batch-orders request bodies before enabling submit commands.",
+                "Add tests for GET /api/v5/trade/order by ordId and by clOrdId.",
+                "Add tests for GET /api/v5/trade/fills parsing fillSz, fillPx, tradeId, ordId, clOrdId, fee, and fillTime.",
+                "Add tests that malformed OKX lifecycle payloads raise the existing stable invalid-payload error.",
+            ],
+        },
+        {
+            "file": "new okx_codex_trader/eth_robust_twap_state.py",
+            "patch_plan": [
+                "Add only the dedicated state schema and load/save functions needed by the ETH quasi-live lifecycle.",
+                "Persist exchange order ids, client order ids, fills, position, last confirmed candle, active state, and audit cursor.",
+                "Reject state whose symbol/bar/strategy_id does not match the command arguments.",
+            ],
+        },
+        {
+            "file": "new tests/test_eth_robust_twap_state.py",
+            "patch_plan": [
+                "Add load empty state test.",
+                "Add save/read roundtrip for one signal with three open orders and one partial fill.",
+                "Add state mismatch rejection tests for symbol, bar, and strategy_id.",
+            ],
+        },
+    ],
+    "readonly_boundary": [
+        "Render post_only single and batch order payloads without calling POST /api/v5/trade/order or /batch-orders.",
+        "Read account balance, positions, pending orders, order details, and fills.",
+        "Write local state snapshots and append-only JSONL audit events.",
+        "Render cancel intents without calling cancel endpoints.",
+    ],
+    "submit_capable_boundary": [
+        "POST /api/v5/trade/order with ordType=post_only.",
+        "POST /api/v5/trade/batch-orders.",
+        "POST /api/v5/trade/cancel-order.",
+        "POST /api/v5/trade/cancel-batch-orders.",
+        "Any reduce-only market close path.",
+    ],
+    "test_checklist": [
+        "Existing tests: rtk .venv/bin/pytest tests/test_okx_client.py",
+        "New client tests for render-only post_only payloads make no DummySession requests.",
+        "New client tests for list_open_orders/get_order/list_fills assert method/path/params and parsed output.",
+        "New cancel tests assert request body shape but stay behind commands that are not wired in the read-only phase.",
+        "New CLI tests assert read-only order-intent/reconcile commands do not call submit/cancel endpoints.",
+        "New state tests assert deterministic state roundtrip and event append order.",
+    ],
+    "sources": [
+        "https://www.okx.com/docs-v5/en/",
+        "reports/eth-exploration/eth-focused-portfolio-live-readiness.md",
+        "reports/eth-exploration/eth-robust-twap-live-plan.md",
+    ],
+}
+
+
+def _md_table(rows: list[list[str]]) -> str:
+    header = rows[0]
+    divider = ["---"] * len(header)
+    lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(divider) + " |"]
+    lines.extend("| " + " | ".join(row) + " |" for row in rows[1:])
+    return "\n".join(lines)
+
+
+def render_markdown() -> str:
+    lines = [
+        "# OKX order support minimum implementation plan",
+        "",
+        "Static implementation plan only. No OKX request, order, or cancel was made.",
+        "",
+        "## Goal",
+        "",
+        PLAN["readiness_gap"],
+        "",
+        "Minimum direct path: first add read-only payload/state/reconciliation support, then add submit-capable order and cancel calls behind explicit commands.",
+        "",
+        "## Current code facts",
+        "",
+    ]
+    for item in PLAN["source_files_checked"]:
+        lines.append(f"### `{item['path']}`")
+        lines.extend(f"- {fact}" for fact in item["facts"])
+        lines.append("")
+
+    lines.extend(
+        [
+            "## Required OKX API surface",
+            "",
+            _md_table(
+                [["Capability", "Endpoint", "Boundary"]]
+                + [[item["capability"], f"`{item['endpoint']}`", item["boundary"]] for item in PLAN["official_okx_api_surface"]]
+            ),
+            "",
+            "State persistence is local state, not an OKX API call. Minimum file: "
+            f"`{PLAN['state_persistence']['minimum_file']}`.",
+            "",
+            "## Read-only boundary",
+            "",
+        ]
+    )
+    lines.extend(f"- {item}" for item in PLAN["readonly_boundary"])
+    lines.extend(["", "## Submit-capable boundary", ""])
+    lines.extend(f"- {item}" for item in PLAN["submit_capable_boundary"])
+
+    lines.extend(["", "## Minimum patch plan by file", ""])
+    for item in PLAN["minimum_patch_plan_by_file"]:
+        lines.append(f"### `{item['file']}`")
+        lines.extend(f"- {step}" for step in item["patch_plan"])
+        lines.append("")
+
+    lines.extend(["## Test checklist", ""])
+    lines.extend(f"- {item}" for item in PLAN["test_checklist"])
+    lines.extend(
+        [
+            "",
+            "## Sources",
+            "",
+            "- OKX API v5 docs: https://www.okx.com/docs-v5/en/",
+            "- Live readiness report: `reports/eth-exploration/eth-focused-portfolio-live-readiness.md`",
+            "- ETH live execution plan: `reports/eth-exploration/eth-robust-twap-live-plan.md`",
+            "",
+        ]
+    )
+    return "\n".join(lines)
+
+
+def main() -> int:
+    REPORT_DIR.mkdir(parents=True, exist_ok=True)
+    JSON_PATH.write_text(json.dumps(PLAN, indent=2) + "\n")
+    MD_PATH.write_text(render_markdown())
+    print(json.dumps({"json": str(JSON_PATH), "markdown": str(MD_PATH)}, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())