소스 검색

grid trader working

Lukas Goldschmidt 1 개월 전
부모
커밋
e626df2165
7개의 변경된 파일422개의 추가작업 그리고 324개의 파일을 삭제
  1. 59 0
      TODO.md
  2. 1 14
      src/trader_mcp/strategy_context.py
  3. 14 0
      src/trader_mcp/strategy_engine.py
  4. 3 0
      src/trader_mcp/strategy_sdk.py
  5. 4 36
      strategies/grid_trader.md
  6. 215 264
      strategies/grid_trader.py
  7. 126 10
      tests/test_strategies.py

+ 59 - 0
TODO.md

@@ -0,0 +1,59 @@
+# Trader-MCP TODO
+
+Trader-MCP concerns only strategy behavior and the surfaces strategies must expose.
+
+## Strategy taxonomy
+- Reduce strategy set to a small, orthogonal registry.
+- Treat parameter variants as modes inside a strategy, not separate strategies.
+- Keep at minimum: `idle`, `trend_following`, `mean_reversion`, `breakout`, `grid`, `event_driven`, `defensive`.
+- Remove or fold redundant strategy names that differ only by tuning.
+
+## Strategy metadata
+- Add explicit `expects` / `avoids` regime metadata to every strategy.
+- Declare natural habitat fields for trend, volatility, event risk, and liquidity.
+- Add a risk profile per strategy.
+
+### Example: strategy capability declaration
+```json
+{
+  "name": "mean_reversion",
+  "capabilities": ["fade_extremes", "range_entry", "range_exit"],
+  "expects": {
+    "trend": "none",
+    "volatility": "low",
+    "event_risk": "low",
+    "liquidity": "normal"
+  },
+  "avoids": {
+    "trend": "strong",
+    "volatility": "expanding",
+    "event_risk": "high"
+  },
+  "risk_profile": "medium"
+}
+```
+
+### Example: strategy contract to Hermes
+```json
+{
+  "name": "trend_following",
+  "inputs": ["market_regime", "sentiment_pressure", "liquidity_state"],
+  "outputs": ["target_position", "risk_mode", "reason"],
+  "exposes": ["status", "recent_changes", "decision_history"],
+  "confidence_threshold": 0.65,
+  "minimum_hold_minutes": 15
+}
+```
+
+## Strategy surface
+- Expose clean strategy state.
+- Provide strategy status, recent changes, and decision history.
+
+## Execution feedback
+- Feed fills, slippage, execution quality, and stress into strategy selection.
+- Let execution degradation influence strategy selection.
+
+## Cleanup / simplification
+- Separate signal-based strategies from structure-based ones, especially `mean_reversion` vs `grid`.
+- Avoid adding micro-strategies until the minimal taxonomy is stable.
+- Keep strategy names human-readable and stable.

+ 1 - 14
src/trader_mcp/strategy_context.py

@@ -31,7 +31,7 @@ class StrategyContext:
         return payload
 
     def query_order(self, order_id: str) -> Any:
-        return query_order(self.account_id, order_id, self.client_id)
+        return query_order(self.account_id, order_id)
 
     def cancel_all_orders(self) -> Any:
         return cancel_all_orders(self.account_id, self.client_id)
@@ -114,7 +114,6 @@ class StrategyContext:
         fee_rate: float,
         max_notional_per_order: float = 0.0,
         dust_collect: bool = False,
-        inventory_cap_pct: float = 0.0,
         order_size: float = 0.0,
         safety: float = 0.995,
     ) -> float:
@@ -129,7 +128,6 @@ class StrategyContext:
         side = str(side or "").strip().lower()
         fee_rate = max(float(fee_rate or 0.0), 0.0)
         max_notional_per_order = float(max_notional_per_order or 0.0)
-        inventory_cap_pct = float(inventory_cap_pct or 0.0)
         order_size = float(order_size or 0.0)
         min_amount = (min_notional / price) if min_notional > 0 else 0.0
 
@@ -139,17 +137,6 @@ class StrategyContext:
             spendable_quote = quote_available * safety
             quote_cap = spendable_quote if max_notional_per_order <= 0 else min(spendable_quote, max_notional_per_order)
 
-            if 0.0 < inventory_cap_pct < 1.0:
-                base = self.base_currency or (self.market_symbol or "XRP")
-                base_available = self._available_balance(base) if hasattr(self, "_available_balance") else 0.0
-                base_value = base_available * price
-                total_value = base_value + quote_available
-                max_base_value = total_value * inventory_cap_pct
-                remaining_base_value = max(max_base_value - base_value, 0.0)
-                if remaining_base_value <= 0:
-                    return 0.0
-                quote_cap = min(quote_cap, remaining_base_value * (1 + fee_rate))
-
             if dust_collect and max_notional_per_order > 0:
                 leftover_quote = max(spendable_quote - max_notional_per_order, 0.0)
                 if 0.0 < leftover_quote < min_notional:

+ 14 - 0
src/trader_mcp/strategy_engine.py

@@ -40,6 +40,17 @@ def pause_strategy(instance_id: str) -> dict[str, Any]:
     return {"ok": True, "id": instance_id, "paused": True}
 
 
+def _stop_strategy_runtime(runtime: RuntimeStrategy) -> None:
+    stop_hook = getattr(runtime.instance, "on_stop", None)
+    if callable(stop_hook):
+        try:
+            stop_hook()
+        except Exception:
+            # stopping should still proceed even if cleanup fails
+            pass
+    update_strategy_state(runtime.record.id, runtime.instance.state)
+
+
 def resume_strategy(instance_id: str) -> dict[str, Any]:
     runtime = _running.get(instance_id)
     if runtime is None:
@@ -83,6 +94,8 @@ def reconcile_all() -> dict[str, Any]:
     for instance_id, runtime in list(_running.items()):
         record = records.get(instance_id)
         if record is None or record.mode == "off":
+            if runtime.record.mode != "off":
+                _stop_strategy_runtime(runtime)
             update_strategy_state(instance_id, runtime.instance.state)
             _running.pop(instance_id, None)
             unloaded.append(instance_id)
@@ -106,6 +119,7 @@ def reconcile_instance(instance_id: str) -> dict[str, Any]:
     if record is None or record.mode == "off":
         removed = _running.pop(instance_id, None)
         if removed is not None:
+            _stop_strategy_runtime(removed)
             update_strategy_state(instance_id, removed.instance.state)
         return {"loaded": False, "unloaded": removed is not None, "running": running_strategy_ids()}
 

+ 3 - 0
src/trader_mcp/strategy_sdk.py

@@ -21,5 +21,8 @@ class Strategy:
     def on_tick(self, tick):
         return None
 
+    def on_stop(self):
+        return None
+
     def render(self):
         return {"widgets": []}

+ 4 - 36
strategies/grid_trader.md

@@ -71,15 +71,6 @@ This file explains every config parameter used by `strategies/grid_trader.py`.
   - Set it when you want a static size instead of wallet-derived sizing.
   - Leave it at `0.0` to let the strategy derive size from balance logic.
 
-### `inventory_cap_pct`
-- **Type:** float
-- **Default:** `0.7`
-- **Range:** `0.0..1.0`
-- **What it does:** Caps how much inventory the strategy should commit.
-- **When to change it:**
-  - Lower it for more conservative inventory usage.
-  - Raise it if you want the grid to keep more capital active.
-
 ### `max_notional_per_order`
 - **Type:** float
 - **Default:** `0.0`
@@ -97,14 +88,6 @@ This file explains every config parameter used by `strategies/grid_trader.py`.
   - Enable it when you want the strategy to clean up small leftover balances.
   - Leave it off if you want strict order caps.
 
-### `use_all_available`
-- **Type:** bool
-- **Default:** `True`
-- **What it does:** Lets the strategy use the available balance more fully when sizing orders.
-- **When to change it:**
-  - Disable it for stricter, more conservative capital usage.
-  - Keep it enabled if you want the grid to self-fill as much as possible.
-
 ## Re-centering
 
 ### `recenter_pct`
@@ -144,20 +127,6 @@ This file explains every config parameter used by `strategies/grid_trader.py`.
   - Raise it if you want the strategy to tolerate larger drifts before recentring.
   - Lower it if you want a tighter leash.
 
-### `center_shift_factor`
-- **Type:** float
-- **Default:** `0.3333333333`
-- **Range:** `0.0..1.0`
-- **What it does:** Controls where the new center lands between the old center and the new price.
-- **Formula:** `new_center = price + (old_center - price) * center_shift_factor`
-- **Meaning:**
-  - `0.0` = snap center to current price
-  - `1.0` = keep old center
-  - `0.333...` = move the center one third of the way back from price toward the old center
-- **When to change it:**
-  - Lower it if you want the grid to follow price more aggressively.
-  - Raise it if you want the grid to retain more of its old anchor.
-
 ## Fees and execution
 
 ### `fee_rate`
@@ -209,12 +178,11 @@ This file explains every config parameter used by `strategies/grid_trader.py`.
 
 ## Practical starting points
 
-- **Conservative:** lower `inventory_cap_pct`, higher `recenter_pct`, higher `center_shift_factor`
-- **Reactive:** lower `recenter_pct`, lower `center_shift_factor`, smaller `grid_step_pct`
+- **Conservative:** higher `recenter_pct`
+- **Reactive:** lower `recenter_pct`, smaller `grid_step_pct`
 - **Range-friendly:** moderate `grid_levels`, moderate `grid_step_pct`, `enable_trend_guard=true`
 
 ## Current recommended interpretation
 
-If you want the grid to feel like it “leans” toward the fill instead of snapping, the key knob is `center_shift_factor`.
-
-For your described behavior, `0.3333333333` is the right default.
+Rebuild now means: cancel all open orders, recenter to the current price, then place a fresh full grid.
+The only rebuild triggers are the configured out-of-range recenter and missing tracked orders.

+ 215 - 264
strategies/grid_trader.py

@@ -9,7 +9,7 @@ from src.trader_mcp.logging_utils import log_event
 
 class Strategy(Strategy):
     LABEL = "Grid Trader"
-    TICK_MINUTES = 1.0
+    TICK_MINUTES = 0.50
     CONFIG_SCHEMA = {
         "grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
         "grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
@@ -18,12 +18,10 @@ class Strategy(Strategy):
         "grid_step_min_pct": {"type": "float", "default": 0.005, "min": 0.0001, "max": 0.5},
         "grid_step_max_pct": {"type": "float", "default": 0.03, "min": 0.0001, "max": 1.0},
         "order_size": {"type": "float", "default": 0.0, "min": 0.0},
-        "inventory_cap_pct": {"type": "float", "default": 0.7, "min": 0.0, "max": 1.0},
         "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
         "recenter_atr_multiplier": {"type": "float", "default": 0.35, "min": 0.0, "max": 10.0},
         "recenter_min_pct": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.5},
         "recenter_max_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 0.5},
-        "center_shift_factor": {"type": "float", "default": 0.3333333333, "min": 0.0, "max": 1.0},
         "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
         "trade_sides": {"type": "string", "default": "both"},
         "max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
@@ -32,7 +30,6 @@ class Strategy(Strategy):
         "enable_trend_guard": {"type": "bool", "default": True},
         "trend_guard_reversal_max": {"type": "float", "default": 0.25, "min": 0.0, "max": 1.0},
         "debug_orders": {"type": "bool", "default": True},
-        "use_all_available": {"type": "bool", "default": True},
     }
     STATE_SCHEMA = {
         "center_price": {"type": "float", "default": 0.0},
@@ -338,7 +335,7 @@ class Strategy(Strategy):
                 updated_at=now_iso,
             )
 
-    def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
+    def _supported_levels(self, side: str, price: float, min_notional: float, *, balance_total: float | None = None) -> int:
         if min_notional <= 0 or price <= 0:
             return 0
         safety = 0.995
@@ -347,13 +344,14 @@ class Strategy(Strategy):
             quote = self.context.counter_currency or "USD"
             quote_available = self._available_balance(quote)
             self.state["counter_available"] = quote_available
-            usable_notional = quote_available * safety
+            usable_notional = (quote_available if balance_total is None else balance_total) * safety
             return max(int(usable_notional / min_notional), 0)
 
         base = self._base_symbol()
         base_available = self._available_balance(base)
         self.state["base_available"] = base_available
-        usable_notional = base_available * safety * price / (1 + fee_rate)
+        usable_base = base_available if balance_total is None else balance_total
+        usable_notional = usable_base * safety * price / (1 + fee_rate)
         return max(int(usable_notional / min_notional), 0)
 
     def _side_allowed(self, side: str) -> bool:
@@ -379,10 +377,63 @@ class Strategy(Strategy):
             fee_rate=self._live_fee_rate(),
             max_notional_per_order=float(self.config.get("max_notional_per_order", 0.0) or 0.0),
             dust_collect=bool(self.config.get("dust_collect", False)),
-            inventory_cap_pct=float(self.config.get("inventory_cap_pct", 0.0) or 0.0),
             order_size=float(self.config.get("order_size", 0.0) or 0.0),
         )
 
+    def _target_levels_for_side(self, side: str, center: float, live_orders: list[dict], balance_total: float, expected_levels: int, min_notional: float) -> int:
+        if expected_levels <= 0 or center <= 0 or balance_total <= 0:
+            return 0
+
+        side_orders = [
+            order for order in live_orders
+            if isinstance(order, dict) and str(order.get("side") or "").lower() == side
+        ]
+        amount = 0.0
+        if side_orders:
+            amounts = []
+            for order in side_orders:
+                try:
+                    amounts.append(float(order.get("amount") or 0.0))
+                except Exception:
+                    continue
+            if amounts:
+                amount = sum(amounts) / len(amounts)
+
+        if amount <= 0:
+            amount = self._suggest_amount(side, center, max(expected_levels, 1), min_notional)
+        if amount <= 0:
+            return 0
+
+        fee_rate = self._live_fee_rate()
+        safety = 0.995
+        step = self._grid_step_pct()
+        spendable_total = balance_total * safety
+
+        for level_count in range(expected_levels, 0, -1):
+            feasible = True
+            if side == "buy":
+                needed = 0.0
+                for i in range(1, level_count + 1):
+                    level_price = center * (1 - (step * i))
+                    min_size = (min_notional / level_price) if level_price > 0 and min_notional > 0 else 0.0
+                    if amount < min_size:
+                        feasible = False
+                        break
+                    needed += amount * level_price * (1 + fee_rate)
+            else:
+                needed = amount * level_count
+                for i in range(1, level_count + 1):
+                    level_price = center * (1 + (step * i))
+                    min_size = (min_notional / level_price) if level_price > 0 and min_notional > 0 else 0.0
+                    if amount < min_size:
+                        feasible = False
+                        break
+
+            if feasible and needed <= spendable_total + 1e-9:
+                return level_count
+
+        return 0
+
     def _place_grid(self, center: float) -> None:
         center = self._maybe_refresh_center(center)
         mode = self._mode()
@@ -441,207 +492,71 @@ class Strategy(Strategy):
             delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
             if delay > 0:
                 time.sleep(delay)
+            self._refresh_balance_snapshot()
 
         self.state["orders"] = orders
         self.state["order_ids"] = order_ids
         self.state["last_action"] = "seeded grid"
         self._set_grid_refresh_pause()
 
-    def _place_side_grid(self, side: str, center: float, *, start_level: int = 1) -> None:
+    def _top_up_grid(self, center: float, live_orders: list[dict]) -> list[str]:
         center = self._maybe_refresh_center(center)
         levels = int(self.config.get("grid_levels", 6) or 6)
-        step = self._grid_step_pct()
+        if levels <= 0 or center <= 0:
+            return []
+
         min_notional = float(self.context.minimum_order_value or 0.0)
-        fee_rate = self._live_fee_rate()
-        safety = 0.995
+        step = self._grid_step_pct()
         market = self._market_symbol()
-        orders = list(self.state.get("orders") or [])
-        order_ids = list(self.state.get("order_ids") or [])
-        placement_levels = max(levels - max(start_level, 1) + 1, 0)
-
-        side_levels = min(placement_levels, self._supported_levels(side, center, min_notional))
-        amount = self._suggest_amount(side, center, max(side_levels, 1), min_notional)
-
-        if side == "buy":
-            quote = self.context.counter_currency or "USD"
-            quote_available = self._available_balance(quote)
-            max_affordable_amount = (quote_available * safety) / (center * (1 + fee_rate)) if center > 0 else 0.0
-            min_amount = (min_notional / center) if center > 0 and min_notional > 0 else 0.0
-            if max_affordable_amount < min_amount:
-                self._log_decision(
-                    f"skip side {side}",
-                    reason="insufficient_counter_balance",
-                    quote=f"{quote_available:.6g}",
-                    max_affordable_amount=f"{max_affordable_amount:.6g}",
-                    min_amount=f"{min_amount:.6g}",
-                    fee_rate=f"{fee_rate:.6g}",
-                )
-                return
-            amount = min(amount, max_affordable_amount)
-
-        if side_levels <= 0 and min_notional > 0 and center > 0:
-            min_amount = min_notional / center
-            if amount >= min_amount:
-                side_levels = 1
-                self._log(f"side {side} restored to 1 level because amount clears minimum: amount={amount:.6g} min_amount={min_amount:.6g}")
-        self._log(
-            f"prepare side {side}, market={market}, center={center}, levels={side_levels}, start_level={start_level}, amount={amount:.6g}, min_notional={min_notional}, existing_ids={order_ids}"
-        )
-
-        for i in range(start_level, levels + 1):
-            price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
-            min_size = (min_notional / price) if price > 0 else 0.0
-            relative_level = i - start_level + 1
-            if relative_level > side_levels or amount < min_size:
-                self._log_decision(
-                    f"skip side {side} level {i}",
-                    reason="below_min_size",
-                    amount=f"{amount:.6g}",
-                    min_size=f"{min_size:.6g}",
-                    min_notional=min_notional,
-                    price=price,
-                )
-                continue
-            try:
-                self._log_decision(f"place side {side} level {i}", price=price, amount=f"{amount:.6g}")
-                result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
-                status = None
-                order_id = None
-                if isinstance(result, dict):
-                    status = result.get("status")
-                    order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
-                self._log_decision(f"place side {side} level {i} result", status=status, order_id=order_id)
-                orders.append({"side": side, "price": price, "amount": amount, "result": result})
-                if order_id is not None:
-                    order_ids.append(str(order_id))
-                self._log_decision(f"seed side {side} level {i}", price=price, amount=f"{amount:.6g}")
-            except Exception as exc:
-                self.state["last_error"] = str(exc)
-                self._log_decision(f"seed side {side} level {i} failed", error=str(exc))
-                continue
-
-            delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
-            if delay > 0:
-                time.sleep(delay)
-
-        self.state["orders"] = orders
-        self.state["order_ids"] = order_ids
-        self._log_decision(f"side {side} placement complete", tracked_ids=order_ids)
-        self._set_grid_refresh_pause()
+        placed: list[str] = []
 
-    def _top_up_missing_levels(self, center: float, live_orders: list[dict]) -> None:
-        center = self._maybe_refresh_center(center)
-        target_levels = int(self.config.get("grid_levels", 6) or 6)
-        if target_levels <= 0:
-            return
-        for side in ("buy", "sell"):
-            count = 0
-            for order in live_orders:
-                if not isinstance(order, dict):
-                    continue
-                if str(order.get("side") or "").lower() == side:
-                    count += 1
-            if 0 < count < target_levels:
-                self._log(f"top up side {side}: have {count}, want {target_levels}")
-                self._place_side_grid(side, center, start_level=count + 1)
-
-    def _cancel_obsolete_side_orders(self, open_orders: list[dict], desired_sides: set[str]) -> list[str]:
-        removed: list[str] = []
-        for order in open_orders:
+        live_by_side: dict[str, int] = {"buy": 0, "sell": 0}
+        for order in live_orders:
             if not isinstance(order, dict):
                 continue
             side = str(order.get("side") or "").lower()
-            order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
-            if not order_id or side in desired_sides:
-                continue
-            try:
-                self.context.cancel_order(order_id)
-                removed.append(order_id)
-                self._log(f"cancelled obsolete {side} order {order_id}")
-            except Exception as exc:
-                self.state["last_error"] = str(exc)
-                self._log(f"cancel obsolete {side} order {order_id} failed: {exc}")
-        return removed
+            if side in live_by_side:
+                live_by_side[side] += 1
 
-    def _cancel_surplus_side_orders(self, open_orders: list[dict], target_levels: int) -> list[str]:
-        removed: list[str] = []
-        if target_levels <= 0:
-            return removed
         for side in ("buy", "sell"):
-            side_orders = [order for order in open_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == side]
-            if len(side_orders) <= target_levels:
+            live_count = live_by_side.get(side, 0)
+            max_levels = self._supported_levels(side, center, min_notional)
+            target_levels = min(levels, max_levels)
+            if target_levels <= live_count:
                 continue
-            surplus = side_orders[target_levels:]
-            for order in surplus:
-                order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
-                if not order_id:
+
+            amount = self._suggest_amount(side, center, max(target_levels, 1), min_notional)
+            for i in range(live_count + 1, target_levels + 1):
+                price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
+                min_size = (min_notional / price) if price > 0 else 0.0
+                if amount < min_size:
+                    self._log_decision(
+                        f"skip top-up {side} level {i}",
+                        reason="below_min_size",
+                        amount=f"{amount:.6g}",
+                        min_size=f"{min_size:.6g}",
+                        price=price,
+                    )
                     continue
                 try:
-                    self.context.cancel_order(order_id)
-                    removed.append(order_id)
-                    self._log(f"cancelled surplus {side} order {order_id}")
+                    self._log_decision(f"top-up {side} level {i}", price=price, amount=f"{amount:.6g}")
+                    result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
+                    order_id = None
+                    if isinstance(result, dict):
+                        order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
+                    if order_id is not None:
+                        placed.append(str(order_id))
+                    live_orders.append({"side": side, "price": price, "amount": amount, "result": result})
+                    self._refresh_balance_snapshot()
                 except Exception as exc:
                     self.state["last_error"] = str(exc)
-                    self._log(f"cancel surplus {side} order {order_id} failed: {exc}")
-        return removed
+                    self._log_decision(f"top-up {side} level {i} failed", error=str(exc))
+                    continue
 
-    def _cancel_duplicate_level_orders(self, open_orders: list[dict]) -> list[str]:
-        removed: list[str] = []
-        seen: set[tuple[str, str]] = set()
-        for order in open_orders:
-            if not isinstance(order, dict):
-                continue
-            side = str(order.get("side") or "").lower()
-            try:
-                price_key = f"{float(order.get('price') or 0.0):.8f}"
-            except Exception:
-                price_key = str(order.get("price") or "")
-            key = (side, price_key)
-            order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
-            if not order_id:
-                continue
-            if key in seen:
-                try:
-                    self.context.cancel_order(order_id)
-                    removed.append(order_id)
-                    self._log(f"cancelled duplicate {side} level order {order_id} price={price_key}")
-                except Exception as exc:
-                    self.state["last_error"] = str(exc)
-                    self._log(f"cancel duplicate {side} order {order_id} failed: {exc}")
-                continue
-            seen.add(key)
-        return removed
+                delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
+                if delay > 0:
+                    time.sleep(delay)
 
-    def _place_replacement_orders(self, vanished_orders: list[dict], price_hint: float) -> list[str]:
-        placed: list[str] = []
-        if not vanished_orders:
-            return placed
-        market = self._market_symbol()
-        for order in vanished_orders:
-            if not isinstance(order, dict):
-                continue
-            side = str(order.get("side") or "").lower()
-            opposite = "sell" if side == "buy" else "buy" if side == "sell" else ""
-            if not opposite:
-                continue
-            try:
-                amount = float(order.get("amount") or 0.0)
-                price = float(order.get("price") or price_hint or 0.0)
-            except Exception:
-                continue
-            if amount <= 0 or price <= 0:
-                continue
-            try:
-                self._log(f"replace filled {side} order with {opposite}: price={price} amount={amount:.6g}")
-                result = self.context.place_order(side=opposite, order_type="limit", amount=amount, price=price, market=market)
-                order_id = None
-                if isinstance(result, dict):
-                    order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
-                if order_id is not None:
-                    placed.append(str(order_id))
-            except Exception as exc:
-                self.state["last_error"] = str(exc)
-                self._log(f"replacement order failed for {side}→{opposite} at {price}: {exc}")
         return placed
 
     def _recenter_and_rebuild_from_fill(self, fill_price: float) -> None:
@@ -651,31 +566,37 @@ class Strategy(Strategy):
             return
         self._recenter_and_rebuild_from_price(fill_price, "fill rebuild")
 
-    def _shifted_center_price(self, current_center: float, price: float) -> float:
-        if current_center <= 0:
-            return price
-        if price <= 0:
-            return current_center
-        factor = float(self.config.get("center_shift_factor", 1.0 / 3.0) or 0.0)
-        factor = max(0.0, min(1.0, factor))
-        return price + (current_center - price) * factor
-
     def _recenter_and_rebuild_from_price(self, price: float, reason: str) -> None:
-        current = float(self.state.get("center_price") or 0.0)
         if price <= 0:
             return
-        new_center = self._shifted_center_price(current, price)
-        self._log(f"{reason}: shift center from {current} to {new_center} using price={price}")
+        current = float(self.state.get("center_price") or 0.0)
+        self._log(f"{reason}: recenter from {current} to {price}")
         try:
             self.context.cancel_all_orders()
         except Exception as exc:
             self.state["last_error"] = str(exc)
             self._log(f"{reason} cancel-all failed: {exc}")
-        self.state["center_price"] = new_center
+        # Give the exchange a moment to release balance before we rebuild.
+        time.sleep(3.0)
+        self._refresh_balance_snapshot()
+        self.state["center_price"] = price
         self.state["seeded"] = True
-        self._place_grid(new_center)
+        self._place_grid(price)
+        self._refresh_balance_snapshot()
         self._set_grid_refresh_pause()
 
+    def on_stop(self):
+        self._log("stopping: cancel all open orders")
+        try:
+            self.context.cancel_all_orders()
+        except Exception as exc:
+            self.state["last_error"] = str(exc)
+            self._log(f"stop cancel-all failed: {exc}")
+        self.state["orders"] = []
+        self.state["order_ids"] = []
+        self.state["open_order_count"] = 0
+        self.state["last_action"] = "stopped"
+
     def _maybe_refresh_center(self, price: float) -> float:
         if price <= 0:
             return price
@@ -776,23 +697,12 @@ class Strategy(Strategy):
                     self._log(f"vanished order {order_id} resolved as {status}")
                     continue
 
-        surplus_cancelled = self._cancel_surplus_side_orders(live_orders, int(self.config.get("grid_levels", 6) or 6))
-        duplicate_cancelled = self._cancel_duplicate_level_orders(live_orders)
-        if surplus_cancelled or duplicate_cancelled:
-            live_orders = self._sync_open_orders_state()
-            live_ids = list(self.state.get("order_ids") or [])
-            open_order_count = len(live_ids)
-
-        if desired_sides != {"buy", "sell"}:
-            live_orders = self._sync_open_orders_state()
-            live_ids = list(self.state.get("order_ids") or [])
-            open_order_count = len(live_ids)
-
         return live_orders, live_ids, open_order_count
 
     def on_tick(self, tick):
         previous_orders = list(self.state.get("orders") or [])
         tracked_ids_before_sync = list(self.state.get("order_ids") or [])
+        rebuild_done = False
         self._refresh_balance_snapshot()
         price = self._price()
         self.state["last_price"] = price
@@ -803,7 +713,7 @@ class Strategy(Strategy):
             live_orders = self._sync_open_orders_state()
             live_ids = list(self.state.get("order_ids") or [])
             open_order_count = len(live_ids)
-            expected_ids = [str(oid) for oid in (self.state.get("order_ids") or []) if oid]
+            expected_ids = [str(oid) for oid in tracked_ids_before_sync if oid]
             stale_ids = []
             missing_ids = []
         except Exception as exc:
@@ -834,21 +744,32 @@ class Strategy(Strategy):
             return {"action": "guard", "price": price, "reason": guard_reason}
 
         if mode != "active":
+            if open_order_count > 0:
+                self._log("observe mode: cancel all open orders")
+                try:
+                    self.context.cancel_all_orders()
+                except Exception as exc:
+                    self.state["last_error"] = str(exc)
+                    self._log(f"observe cancel failed: {exc}")
+                self.state["orders"] = []
+                self.state["order_ids"] = []
+                self.state["open_order_count"] = 0
+
             if not self.state.get("seeded") or not self.state.get("center_price"):
                 self.state["center_price"] = price
-                self._place_grid(price)
                 self.state["seeded"] = True
-                self._log(f"planned grid at {price}")
-                return {"action": "plan", "price": price}
+                self.state["last_action"] = "observe monitor"
+                self._log(f"observe at {price} dev 0.0000")
+                return {"action": "observe", "price": price, "deviation": 0.0}
 
             center = float(self.state.get("center_price") or price)
             recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
             deviation = abs(price - center) / center if center else 0.0
             if deviation >= recenter_pct:
                 self.state["center_price"] = price
-                self._place_grid(price)
-                self._log(f"planned recenter to {price}")
-                return {"action": "plan", "price": price, "deviation": deviation}
+                self.state["last_action"] = "observe monitor"
+                self._log(f"observe at {price} dev {deviation:.4f}")
+                return {"action": "observe", "price": price, "deviation": deviation}
 
             self.state["last_action"] = "observe monitor"
             self._log(f"observe at {price} dev {deviation:.4f}")
@@ -863,20 +784,16 @@ class Strategy(Strategy):
             self._log(f"missing tracked orders: {missing_ids}")
             self.state["order_ids"] = live_ids
 
-        if self._order_count_mismatch(tracked_ids_before_sync, live_orders):
-            self.state["mismatch_ticks"] = int(self.state.get("mismatch_ticks") or 0) + 1
-            self._log(f"order count mismatch detected: tracked={len(tracked_ids_before_sync)} live={len(live_orders)} ticks={self.state['mismatch_ticks']}")
-            if self.state["mismatch_ticks"] >= 2 and not self._recovery_paused() and self._mode() == "active":
-                self._recover_grid(price)
-                return {"action": "recovery", "price": price}
-        else:
-            self.state["mismatch_ticks"] = 0
+        missing_tracked = bool(set(expected_ids) - set(live_ids))
 
         center = self._maybe_refresh_center(float(self.state.get("center_price") or price))
         recenter_pct = self._recenter_threshold_pct()
         deviation = abs(price - center) / center if center else 0.0
         if mode == "active" and deviation >= recenter_pct and not self._grid_refresh_paused():
+            if rebuild_done:
+                return {"action": "hold", "price": price}
             self._log(f"recenter needed at price={price} center={center} dev={deviation:.4f} threshold={recenter_pct:.4f}")
+            rebuild_done = True
             self._recenter_and_rebuild_from_price(price, "recenter")
             live_orders = self._sync_open_orders_state()
             live_ids = list(self.state.get("order_ids") or [])
@@ -887,40 +804,71 @@ class Strategy(Strategy):
         live_orders, live_ids, open_order_count = self._reconcile_after_sync(previous_orders, live_orders, desired_sides, price)
 
         if desired_sides != {"buy", "sell"}:
-            current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
-            missing_side = next((side for side in desired_sides if side not in current_sides), None)
-            if missing_side and self.state.get("center_price"):
-                self._log(f"adding missing {missing_side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
-                self._place_side_grid(missing_side, float(self.state.get("center_price") or price))
-                live_orders = self._sync_open_orders_state()
-                self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
-                self.state["last_action"] = f"added {missing_side} side"
-                return {"action": "add_side", "price": price, "side": missing_side}
-
-        if desired_sides == {"buy", "sell"}:
-            current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
-            tracked_sides = {str(order.get("side") or "").lower() for order in previous_orders if isinstance(order, dict)}
-            missing_sides = [side for side in ("buy", "sell") if side not in current_sides]
-            reconciled_sides: list[str] = []
-            has_live_grid = bool(live_orders) or bool(live_ids) or bool(tracked_sides)
-
-            # If the grid is empty because both sides were skipped, do not keep
-            # trying to "restore" a missing side every tick. Let the normal
-            # reseed path decide when to try again.
-            if missing_sides and has_live_grid and self.state.get("center_price") and not self._grid_refresh_paused():
-                for side in missing_sides:
-                    if current_sides or tracked_sides:
-                        self._log(f"adding missing {side} side, live_sides={sorted(current_sides)} tracked_sides={sorted(tracked_sides)} live_ids={live_ids}")
-                    self._place_side_grid(side, float(self.state.get("center_price") or price))
-                    reconciled_sides.append(side)
-                live_orders = self._sync_open_orders_state()
-                self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
-            if live_orders and self.state.get("center_price") and not self._grid_refresh_paused():
-                self._top_up_missing_levels(float(self.state.get("center_price") or price), live_orders)
-                live_orders = self._sync_open_orders_state()
-                if reconciled_sides:
-                    self.state["last_action"] = f"reconciled {','.join(reconciled_sides)}"
-                    return {"action": "reconcile", "price": price, "side": ",".join(reconciled_sides)}
+            self._log("single-side mode is disabled for this strategy, forcing full-grid rebuilds only")
+
+        current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
+        current_buy = sum(1 for order in live_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == "buy")
+        current_sell = sum(1 for order in live_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == "sell")
+        expected_levels = int(self.config.get("grid_levels", 6) or 6)
+        quote_currency = self.context.counter_currency or "USD"
+        quote_available = self._available_balance(quote_currency)
+        base_symbol = self._base_symbol()
+        base_available = self._available_balance(base_symbol)
+        reserved_quote = sum(
+            float(order.get("price") or 0.0) * float(order.get("amount") or 0.0)
+            for order in live_orders
+            if isinstance(order, dict) and str(order.get("side") or "").lower() == "buy"
+        )
+        reserved_base = sum(
+            float(order.get("amount") or 0.0)
+            for order in live_orders
+            if isinstance(order, dict) and str(order.get("side") or "").lower() == "sell"
+        )
+        total_quote = quote_available + reserved_quote
+        total_base = base_available + reserved_base
+        target_buy = self._target_levels_for_side("buy", price, live_orders, total_quote, expected_levels, float(self.context.minimum_order_value or 0.0))
+        target_sell = self._target_levels_for_side("sell", price, live_orders, total_base, expected_levels, float(self.context.minimum_order_value or 0.0))
+        target_total = target_buy + target_sell
+        grid_not_as_expected = (
+            bool(live_orders)
+            and (
+                current_buy != target_buy
+                or current_sell != target_sell
+            )
+        )
+
+        can_make_better = target_total > 0 and (current_buy != target_buy or current_sell != target_sell)
+
+        if grid_not_as_expected and can_make_better and not self._grid_refresh_paused():
+            if rebuild_done:
+                return {"action": "hold", "price": price}
+            self._log(
+                f"grid shape mismatch, rebuilding full grid: live_buy={current_buy} live_sell={current_sell} target_buy={target_buy} target_sell={target_sell}"
+            )
+            rebuild_done = True
+            self.state["center_price"] = price
+            self._recenter_and_rebuild_from_price(price, "grid shape rebuild")
+            live_orders = self._sync_open_orders_state()
+            mode = self._mode()
+            self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
+            return {"action": "reseed" if mode == "active" else "plan", "price": price}
+
+        if grid_not_as_expected and not can_make_better:
+            self._log(
+                f"grid shape left unchanged, balance cannot improve it: live_buy={current_buy} live_sell={current_sell} target_buy={target_buy} target_sell={target_sell}"
+            )
+
+        if self._order_count_mismatch(tracked_ids_before_sync, live_orders):
+            if rebuild_done:
+                return {"action": "hold", "price": price}
+            self._log(f"grid mismatch detected, rebuilding full grid: tracked={len(tracked_ids_before_sync)} live={len(live_orders)}")
+            rebuild_done = True
+            self.state["center_price"] = price
+            self._recenter_and_rebuild_from_price(price, "grid mismatch rebuild")
+            live_orders = self._sync_open_orders_state()
+            mode = self._mode()
+            self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
+            return {"action": "reseed" if mode == "active" else "plan", "price": price}
 
         if (not self.state.get("seeded") or not self.state.get("center_price")) and not self._grid_refresh_paused():
             self.state["center_price"] = price
@@ -931,10 +879,13 @@ class Strategy(Strategy):
             self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
             return {"action": "seed" if mode == "active" else "plan", "price": price}
 
-        if (open_order_count == 0 or (expected_ids and not set(expected_ids).intersection(set(live_ids)))) and not self._grid_refresh_paused():
-            self._log("no open orders, reseeding grid")
+        if ((open_order_count == 0) or missing_tracked) and not self._grid_refresh_paused():
+            if rebuild_done:
+                return {"action": "hold", "price": price}
+            self._log("missing tracked order(s), rebuilding full grid")
+            rebuild_done = True
             self.state["center_price"] = price
-            self._place_grid(price)
+            self._recenter_and_rebuild_from_price(price, "missing order rebuild")
             live_orders = self._sync_open_orders_state()
             mode = self._mode()
             self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"

+ 126 - 10
tests/test_strategies.py

@@ -112,7 +112,7 @@ def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
         strategy_registry.STRATEGIES_DIR = original_dir
 
 
-def test_grid_top_up_uses_missing_levels_budget():
+def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
     class FakeContext:
         base_currency = "XRP"
         counter_currency = "USD"
@@ -121,6 +121,7 @@ def test_grid_top_up_uses_missing_levels_budget():
         mode = "active"
 
         def __init__(self):
+            self.cancelled_all = 0
             self.placed_orders = []
 
         def get_fee_rates(self, market):
@@ -144,7 +145,6 @@ def test_grid_top_up_uses_missing_levels_budget():
             fee_rate,
             max_notional_per_order=0.0,
             dust_collect=False,
-            inventory_cap_pct=0.0,
             order_size=0.0,
             safety=0.995,
         ):
@@ -157,6 +157,10 @@ def test_grid_top_up_uses_missing_levels_budget():
                 return quote_cap / (price * (1 + fee_rate))
             return 0.0
 
+        def cancel_all_orders(self):
+            self.cancelled_all += 1
+            return {"ok": True}
+
         def place_order(self, **kwargs):
             self.placed_orders.append(kwargs)
             return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
@@ -179,6 +183,9 @@ def test_grid_top_up_uses_missing_levels_budget():
         },
     )
     strategy.state["center_price"] = 1.3285
+    strategy.state["seeded"] = True
+    strategy.state["base_available"] = 22.0103
+    strategy.state["counter_available"] = 13.55
     strategy.state["orders"] = [
         {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
         {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
@@ -186,14 +193,87 @@ def test_grid_top_up_uses_missing_levels_budget():
     ]
     strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
 
-    strategy._top_up_missing_levels(strategy.state["center_price"], strategy.state["orders"])
+    def fake_sync_open_orders_state():
+        live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
+        strategy.state["orders"] = live
+        strategy.state["order_ids"] = ["sell-1"]
+        strategy.state["open_order_count"] = 1
+        return live
+
+    monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
+    monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
+    monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
+    monkeypatch.setattr(strategy, "_trend_guard_status", lambda: (False, "disabled"))
+    monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
+    monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
+
+    result = strategy.on_tick({})
+
+    assert result["action"] in {"seed", "reseed"}
+    assert ctx.cancelled_all == 1
+    assert len(ctx.placed_orders) > 0
+    assert strategy.state["last_action"] == "reseeded"
+
+
+def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
+    class FakeContext:
+        base_currency = "XRP"
+        counter_currency = "USD"
+        market_symbol = "xrpusd"
+        minimum_order_value = 10.0
+        mode = "active"
+
+        def __init__(self):
+            self.cancelled_all = 0
+            self.placed_orders = []
+
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.004}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
+
+        def cancel_all_orders(self):
+            self.cancelled_all += 1
+            return {"ok": True}
 
-    assert len(ctx.placed_orders) == 1
-    assert ctx.placed_orders[0]["side"] == "buy"
-    assert float(ctx.placed_orders[0]["amount"]) > 7.57
+        def suggest_order_amount(self, **kwargs):
+            return 10.0
 
+        def place_order(self, **kwargs):
+            self.placed_orders.append(kwargs)
+            return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
 
-def test_grid_center_shifts_towards_price_by_configured_fraction():
+    ctx = FakeContext()
+    strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
+    strategy.state["center_price"] = 1.3907
+    strategy.state["seeded"] = True
+    strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
+    strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
+
+    def fake_sync_open_orders_state():
+        live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
+        strategy.state["orders"] = live
+        strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
+        strategy.state["open_order_count"] = 5
+        return live
+
+    monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
+    monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
+    monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
+    monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
+    monkeypatch.setattr(strategy, "_trend_guard_status", lambda: (False, "disabled"))
+    monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
+    monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
+
+    result = strategy.on_tick({})
+
+    assert result["action"] in {"seed", "reseed"}
+    assert ctx.cancelled_all == 1
+    assert len(ctx.placed_orders) > 0
+
+
+def test_grid_recenters_exactly_on_live_price():
     class FakeContext:
         base_currency = "XRP"
         counter_currency = "USD"
@@ -204,12 +284,48 @@ def test_grid_center_shifts_towards_price_by_configured_fraction():
         def cancel_all_orders(self):
             return {"ok": True}
 
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return 0.1
+
         def place_order(self, **kwargs):
             return {"status": "ok", "id": "oid-1"}
 
-    strategy = GridStrategy(FakeContext(), {"center_shift_factor": 0.25})
+    strategy = GridStrategy(FakeContext(), {})
     strategy.state["center_price"] = 100.0
 
-    shifted = strategy._shifted_center_price(100.0, 160.0)
+    strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
+
+    assert strategy.state["center_price"] == 160.0
+
+
+def test_grid_stop_cancels_all_open_orders():
+    class FakeContext:
+        base_currency = "XRP"
+        counter_currency = "USD"
+        market_symbol = "xrpusd"
+        minimum_order_value = 10.0
+        mode = "active"
+
+        def __init__(self):
+            self.cancelled = False
+
+        def cancel_all_orders(self):
+            self.cancelled = True
+            return {"ok": True}
+
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.0}
+
+    strategy = GridStrategy(FakeContext(), {})
+    strategy.state["orders"] = [{"id": "o1"}]
+    strategy.state["order_ids"] = ["o1"]
+    strategy.state["open_order_count"] = 1
+
+    strategy.on_stop()
 
-    assert shifted == 145.0
+    assert strategy.context.cancelled is True
+    assert strategy.state["open_order_count"] == 0
+    assert strategy.state["last_action"] == "stopped"