Bläddra i källkod

Harden BB squeeze live executor

lxy 3 veckor sedan
förälder
incheckning
8fdaaf7862

+ 2 - 2
deploy/bb-squeeze-executor.service

@@ -8,9 +8,9 @@ Type=simple
 WorkingDirectory=/opt/okx-codex-trader
 WorkingDirectory=/opt/okx-codex-trader
 EnvironmentFile=/etc/okx-codex-trader/okx.env
 EnvironmentFile=/etc/okx-codex-trader/okx.env
 Environment=PYTHONUNBUFFERED=1
 Environment=PYTHONUNBUFFERED=1
-ExecStart=/opt/okx-codex-trader/.venv/bin/python scripts/run_bb_squeeze_executor.py --submit-live --confirm-live
+ExecStart=/opt/okx-codex-trader/.venv/bin/python scripts/run_bb_squeeze_executor.py --submit-live --confirm-live --loop --poll-seconds 1
 Restart=always
 Restart=always
-RestartSec=300
+RestartSec=20
 User=okxbot
 User=okxbot
 Group=okxbot
 Group=okxbot
 NoNewPrivileges=true
 NoNewPrivileges=true

+ 68 - 0
docs/live-strategy-status-2026-05-18.md

@@ -0,0 +1,68 @@
+# Live Strategy Status - 2026-05-18
+
+Current live service:
+
+- `bb-squeeze-executor.service`
+- Strategy: `BB squeeze live l48-bw960-q25 both vc006`
+- Symbol: `ETH-USDT-SWAP`
+- Bar: `15m`
+- Execution mode: live OKX candles, loop polling every 1 second, only newly confirmed candles are processed.
+- Status after the manual close on 2026-05-18: service stopped until the exchange-side stop-loss path is deployed and checked.
+
+## Current Decision
+
+Keep the live strategy unchanged.
+
+Recent exploration did not find a replacement that is better across enough windows. The current live BB squeeze strategy is weak over the latest 7d/14d windows, but it remains stronger than the newly tested alternatives over the 30d/90d windows and has usable trade frequency.
+
+Do not increase risk until a replacement has positive 30d and 90d performance after fees with enough trades.
+
+## Live Risk Control Requirement
+
+Every live opening order must include an OKX exchange-side stop-loss attachment.
+
+The executor uses the strategy stop-loss percentage and submits it through `attachAlgoOrds` on the opening market order:
+
+- long stop trigger: `entry_reference * (1 - STOP_LOSS_PCT)`
+- short stop trigger: `entry_reference * (1 + STOP_LOSS_PCT)`
+- stop order price: `-1`, meaning market execution after trigger
+
+The live strategy still has no fixed take-profit. Its intended exit remains the strategy-side middle-band exit or stop-loss. Adding a fixed take-profit would change the traded strategy and requires separate backtesting.
+
+## Recent Data State
+
+Local OKX cache was refreshed for low timeframes:
+
+- ETH 3m: through `2026-05-17 09:15 UTC`
+- ETH 5m: through `2026-05-17 09:10 UTC`
+- BTC 3m: through `2026-05-17 09:15 UTC`
+- BTC 5m: through `2026-05-17 09:15 UTC`
+- ETH/BTC 15m: through `2026-05-17 06:45 UTC`
+
+Data validation passed for ETH/BTC `3m`, `5m`, and `15m`: no duplicate timestamps, no gaps, valid OHLCV rows, and cache roundtrip checks passed.
+
+## Recent Exploration Read
+
+Market state:
+
+- ETH has been materially weaker than BTC over the latest 30d.
+- BTC/ETH same-bar correlation remains high, but BTC lead/lag is not strong enough to use as the main alpha.
+- Low-bandwidth squeeze is present, but recent squeeze follow-through is weak and needs fast confirmation.
+
+Explored directions:
+
+- False-breakout mean reversion: rejected. Best 5m ETH short is only near flat over 3y.
+- BTC regime router v2: rejected. Trade frequency is high, but fee-adjusted returns remain negative.
+- 3m/5m recent squeeze and BTC lead/lag candidates: not live-ready. Some 30d/14d/7d rows are positive, but 90d remains negative.
+
+Acceptable next action:
+
+- Keep current live BB squeeze executor.
+- Add only read-only observation for promising 3m/5m candidates if signal telemetry is needed.
+- Reconsider strategy replacement only after the observation candidate has at least 30-50 forward signals and positive 30d/90d after fees.
+
+Primary reports:
+
+- `reports/live-recent/recent-strategy-report.md`
+- `reports/recent-regime/recent-regime-exploration-summary.md`
+- `reports/recent-regime/recent-regime-low-timeframe-refresh-summary.md`

+ 18 - 4
okx_codex_trader/live_execution.py

@@ -19,6 +19,7 @@ class TargetPosition:
     unit: float
     unit: float
     known: bool
     known: bool
     reason: str
     reason: str
+    contracts: float | None = None
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -165,17 +166,18 @@ def current_position_from_okx(
         raise ValueError("position normalization inputs are invalid")
         raise ValueError("position normalization inputs are invalid")
     active = [position for position in positions if position.size > 0.0]
     active = [position for position in positions if position.size > 0.0]
     if not active:
     if not active:
-        return TargetPosition(side="flat", unit=0.0, known=True, reason="no open OKX position")
+        return TargetPosition(side="flat", unit=0.0, known=True, reason="no open OKX position", contracts=0.0)
     sides = {position.pos_side for position in active}
     sides = {position.pos_side for position in active}
     if len(sides) != 1:
     if len(sides) != 1:
         return TargetPosition(side="flat", unit=0.0, known=False, reason="both OKX hedge sides are open")
         return TargetPosition(side="flat", unit=0.0, known=False, reason="both OKX hedge sides are open")
     side = active[0].pos_side
     side = active[0].pos_side
     if side not in {"long", "short"}:
     if side not in {"long", "short"}:
         return TargetPosition(side="flat", unit=0.0, known=False, reason="OKX position side is unsupported")
         return TargetPosition(side="flat", unit=0.0, known=False, reason="OKX position side is unsupported")
-    notional = sum(position.size for position in active) * metadata.ct_val * mark_price
+    contracts = sum(position.size for position in active)
+    notional = contracts * metadata.ct_val * mark_price
     margin = notional / leverage
     margin = notional / leverage
     unit = margin / margin_per_unit_usdt
     unit = margin / margin_per_unit_usdt
-    return TargetPosition(side=side, unit=unit, known=True, reason="OKX position normalized by configured strategy unit margin")
+    return TargetPosition(side=side, unit=unit, known=True, reason="OKX position normalized by configured strategy unit margin", contracts=contracts)
 
 
 
 
 def render_market_order_bodies(
 def render_market_order_bodies(
@@ -189,9 +191,12 @@ def render_market_order_bodies(
     max_new_margin_usdt: float,
     max_new_margin_usdt: float,
     max_total_margin_usdt: float,
     max_total_margin_usdt: float,
     client_order_id_prefix: str,
     client_order_id_prefix: str,
+    stop_loss_pct: float | None = None,
 ) -> tuple[RenderedOrder, ...]:
 ) -> tuple[RenderedOrder, ...]:
     if leverage <= 0 or margin_per_unit_usdt <= 0.0 or max_new_margin_usdt < 0.0 or max_total_margin_usdt < 0.0:
     if leverage <= 0 or margin_per_unit_usdt <= 0.0 or max_new_margin_usdt < 0.0 or max_total_margin_usdt < 0.0:
         raise ValueError("order rendering inputs are invalid")
         raise ValueError("order rendering inputs are invalid")
+    if stop_loss_pct is not None and stop_loss_pct <= 0.0:
+        raise ValueError("stop_loss_pct is invalid")
     if plan.target.known and plan.target.unit * margin_per_unit_usdt > max_total_margin_usdt:
     if plan.target.known and plan.target.unit * margin_per_unit_usdt > max_total_margin_usdt:
         raise ValueError("target margin exceeds max_total_margin_usdt")
         raise ValueError("target margin exceeds max_total_margin_usdt")
     rendered: list[RenderedOrder] = []
     rendered: list[RenderedOrder] = []
@@ -208,7 +213,15 @@ def render_market_order_bodies(
             if new_margin > max_new_margin_usdt:
             if new_margin > max_new_margin_usdt:
                 raise ValueError("new margin exceeds max_new_margin_usdt")
                 raise ValueError("new margin exceeds max_new_margin_usdt")
         side = _okx_side(action)
         side = _okx_side(action)
-        size = build_contract_size(margin * leverage, mark_price, metadata)
+        if action.action == "close":
+            if plan.current.contracts is None or plan.current.contracts <= 0.0:
+                raise ValueError("current contracts are required for close orders")
+            size = plan.current.contracts
+        else:
+            size = build_contract_size(margin * leverage, mark_price, metadata)
+        stop_loss_trigger_price = None
+        if not action.reduce_only and stop_loss_pct is not None:
+            stop_loss_trigger_price = mark_price * (1.0 - stop_loss_pct if action.side == "long" else 1.0 + stop_loss_pct)
         rendered.append(
         rendered.append(
             RenderedOrder(
             RenderedOrder(
                 action=action.action,
                 action=action.action,
@@ -220,6 +233,7 @@ def render_market_order_bodies(
                     size=size,
                     size=size,
                     client_order_id=market_client_order_id(client_order_id_prefix, index, action.action),
                     client_order_id=market_client_order_id(client_order_id_prefix, index, action.action),
                     reduce_only=action.reduce_only,
                     reduce_only=action.reduce_only,
+                    stop_loss_trigger_price=stop_loss_trigger_price,
                 ),
                 ),
             )
             )
         )
         )

+ 17 - 1
okx_codex_trader/okx_client.py

@@ -182,6 +182,7 @@ class OkxClient:
         size: object,
         size: object,
         client_order_id: str,
         client_order_id: str,
         reduce_only: bool,
         reduce_only: bool,
+        stop_loss_trigger_price: object | None = None,
     ) -> dict[str, str]:
     ) -> dict[str, str]:
         if side not in {"buy", "sell"}:
         if side not in {"buy", "sell"}:
             raise ValueError("side is invalid")
             raise ValueError("side is invalid")
@@ -198,6 +199,15 @@ class OkxClient:
         }
         }
         if reduce_only:
         if reduce_only:
             body["reduceOnly"] = "true"
             body["reduceOnly"] = "true"
+        if stop_loss_trigger_price is not None:
+            if reduce_only:
+                raise ValueError("stop loss is invalid for reduce-only orders")
+            body["attachAlgoOrds"] = [
+                {
+                    "slTriggerPx": _format_number(stop_loss_trigger_price),
+                    "slOrdPx": "-1",
+                }
+            ]
         return body
         return body
 
 
     @staticmethod
     @staticmethod
@@ -265,6 +275,12 @@ class OkxClient:
         return data
         return data
 
 
     def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
     def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
+        return self._get_candles_from_path("/api/v5/market/history-candles", symbol, bar, limit)
+
+    def get_recent_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
+        return self._get_candles_from_path("/api/v5/market/candles", symbol, bar, limit)
+
+    def _get_candles_from_path(self, path: str, symbol: str, bar: str, limit: int) -> list[Candle]:
         remaining = limit
         remaining = limit
         after: int | None = None
         after: int | None = None
         candles_by_ts: dict[int, Candle] = {}
         candles_by_ts: dict[int, Candle] = {}
@@ -274,7 +290,7 @@ class OkxClient:
             params: dict[str, object] = {"instId": symbol, "bar": bar, "limit": page_limit}
             params: dict[str, object] = {"instId": symbol, "bar": bar, "limit": page_limit}
             if after is not None:
             if after is not None:
                 params["after"] = after
                 params["after"] = after
-            data = self._request("GET", "/api/v5/market/history-candles", params=params)
+            data = self._request("GET", path, params=params)
             try:
             try:
                 page = []
                 page = []
                 for entry in data:
                 for entry in data:

+ 58 - 14
scripts/run_bb_squeeze_executor.py

@@ -4,6 +4,7 @@ import argparse
 import json
 import json
 import os
 import os
 import sys
 import sys
+import time
 from dataclasses import asdict, dataclass
 from dataclasses import asdict, dataclass
 from datetime import UTC, datetime
 from datetime import UTC, datetime
 from pathlib import Path
 from pathlib import Path
@@ -19,6 +20,7 @@ from okx_codex_trader.live_execution import (
     plan_position_delta,
     plan_position_delta,
     render_market_order_bodies,
     render_market_order_bodies,
 )
 )
+from okx_codex_trader.models import Candle
 from okx_codex_trader.okx_client import OkxClient
 from okx_codex_trader.okx_client import OkxClient
 
 
 
 
@@ -29,7 +31,6 @@ EVENTS_FILE = "events.jsonl"
 SYMBOL = "ETH-USDT-SWAP"
 SYMBOL = "ETH-USDT-SWAP"
 BAR = "15m"
 BAR = "15m"
 LEVERAGE = 3
 LEVERAGE = 3
-CANDLES_PATH = ROOT / "data" / "okx-candles" / SYMBOL / f"{BAR}.csv"
 
 
 BAND_LENGTH = 48
 BAND_LENGTH = 48
 BANDWIDTH_LOOKBACK = 960
 BANDWIDTH_LOOKBACK = 960
@@ -37,6 +38,8 @@ BANDWIDTH_QUANTILE = 0.25
 STOP_LOSS_PCT = 0.01
 STOP_LOSS_PCT = 0.01
 ETH_VOL_CAP = 0.006
 ETH_VOL_CAP = 0.006
 COOLDOWN_BARS = 24
 COOLDOWN_BARS = 24
+LIVE_CANDLE_LIMIT = 1_200
+RECENT_CANDLE_LIMIT = 20
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -79,14 +82,27 @@ def append_event(state_dir: Path, payload: dict[str, object]) -> None:
         handle.write(json.dumps(payload, sort_keys=True) + "\n")
         handle.write(json.dumps(payload, sort_keys=True) + "\n")
 
 
 
 
-def load_frame() -> pd.DataFrame:
-    frame = pd.read_csv(CANDLES_PATH)
+def frame_from_candles(candles: list[Candle]) -> pd.DataFrame:
+    frame = pd.DataFrame([asdict(candle) for candle in candles])
     frame["time"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
     frame["time"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
     return frame.sort_values("ts").drop_duplicates("ts", keep="last").reset_index(drop=True)
     return frame.sort_values("ts").drop_duplicates("ts", keep="last").reset_index(drop=True)
 
 
 
 
+def load_live_frame(client: OkxClient) -> pd.DataFrame:
+    return frame_from_candles(client.get_candles(SYMBOL, BAR, LIVE_CANDLE_LIMIT))
+
+
+def refresh_live_frame(client: OkxClient, frame: pd.DataFrame | None) -> pd.DataFrame:
+    if frame is None or len(frame) < BANDWIDTH_LOOKBACK + 2:
+        return load_live_frame(client)
+    recent = frame_from_candles(client.get_recent_candles(SYMBOL, BAR, RECENT_CANDLE_LIMIT))
+    merged = pd.concat([frame, recent], ignore_index=True)
+    merged = merged.sort_values("ts").drop_duplicates("ts", keep="last").tail(LIVE_CANDLE_LIMIT)
+    return merged.reset_index(drop=True)
+
+
 def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
 def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
-    if len(frame) < BANDWIDTH_LOOKBACK + 3:
+    if len(frame) < BANDWIDTH_LOOKBACK + 2:
         raise ValueError("not enough candles")
         raise ValueError("not enough candles")
     close = frame["close"].astype(float)
     close = frame["close"].astype(float)
     middle = close.rolling(BAND_LENGTH).mean()
     middle = close.rolling(BAND_LENGTH).mean()
@@ -96,7 +112,7 @@ def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[Strate
     bandwidth = (upper - lower) / middle
     bandwidth = (upper - lower) / middle
     threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
     threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
     eth_vol = close.pct_change().rolling(96).std(ddof=0)
     eth_vol = close.pct_change().rolling(96).std(ddof=0)
-    decision_index = len(frame) - 2
+    decision_index = len(frame) - 1
     row = frame.iloc[decision_index]
     row = frame.iloc[decision_index]
     candle_ts = int(row["ts"])
     candle_ts = int(row["ts"])
     candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
     candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
@@ -215,12 +231,15 @@ def run_once(
     max_new_margin_usdt: float,
     max_new_margin_usdt: float,
     max_total_margin_usdt: float,
     max_total_margin_usdt: float,
     submit_live: bool,
     submit_live: bool,
+    frame: pd.DataFrame | None = None,
 ) -> dict[str, object]:
 ) -> dict[str, object]:
     state_dir.mkdir(parents=True, exist_ok=True)
     state_dir.mkdir(parents=True, exist_ok=True)
     state_path = state_dir / STATE_FILE
     state_path = state_dir / STATE_FILE
     previous_state = load_state(state_path)
     previous_state = load_state(state_path)
-    next_state, signal = signal_from_frame(load_frame(), previous_state)
     client = OkxClient(load_config())
     client = OkxClient(load_config())
+    if frame is None:
+        frame = load_live_frame(client)
+    next_state, signal = signal_from_frame(frame, previous_state)
     current, account = account_current_position(client, margin_per_unit_usdt)
     current, account = account_current_position(client, margin_per_unit_usdt)
     target = target_position(signal, current)
     target = target_position(signal, current)
     plan = plan_position_delta(current, target)
     plan = plan_position_delta(current, target)
@@ -239,6 +258,7 @@ def run_once(
             max_new_margin_usdt=max_new_margin_usdt,
             max_new_margin_usdt=max_new_margin_usdt,
             max_total_margin_usdt=max_total_margin_usdt,
             max_total_margin_usdt=max_total_margin_usdt,
             client_order_id_prefix=f"bbsq-{signal['decision_candle_ts']}",
             client_order_id_prefix=f"bbsq-{signal['decision_candle_ts']}",
+            stop_loss_pct=STOP_LOSS_PCT,
         )
         )
     snapshot = {
     snapshot = {
         "created_at": now_iso(),
         "created_at": now_iso(),
@@ -293,17 +313,41 @@ def main() -> int:
     parser.add_argument("--max-total-margin-usdt", type=float)
     parser.add_argument("--max-total-margin-usdt", type=float)
     parser.add_argument("--submit-live", action="store_true")
     parser.add_argument("--submit-live", action="store_true")
     parser.add_argument("--confirm-live", action="store_true")
     parser.add_argument("--confirm-live", action="store_true")
+    parser.add_argument("--loop", action="store_true")
+    parser.add_argument("--poll-seconds", type=float, default=10.0)
     args = parser.parse_args()
     args = parser.parse_args()
     if args.submit_live != args.confirm_live:
     if args.submit_live != args.confirm_live:
         raise ValueError("--submit-live and --confirm-live must be used together")
         raise ValueError("--submit-live and --confirm-live must be used together")
-    snapshot = run_once(
-        state_dir=args.state_dir,
-        margin_per_unit_usdt=risk_arg(args.margin_per_unit_usdt, "ETH_NEXTGEN_MARGIN_PER_UNIT_USDT"),
-        max_new_margin_usdt=risk_arg(args.max_new_margin_usdt, "ETH_NEXTGEN_MAX_NEW_MARGIN_USDT"),
-        max_total_margin_usdt=risk_arg(args.max_total_margin_usdt, "ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT"),
-        submit_live=args.submit_live,
-    )
-    print(json.dumps(snapshot, indent=2, sort_keys=True))
+    margin_per_unit_usdt = risk_arg(args.margin_per_unit_usdt, "ETH_NEXTGEN_MARGIN_PER_UNIT_USDT")
+    max_new_margin_usdt = risk_arg(args.max_new_margin_usdt, "ETH_NEXTGEN_MAX_NEW_MARGIN_USDT")
+    max_total_margin_usdt = risk_arg(args.max_total_margin_usdt, "ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT")
+    if not args.loop:
+        snapshot = run_once(
+            state_dir=args.state_dir,
+            margin_per_unit_usdt=margin_per_unit_usdt,
+            max_new_margin_usdt=max_new_margin_usdt,
+            max_total_margin_usdt=max_total_margin_usdt,
+            submit_live=args.submit_live,
+        )
+        print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
+        return 0
+
+    frame: pd.DataFrame | None = None
+    while True:
+        frame = refresh_live_frame(OkxClient(), frame)
+        state = load_state(args.state_dir / STATE_FILE)
+        _, loop_signal = signal_from_frame(frame, state)
+        if loop_signal["signal"] != "state_replay":
+            snapshot = run_once(
+                state_dir=args.state_dir,
+                margin_per_unit_usdt=margin_per_unit_usdt,
+                max_new_margin_usdt=max_new_margin_usdt,
+                max_total_margin_usdt=max_total_margin_usdt,
+                submit_live=args.submit_live,
+                frame=frame,
+            )
+            print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
+        time.sleep(args.poll_seconds)
     return 0
     return 0
 
 
 
 

+ 28 - 1
tests/test_live_execution.py

@@ -133,6 +133,7 @@ def test_render_market_order_bodies_builds_open_order_body():
         max_new_margin_usdt=500.0,
         max_new_margin_usdt=500.0,
         max_total_margin_usdt=1000.0,
         max_total_margin_usdt=1000.0,
         client_order_id_prefix="eth-1000",
         client_order_id_prefix="eth-1000",
+        stop_loss_pct=0.01,
     )
     )
 
 
     assert len(orders) == 1
     assert len(orders) == 1
@@ -145,12 +146,13 @@ def test_render_market_order_bodies_builds_open_order_body():
         "ordType": "market",
         "ordType": "market",
         "sz": "5",
         "sz": "5",
         "clOrdId": "eth10001open",
         "clOrdId": "eth10001open",
+        "attachAlgoOrds": [{"slTriggerPx": "2970", "slOrdPx": "-1"}],
     }
     }
 
 
 
 
 def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
 def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
     plan = plan_position_delta(
     plan = plan_position_delta(
-        TargetPosition(side="long", unit=1.0, known=True, reason="current"),
+        TargetPosition(side="long", unit=1.0, known=True, reason="current", contracts=10.0),
         TargetPosition(side="short", unit=0.5, known=True, reason="target"),
         TargetPosition(side="short", unit=0.5, known=True, reason="target"),
     )
     )
 
 
@@ -164,6 +166,7 @@ def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
         max_new_margin_usdt=500.0,
         max_new_margin_usdt=500.0,
         max_total_margin_usdt=1000.0,
         max_total_margin_usdt=1000.0,
         client_order_id_prefix="eth-2000",
         client_order_id_prefix="eth-2000",
+        stop_loss_pct=0.01,
     )
     )
 
 
     assert [order.body for order in orders] == [
     assert [order.body for order in orders] == [
@@ -185,10 +188,34 @@ def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
             "ordType": "market",
             "ordType": "market",
             "sz": "5",
             "sz": "5",
             "clOrdId": "eth20002reverse",
             "clOrdId": "eth20002reverse",
+            "attachAlgoOrds": [{"slTriggerPx": "3030", "slOrdPx": "-1"}],
         },
         },
     ]
     ]
 
 
 
 
+def test_render_market_order_bodies_closes_actual_contract_size():
+    plan = plan_position_delta(
+        TargetPosition(side="short", unit=1.0024642, known=True, reason="current", contracts=1.38),
+        TargetPosition(side="flat", unit=0.0, known=True, reason="target"),
+    )
+
+    orders = render_market_order_bodies(
+        plan=plan,
+        symbol="ETH-USDT-SWAP",
+        mark_price=2179.27,
+        metadata=InstrumentMeta(ct_val=0.1, lot_sz=0.01, min_sz=0.01),
+        leverage=3,
+        margin_per_unit_usdt=100.0,
+        max_new_margin_usdt=100.0,
+        max_total_margin_usdt=200.0,
+        client_order_id_prefix="bbsq-1778988600000",
+    )
+
+    assert len(orders) == 1
+    assert orders[0].body["sz"] == "1.38"
+    assert orders[0].body["reduceOnly"] == "true"
+
+
 def test_market_client_order_id_removes_unsupported_characters_and_caps_length():
 def test_market_client_order_id_removes_unsupported_characters_and_caps_length():
     assert market_client_order_id("bbsq-1778508900000", 1, "open") == "bbsq17785089000001open"
     assert market_client_order_id("bbsq-1778508900000", 1, "open") == "bbsq17785089000001open"
     assert len(market_client_order_id("x" * 40, 1, "open")) == 32
     assert len(market_client_order_id("x" * 40, 1, "open")) == 32

+ 34 - 0
tests/test_okx_client.py

@@ -760,6 +760,40 @@ def test_market_order_body_supports_reduce_only_close():
     }
     }
 
 
 
 
+def test_market_order_body_attaches_stop_loss_for_open_order():
+    assert OkxClient.build_market_order_body(
+        symbol="ETH-USDT-SWAP",
+        side="sell",
+        pos_side="short",
+        size=1.38,
+        client_order_id="eth-open-1",
+        reduce_only=False,
+        stop_loss_trigger_price=2140.25,
+    ) == {
+        "instId": "ETH-USDT-SWAP",
+        "tdMode": "isolated",
+        "side": "sell",
+        "posSide": "short",
+        "ordType": "market",
+        "sz": "1.38",
+        "clOrdId": "eth-open-1",
+        "attachAlgoOrds": [{"slTriggerPx": "2140.25", "slOrdPx": "-1"}],
+    }
+
+
+def test_market_order_body_rejects_stop_loss_on_reduce_only_order():
+    with pytest.raises(ValueError, match="stop loss is invalid"):
+        OkxClient.build_market_order_body(
+            symbol="ETH-USDT-SWAP",
+            side="sell",
+            pos_side="long",
+            size=1.0,
+            client_order_id="eth-close-1",
+            reduce_only=True,
+            stop_loss_trigger_price=2000.0,
+        )
+
+
 def test_submit_market_order_body_posts_body_and_returns_order_result():
 def test_submit_market_order_body_posts_body_and_returns_order_result():
     session = DummySession([place_order_response()])
     session = DummySession([place_order_response()])
     client = OkxClient(sample_config(), session=session)
     client = OkxClient(sample_config(), session=session)

+ 88 - 0
tests/test_run_bb_squeeze_executor.py

@@ -0,0 +1,88 @@
+from dataclasses import replace
+
+from okx_codex_trader.models import Candle
+from scripts.run_bb_squeeze_executor import EMPTY_STATE, frame_from_candles, refresh_live_frame, signal_from_frame
+
+
+def candles_with_latest_breakout() -> list[Candle]:
+    candles = []
+    price = 100.0
+    for index in range(1_001):
+        if index == 1_000:
+            price = 101.5
+        candles.append(
+            Candle(
+                symbol="ETH-USDT-SWAP",
+                ts=1_700_000_000_000 + (index * 900_000),
+                open=price,
+                high=price,
+                low=price,
+                close=price,
+                volume=1_000.0,
+            )
+        )
+    return candles
+
+
+def test_signal_uses_latest_confirmed_candle() -> None:
+    candles = candles_with_latest_breakout()
+    frame = frame_from_candles(candles)
+
+    next_state, signal = signal_from_frame(frame, EMPTY_STATE)
+
+    assert signal["decision_candle_ts"] == candles[-1].ts
+    assert next_state.last_candle_ts == candles[-1].ts
+
+
+def test_seen_latest_candle_replays_without_order_signal() -> None:
+    candles = candles_with_latest_breakout()
+    frame = frame_from_candles(candles)
+    state = replace(EMPTY_STATE, last_candle_ts=candles[-1].ts)
+
+    _, signal = signal_from_frame(frame, state)
+
+    assert signal["decision_candle_ts"] == candles[-1].ts
+    assert signal["signal"] == "state_replay"
+
+
+def test_loop_predicate_skips_seen_decision_candle() -> None:
+    candles = candles_with_latest_breakout()
+    frame = frame_from_candles(candles)
+    state = replace(EMPTY_STATE, last_candle_ts=candles[-1].ts)
+
+    _, signal = signal_from_frame(frame, state)
+
+    assert signal["signal"] == "state_replay"
+
+
+def test_refresh_live_frame_fetches_recent_candles_after_initial_load() -> None:
+    class Client:
+        def __init__(self) -> None:
+            self.limits = []
+
+        def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
+            self.limits.append(limit)
+            return candles_with_latest_breakout()
+
+        def get_recent_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
+            self.limits.append(limit)
+            candles = candles_with_latest_breakout()
+            return [
+                *candles[-19:],
+                Candle(
+                    symbol="ETH-USDT-SWAP",
+                    ts=candles[-1].ts + 900_000,
+                    open=102.0,
+                    high=102.0,
+                    low=102.0,
+                    close=102.0,
+                    volume=1_000.0,
+                ),
+            ]
+
+    client = Client()
+    initial = refresh_live_frame(client, None)
+    refreshed = refresh_live_frame(client, initial)
+
+    assert client.limits == [1_200, 20]
+    assert int(refreshed.iloc[-1]["ts"]) == int(initial.iloc[-1]["ts"]) + 900_000