Przeglądaj źródła

grid trader reworked

Lukas Goldschmidt 1 tydzień temu
rodzic
commit
551cfaddfd

+ 11 - 2
src/trader_mcp/strategy_context.py

@@ -245,6 +245,7 @@ class StrategyContext:
         dust_collect: bool = False,
         order_size: float = 0.0,
         safety: float = 0.995,
+        available_balances: dict[str, float] | None = None,
     ) -> float:
         """Return a conservative per-order amount for this venue/account.
 
@@ -260,11 +261,17 @@ class StrategyContext:
         quote_notional = float(quote_notional or 0.0)
         max_notional_per_order = float(max_notional_per_order or 0.0)
         order_size = float(order_size or 0.0)
+        balance_overrides = {
+            str(asset_code or "").upper(): max(float(amount or 0.0), 0.0)
+            for asset_code, amount in dict(available_balances or {}).items()
+        }
         min_amount = (min_notional / price) if min_notional > 0 else 0.0
 
         if side == "buy":
             quote = self.counter_currency or "USD"
-            quote_available = self._available_balance(quote) if hasattr(self, "_available_balance") else 0.0
+            quote_available = balance_overrides.get(str(quote).upper())
+            if quote_available is None:
+                quote_available = self._available_balance(quote) if hasattr(self, "_available_balance") else 0.0
             spendable_quote = quote_available * safety
             quote_cap = spendable_quote if max_notional_per_order <= 0 else min(spendable_quote, max_notional_per_order)
             if quote_notional > 0:
@@ -285,7 +292,9 @@ class StrategyContext:
             amount = per_order_quote / (price * (1 + fee_rate))
         else:
             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_available = balance_overrides.get(str(base).upper())
+            if base_available is None:
+                base_available = self._available_balance(base) if hasattr(self, "_available_balance") else 0.0
             spendable_base = base_available * safety
             if quote_notional > 0 and price > 0:
                 spendable_base = min(spendable_base, quote_notional / price)

+ 10 - 0
src/trader_mcp/strategy_sizing.py

@@ -10,13 +10,21 @@ def _call_context_suggest_order_amount(context: Any, kwargs: dict[str, Any]) ->
     variants: list[dict[str, Any]] = []
     drop_sets = (
         (),
+        ("available_balances",),
         ("quote_notional",),
         ("dust_collect",),
         ("order_size",),
+        ("available_balances", "quote_notional"),
+        ("available_balances", "dust_collect"),
+        ("available_balances", "order_size"),
         ("quote_notional", "dust_collect"),
         ("quote_notional", "order_size"),
         ("dust_collect", "order_size"),
+        ("available_balances", "quote_notional", "dust_collect"),
+        ("available_balances", "quote_notional", "order_size"),
+        ("available_balances", "dust_collect", "order_size"),
         ("quote_notional", "dust_collect", "order_size"),
+        ("available_balances", "quote_notional", "dust_collect", "order_size"),
     )
     for drop_keys in drop_sets:
         variant = {key: value for key, value in kwargs.items() if key not in drop_keys}
@@ -47,6 +55,7 @@ def suggest_quote_sized_amount(
     max_order_notional_quote: float = 0.0,
     dust_collect: bool = False,
     order_size: float = 0.0,
+    available_balances: dict[str, float] | None = None,
 ) -> float:
     side = str(side or "").strip().lower()
     price = float(price or 0.0)
@@ -70,6 +79,7 @@ def suggest_quote_sized_amount(
         "max_notional_per_order": max_order_notional_quote,
         "dust_collect": bool(dust_collect),
         "order_size": order_size,
+        "available_balances": available_balances or {},
     }
     amount = _call_context_suggest_order_amount(context, kwargs)
     if amount is not None:

+ 193 - 197
strategies/grid_trader.py

@@ -353,9 +353,22 @@ class Strategy(Strategy):
         self.state["atr_percent"] = atr_pct
         return step
 
-    def _inventory_rebalance_profile(self, price: float) -> dict[str, float | str]:
+    def _inventory_rebalance_profile(
+        self,
+        price: float,
+        *,
+        base_total: float | None = None,
+        quote_total: float | None = None,
+    ) -> dict[str, float | str]:
         ratio_price = price if price > 0 else float(self.state.get("last_price") or self.state.get("center_price") or 1.0)
-        ratio = self._inventory_ratio(ratio_price if ratio_price > 0 else 1.0)
+        if base_total is not None or quote_total is not None:
+            # Shape planning should see the whole wallet, not only the free slice.
+            base_value = max(float(base_total if base_total is not None else self.state.get("base_available") or 0.0), 0.0) * ratio_price
+            counter_value = max(float(quote_total if quote_total is not None else self.state.get("counter_available") or 0.0), 0.0)
+            total = base_value + counter_value
+            ratio = base_value / total if total > 0 else 0.5
+        else:
+            ratio = self._inventory_ratio(ratio_price if ratio_price > 0 else 1.0)
         imbalance = min(abs(ratio - 0.5) * 2.0, 1.0)
         factor = float(self.config.get("inventory_rebalance_step_factor", 0.15) or 0.0)
         factor = min(max(factor, 0.0), 0.9)
@@ -373,10 +386,16 @@ class Strategy(Strategy):
             "reduction": reduction,
         }
 
-    def _effective_grid_steps(self, price: float) -> dict[str, float | str]:
+    def _effective_grid_steps(
+        self,
+        price: float,
+        *,
+        base_total: float | None = None,
+        quote_total: float | None = None,
+    ) -> dict[str, float | str]:
         base_step = float(self.state.get("grid_step_pct") or self._grid_step_pct())
         min_step = float(self.config.get("grid_step_min_pct", 0.005) or 0.0)
-        profile = self._inventory_rebalance_profile(price)
+        profile = self._inventory_rebalance_profile(price, base_total=base_total, quote_total=quote_total)
         favored_side = str(profile.get("favored_side") or "none")
         reduction = float(profile.get("reduction") or 0.0)
 
@@ -597,73 +616,6 @@ class Strategy(Strategy):
             )
         return True
 
-    def _max_fundable_levels(self, side: str, price: float, amount: float, min_notional: float, *, balance_total: float | None = None) -> int:
-        if min_notional <= 0 or price <= 0 or amount <= 0:
-            return 0
-        safety = 0.995
-        fee_rate = self._live_fee_rate()
-        if side == "buy":
-            quote = self.context.counter_currency or "USD"
-            quote_available = self._available_balance(quote)
-            self.state["counter_available"] = quote_available
-            usable_notional = (quote_available if balance_total is None else balance_total) * safety
-            max_levels = 0
-            for level_count in range(1, 1000):
-                needed = 0.0
-                for i in range(1, level_count + 1):
-                    level_price = price
-                    if level_price <= 0:
-                        return max_levels
-                    min_size = min_notional / level_price if min_notional > 0 else 0.0
-                    if amount < min_size:
-                        return max_levels
-                    needed += amount * level_price * (1 + fee_rate)
-                if needed <= usable_notional + 1e-9:
-                    max_levels = level_count
-                else:
-                    break
-            return max_levels
-
-        base = self._base_symbol()
-        base_available = self._available_balance(base)
-        self.state["base_available"] = base_available
-        usable_base = base_available if balance_total is None else balance_total
-        spendable_base = usable_base * safety
-        max_levels = 0
-        for level_count in range(1, 1000):
-            needed = amount * level_count
-            if amount < (min_notional / price):
-                return max_levels
-            if needed <= spendable_base + 1e-9:
-                max_levels = level_count
-            else:
-                break
-        return max_levels
-
-    def _supported_levels(self, side: str, price: float, min_notional: float, *, balance_total: float | None = None) -> int:
-        amount = self._suggest_amount(side, price, int(self.config.get("grid_levels", 6) or 6), min_notional)
-        return self._max_fundable_levels(side, price, amount, min_notional, balance_total=balance_total)
-
-    def _placeable_levels_for_side(self, side: str, center: float, amount: float, expected_levels: int, min_notional: float) -> int:
-        if expected_levels <= 0 or center <= 0 or amount <= 0:
-            return 0
-
-        step_profile = self._effective_grid_steps(center)
-        step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
-        if step <= 0:
-            return 0
-
-        placeable = 0
-        for i in range(1, expected_levels + 1):
-            price = center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i))
-            if price <= 0:
-                break
-            min_size = (min_notional / price) if min_notional > 0 else 0.0
-            if amount < min_size:
-                continue
-            placeable += 1
-        return placeable
-
     def _side_allowed(self, side: str) -> bool:
         selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
         if selected == "both":
@@ -678,7 +630,15 @@ class Strategy(Strategy):
             return {selected}
         return {"buy", "sell"}
 
-    def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
+    def _suggest_amount(
+        self,
+        side: str,
+        price: float,
+        levels: int,
+        min_notional: float,
+        *,
+        available_balances: dict[str, float] | None = None,
+    ) -> float:
         return suggest_quote_sized_amount(
             self.context,
             side=side,
@@ -690,6 +650,7 @@ class Strategy(Strategy):
             max_order_notional_quote=float(self.config.get("max_order_notional_quote") or self.config.get("max_notional_per_order") or 0.0),
             dust_collect=bool(self.config.get("dust_collect", False)),
             order_size=0.0,
+            available_balances=available_balances,
         )
 
     def _grid_extreme_price(self, center: float, side: str, levels: int) -> float:
@@ -701,76 +662,145 @@ class Strategy(Strategy):
             return round(center * (1 - (step * levels)), 8)
         return round(center * (1 + (step * levels)), 8)
 
-    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)
+    def _resource_total_for_side(self, side: str, base_total: float, quote_total: float) -> float:
+        if side == "buy":
+            return max(float(quote_total or 0.0), 0.0)
+        return max(float(base_total or 0.0), 0.0)
 
-        reference_price = self._grid_extreme_price(center, side, expected_levels)
-        seeded_amount = self._suggest_amount(side, reference_price, max(expected_levels, 1), min_notional)
-        amount = max(amount, seeded_amount)
-        if amount <= 0:
-            return 0
+    def _resource_cost_for_order(self, side: str, amount: float, price: float, fee_rate: float) -> float:
+        if side == "buy":
+            return max(amount, 0.0) * max(price, 0.0) * (1.0 + max(fee_rate, 0.0))
+        return max(amount, 0.0)
 
-        free_balance = max(balance_total - sum(
+    def _inventory_totals_from_live_orders(self, live_orders: list[dict]) -> tuple[float, float]:
+        reserved_quote = sum(
             float(order.get("price") or 0.0) * float(order.get("amount") or 0.0)
-            for order in side_orders
-        ) if side == "buy" else balance_total - sum(
+            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 side_orders
-        ), 0.0)
-        placeable_levels = self._placeable_levels_for_side(side, center, amount, expected_levels, min_notional)
-        if placeable_levels <= 0:
-            return 0
+            for order in live_orders
+            if isinstance(order, dict) and str(order.get("side") or "").lower() == "sell"
+        )
+        base_total = max(float(self.state.get("base_available") or 0.0), 0.0) + reserved_base
+        quote_total = max(float(self.state.get("counter_available") or 0.0), 0.0) + reserved_quote
+        return base_total, quote_total
+
+    def _planned_side_orders(
+        self,
+        side: str,
+        center: float,
+        expected_levels: int,
+        min_notional: float,
+        fee_rate: float,
+        *,
+        step: float,
+        base_total: float,
+        quote_total: float,
+    ) -> dict:
+        empty = {"amount": 0.0, "orders": [], "skipped": []}
+        if not self._side_allowed(side):
+            return empty
+        if expected_levels <= 0 or center <= 0 or step <= 0:
+            return empty
 
-        step_profile = self._effective_grid_steps(center)
-        step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
-        if step <= 0:
-            return 0
+        base_symbol = self._base_symbol()
+        quote_symbol = str(self.context.counter_currency or "USD").upper()
+        balances = {
+            base_symbol: max(float(base_total or 0.0), 0.0),
+            quote_symbol: max(float(quote_total or 0.0), 0.0),
+        }
+        # Ask the shared sizing layer for a venue-valid amount once, then
+        # walk the ladder outward until we either fill the target or run out.
+        reference_price = round(center * (1 - (step * expected_levels)) if side == "buy" else center * (1 + (step * expected_levels)), 8)
+        amount = self._suggest_amount(
+            side,
+            reference_price,
+            max(expected_levels, 1),
+            min_notional,
+            available_balances=balances,
+        )
+        if amount <= 0:
+            return empty
 
-        fee_rate = self._live_fee_rate()
-        safety = 0.995
-        spendable_free = free_balance * safety
+        spendable_total = self._resource_total_for_side(side, base_total, quote_total) * 0.995
         total_cost = 0.0
-        confirmed = 0
-        for i in range(1, expected_levels + 1):
-            level_price = center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i))
-            if level_price <= 0:
+        planned_orders = []
+        skipped = []
+        max_index = max(expected_levels * 4, expected_levels + 8, 12)
+
+        for level_index in range(1, max_index + 1):
+            # Skip inner levels that fail min-size, but keep pushing outward.
+            price = round(center * (1 - (step * level_index)) if side == "buy" else center * (1 + (step * level_index)), 8)
+            if price <= 0:
                 break
-            min_size = (min_notional / level_price) if min_notional > 0 else 0.0
+
+            min_size = (min_notional / price) if min_notional > 0 else 0.0
             if amount < min_size:
+                skipped.append({"level": level_index, "reason": "below minimum size", "price": price})
+                if side == "buy":
+                    break
                 continue
-            level_cost = amount * level_price * (1 + fee_rate) if side == "buy" else max(amount, 0.0)
-            if total_cost + level_cost <= spendable_free + 1e-9:
-                total_cost += level_cost
-                confirmed += 1
-            else:
+
+            cost = self._resource_cost_for_order(side, amount, price, fee_rate)
+            if total_cost + cost > spendable_total + 1e-9:
                 break
 
-        return min(expected_levels, confirmed)
+            total_cost += cost
+            planned_orders.append({"side": side, "price": price, "amount": amount, "level": level_index})
+            if len(planned_orders) >= expected_levels:
+                break
 
-    def _place_grid(self, center: float) -> None:
-        center = self._maybe_refresh_center(center)
-        mode = self._mode()
+        return {"amount": amount, "orders": planned_orders, "skipped": skipped}
+
+    def _plan_grid(self, center: float, *, base_total: float | None = None, quote_total: float | None = None) -> dict:
+        center = float(center or 0.0)
         levels = int(self.config.get("grid_levels", 6) or 6)
-        step_profile = self._effective_grid_steps(center)
+        min_notional = float(self.context.minimum_order_value or 0.0)
+        fee_rate = self._live_fee_rate()
+        # One planner feeds both seeding and shape checking, so they never
+        # invent different notions of the "correct" grid.
+        step_profile = self._effective_grid_steps(center, base_total=base_total, quote_total=quote_total)
         buy_step = float(step_profile.get("buy") or step_profile.get("base") or 0.0)
         sell_step = float(step_profile.get("sell") or step_profile.get("base") or 0.0)
-        min_notional = float(self.context.minimum_order_value or 0.0)
+        base_total = max(float(self.state.get("base_available") if base_total is None else base_total) or 0.0, 0.0)
+        quote_total = max(float(self.state.get("counter_available") if quote_total is None else quote_total) or 0.0, 0.0)
+
+        buy_plan = self._planned_side_orders(
+            "buy",
+            center,
+            levels,
+            min_notional,
+            fee_rate,
+            step=buy_step,
+            base_total=base_total,
+            quote_total=quote_total,
+        )
+        sell_plan = self._planned_side_orders(
+            "sell",
+            center,
+            levels,
+            min_notional,
+            fee_rate,
+            step=sell_step,
+            base_total=base_total,
+            quote_total=quote_total,
+        )
+        orders = [*buy_plan["orders"], *sell_plan["orders"]]
+        return {
+            "center": center,
+            "buy_orders": buy_plan["orders"],
+            "sell_orders": sell_plan["orders"],
+            "orders": orders,
+            "buy_skipped": buy_plan["skipped"],
+            "sell_skipped": sell_plan["skipped"],
+            "counts": {"buy": len(buy_plan["orders"]), "sell": len(sell_plan["orders"])},
+        }
+
+    def _place_grid(self, center: float) -> None:
+        center = self._maybe_refresh_center(center)
+        mode = self._mode()
         market = self._market_symbol()
         orders = []
         order_ids = []
@@ -780,41 +810,36 @@ class Strategy(Strategy):
                 return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
             return None
 
-        buy_amount = self._suggest_amount("buy", self._grid_extreme_price(center, "buy", levels), max(levels, 1), min_notional)
-        sell_amount = self._suggest_amount("sell", self._grid_extreme_price(center, "sell", levels), max(levels, 1), min_notional)
-        buy_levels = min(levels, self._supported_levels("buy", center, min_notional)) if (mode == "active" and self._side_allowed("buy")) else (levels if self._side_allowed("buy") else 0)
-        sell_levels = min(levels, self._supported_levels("sell", center, min_notional)) if (mode == "active" and self._side_allowed("sell")) else (levels if self._side_allowed("sell") else 0)
-
-        def _place_side(side: str, side_levels: int, side_amount: float, step: float) -> None:
-            if side_levels <= 0 or side_amount <= 0:
-                return
-            for i in range(1, side_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 side_amount < min_size:
-                    self._log(f"seed level {i} {side} skipped: below minimum size")
-                    continue
-                if mode != "active":
-                    orders.append({"side": side, "price": price, "amount": side_amount, "result": {"simulated": True}})
-                    self._log(f"plan level {i}: {side} {price} amount {side_amount:.6g}")
-                    continue
-                try:
-                    result = self.context.place_order(side=side, order_type="limit", amount=side_amount, price=price, market=market)
-                    orders.append({"side": side, "price": price, "amount": side_amount, "result": result})
-                    order_id = _capture_order_id(result)
-                    if order_id is not None:
-                        order_ids.append(str(order_id))
-                    self._log(f"seed level {i}: {side} {price} amount {side_amount:.6g}")
-                    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()
-                except Exception as exc:  # best effort for first draft
-                    self.state["last_error"] = str(exc)
-                    self._log(f"seed level {i} {side} failed: {exc}")
-
-        _place_side("buy", buy_levels, buy_amount, buy_step)
-        _place_side("sell", sell_levels, sell_amount, sell_step)
+        plan = self._plan_grid(center)
+        for side, skipped in (("buy", plan.get("buy_skipped") or []), ("sell", plan.get("sell_skipped") or [])):
+            for skipped_level in skipped:
+                self._log(f"seed level {skipped_level.get('level')} {side} skipped: {skipped_level.get('reason')}")
+
+        for planned_order in plan.get("orders") or []:
+            side = str(planned_order.get("side") or "").lower()
+            level = int(planned_order.get("level") or 0)
+            price = float(planned_order.get("price") or 0.0)
+            amount = float(planned_order.get("amount") or 0.0)
+            if price <= 0 or amount <= 0:
+                continue
+            if mode != "active":
+                orders.append({"side": side, "price": price, "amount": amount, "result": {"simulated": True}})
+                self._log(f"plan level {level}: {side} {price} amount {amount:.6g}")
+                continue
+            try:
+                result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
+                orders.append({"side": side, "price": price, "amount": amount, "result": result})
+                order_id = _capture_order_id(result)
+                if order_id is not None:
+                    order_ids.append(str(order_id))
+                self._log(f"seed level {level}: {side} {price} amount {amount:.6g}")
+                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()
+            except Exception as exc:  # best effort for first draft
+                self.state["last_error"] = str(exc)
+                self._log(f"seed level {level} {side} failed: {exc}")
 
         self.state["orders"] = orders
         self.state["order_ids"] = order_ids
@@ -1075,43 +1100,19 @@ class Strategy(Strategy):
         if desired_sides != {"buy", "sell"}:
             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
+        total_base, total_quote = self._inventory_totals_from_live_orders(live_orders)
+        planned_grid = self._plan_grid(center, base_total=total_base, quote_total=total_quote)
+        target_buy = int((planned_grid.get("counts") or {}).get("buy") or 0)
+        target_sell = int((planned_grid.get("counts") or {}).get("sell") or 0)
         balance_shape_inconclusive = bool(self.state.get("balance_shape_inconclusive"))
-        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)
+        # Shape means side counts here. Exact ids are handled by the tracked-order path below.
+        grid_not_as_expected = current_buy != target_buy or current_sell != target_sell
 
         if balance_shape_inconclusive:
             self._log("balance info not conclusive, skipping grid shape rebuild checks this tick")
-        elif grid_not_as_expected and can_make_better:
+        elif grid_not_as_expected:
             if rebuild_done:
                 return {"action": "hold", "price": price}
             self._log(
@@ -1125,11 +1126,6 @@ class Strategy(Strategy):
             self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
             return {"action": "reseed" if mode == "active" else "plan", "price": price}
 
-        if not balance_shape_inconclusive and 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 not balance_shape_inconclusive and self._order_count_mismatch(tracked_ids_before_sync, live_orders):
             if rebuild_done:
                 return {"action": "hold", "price": price}

+ 109 - 4
tests/test_strategies.py

@@ -402,7 +402,7 @@ def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
             return {"maker": 0.0, "taker": 0.0}
 
         def suggest_order_amount(self, **kwargs):
-            return 10.0
+            return 1.0
 
         def place_order(self, **kwargs):
             self.attempts.append(kwargs)
@@ -417,7 +417,8 @@ def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
     ctx = FakeContext()
     strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.0})
     strategy.state["center_price"] = 100.0
-    monkeypatch.setattr(strategy, "_supported_levels", lambda side, center, min_notional: 5)
+    strategy.state["base_available"] = 1000.0
+    strategy.state["counter_available"] = 1000.0
     monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
 
     strategy._place_grid(100.0)
@@ -430,6 +431,100 @@ def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
     assert any("partial success" in line for line in (strategy.state.get("debug_log") or [])) or strategy.state.get("last_error") == "insufficient USD"
 
 
+def test_grid_plan_extends_sell_ladder_past_skipped_inner_level(monkeypatch):
+    class FakeContext:
+        base_currency = "XRP"
+        counter_currency = "USD"
+        market_symbol = "xrpusd"
+        minimum_order_value = 10.0
+        mode = "active"
+
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return 7.29474
+
+    strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "order_call_delay_ms": 0})
+    monkeypatch.setattr(
+        strategy,
+        "_effective_grid_steps",
+        lambda center, **kwargs: {"base": 0.00718865, "buy": 0.00718865, "sell": 0.006407884093550591},
+    )
+
+    plan = strategy._plan_grid(1.3615, base_total=49.035, quote_total=19.77)
+
+    assert plan["counts"]["buy"] == 0
+    assert plan["counts"]["sell"] == 5
+    assert [order["level"] for order in plan["sell_orders"]] == [2, 3, 4, 5, 6]
+    assert (plan["sell_skipped"] or [])[0]["level"] == 1
+
+
+def test_grid_shape_check_reuses_canonical_plan_without_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
+
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def get_account_info(self):
+            return {
+                "balances": [
+                    {"asset_code": "USD", "available": 19.77},
+                    {"asset_code": "XRP", "available": 12.5613},
+                ]
+            }
+
+        def get_price(self, symbol):
+            return {"price": 1.3615}
+
+        def get_regime(self, symbol, timeframe="1h"):
+            return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
+
+        def suggest_order_amount(self, **kwargs):
+            return 7.29474
+
+        def get_open_orders(self):
+            return [
+                {"side": "sell", "price": 1.37894867, "amount": 7.29474, "id": "s2"},
+                {"side": "sell", "price": 1.387673, "amount": 7.29474, "id": "s3"},
+                {"side": "sell", "price": 1.39639734, "amount": 7.29474, "id": "s4"},
+                {"side": "sell", "price": 1.40512167, "amount": 7.29474, "id": "s5"},
+                {"side": "sell", "price": 1.41384601, "amount": 7.29474, "id": "s6"},
+            ]
+
+        def cancel_all_orders(self):
+            self.cancelled_all += 1
+            return {"ok": True}
+
+    ctx = FakeContext()
+    strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0})
+    strategy.state["center_price"] = 1.3615
+    strategy.state["seeded"] = True
+    strategy.state["orders"] = ctx.get_open_orders()
+    strategy.state["order_ids"] = ["s2", "s3", "s4", "s5", "s6"]
+
+    monkeypatch.setattr(
+        strategy,
+        "_effective_grid_steps",
+        lambda center, **kwargs: {"base": 0.00718865, "buy": 0.00718865, "sell": 0.006407884093550591},
+    )
+    monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
+    monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
+
+    result = strategy.on_tick({})
+
+    assert result["action"] == "hold"
+    assert ctx.cancelled_all == 0
+
+
 def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
     class FakeContext:
         base_currency = "XRP"
@@ -478,7 +573,7 @@ def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
     assert ctx.placed_orders == []
 
 
-def test_grid_skips_shape_rebuild_when_balance_reads_turn_inconclusive(monkeypatch):
+def test_grid_shape_check_uses_initial_balance_snapshot_without_extra_reads(monkeypatch):
     class FakeContext:
         base_currency = "XRP"
         counter_currency = "USD"
@@ -534,7 +629,13 @@ def test_grid_skips_shape_rebuild_when_balance_reads_turn_inconclusive(monkeypat
         strategy.state["open_order_count"] = 1
         return live
 
+    rebuilds = {"count": 0}
+
+    def fake_rebuild(price, reason):
+        rebuilds["count"] += 1
+
     monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
+    monkeypatch.setattr(strategy, "_recenter_and_rebuild_from_price", fake_rebuild)
     monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
     monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
     monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
@@ -542,7 +643,9 @@ def test_grid_skips_shape_rebuild_when_balance_reads_turn_inconclusive(monkeypat
 
     result = strategy.on_tick({})
 
-    assert result["action"] == "hold"
+    assert result["action"] == "reseed"
+    assert ctx.calls == 1
+    assert rebuilds["count"] == 1
     assert ctx.cancelled_all == 0
     assert ctx.placed_orders == []
 
@@ -682,6 +785,8 @@ def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
     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["base_available"] = 9.98954
+    strategy.state["counter_available"] = 41.29
     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)]