Преглед на файлове

grid trader levels fixed

Lukas Goldschmidt преди 1 седмица
родител
ревизия
2c6cd0b907
променени са 3 файла, в които са добавени 91 реда и са изтрити 24 реда
  1. 2 0
      strategies/grid_trader.md
  2. 7 9
      strategies/grid_trader.py
  3. 82 15
      tests/test_strategies.py

+ 2 - 0
strategies/grid_trader.md

@@ -21,6 +21,7 @@ Passive, structure-based liquidity strategy.
 - Inventory skew only reduces the favored side. If the live step is already at the floor, the skew can disappear entirely.
 - Grid rebuilds use a separate recenter threshold built from `recenter_pct`, ATR, and the recenter clamps.
 - `report()` and `render()` expose the live base step plus the effective buy/sell split and current rebalance bias.
+- The grid is planned from the center outward. Inner levels are always placed first, and if balance or venue constraints stop the full ladder, the outermost levels are the ones that get trimmed.
 
 ## Parameters
 - `grid_levels`: Number of order levels per side.
@@ -56,6 +57,7 @@ Passive, structure-based liquidity strategy.
 - If base value dominates quote value, the sell ladder is tightened to encourage rebalancing back into quote.
 - If quote value dominates base value, the buy ladder is tightened to encourage rebalancing back into base.
 - `inventory_rebalance_step_factor` is a cap, not a fixed override: small imbalances apply a small reduction and extreme imbalances apply up to the configured maximum reduction on the favored side only.
+- The strategy does not skip over an inner level to salvage later outer levels. If the ladder cannot continue, it truncates from the outside.
 - ordinary directional conditions alone should not force a rebuild or switch.
 - live fee rates are used directly, and the quote notional is the canonical sizing unit.
 - quote sizing flows through the shared strategy sizing helper, so grid and directional strategies use the same fee-aware notional rules.

+ 7 - 9
strategies/grid_trader.py

@@ -713,7 +713,7 @@ class Strategy(Strategy):
         base_total: float,
         quote_total: float,
     ) -> dict:
-        empty = {"amount": 0.0, "orders": [], "skipped": []}
+        empty = {"amount": 0.0, "orders": [], "skipped": [], "valid": True}
         if not self._side_allowed(side):
             return empty
         if expected_levels <= 0 or center <= 0 or step <= 0:
@@ -725,9 +725,10 @@ class Strategy(Strategy):
             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)
+        # Ask the shared sizing layer for a venue-valid amount at the first
+        # intended level, then walk the ladder outward. That keeps the inner
+        # levels honest and lets truncation happen on the outside.
+        reference_price = round(center * (1 - step) if side == "buy" else center * (1 + step), 8)
         amount = self._suggest_amount(
             side,
             reference_price,
@@ -745,7 +746,6 @@ class Strategy(Strategy):
         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
@@ -753,9 +753,7 @@ class Strategy(Strategy):
             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
+                break
 
             cost = self._resource_cost_for_order(side, amount, price, fee_rate)
             if total_cost + cost > spendable_total + 1e-9:
@@ -766,7 +764,7 @@ class Strategy(Strategy):
             if len(planned_orders) >= expected_levels:
                 break
 
-        return {"amount": amount, "orders": planned_orders, "skipped": skipped}
+        return {"amount": amount, "orders": planned_orders, "skipped": skipped, "valid": True}
 
     def _plan_grid(self, center: float, *, base_total: float | None = None, quote_total: float | None = None) -> dict:
         center = float(center or 0.0)

+ 82 - 15
tests/test_strategies.py

@@ -456,7 +456,7 @@ 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):
+def test_grid_plan_keeps_inner_levels_first(monkeypatch):
     class FakeContext:
         base_currency = "XRP"
         counter_currency = "USD"
@@ -468,7 +468,7 @@ def test_grid_plan_extends_sell_ladder_past_skipped_inner_level(monkeypatch):
             return {"maker": 0.0, "taker": 0.0}
 
         def suggest_order_amount(self, **kwargs):
-            return 7.29474
+            return 8.0 if kwargs["side"] == "buy" else 7.31
 
     strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "order_call_delay_ms": 0})
     monkeypatch.setattr(
@@ -477,12 +477,74 @@ def test_grid_plan_extends_sell_ladder_past_skipped_inner_level(monkeypatch):
         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)
+    plan = strategy._plan_grid(1.3615, base_total=100.0, quote_total=100.0)
 
-    assert plan["counts"]["buy"] == 0
+    assert plan["counts"]["buy"] == 5
     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
+    assert [order["level"] for order in plan["buy_orders"]] == [1, 2, 3, 4, 5]
+    assert [order["level"] for order in plan["sell_orders"]] == [1, 2, 3, 4, 5]
+    assert plan["buy_skipped"] == []
+    assert plan["sell_skipped"] == []
+
+
+def test_grid_plan_truncates_outer_levels_without_skipping_inner_levels(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 get_account_info(self):
+            return {
+                "balances": [
+                    {"asset_code": "USD", "available": 19.77},
+                    {"asset_code": "XRP", "available": 49.035},
+                ]
+            }
+
+        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 8.0 if kwargs["side"] == "buy" else 7.31
+
+        def get_open_orders(self):
+            return []
+
+    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"] = []
+    strategy.state["order_ids"] = []
+
+    monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
+    monkeypatch.setattr(strategy, "_price", lambda: 1.3615)
+    monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
+    monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [])
+    monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
+    monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
+    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=100.0, quote_total=35.0)
+
+    assert plan["counts"]["buy"] == 3
+    assert plan["counts"]["sell"] == 5
+    assert [order["level"] for order in plan["buy_orders"]] == [1, 2, 3]
+    assert [order["level"] for order in plan["sell_orders"]] == [1, 2, 3, 4, 5]
+    assert plan["buy_skipped"] == []
+    assert plan["sell_skipped"] == []
 
 
 def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
@@ -502,8 +564,8 @@ def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
         def get_account_info(self):
             return {
                 "balances": [
-                    {"asset_code": "USD", "available": 19.77},
-                    {"asset_code": "XRP", "available": 12.5613},
+                    {"asset_code": "USD", "available": 40.0},
+                    {"asset_code": "XRP", "available": 40.0},
                 ]
             }
 
@@ -514,15 +576,20 @@ def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
             return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
 
         def suggest_order_amount(self, **kwargs):
-            return 7.29474
+            return 7.7
 
         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"},
+                {"side": "buy", "price": 1.35171265, "amount": 7.7, "id": "b1"},
+                {"side": "buy", "price": 1.34192531, "amount": 7.7, "id": "b2"},
+                {"side": "buy", "price": 1.33213796, "amount": 7.7, "id": "b3"},
+                {"side": "buy", "price": 1.32235061, "amount": 7.7, "id": "b4"},
+                {"side": "buy", "price": 1.31256327, "amount": 7.7, "id": "b5"},
+                {"side": "sell", "price": 1.37022433, "amount": 7.7, "id": "s1"},
+                {"side": "sell", "price": 1.37894867, "amount": 7.7, "id": "s2"},
+                {"side": "sell", "price": 1.387673, "amount": 7.7, "id": "s3"},
+                {"side": "sell", "price": 1.39639734, "amount": 7.7, "id": "s4"},
+                {"side": "sell", "price": 1.40512167, "amount": 7.7, "id": "s5"},
             ]
 
         def cancel_all_orders(self):
@@ -534,7 +601,7 @@ def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
     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"]
+    strategy.state["order_ids"] = ["b1", "b2", "b3", "b4", "b5", "s1", "s2", "s3", "s4", "s5"]
 
     monkeypatch.setattr(
         strategy,