|
|
@@ -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)
|