Просмотр исходного кода

research: add eth execution prototypes

lxy 1 месяц назад
Родитель
Сommit
505aad7ec9

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

@@ -199,3 +199,17 @@ Reports:
 - `reports/eth-exploration/freqtrade-eth-skeleton-20260429T183230Z.json`
 
 Core conclusion: OKX 实盘支持缺口的最小实现计划已生成;Freqtrade no-maker-dependent 对照骨架已生成。
+
+## Execution readonly prototypes
+
+Scripts:
+- `scripts/run_freqtrade_eth_skeleton_backtest.py`
+- `scripts/prototype_okx_order_support_readonly.py`
+
+Reports:
+- `reports/eth-exploration/freqtrade-eth-skeleton-backtest-20260429T183627Z.md`
+- `reports/eth-exploration/freqtrade-eth-skeleton-backtest-20260429T183627Z.json`
+- `reports/eth-exploration/okx-order-support-readonly-prototype.md`
+- `reports/eth-exploration/okx-order-support-readonly-prototype.json`
+
+Core conclusion: Freqtrade runner currently blocks because `uv run` cannot find `freqtrade`; fix by exposing Freqtrade through `uvx` or installing it into the uv environment. OKX payload prototype has 9 tests passed and can be migrated into `okx_client`.

+ 135 - 0
reports/eth-exploration/freqtrade-eth-skeleton-backtest-20260429T183627Z.json

@@ -0,0 +1,135 @@
+{
+  "backtest": {
+    "command": [
+      "rtk",
+      "uv",
+      "run",
+      "freqtrade",
+      "backtesting",
+      "--config",
+      "/tmp/okx-codex-trader-freqtrade-eth-skeleton/config-eth-skeleton-okx-futures.json",
+      "--userdir",
+      "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data",
+      "--strategy",
+      "EthFocusedInformativeDry",
+      "--timeframe",
+      "5m",
+      "--pairs",
+      "ETH/USDT:USDT",
+      "--timerange",
+      "20230101-"
+    ],
+    "finished_at": "2026-04-29T18:36:34.976614+00:00",
+    "returncode": 2,
+    "started_at": "2026-04-29T18:36:34.893748+00:00",
+    "stderr": "warning: No `requires-python` value found in the workspace. Defaulting to `>=3.11`.\nerror: Failed to spawn: `freqtrade`\n  Caused by: No such file or directory (os error 2)\n",
+    "stdout": ""
+  },
+  "exports": [
+    {
+      "command": [
+        "rtk",
+        "uv",
+        "run",
+        "python",
+        "scripts/export_freqtrade_data.py",
+        "--symbol",
+        "ETH-USDT-SWAP",
+        "--bar",
+        "5m",
+        "--output-dir",
+        "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures"
+      ],
+      "finished_at": "2026-04-29T18:36:30.011504+00:00",
+      "returncode": 0,
+      "started_at": "2026-04-29T18:36:27.142418+00:00",
+      "stderr": "warning: No `requires-python` value found in the workspace. Defaulting to `>=3.11`.\n",
+      "stdout": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures/ETH_USDT_USDT-5m-futures.json\n"
+    },
+    {
+      "command": [
+        "rtk",
+        "uv",
+        "run",
+        "python",
+        "scripts/export_freqtrade_data.py",
+        "--symbol",
+        "BTC-USDT-SWAP",
+        "--bar",
+        "5m",
+        "--output-dir",
+        "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures"
+      ],
+      "finished_at": "2026-04-29T18:36:32.533573+00:00",
+      "returncode": 0,
+      "started_at": "2026-04-29T18:36:30.011524+00:00",
+      "stderr": "warning: No `requires-python` value found in the workspace. Defaulting to `>=3.11`.\n",
+      "stdout": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures/BTC_USDT_USDT-5m-futures.json\n"
+    },
+    {
+      "command": [
+        "rtk",
+        "uv",
+        "run",
+        "python",
+        "scripts/export_freqtrade_data.py",
+        "--symbol",
+        "ETH-USDT-SWAP",
+        "--bar",
+        "15m",
+        "--output-dir",
+        "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures"
+      ],
+      "finished_at": "2026-04-29T18:36:33.698718+00:00",
+      "returncode": 0,
+      "started_at": "2026-04-29T18:36:32.533592+00:00",
+      "stderr": "warning: No `requires-python` value found in the workspace. Defaulting to `>=3.11`.\n",
+      "stdout": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures/ETH_USDT_USDT-15m-futures.json\n"
+    },
+    {
+      "command": [
+        "rtk",
+        "uv",
+        "run",
+        "python",
+        "scripts/export_freqtrade_data.py",
+        "--symbol",
+        "BTC-USDT-SWAP",
+        "--bar",
+        "15m",
+        "--output-dir",
+        "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures"
+      ],
+      "finished_at": "2026-04-29T18:36:34.893718+00:00",
+      "returncode": 0,
+      "started_at": "2026-04-29T18:36:33.698741+00:00",
+      "stderr": "warning: No `requires-python` value found in the workspace. Defaulting to `>=3.11`.\n",
+      "stdout": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures/BTC_USDT_USDT-15m-futures.json\n"
+    }
+  ],
+  "generated_at": "2026-04-29T18:36:34.976636+00:00",
+  "json_report": "reports/eth-exploration/freqtrade-eth-skeleton-backtest-20260429T183627Z.json",
+  "markdown_report": "reports/eth-exploration/freqtrade-eth-skeleton-backtest-20260429T183627Z.md",
+  "mode": "freqtrade_eth_skeleton_backtest_attempt",
+  "next_steps": [
+    "Install or expose a local Freqtrade executable in the uv environment, then rerun this script."
+  ],
+  "real_trading": false,
+  "repo_config_changed": false,
+  "strategy": "freqtrade/user_data/strategies/EthFocusedInformativeDry.py",
+  "tmp_config": {
+    "dry_run": true,
+    "pair_whitelist": [
+      "ETH/USDT:USDT"
+    ],
+    "path": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/config-eth-skeleton-okx-futures.json",
+    "proxy_injected": true,
+    "proxy_source": "environment",
+    "timeframe": "5m"
+  },
+  "tmp_userdir": {
+    "data_dir": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures",
+    "strategy": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/strategies/EthFocusedInformativeDry.py",
+    "userdir": "/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data"
+  }
+}

+ 40 - 0
reports/eth-exploration/freqtrade-eth-skeleton-backtest-20260429T183627Z.md

@@ -0,0 +1,40 @@
+# Freqtrade ETH skeleton backtest attempt
+
+- Generated at: `2026-04-29T18:36:34.976636+00:00`
+- Scope: backtest only; no live or dry-run trading process was started.
+- Repo config changed: `false`; temporary config was written under `/tmp`.
+- Temporary userdir: `/tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data`
+- Temporary config: `/tmp/okx-codex-trader-freqtrade-eth-skeleton/config-eth-skeleton-okx-futures.json`
+- Proxy injected: `True`
+
+## Data export
+
+- `rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 5m --output-dir /tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures`: exit `0`
+- `rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 5m --output-dir /tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures`: exit `0`
+- `rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 15m --output-dir /tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures`: exit `0`
+- `rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 15m --output-dir /tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data/data/okx/futures`: exit `0`
+
+## Backtest
+
+- Command: `rtk uv run freqtrade backtesting --config /tmp/okx-codex-trader-freqtrade-eth-skeleton/config-eth-skeleton-okx-futures.json --userdir /tmp/okx-codex-trader-freqtrade-eth-skeleton/user_data --strategy EthFocusedInformativeDry --timeframe 5m --pairs ETH/USDT:USDT --timerange 20230101-`
+- Result: `failed` with exit `2`
+
+## Full failure output
+
+### stderr
+
+```text
+warning: No `requires-python` value found in the workspace. Defaulting to `>=3.11`.
+error: Failed to spawn: `freqtrade`
+  Caused by: No such file or directory (os error 2)
+```
+
+### stdout
+
+```text
+
+```
+
+## Next step
+
+- Install or expose a local Freqtrade executable in the uv environment, then rerun this script.

+ 215 - 0
reports/eth-exploration/okx-order-support-readonly-prototype.json

@@ -0,0 +1,215 @@
+{
+  "formal_okx_client_readiness": "ready after prototype tests pass",
+  "network_requests_made": 0,
+  "paths": {
+    "batch_orders": "/api/v5/trade/batch-orders",
+    "cancel_order": "/api/v5/trade/cancel-order",
+    "fills": "/api/v5/trade/fills",
+    "orders_pending": "/api/v5/trade/orders-pending",
+    "single_order": "/api/v5/trade/order"
+  },
+  "sample_batch_order_body": [
+    {
+      "clOrdId": "eth-twap-20260430T000000Z-1",
+      "instId": "ETH-USDT-SWAP",
+      "ordType": "post_only",
+      "posSide": "long",
+      "px": "2991",
+      "side": "buy",
+      "sz": "2",
+      "tdMode": "isolated"
+    },
+    {
+      "clOrdId": "eth-twap-20260430T000000Z-2",
+      "instId": "ETH-USDT-SWAP",
+      "ordType": "post_only",
+      "posSide": "long",
+      "px": "2982",
+      "side": "buy",
+      "sz": "2",
+      "tdMode": "isolated"
+    },
+    {
+      "clOrdId": "eth-twap-20260430T000000Z-3",
+      "instId": "ETH-USDT-SWAP",
+      "ordType": "post_only",
+      "posSide": "long",
+      "px": "2973",
+      "side": "buy",
+      "sz": "2",
+      "tdMode": "isolated"
+    }
+  ],
+  "sample_cancel_body": {
+    "clOrdId": "eth-twap-20260430T000000Z-1",
+    "instId": "ETH-USDT-SWAP"
+  },
+  "sample_fills_params": {
+    "instId": "ETH-USDT-SWAP",
+    "instType": "SWAP"
+  },
+  "sample_pending_params": {
+    "instId": "ETH-USDT-SWAP",
+    "instType": "SWAP"
+  },
+  "sample_post_only_limit_body": {
+    "clOrdId": "eth-twap-20260430T000000Z-1",
+    "instId": "ETH-USDT-SWAP",
+    "ordType": "post_only",
+    "posSide": "long",
+    "px": "2991",
+    "side": "buy",
+    "sz": "2",
+    "tdMode": "isolated"
+  },
+  "scope": "readonly OKX order support prototype",
+  "state_json_schema": {
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "additionalProperties": false,
+    "properties": {
+      "bar": {
+        "minLength": 1,
+        "type": "string"
+      },
+      "fills": {
+        "items": {
+          "additionalProperties": false,
+          "properties": {
+            "clOrdId": {
+              "type": "string"
+            },
+            "fee": {
+              "type": "string"
+            },
+            "fillPx": {
+              "type": "string"
+            },
+            "fillSz": {
+              "type": "string"
+            },
+            "fillTime": {
+              "type": "string"
+            },
+            "instId": {
+              "type": "string"
+            },
+            "ordId": {
+              "type": "string"
+            },
+            "tradeId": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "instId",
+            "tradeId",
+            "ordId",
+            "clOrdId",
+            "fillPx",
+            "fillSz",
+            "fillTime",
+            "fee"
+          ],
+          "type": "object"
+        },
+        "type": "array"
+      },
+      "lifecycle": {
+        "enum": [
+          "idle",
+          "entry_pending",
+          "partially_filled",
+          "filled",
+          "cancel_pending"
+        ]
+      },
+      "mode": {
+        "const": "readonly"
+      },
+      "open_orders": {
+        "items": {
+          "additionalProperties": false,
+          "properties": {
+            "clOrdId": {
+              "type": "string"
+            },
+            "instId": {
+              "type": "string"
+            },
+            "ordId": {
+              "type": "string"
+            },
+            "ordType": {
+              "const": "post_only"
+            },
+            "posSide": {
+              "enum": [
+                "long",
+                "short"
+              ]
+            },
+            "px": {
+              "type": "string"
+            },
+            "side": {
+              "enum": [
+                "buy",
+                "sell"
+              ]
+            },
+            "state": {
+              "enum": [
+                "live",
+                "partially_filled"
+              ]
+            },
+            "sz": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "instId",
+            "ordId",
+            "clOrdId",
+            "side",
+            "posSide",
+            "ordType",
+            "px",
+            "sz",
+            "state"
+          ],
+          "type": "object"
+        },
+        "type": "array"
+      },
+      "schema_version": {
+        "const": 1
+      },
+      "strategy_id": {
+        "minLength": 1,
+        "type": "string"
+      },
+      "symbol": {
+        "pattern": "^[A-Z0-9]+-[A-Z0-9]+-SWAP$",
+        "type": "string"
+      },
+      "updated_at": {
+        "format": "date-time",
+        "type": "string"
+      }
+    },
+    "required": [
+      "schema_version",
+      "strategy_id",
+      "symbol",
+      "bar",
+      "mode",
+      "lifecycle",
+      "updated_at",
+      "open_orders",
+      "fills"
+    ],
+    "title": "OKX read-only order lifecycle state",
+    "type": "object"
+  }
+}

+ 113 - 0
reports/eth-exploration/okx-order-support-readonly-prototype.md

@@ -0,0 +1,113 @@
+# OKX order support read-only prototype
+
+No OKX request, order, or cancel was made.
+
+## Implemented prototype surface
+
+- `POST /api/v5/trade/order` payload builder for post-only limit orders.
+- `POST /api/v5/trade/batch-orders` payload builder for three independent post-only entry levels.
+- `POST /api/v5/trade/cancel-order` cancel intent body builder.
+- `GET /api/v5/trade/orders-pending` query param builder.
+- `GET /api/v5/trade/fills` query param builder.
+- OKX signing pre-hash builder for `path?query` and compact JSON body bytes.
+- Read-only local lifecycle state JSON schema.
+
+## Sample payloads
+
+Single post-only order:
+
+```json
+{
+  "instId": "ETH-USDT-SWAP",
+  "tdMode": "isolated",
+  "side": "buy",
+  "posSide": "long",
+  "ordType": "post_only",
+  "px": "2991",
+  "sz": "2",
+  "clOrdId": "eth-twap-20260430T000000Z-1"
+}
+```
+
+Batch order body:
+
+```json
+[
+  {
+    "instId": "ETH-USDT-SWAP",
+    "tdMode": "isolated",
+    "side": "buy",
+    "posSide": "long",
+    "ordType": "post_only",
+    "px": "2991",
+    "sz": "2",
+    "clOrdId": "eth-twap-20260430T000000Z-1"
+  },
+  {
+    "instId": "ETH-USDT-SWAP",
+    "tdMode": "isolated",
+    "side": "buy",
+    "posSide": "long",
+    "ordType": "post_only",
+    "px": "2982",
+    "sz": "2",
+    "clOrdId": "eth-twap-20260430T000000Z-2"
+  },
+  {
+    "instId": "ETH-USDT-SWAP",
+    "tdMode": "isolated",
+    "side": "buy",
+    "posSide": "long",
+    "ordType": "post_only",
+    "px": "2973",
+    "sz": "2",
+    "clOrdId": "eth-twap-20260430T000000Z-3"
+  }
+]
+```
+
+Cancel body:
+
+```json
+{
+  "instId": "ETH-USDT-SWAP",
+  "clOrdId": "eth-twap-20260430T000000Z-1"
+}
+```
+
+Read-only query params:
+
+```json
+{
+  "orders_pending": {
+    "instType": "SWAP",
+    "instId": "ETH-USDT-SWAP"
+  },
+  "fills": {
+    "instType": "SWAP",
+    "instId": "ETH-USDT-SWAP"
+  }
+}
+```
+
+## Verification
+
+Command:
+
+```bash
+rtk .venv/bin/pytest -q tests/test_okx_order_support_readonly_prototype.py
+```
+
+Result:
+
+```text
+9 passed in 0.01s
+```
+
+## Formal implementation decision
+
+This can enter formal `okx_client.py` implementation.
+
+Reason: the exact request bodies, read-only query params, compact JSON signing body, and `path?query` signing string are now covered outside production code. The next implementation should move these shapes into `okx_client.py` without changing existing `place_order` semantics.
+
+Source boundary: OKX API v5 trade endpoints, `https://www.okx.com/docs-v5/en/`.

+ 260 - 0
scripts/prototype_okx_order_support_readonly.py

@@ -0,0 +1,260 @@
+import base64
+import hashlib
+import hmac
+import json
+from dataclasses import dataclass
+from decimal import Decimal, ROUND_DOWN
+from urllib.parse import urlencode
+
+
+ORDER_PATH = "/api/v5/trade/order"
+BATCH_ORDERS_PATH = "/api/v5/trade/batch-orders"
+CANCEL_ORDER_PATH = "/api/v5/trade/cancel-order"
+ORDERS_PENDING_PATH = "/api/v5/trade/orders-pending"
+FILLS_PATH = "/api/v5/trade/fills"
+
+
+STATE_JSON_SCHEMA: dict[str, object] = {
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "title": "OKX read-only order lifecycle state",
+    "type": "object",
+    "additionalProperties": False,
+    "required": [
+        "schema_version",
+        "strategy_id",
+        "symbol",
+        "bar",
+        "mode",
+        "lifecycle",
+        "updated_at",
+        "open_orders",
+        "fills",
+    ],
+    "properties": {
+        "schema_version": {"const": 1},
+        "strategy_id": {"type": "string", "minLength": 1},
+        "symbol": {"type": "string", "pattern": "^[A-Z0-9]+-[A-Z0-9]+-SWAP$"},
+        "bar": {"type": "string", "minLength": 1},
+        "mode": {"const": "readonly"},
+        "lifecycle": {"enum": ["idle", "entry_pending", "partially_filled", "filled", "cancel_pending"]},
+        "updated_at": {"type": "string", "format": "date-time"},
+        "open_orders": {
+            "type": "array",
+            "items": {
+                "type": "object",
+                "additionalProperties": False,
+                "required": ["instId", "ordId", "clOrdId", "side", "posSide", "ordType", "px", "sz", "state"],
+                "properties": {
+                    "instId": {"type": "string"},
+                    "ordId": {"type": "string"},
+                    "clOrdId": {"type": "string"},
+                    "side": {"enum": ["buy", "sell"]},
+                    "posSide": {"enum": ["long", "short"]},
+                    "ordType": {"const": "post_only"},
+                    "px": {"type": "string"},
+                    "sz": {"type": "string"},
+                    "state": {"enum": ["live", "partially_filled"]},
+                },
+            },
+        },
+        "fills": {
+            "type": "array",
+            "items": {
+                "type": "object",
+                "additionalProperties": False,
+                "required": ["instId", "tradeId", "ordId", "clOrdId", "fillPx", "fillSz", "fillTime", "fee"],
+                "properties": {
+                    "instId": {"type": "string"},
+                    "tradeId": {"type": "string"},
+                    "ordId": {"type": "string"},
+                    "clOrdId": {"type": "string"},
+                    "fillPx": {"type": "string"},
+                    "fillSz": {"type": "string"},
+                    "fillTime": {"type": "string"},
+                    "fee": {"type": "string"},
+                },
+            },
+        },
+    },
+}
+
+
+@dataclass(frozen=True)
+class InstrumentMeta:
+    ct_val: str
+    lot_sz: str
+    min_sz: str
+
+
+@dataclass(frozen=True)
+class SignableRequest:
+    method: str
+    path: str
+    params: dict[str, object] | None
+    body: str
+    path_with_query: str
+    pre_hash: str
+    signature: str
+
+
+def _decimal(value: object) -> Decimal:
+    parsed = Decimal(str(value))
+    if not parsed.is_finite():
+        raise ValueError("numeric input is invalid")
+    return parsed
+
+
+def format_number(value: object) -> str:
+    return format(_decimal(value).normalize(), "f")
+
+
+def build_contract_size(notional: object, price: object, metadata: InstrumentMeta) -> str:
+    notional_decimal = _decimal(notional)
+    price_decimal = _decimal(price)
+    ct_val_decimal = _decimal(metadata.ct_val)
+    lot_size = _decimal(metadata.lot_sz)
+    min_size = _decimal(metadata.min_sz)
+    if notional_decimal <= 0 or price_decimal <= 0 or ct_val_decimal <= 0 or lot_size <= 0 or min_size <= 0:
+        raise ValueError("contract sizing input is invalid")
+    raw_size = notional_decimal / (price_decimal * ct_val_decimal)
+    size = (raw_size / lot_size).to_integral_value(rounding=ROUND_DOWN) * lot_size
+    if size < min_size:
+        raise ValueError("contract size below minimum")
+    return format_number(size)
+
+
+def build_post_only_limit_order_body(
+    *,
+    symbol: str,
+    action: str,
+    price: object,
+    size: object,
+    client_order_id: str,
+) -> dict[str, str]:
+    if action not in {"long", "short"}:
+        raise ValueError("action is invalid")
+    return {
+        "instId": symbol,
+        "tdMode": "isolated",
+        "side": "buy" if action == "long" else "sell",
+        "posSide": action,
+        "ordType": "post_only",
+        "px": format_number(price),
+        "sz": format_number(size),
+        "clOrdId": client_order_id,
+    }
+
+
+def build_entry_batch_order_body(
+    *,
+    symbol: str,
+    action: str,
+    reference_price: object,
+    margin_usdt: object,
+    leverage: object,
+    metadata: InstrumentMeta,
+    client_order_id_prefix: str,
+) -> list[dict[str, str]]:
+    reference_price_decimal = _decimal(reference_price)
+    notional_per_order = _decimal(margin_usdt) * _decimal(leverage) / Decimal("3")
+    bodies = []
+    for index, offset in enumerate((Decimal("0.003"), Decimal("0.006"), Decimal("0.009")), start=1):
+        multiplier = Decimal("1") - offset if action == "long" else Decimal("1") + offset
+        price = reference_price_decimal * multiplier
+        size = build_contract_size(notional_per_order, price, metadata)
+        bodies.append(
+            build_post_only_limit_order_body(
+                symbol=symbol,
+                action=action,
+                price=price,
+                size=size,
+                client_order_id=f"{client_order_id_prefix}-{index}",
+            )
+        )
+    return bodies
+
+
+def build_cancel_order_body(*, symbol: str, order_id: str | None = None, client_order_id: str | None = None) -> dict[str, str]:
+    if bool(order_id) == bool(client_order_id):
+        raise ValueError("exactly one order identifier is required")
+    body = {"instId": symbol}
+    if order_id:
+        body["ordId"] = order_id
+    if client_order_id:
+        body["clOrdId"] = client_order_id
+    return body
+
+
+def build_pending_orders_params(*, symbol: str) -> dict[str, str]:
+    return {"instType": "SWAP", "instId": symbol}
+
+
+def build_fills_params(*, symbol: str) -> dict[str, str]:
+    return {"instType": "SWAP", "instId": symbol}
+
+
+def compact_json_body(body: dict[str, object] | list[dict[str, object]] | None) -> str:
+    if body is None:
+        return ""
+    return json.dumps(body, separators=(",", ":"))
+
+
+def build_signable_request(
+    *,
+    timestamp: str,
+    method: str,
+    path: str,
+    api_secret: str,
+    params: dict[str, object] | None = None,
+    body: dict[str, object] | list[dict[str, object]] | None = None,
+) -> SignableRequest:
+    method_upper = method.upper()
+    query = urlencode(params or {})
+    path_with_query = path if not query else f"{path}?{query}"
+    serialized_body = compact_json_body(body)
+    pre_hash = f"{timestamp}{method_upper}{path_with_query}{serialized_body}"
+    signature = base64.b64encode(hmac.new(api_secret.encode(), pre_hash.encode(), hashlib.sha256).digest()).decode()
+    return SignableRequest(
+        method=method_upper,
+        path=path,
+        params=params,
+        body=serialized_body,
+        path_with_query=path_with_query,
+        pre_hash=pre_hash,
+        signature=signature,
+    )
+
+
+def build_prototype_report() -> dict[str, object]:
+    metadata = InstrumentMeta(ct_val="0.01", lot_sz="0.1", min_sz="0.1")
+    batch_body = build_entry_batch_order_body(
+        symbol="ETH-USDT-SWAP",
+        action="long",
+        reference_price="3000",
+        margin_usdt="90",
+        leverage="2",
+        metadata=metadata,
+        client_order_id_prefix="eth-twap-20260430T000000Z",
+    )
+    return {
+        "scope": "readonly OKX order support prototype",
+        "network_requests_made": 0,
+        "paths": {
+            "single_order": ORDER_PATH,
+            "batch_orders": BATCH_ORDERS_PATH,
+            "cancel_order": CANCEL_ORDER_PATH,
+            "orders_pending": ORDERS_PENDING_PATH,
+            "fills": FILLS_PATH,
+        },
+        "sample_post_only_limit_body": batch_body[0],
+        "sample_batch_order_body": batch_body,
+        "sample_cancel_body": build_cancel_order_body(symbol="ETH-USDT-SWAP", client_order_id=batch_body[0]["clOrdId"]),
+        "sample_pending_params": build_pending_orders_params(symbol="ETH-USDT-SWAP"),
+        "sample_fills_params": build_fills_params(symbol="ETH-USDT-SWAP"),
+        "state_json_schema": STATE_JSON_SCHEMA,
+        "formal_okx_client_readiness": "ready after prototype tests pass",
+    }
+
+
+if __name__ == "__main__":
+    print(json.dumps(build_prototype_report(), indent=2, sort_keys=True))

+ 244 - 0
scripts/run_freqtrade_eth_skeleton_backtest.py

@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shlex
+import shutil
+import subprocess
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+
+ROOT = Path(__file__).resolve().parents[1]
+REPORT_DIR = ROOT / "reports" / "eth-exploration"
+BASE_CONFIG = ROOT / "freqtrade" / "config-okx-futures.json"
+STRATEGY = ROOT / "freqtrade" / "user_data" / "strategies" / "EthFocusedInformativeDry.py"
+TMP_ROOT = Path("/tmp/okx-codex-trader-freqtrade-eth-skeleton")
+TMP_USERDIR = TMP_ROOT / "user_data"
+TMP_DATA_DIR = TMP_USERDIR / "data" / "okx" / "futures"
+TMP_STRATEGY_DIR = TMP_USERDIR / "strategies"
+TMP_CONFIG = TMP_ROOT / "config-eth-skeleton-okx-futures.json"
+PAIR = "ETH/USDT:USDT"
+EXPORTS = (
+    ("ETH-USDT-SWAP", "5m"),
+    ("BTC-USDT-SWAP", "5m"),
+    ("ETH-USDT-SWAP", "15m"),
+    ("BTC-USDT-SWAP", "15m"),
+)
+FREQTRADE_COMMAND_ENV = "FREQTRADE_COMMAND"
+
+
+def run_command(command: list[str]) -> dict[str, Any]:
+    started_at = datetime.now(UTC)
+    completed = subprocess.run(
+        command,
+        cwd=ROOT,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        check=False,
+    )
+    finished_at = datetime.now(UTC)
+    return {
+        "command": command,
+        "returncode": completed.returncode,
+        "started_at": started_at.isoformat(),
+        "finished_at": finished_at.isoformat(),
+        "stdout": completed.stdout,
+        "stderr": completed.stderr,
+    }
+
+
+def proxy_url() -> str | None:
+    for name in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
+        value = os.environ.get(name)
+        if value:
+            return value
+    return None
+
+
+def write_tmp_config() -> dict[str, Any]:
+    config = json.loads(BASE_CONFIG.read_text(encoding="utf-8"))
+    config["timeframe"] = "5m"
+    config["bot_name"] = "okx-codex-eth-skeleton-backtest"
+    config["dry_run"] = True
+    config["exchange"]["pair_whitelist"] = [PAIR]
+
+    proxy = proxy_url()
+    if proxy:
+        for key in ("ccxt_config", "ccxt_async_config"):
+            config["exchange"].setdefault(key, {})
+            config["exchange"][key]["proxies"] = {"http": proxy, "https": proxy}
+
+    TMP_ROOT.mkdir(parents=True, exist_ok=True)
+    TMP_CONFIG.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    return {
+        "path": str(TMP_CONFIG),
+        "pair_whitelist": config["exchange"]["pair_whitelist"],
+        "timeframe": config["timeframe"],
+        "dry_run": config["dry_run"],
+        "proxy_injected": proxy is not None,
+        "proxy_source": "environment" if proxy else None,
+    }
+
+
+def prepare_tmp_userdir() -> dict[str, str]:
+    TMP_DATA_DIR.mkdir(parents=True, exist_ok=True)
+    TMP_STRATEGY_DIR.mkdir(parents=True, exist_ok=True)
+    target_strategy = TMP_STRATEGY_DIR / STRATEGY.name
+    shutil.copy2(STRATEGY, target_strategy)
+    return {
+        "userdir": str(TMP_USERDIR),
+        "data_dir": str(TMP_DATA_DIR),
+        "strategy": str(target_strategy),
+    }
+
+
+def export_data() -> list[dict[str, Any]]:
+    results = []
+    for symbol, bar in EXPORTS:
+        results.append(
+            run_command(
+                [
+                    "rtk",
+                    "uv",
+                    "run",
+                    "python",
+                    "scripts/export_freqtrade_data.py",
+                    "--symbol",
+                    symbol,
+                    "--bar",
+                    bar,
+                    "--output-dir",
+                    str(TMP_DATA_DIR),
+                ]
+            )
+        )
+    return results
+
+
+def run_backtest() -> dict[str, Any]:
+    freqtrade_command = shlex.split(
+        os.environ.get(FREQTRADE_COMMAND_ENV, "uvx --from freqtrade freqtrade")
+    )
+    return run_command(
+        [
+            "rtk",
+            *freqtrade_command,
+            "backtesting",
+            "--config",
+            str(TMP_CONFIG),
+            "--userdir",
+            str(TMP_USERDIR),
+            "--strategy",
+            "EthFocusedInformativeDry",
+            "--timeframe",
+            "5m",
+            "--pairs",
+            PAIR,
+            "--timerange",
+            "20230101-",
+        ]
+    )
+
+
+def next_steps(payload: dict[str, Any]) -> list[str]:
+    backtest = payload["backtest"]
+    if backtest["returncode"] == 0:
+        return ["Review the Freqtrade result table and compare trade count, drawdown, and total profit with the research backtest."]
+    reason = f"{backtest['stderr']}\n{backtest['stdout']}"
+    if "Failed to spawn" in reason or "No such file or directory" in reason:
+        return [f"Set {FREQTRADE_COMMAND_ENV} to a runnable Freqtrade command, then rerun this script."]
+    if "No data found" in reason or "No history data" in reason:
+        return ["Verify the four exported futures JSON files under the temporary userdir data directory and rerun backtesting."]
+    if "OperationalException" in reason or "ImportError" in reason:
+        return ["Fix the reported Freqtrade strategy/config error directly, then rerun the same script."]
+    return ["Use the full stderr/stdout captured in the JSON report to identify the first Freqtrade failure and rerun after that specific issue is fixed."]
+
+
+def render_markdown(payload: dict[str, Any]) -> str:
+    export_rows = "\n".join(
+        f"- `{ ' '.join(item['command']) }`: exit `{item['returncode']}`"
+        for item in payload["exports"]
+    )
+    backtest = payload["backtest"]
+    status = "succeeded" if backtest["returncode"] == 0 else "failed"
+    return "\n".join(
+        [
+            "# Freqtrade ETH skeleton backtest attempt",
+            "",
+            f"- Generated at: `{payload['generated_at']}`",
+            "- Scope: backtest only; no live or dry-run trading process was started.",
+            "- Repo config changed: `false`; temporary config was written under `/tmp`.",
+            f"- Temporary userdir: `{payload['tmp_userdir']['userdir']}`",
+            f"- Temporary config: `{payload['tmp_config']['path']}`",
+            f"- Proxy injected: `{payload['tmp_config']['proxy_injected']}`",
+            "",
+            "## Data export",
+            "",
+            export_rows,
+            "",
+            "## Backtest",
+            "",
+            f"- Command: `{ ' '.join(backtest['command']) }`",
+            f"- Result: `{status}` with exit `{backtest['returncode']}`",
+            "",
+            "## Full failure output",
+            "",
+            "### stderr",
+            "",
+            "```text",
+            backtest["stderr"].rstrip(),
+            "```",
+            "",
+            "### stdout",
+            "",
+            "```text",
+            backtest["stdout"].rstrip(),
+            "```",
+            "",
+            "## Next step",
+            "",
+            *[f"- {step}" for step in payload["next_steps"]],
+            "",
+        ]
+    )
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--stamp", default=datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ"))
+    args = parser.parse_args()
+
+    tmp_userdir = prepare_tmp_userdir()
+    tmp_config = write_tmp_config()
+    exports = export_data()
+    backtest = run_backtest()
+    payload: dict[str, Any] = {
+        "generated_at": datetime.now(UTC).isoformat(),
+        "mode": "freqtrade_eth_skeleton_backtest_attempt",
+        "real_trading": False,
+        "repo_config_changed": False,
+        "strategy": str(STRATEGY.relative_to(ROOT)),
+        "tmp_userdir": tmp_userdir,
+        "tmp_config": tmp_config,
+        "exports": exports,
+        "backtest": backtest,
+    }
+    payload["next_steps"] = next_steps(payload)
+
+    REPORT_DIR.mkdir(parents=True, exist_ok=True)
+    json_path = REPORT_DIR / f"freqtrade-eth-skeleton-backtest-{args.stamp}.json"
+    md_path = REPORT_DIR / f"freqtrade-eth-skeleton-backtest-{args.stamp}.md"
+    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(render_markdown(payload), encoding="utf-8")
+    print(md_path)
+    return 0 if backtest["returncode"] == 0 else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 182 - 0
tests/test_okx_order_support_readonly_prototype.py

@@ -0,0 +1,182 @@
+import base64
+import hashlib
+import hmac
+import sys
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "scripts"))
+
+import prototype_okx_order_support_readonly as prototype  # noqa: E402
+
+
+def test_post_only_limit_order_body_is_exact_okx_payload():
+    body = prototype.build_post_only_limit_order_body(
+        symbol="ETH-USDT-SWAP",
+        action="long",
+        price="2991.0000",
+        size="2.000",
+        client_order_id="eth-twap-1",
+    )
+
+    assert body == {
+        "instId": "ETH-USDT-SWAP",
+        "tdMode": "isolated",
+        "side": "buy",
+        "posSide": "long",
+        "ordType": "post_only",
+        "px": "2991",
+        "sz": "2",
+        "clOrdId": "eth-twap-1",
+    }
+
+
+def test_short_post_only_limit_order_body_uses_sell_short():
+    body = prototype.build_post_only_limit_order_body(
+        symbol="ETH-USDT-SWAP",
+        action="short",
+        price="3018",
+        size="1.5",
+        client_order_id="eth-twap-short-2",
+    )
+
+    assert body["side"] == "sell"
+    assert body["posSide"] == "short"
+    assert body["ordType"] == "post_only"
+
+
+def test_batch_order_body_uses_three_independent_post_only_levels():
+    metadata = prototype.InstrumentMeta(ct_val="0.01", lot_sz="0.1", min_sz="0.1")
+
+    body = prototype.build_entry_batch_order_body(
+        symbol="ETH-USDT-SWAP",
+        action="long",
+        reference_price="3000",
+        margin_usdt="90",
+        leverage="2",
+        metadata=metadata,
+        client_order_id_prefix="eth-twap-20260430T000000Z",
+    )
+
+    assert body == [
+        {
+            "instId": "ETH-USDT-SWAP",
+            "tdMode": "isolated",
+            "side": "buy",
+            "posSide": "long",
+            "ordType": "post_only",
+            "px": "2991",
+            "sz": "2",
+            "clOrdId": "eth-twap-20260430T000000Z-1",
+        },
+        {
+            "instId": "ETH-USDT-SWAP",
+            "tdMode": "isolated",
+            "side": "buy",
+            "posSide": "long",
+            "ordType": "post_only",
+            "px": "2982",
+            "sz": "2",
+            "clOrdId": "eth-twap-20260430T000000Z-2",
+        },
+        {
+            "instId": "ETH-USDT-SWAP",
+            "tdMode": "isolated",
+            "side": "buy",
+            "posSide": "long",
+            "ordType": "post_only",
+            "px": "2973",
+            "sz": "2",
+            "clOrdId": "eth-twap-20260430T000000Z-3",
+        },
+    ]
+
+
+def test_cancel_order_body_uses_exactly_one_identifier():
+    assert prototype.build_cancel_order_body(symbol="ETH-USDT-SWAP", order_id="123") == {
+        "instId": "ETH-USDT-SWAP",
+        "ordId": "123",
+    }
+    assert prototype.build_cancel_order_body(symbol="ETH-USDT-SWAP", client_order_id="eth-twap-1") == {
+        "instId": "ETH-USDT-SWAP",
+        "clOrdId": "eth-twap-1",
+    }
+
+
+def test_pending_orders_query_params_are_minimal_readonly_params():
+    assert prototype.build_pending_orders_params(symbol="ETH-USDT-SWAP") == {
+        "instType": "SWAP",
+        "instId": "ETH-USDT-SWAP",
+    }
+
+
+def test_fills_query_params_are_minimal_readonly_params():
+    assert prototype.build_fills_params(symbol="ETH-USDT-SWAP") == {
+        "instType": "SWAP",
+        "instId": "ETH-USDT-SWAP",
+    }
+
+
+def test_get_signature_path_includes_urlencoded_query_and_no_body():
+    signable = prototype.build_signable_request(
+        timestamp="2026-04-30T00:00:00.000Z",
+        method="GET",
+        path=prototype.ORDERS_PENDING_PATH,
+        params={"instType": "SWAP", "instId": "ETH-USDT-SWAP"},
+        api_secret="secret",
+    )
+
+    assert signable.path_with_query == "/api/v5/trade/orders-pending?instType=SWAP&instId=ETH-USDT-SWAP"
+    assert (
+        signable.pre_hash
+        == "2026-04-30T00:00:00.000ZGET/api/v5/trade/orders-pending?instType=SWAP&instId=ETH-USDT-SWAP"
+    )
+    expected_signature = base64.b64encode(
+        hmac.new(b"secret", signable.pre_hash.encode(), hashlib.sha256).digest()
+    ).decode()
+    assert signable.signature == expected_signature
+
+
+def test_post_signature_path_uses_compact_json_body_bytes():
+    body = prototype.build_post_only_limit_order_body(
+        symbol="ETH-USDT-SWAP",
+        action="long",
+        price="2991",
+        size="2",
+        client_order_id="eth-twap-1",
+    )
+
+    signable = prototype.build_signable_request(
+        timestamp="2026-04-30T00:00:00.000Z",
+        method="POST",
+        path=prototype.ORDER_PATH,
+        body=body,
+        api_secret="secret",
+    )
+
+    assert signable.body == (
+        '{"instId":"ETH-USDT-SWAP","tdMode":"isolated","side":"buy","posSide":"long",'
+        '"ordType":"post_only","px":"2991","sz":"2","clOrdId":"eth-twap-1"}'
+    )
+    assert signable.pre_hash == f"2026-04-30T00:00:00.000ZPOST/api/v5/trade/order{signable.body}"
+
+
+def test_state_json_schema_captures_readonly_lifecycle_shape():
+    schema = prototype.STATE_JSON_SCHEMA
+
+    assert schema["type"] == "object"
+    assert schema["additionalProperties"] is False
+    assert schema["properties"]["schema_version"] == {"const": 1}
+    assert schema["properties"]["mode"] == {"const": "readonly"}
+    assert schema["required"] == [
+        "schema_version",
+        "strategy_id",
+        "symbol",
+        "bar",
+        "mode",
+        "lifecycle",
+        "updated_at",
+        "open_orders",
+        "fills",
+    ]