Jelajahi Sumber

grid trader supposedly fixed again

Lukas Goldschmidt 1 Minggu lalu
induk
melakukan
1d10a8cdf9
2 mengubah file dengan 108 tambahan dan 37 penghapusan
  1. 57 37
      strategies/grid_trader.py
  2. 51 0
      tests/test_strategies.py

+ 57 - 37
strategies/grid_trader.py

@@ -644,6 +644,26 @@ class Strategy(Strategy):
         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":
@@ -706,11 +726,6 @@ class Strategy(Strategy):
         if amount <= 0:
             return 0
 
-        fee_rate = self._live_fee_rate()
-        safety = 0.995
-        step_profile = self._effective_grid_steps(center)
-        step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
-        current_levels = len(side_orders)
         free_balance = max(balance_total - sum(
             float(order.get("price") or 0.0) * float(order.get("amount") or 0.0)
             for order in side_orders
@@ -718,40 +733,35 @@ class Strategy(Strategy):
             float(order.get("amount") or 0.0)
             for order in side_orders
         ), 0.0)
-        spendable_free = free_balance * safety
-        additional_levels = 0
+        placeable_levels = self._placeable_levels_for_side(side, center, amount, expected_levels, min_notional)
+        if placeable_levels <= 0:
+            return 0
 
-        if side == "buy":
-            per_level_quote = 0.0
-            for i in range(1, expected_levels + 1):
-                level_price = center * (1 - (step * (current_levels + i)))
-                if level_price <= 0:
-                    break
-                min_size = (min_notional / level_price) if min_notional > 0 else 0.0
-                if amount < min_size:
-                    break
-                level_quote = amount * level_price * (1 + fee_rate)
-                if per_level_quote <= 0:
-                    per_level_quote = level_quote
-                if (additional_levels + 1) * per_level_quote <= spendable_free + 1e-9:
-                    additional_levels += 1
-                else:
-                    break
-        else:
-            per_level_base = max(amount, 0.0)
-            for i in range(1, expected_levels + 1):
-                level_price = center * (1 + (step * (current_levels + i)))
-                if level_price <= 0:
-                    break
-                min_size = (min_notional / level_price) if min_notional > 0 else 0.0
-                if amount < min_size:
-                    break
-                if (additional_levels + 1) * per_level_base <= spendable_free + 1e-9:
-                    additional_levels += 1
-                else:
-                    break
+        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
 
-        return min(expected_levels, current_levels + additional_levels)
+        fee_rate = self._live_fee_rate()
+        safety = 0.995
+        spendable_free = free_balance * safety
+        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:
+                break
+            min_size = (min_notional / level_price) if min_notional > 0 else 0.0
+            if amount < min_size:
+                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:
+                break
+
+        return min(expected_levels, confirmed)
 
     def _place_grid(self, center: float) -> None:
         center = self._maybe_refresh_center(center)
@@ -841,6 +851,9 @@ class Strategy(Strategy):
         self.state["center_price"] = price
         self.state["seeded"] = True
         self._place_grid(price)
+        # Use the freshly placed live orders as the tracked snapshot so the
+        # next tick compares against the rebuilt grid, not the pre-rebuild set.
+        self._sync_open_orders_state()
         self._refresh_balance_snapshot()
         self._set_grid_refresh_pause()
 
@@ -1053,6 +1066,12 @@ class Strategy(Strategy):
 
         live_orders, live_ids, open_order_count = self._reconcile_after_sync(previous_orders, live_orders, desired_sides, price)
 
+        if self._grid_refresh_paused():
+            mode = self._mode()
+            self.state["last_action"] = "hold" if mode == "active" else f"{mode} monitor"
+            self._log(f"grid refresh paused, holding at {price} dev {deviation:.4f}")
+            return {"action": "hold" if mode == "active" else "plan", "price": price, "deviation": deviation, "refresh_paused": True}
+
         if desired_sides != {"buy", "sell"}:
             self._log("single-side mode is disabled for this strategy, forcing full-grid rebuilds only")
 
@@ -1128,6 +1147,7 @@ class Strategy(Strategy):
             self._place_grid(price)
             live_orders = self._sync_open_orders_state()
             self.state["seeded"] = True
+            self._set_grid_refresh_pause()
             mode = self._mode()
             self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
             return {"action": "seed" if mode == "active" else "plan", "price": price}

+ 51 - 0
tests/test_strategies.py

@@ -255,6 +255,57 @@ def test_grid_supervision_exposes_adverse_side_open_orders():
     assert "sell ladder exposed" in " ".join(supervision["concerns"])
 
 
+def test_grid_on_tick_honors_refresh_pause_before_shape_rebuild(monkeypatch):
+    class FakeContext:
+        account_id = "acct-1"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        mode = "active"
+        minimum_order_value = 1.0
+
+        def get_account_info(self):
+            return {
+                "balances": [
+                    {"asset_code": "XRP", "available": 100.0, "total": 100.0},
+                    {"asset_code": "USD", "available": 100.0, "total": 100.0},
+                ]
+            }
+
+        def get_price(self, _asset):
+            return {"price": 1.0}
+
+        def get_regime(self, *_args, **_kwargs):
+            return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
+
+        def get_open_orders(self):
+            return [{"bitstamp_order_id": "live-1", "side": "buy", "price": 0.99, "amount": 1.0}]
+
+    strategy = GridStrategy(FakeContext(), {})
+    strategy.state.update(
+        {
+            "center_price": 1.0,
+            "seeded": True,
+            "orders": [{"bitstamp_order_id": "old-1", "side": "buy", "price": 0.99, "amount": 1.0}],
+            "order_ids": ["old-1"],
+            "grid_refresh_pending_until": 9999999999.0,
+        }
+    )
+
+    called = {"rebuild": 0}
+
+    def fake_rebuild(*_args, **_kwargs):
+        called["rebuild"] += 1
+
+    monkeypatch.setattr(strategy, "_recenter_and_rebuild_from_price", fake_rebuild)
+
+    result = strategy.on_tick({"ts": 0, "strategy_id": "grid-1"})
+
+    assert result["action"] == "hold"
+    assert result["refresh_paused"] is True
+    assert called["rebuild"] == 0
+
+
 def test_dumb_trader_and_protector_supervision_reports_facts_only():
     class FakeContext:
         account_id = "acct-1"