Kaynağa Gözat

Tighten trader supervision and rebalance hysteresis

Lukas Goldschmidt 3 hafta önce
ebeveyn
işleme
5bc7647209

+ 2 - 3
Hermes_Compatibility_Plan.md

@@ -150,12 +150,11 @@ Acceptance criteria:
 ### Phase 6, add structure strategies
 Deliverables:
 - tighten switching semantics around real inventory pressure
-- separate trend handoff from defensive rebalance handoff
+- separate descriptive trader facts from Hermes decisions
 
 Acceptance criteria:
 - grid is clearly structure-based, not signal-based
-- moderate imbalance reports `watch_handoff`, not immediate `ready_for_handoff`
-- true depletion is required before grid self-reports `ready_for_handoff`
+- grid reports factual capacity and inventory shape, not switch advice
 - tests cover the distinction
 
 ### Phase 7, add event-aware behavior

+ 4 - 2
src/trader_mcp/strategy_sdk.py

@@ -115,9 +115,11 @@ class Strategy:
                 "degraded": False,
                 "inventory_pressure": "unknown",
                 "capacity_available": None,
-                "switch_readiness": "unknown",
+                "side_capacity": None,
+                "trend_strength": None,
+                "signal": None,
+                "rebalance_needed": None,
                 "last_reason": None,
-                "desired_companion": None,
             },
         }
 

+ 31 - 3
strategies/exposure_protector.py

@@ -196,16 +196,40 @@ class Strategy(Strategy):
             "degraded": bool(last_error),
             "inventory_pressure": pressure,
             "capacity_available": drift > tolerance,
-            "switch_readiness": "handoff_complete" if drift <= tolerance else "stay_attached",
+            "rebalance_needed": drift > tolerance,
+            "drift": round(drift, 6),
+            "target_ratio": target,
             "last_reason": last_error or f"base_ratio={ratio:.3f}, target={target:.3f}, drift={drift:.3f}",
-            "desired_companion": None,
         }
 
     def _desired_side(self, price: float) -> str:
         # If base dominates, sell some into strength, otherwise buy some back.
         ratio = self._account_value_ratio(price)
         target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
-        return "sell" if ratio > target else "buy"
+        step_ratio = float(self.config.get("rebalance_step_ratio", 0.15) or 0.0)
+        tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.0)
+        hysteresis = max(tolerance, step_ratio * 0.5, 0.02)
+        last_side = str(self.state.get("last_rebalance_side") or "").lower()
+
+        if last_side == "sell":
+            if ratio <= target - hysteresis:
+                return "buy"
+            if ratio >= target + hysteresis:
+                return "sell"
+            return ""
+
+        if last_side == "buy":
+            if ratio >= target + hysteresis:
+                return "sell"
+            if ratio <= target - hysteresis:
+                return "buy"
+            return ""
+
+        if ratio > target + hysteresis:
+            return "sell"
+        if ratio < target - hysteresis:
+            return "buy"
+        return ""
 
     def _suggest_amount(self, side: str, price: float) -> float:
         fee_rate = self._live_fee_rate()
@@ -267,6 +291,9 @@ class Strategy(Strategy):
                 return {"action": "hold", "price": price, "reason": "insufficient price move", "move_pct": move_pct}
 
         side = self._desired_side(price)
+        if not side:
+            self.state["last_action"] = "hold"
+            return {"action": "hold", "price": price, "reason": "within rebalance hysteresis"}
         amount = self._suggest_amount(side, price)
         trail_distance = float(self.config.get("trail_distance_pct", 0.03) or 0.03)
 
@@ -296,6 +323,7 @@ class Strategy(Strategy):
             self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
             self.state["last_order_at"] = now
             self.state["last_order_price"] = order_price
+            self.state["last_rebalance_side"] = side
             self.state["last_action"] = f"{side}_rebalance"
             return {"action": side, "price": order_price, "amount": amount, "result": result}
         except Exception as exc:

+ 3 - 4
strategies/grid_trader.md

@@ -30,7 +30,6 @@ Passive, structure-based liquidity strategy.
 - The strategy does not decide regime fit itself.
 - Hermes decides activation.
 - Trader applies policy on reconcile.
-- `report().supervision` is a hint layer for Hermes, not an autonomous switch order.
-- `ready_for_handoff` means real one-sided depletion.
-- `watch_handoff` means directional pressure plus moderate inventory skew.
-- ordinary directional conditions alone should not mark the grid as handoff-ready.
+- `report().supervision` is descriptive, not imperative.
+- `side_capacity` and `inventory_pressure` describe the grid's current shape.
+- ordinary directional conditions alone should not force a rebuild or switch.

+ 22 - 14
strategies/grid_trader.py

@@ -191,7 +191,13 @@ class Strategy(Strategy):
 
         self.config["grid_step_pct"] = step_map.get(risk, 0.012)
         self.config["recenter_pct"] = recenter_map.get(risk, 0.05)
-        self.config["grid_levels"] = levels_map.get(risk, 6)
+        if self.config.get("grid_levels") in {None, "", 0}:
+            self.config["grid_levels"] = levels_map.get(risk, 6)
+        else:
+            try:
+                self.config["grid_levels"] = max(int(self.config.get("grid_levels") or 0), 1)
+            except Exception:
+                self.config["grid_levels"] = levels_map.get(risk, 6)
         self.config["order_call_delay_ms"] = delay_map.get(risk, 250)
         self.state["policy_derived"] = {
             "grid_step_pct": self.config["grid_step_pct"],
@@ -312,21 +318,17 @@ class Strategy(Strategy):
             pressure = "quote_heavy"
         else:
             pressure = "balanced"
-        directional_1h = regime_1h in {"bull", "bear", "up", "down", "strong_up", "strong_down"}
-        if pressure in {"base_side_depleted", "quote_side_depleted"}:
-            switch_readiness = "ready_for_handoff"
-        elif pressure in {"base_heavy", "quote_heavy"} and directional_1h:
-            switch_readiness = "watch_handoff"
-        else:
-            switch_readiness = "prefer_hold"
+        side_capacity = {
+            "buy": pressure not in {"quote_side_depleted"},
+            "sell": pressure not in {"base_side_depleted"},
+        }
         return {
             "health": "degraded" if last_error or config_warning else "healthy",
             "degraded": bool(last_error or config_warning),
             "inventory_pressure": pressure,
             "capacity_available": pressure == "balanced",
-            "switch_readiness": switch_readiness,
+            "side_capacity": side_capacity,
             "last_reason": last_error or config_warning or f"base_ratio={ratio:.3f}, trend_1h={regime_1h or 'unknown'}",
-            "desired_companion": "exposure_protector" if pressure in {"base_side_depleted", "quote_side_depleted"} else None,
         }
 
     def _available_balance(self, asset_code: str) -> float:
@@ -351,16 +353,16 @@ class Strategy(Strategy):
                 return 0.0
         return 0.0
 
-    def _refresh_balance_snapshot(self) -> None:
+    def _refresh_balance_snapshot(self) -> bool:
         try:
             info = self.context.get_account_info()
         except Exception as exc:
             self._log(f"balance refresh failed: {exc}")
-            return
+            return False
 
         balances = info.get("balances") if isinstance(info, dict) else []
         if not isinstance(balances, list):
-            return
+            return False
 
         base = self._base_symbol()
         quote = self.context.counter_currency or "USD"
@@ -401,6 +403,7 @@ class Strategy(Strategy):
                 quote_available=f"{self.state.get('counter_available', 0.0):.6g}",
                 updated_at=now_iso,
             )
+        return True
 
     def _supported_levels(self, side: str, price: float, min_notional: float, *, balance_total: float | None = None) -> int:
         if min_notional <= 0 or price <= 0:
@@ -770,7 +773,7 @@ class Strategy(Strategy):
         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()
+        balance_refresh_ok = self._refresh_balance_snapshot()
         price = self._price()
         self.state["last_price"] = price
         self.state["last_error"] = ""
@@ -794,6 +797,11 @@ class Strategy(Strategy):
             self._log(f"open orders check failed: {exc}")
 
         self.state["open_order_count"] = open_order_count
+        if not balance_refresh_ok:
+            self._log("balance refresh unavailable, skipping rebuild checks this tick")
+            self.state["last_action"] = "hold"
+            return {"action": "hold", "price": price, "reason": "balance refresh unavailable"}
+
         desired_sides = self._desired_sides()
 
         mode = self._mode()

+ 2 - 2
strategies/trend_follower.py

@@ -150,9 +150,9 @@ class Strategy(Strategy):
             "degraded": bool(last_error),
             "inventory_pressure": pressure,
             "capacity_available": strength >= float(self.config.get("trend_strength_min", 0.65) or 0.65),
-            "switch_readiness": "ready_to_yield_to_grid" if pressure == "balanced" and strength < float(self.config.get("trend_strength_min", 0.65) or 0.65) else "prefer_hold",
             "last_reason": last_error or f"signal={signal}, strength={strength:.3f}, base_ratio={ratio:.3f}",
-            "desired_companion": "exposure_protector" if pressure != "balanced" else None,
+            "trend_strength": strength,
+            "signal": signal,
         }
 
     def _trend_snapshot(self) -> dict:

+ 130 - 6
tests/test_strategies.py

@@ -9,6 +9,7 @@ from src.trader_mcp import strategy_registry, strategy_store
 from src.trader_mcp.server import app
 from src.trader_mcp.strategy_context import StrategyContext
 from src.trader_mcp.strategy_sdk import Strategy as BaseStrategy
+from strategies.exposure_protector import Strategy as ExposureStrategy
 from strategies.grid_trader import Strategy as GridStrategy
 from strategies.trend_follower import Strategy as TrendStrategy
 
@@ -115,7 +116,7 @@ def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
         strategy_registry.STRATEGIES_DIR = original_dir
 
 
-def test_grid_supervision_only_reports_ready_for_handoff_on_true_depletion():
+def test_grid_supervision_reports_factual_capacity_not_handoff_commands():
     class FakeContext:
         account_id = "acct-1"
         market_symbol = "xrpusd"
@@ -132,8 +133,8 @@ def test_grid_supervision_only_reports_ready_for_handoff_on_true_depletion():
     })
     supervision = strategy._supervision()
     assert supervision["inventory_pressure"] == "base_heavy"
-    assert supervision["switch_readiness"] == "watch_handoff"
-    assert supervision["desired_companion"] is None
+    assert supervision["capacity_available"] is False
+    assert supervision["side_capacity"] == {"buy": True, "sell": True}
 
     strategy.state.update({
         "base_available": 88.0,
@@ -141,8 +142,131 @@ def test_grid_supervision_only_reports_ready_for_handoff_on_true_depletion():
     })
     supervision = strategy._supervision()
     assert supervision["inventory_pressure"] == "base_side_depleted"
-    assert supervision["switch_readiness"] == "ready_for_handoff"
-    assert supervision["desired_companion"] == "exposure_protector"
+    assert supervision["side_capacity"] == {"buy": True, "sell": False}
+
+
+def test_trend_and_protector_supervision_reports_facts_only():
+    class FakeContext:
+        account_id = "acct-1"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        mode = "active"
+
+    trend = TrendStrategy(FakeContext(), {})
+    trend.state.update({"last_price": 1.45, "last_strength": 0.42, "last_signal": "up", "base_available": 20.0, "counter_available": 20.0})
+    trend_supervision = trend._supervision()
+    assert trend_supervision["trend_strength"] == 0.42
+    assert trend_supervision["signal"] == "up"
+    assert "switch_readiness" not in trend_supervision
+    assert "desired_companion" not in trend_supervision
+
+    protector = ExposureStrategy(FakeContext(), {})
+    protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
+    protector_supervision = protector._supervision()
+    assert protector_supervision["rebalance_needed"] is True
+    assert "switch_readiness" not in protector_supervision
+    assert "desired_companion" not in protector_supervision
+
+
+def test_exposure_protector_holds_inside_hysteresis_band(monkeypatch):
+    class FakeContext:
+        account_id = "acct-1"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        mode = "active"
+
+        def __init__(self):
+            self.placed_orders = []
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "XRP", "available": 9.2}, {"asset_code": "USD", "available": 10.0}]}
+
+        def get_price(self, market):
+            return {"price": 1.0}
+
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.004}
+
+        def place_order(self, **kwargs):
+            self.placed_orders.append(kwargs)
+            return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
+
+    ctx = FakeContext()
+    strategy = ExposureStrategy(ctx, {"rebalance_target_ratio": 0.5, "rebalance_step_ratio": 0.15, "balance_tolerance": 0.05, "cooldown_ticks": 0, "min_rebalance_seconds": 0, "trail_distance_pct": 0.03})
+    strategy.state["last_rebalance_side"] = "sell"
+    strategy.state["last_order_at"] = 0
+    monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
+
+    result = strategy.on_tick({})
+
+    assert result["action"] == "hold"
+    assert result["reason"] == "within rebalance hysteresis"
+    assert ctx.placed_orders == []
+
+
+def test_grid_apply_policy_keeps_explicit_grid_levels():
+    class FakeContext:
+        account_id = "acct-1"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        mode = "active"
+
+    strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "policy": {"risk_posture": "normal"}})
+    strategy.apply_policy()
+
+    assert strategy.config["grid_levels"] == 5
+    assert strategy.state["policy_derived"]["grid_levels"] == 5
+
+
+def test_grid_skips_rebuild_when_balance_refresh_fails(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):
+            raise RuntimeError("Bitstamp auth breaker active, retry later")
+
+        def cancel_all_orders(self):
+            self.cancelled_all += 1
+            return {"ok": True}
+
+        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)}"}
+
+    ctx = FakeContext()
+    strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
+    strategy.state["center_price"] = 1.4397
+    strategy.state["seeded"] = True
+    strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
+    strategy.state["order_ids"] = ["o1"]
+
+    monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
+    monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
+    monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
+
+    result = strategy.on_tick({})
+
+    assert result["action"] == "hold"
+    assert result["reason"] == "balance refresh unavailable"
+    assert ctx.cancelled_all == 0
+    assert ctx.placed_orders == []
 
 
 def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
@@ -291,7 +415,7 @@ def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
         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, "_refresh_balance_snapshot", lambda: True)
     monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
     monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
     monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)