Lukas Goldschmidt преди 2 седмици
родител
ревизия
b0414d7bf6
променени са 3 файла, в които са добавени 120 реда и са изтрити 15 реда
  1. 3 0
      .gitignore
  2. 7 1
      strategies/grid_trader.md
  3. 110 14
      strategies/grid_trader.py

+ 3 - 0
.gitignore

@@ -27,3 +27,6 @@ data/trader_mcp.sqlite3
 
 # Runtime data
 data/
+
+# Codex
+.codex

+ 7 - 1
strategies/grid_trader.md

@@ -16,7 +16,8 @@ Passive, structure-based liquidity strategy.
 
 ## Core parameters
 - `grid_levels`: number of orders per side
-- `grid_step_pct`: spacing between levels
+- `grid_step_pct`: base spacing between levels before inventory skew is applied
+- `inventory_rebalance_step_factor`: max fractional reduction applied to the rebalance side's step when the wallet is imbalanced
 - `recenter_pct`: when to rebuild the grid
 - `volatility_timeframe`: timeframe used for adaptive sizing
 - `order_notional_quote`: quote-currency notional target per order
@@ -32,5 +33,10 @@ Passive, structure-based liquidity strategy.
 - Trader applies policy on reconcile.
 - `report().supervision` is descriptive, not imperative.
 - `side_capacity` and `inventory_pressure` describe the grid's current shape.
+- The grid step is now asymmetric when inventory drifts away from balance.
+- 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.
+- `report()` and `render()` expose the live base step plus the effective buy/sell step split and current rebalance bias.
 - 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.

+ 110 - 14
strategies/grid_trader.py

@@ -39,6 +39,7 @@ class Strategy(Strategy):
         "volatility_multiplier": {"type": "float", "default": 0.5, "min": 0.0, "max": 10.0},
         "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},
+        "inventory_rebalance_step_factor": {"type": "float", "default": 0.15, "min": 0.0, "max": 0.9},
         "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
         "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
         "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
@@ -61,6 +62,12 @@ class Strategy(Strategy):
         "debug_log": {"type": "list", "default": []},
         "base_available": {"type": "float", "default": 0.0},
         "counter_available": {"type": "float", "default": 0.0},
+        "grid_step_pct_buy": {"type": "float", "default": 0.0},
+        "grid_step_pct_sell": {"type": "float", "default": 0.0},
+        "inventory_skew_side": {"type": "string", "default": "none"},
+        "inventory_skew_ratio": {"type": "float", "default": 0.5},
+        "inventory_skew_imbalance": {"type": "float", "default": 0.0},
+        "inventory_skew_reduction_pct": {"type": "float", "default": 0.0},
         "regimes_updated_at": {"type": "string", "default": ""},
         "account_snapshot_updated_at": {"type": "string", "default": ""},
         "last_balance_log_signature": {"type": "string", "default": ""},
@@ -82,6 +89,12 @@ class Strategy(Strategy):
             "debug_log": ["init cancel all orders"],
             "base_available": 0.0,
             "counter_available": 0.0,
+            "grid_step_pct_buy": 0.0,
+            "grid_step_pct_sell": 0.0,
+            "inventory_skew_side": "none",
+            "inventory_skew_ratio": 0.5,
+            "inventory_skew_imbalance": 0.0,
+            "inventory_skew_reduction_pct": 0.0,
             "regimes_updated_at": "",
             "account_snapshot_updated_at": "",
             "last_balance_log_signature": "",
@@ -277,9 +290,56 @@ class Strategy(Strategy):
         self.state["atr_percent"] = atr_pct
         return step
 
+    def _inventory_rebalance_profile(self, price: float) -> 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)
+        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)
+        favored_side = "sell" if ratio > 0.5 else "buy" if ratio < 0.5 else "none"
+        reduction = factor * imbalance if favored_side in {"buy", "sell"} else 0.0
+
+        self.state["inventory_skew_side"] = favored_side
+        self.state["inventory_skew_ratio"] = ratio
+        self.state["inventory_skew_imbalance"] = imbalance
+        self.state["inventory_skew_reduction_pct"] = reduction
+        return {
+            "ratio": ratio,
+            "imbalance": imbalance,
+            "favored_side": favored_side,
+            "reduction": reduction,
+        }
+
+    def _effective_grid_steps(self, price: float) -> 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)
+        favored_side = str(profile.get("favored_side") or "none")
+        reduction = float(profile.get("reduction") or 0.0)
+
+        buy_step = base_step
+        sell_step = base_step
+        if favored_side == "buy":
+            buy_step = max(base_step * (1.0 - reduction), min_step)
+        elif favored_side == "sell":
+            sell_step = max(base_step * (1.0 - reduction), min_step)
+
+        self.state["grid_step_pct_buy"] = buy_step
+        self.state["grid_step_pct_sell"] = sell_step
+        return {
+            "base": base_step,
+            "buy": buy_step,
+            "sell": sell_step,
+            "favored_side": favored_side,
+            "reduction": reduction,
+            "ratio": float(profile.get("ratio") or 0.5),
+            "imbalance": float(profile.get("imbalance") or 0.0),
+        }
+
     def _config_warning(self) -> str | None:
         recenter_pct = float(self.state.get("recenter_pct_live") or self._recenter_threshold_pct())
-        grid_step_pct = float(self.state.get("grid_step_pct") or self._grid_step_pct())
+        steps = self._effective_grid_steps(float(self.state.get("last_price") or self.state.get("center_price") or 0.0))
+        grid_step_pct = min(float(steps.get("buy") or 0.0), float(steps.get("sell") or 0.0))
         if grid_step_pct <= 0:
             return None
 
@@ -303,6 +363,7 @@ class Strategy(Strategy):
     def _supervision(self) -> dict:
         price = float(self.state.get("last_price") or 0.0)
         ratio = self._inventory_ratio(price if price > 0 else 1.0)
+        step_profile = self._effective_grid_steps(price)
         last_error = str(self.state.get("last_error") or "")
         config_warning = self._config_warning()
         regime_1h = (((self.state.get("regimes") or {}).get("1h") or {}).get("trend") or {}).get("state")
@@ -371,6 +432,14 @@ class Strategy(Strategy):
             "health": "degraded" if last_error or config_warning else "healthy",
             "degraded": bool(last_error or config_warning),
             "inventory_pressure": pressure,
+            "inventory_ratio": round(ratio, 4),
+            "inventory_rebalance_side": step_profile.get("favored_side", "none"),
+            "inventory_rebalance_reduction_pct": round(float(step_profile.get("reduction") or 0.0) * 100.0, 4),
+            "grid_step_pct": {
+                "base": round(float(step_profile.get("base") or 0.0), 6),
+                "buy": round(float(step_profile.get("buy") or 0.0), 6),
+                "sell": round(float(step_profile.get("sell") or 0.0), 6),
+            },
             "capacity_available": pressure == "balanced",
             "side_capacity": side_capacity,
             "market_bias": market_bias,
@@ -544,7 +613,8 @@ class Strategy(Strategy):
 
         fee_rate = self._live_fee_rate()
         safety = 0.995
-        step = self._grid_step_pct()
+        step_profile = self._effective_grid_steps(center)
+        step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
         spendable_total = balance_total * safety
 
         for level_count in range(expected_levels, 0, -1):
@@ -576,7 +646,9 @@ class Strategy(Strategy):
         center = self._maybe_refresh_center(center)
         mode = self._mode()
         levels = int(self.config.get("grid_levels", 6) or 6)
-        step = self._grid_step_pct()
+        step_profile = self._effective_grid_steps(center)
+        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)
         market = self._market_symbol()
         orders = []
@@ -593,8 +665,8 @@ class Strategy(Strategy):
         sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
 
         for i in range(1, levels + 1):
-            buy_price = round(center * (1 - (step * i)), 8)
-            sell_price = round(center * (1 + (step * i)), 8)
+            buy_price = round(center * (1 - (buy_step * i)), 8)
+            sell_price = round(center * (1 + (sell_step * i)), 8)
             if mode != "active":
                 orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
                 orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
@@ -670,7 +742,7 @@ class Strategy(Strategy):
             return []
 
         min_notional = float(self.context.minimum_order_value or 0.0)
-        step = self._grid_step_pct()
+        step_profile = self._effective_grid_steps(center)
         market = self._market_symbol()
         placed: list[str] = []
 
@@ -690,8 +762,9 @@ class Strategy(Strategy):
                 continue
 
             amount = self._suggest_amount(side, center, max(target_levels, 1), min_notional)
+            side_step = float(step_profile.get(side) or step_profile.get("base") or 0.0)
             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)
+                price = round(center * (1 - (side_step * i)) if side == "buy" else center * (1 + (side_step * i)), 8)
                 min_size = (min_notional / price) if price > 0 else 0.0
                 if amount < min_size:
                     self._log_decision(
@@ -723,12 +796,21 @@ class Strategy(Strategy):
 
         return placed
 
-    def _recenter_and_rebuild_from_fill(self, fill_price: float) -> None:
-        fill_price = self._maybe_refresh_center(fill_price)
-        """Treat a fill as the new market anchor and rebuild the full grid from there."""
-        if fill_price <= 0:
+    def _current_market_anchor(self, fallback: float = 0.0) -> float:
+        try:
+            live_price = float(self._price() or 0.0)
+        except Exception as exc:
+            self._log(f"live price refresh failed during rebuild: {exc}")
+            live_price = 0.0
+        return live_price if live_price > 0 else fallback
+
+    def _recenter_and_rebuild_from_fill(self, fill_price: float, market_price: float = 0.0) -> None:
+        """Treat a fill as a forced re-anchor and rebuild from the latest market price."""
+        anchor_price = self._current_market_anchor(market_price or fill_price)
+        if anchor_price <= 0:
             return
-        self._recenter_and_rebuild_from_price(fill_price, "fill rebuild")
+        self._log(f"fill rebuild anchor resolved: fill={fill_price} market={anchor_price}")
+        self._recenter_and_rebuild_from_price(anchor_price, "fill rebuild")
 
     def _recenter_and_rebuild_from_price(self, price: float, reason: str) -> None:
         if price <= 0:
@@ -850,8 +932,8 @@ class Strategy(Strategy):
                         if fill_price > 0:
                             break
                     if fill_price > 0:
-                        self._log(f"filled order {order_id} detected via exec status={status}, recentering at {fill_price}")
-                        self._recenter_and_rebuild_from_fill(fill_price)
+                        self._log(f"filled order {order_id} detected via exec status={status}, recentering from fill={fill_price} market={price}")
+                        self._recenter_and_rebuild_from_fill(fill_price, price)
                         live_orders = self._sync_open_orders_state()
                         live_ids = list(self.state.get("order_ids") or [])
                         open_order_count = len(live_ids)
@@ -1078,6 +1160,12 @@ class Strategy(Strategy):
                 "last_price": self.state.get("last_price", 0.0),
                 "last_action": self.state.get("last_action", "idle"),
                 "open_order_count": self.state.get("open_order_count", 0),
+                "grid_step_pct": self.state.get("grid_step_pct", 0.0),
+                "grid_step_pct_buy": self.state.get("grid_step_pct_buy", 0.0),
+                "grid_step_pct_sell": self.state.get("grid_step_pct_sell", 0.0),
+                "inventory_skew_side": self.state.get("inventory_skew_side", "none"),
+                "inventory_skew_ratio": self.state.get("inventory_skew_ratio", 0.5),
+                "inventory_skew_reduction_pct": self.state.get("inventory_skew_reduction_pct", 0.0),
                 "regimes_updated_at": self.state.get("regimes_updated_at", ""),
             },
             "assessment": {
@@ -1095,10 +1183,15 @@ class Strategy(Strategy):
         # Refresh the market-derived display values on render so the dashboard
         # reflects the same inputs the strategy would use on the next tick.
         live_step_pct = float(self.state.get("grid_step_pct") or 0.0)
+        live_buy_step_pct = float(self.state.get("grid_step_pct_buy") or 0.0)
+        live_sell_step_pct = float(self.state.get("grid_step_pct_sell") or 0.0)
         live_atr_pct = float(self.state.get("atr_percent") or 0.0)
         try:
             self._refresh_balance_snapshot()
             live_step_pct = self._grid_step_pct()
+            step_profile = self._effective_grid_steps(float(self.state.get("last_price") or self.state.get("center_price") or 0.0))
+            live_buy_step_pct = float(step_profile.get("buy") or live_step_pct)
+            live_sell_step_pct = float(step_profile.get("sell") or live_step_pct)
             live_atr_pct = float(self.state.get("atr_percent") or live_atr_pct)
         except Exception as exc:
             self._log(f"render refresh failed: {exc}")
@@ -1113,6 +1206,9 @@ class Strategy(Strategy):
                 {"type": "metric", "label": "open orders", "value": self.state.get("open_order_count", 0)},
                 {"type": "metric", "label": f"ATR({self.config.get('volatility_timeframe', '1h')}) %", "value": round(live_atr_pct, 4)},
                 {"type": "metric", "label": "grid step %", "value": round(live_step_pct * 100.0, 4)},
+                {"type": "metric", "label": "buy step %", "value": round(live_buy_step_pct * 100.0, 4)},
+                {"type": "metric", "label": "sell step %", "value": round(live_sell_step_pct * 100.0, 4)},
+                {"type": "metric", "label": "rebalance bias", "value": self.state.get("inventory_skew_side", "none")},
                 {"type": "metric", "label": "1d", "value": ((self.state.get('regimes') or {}).get('1d') or {}).get('trend', {}).get('state', 'n/a')},
                 {"type": "metric", "label": "4h", "value": ((self.state.get('regimes') or {}).get('4h') or {}).get('trend', {}).get('state', 'n/a')},
                 {"type": "metric", "label": "1h", "value": ((self.state.get('regimes') or {}).get('1h') or {}).get('trend', {}).get('state', 'n/a')},