|
@@ -7,12 +7,25 @@ from src.trader_mcp.logging_utils import log_event
|
|
|
|
|
|
|
|
|
|
|
|
|
class Strategy(Strategy):
|
|
class Strategy(Strategy):
|
|
|
- LABEL = "Stop Loss Rebalancer"
|
|
|
|
|
|
|
+ LABEL = "Exposure Protector"
|
|
|
|
|
+ STRATEGY_PROFILE = {
|
|
|
|
|
+ "expects": {
|
|
|
|
|
+ "trend": "strong",
|
|
|
|
|
+ "volatility": "moderate",
|
|
|
|
|
+ "event_risk": "low",
|
|
|
|
|
+ "liquidity": "normal",
|
|
|
|
|
+ },
|
|
|
|
|
+ "avoids": {
|
|
|
|
|
+ "trend": "range",
|
|
|
|
|
+ "volatility": "chaotic",
|
|
|
|
|
+ "event_risk": "high",
|
|
|
|
|
+ "liquidity": "thin",
|
|
|
|
|
+ },
|
|
|
|
|
+ "risk_profile": "defensive",
|
|
|
|
|
+ "capabilities": ["exposure_trim", "trail_protection", "balance_recovery"],
|
|
|
|
|
+ }
|
|
|
TICK_MINUTES = 0.2
|
|
TICK_MINUTES = 0.2
|
|
|
CONFIG_SCHEMA = {
|
|
CONFIG_SCHEMA = {
|
|
|
- "regime_timeframes": {"type": "list", "default": ["1d", "4h", "1h", "15m"]},
|
|
|
|
|
- "trend_enter_threshold": {"type": "float", "default": 0.7, "min": 0.0, "max": 1.0},
|
|
|
|
|
- "trend_exit_threshold": {"type": "float", "default": 0.45, "min": 0.0, "max": 1.0},
|
|
|
|
|
"trail_distance_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 1.0},
|
|
"trail_distance_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 1.0},
|
|
|
"rebalance_target_ratio": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0},
|
|
"rebalance_target_ratio": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0},
|
|
|
"rebalance_step_ratio": {"type": "float", "default": 0.15, "min": 0.0, "max": 1.0},
|
|
"rebalance_step_ratio": {"type": "float", "default": 0.15, "min": 0.0, "max": 1.0},
|
|
@@ -20,6 +33,8 @@ class Strategy(Strategy):
|
|
|
"max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
|
|
"max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
|
|
|
"order_spacing_ticks": {"type": "int", "default": 1, "min": 0, "max": 1000},
|
|
"order_spacing_ticks": {"type": "int", "default": 1, "min": 0, "max": 1000},
|
|
|
"cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
|
|
"cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
|
|
|
|
|
+ "min_rebalance_seconds": {"type": "int", "default": 300, "min": 0, "max": 86400},
|
|
|
|
|
+ "min_price_move_pct": {"type": "float", "default": 0.01, "min": 0.0, "max": 1.0},
|
|
|
"balance_tolerance": {"type": "float", "default": 0.05, "min": 0.0, "max": 1.0},
|
|
"balance_tolerance": {"type": "float", "default": 0.05, "min": 0.0, "max": 1.0},
|
|
|
"fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
|
|
"fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
|
|
|
"debug_orders": {"type": "bool", "default": True},
|
|
"debug_orders": {"type": "bool", "default": True},
|
|
@@ -35,6 +50,8 @@ class Strategy(Strategy):
|
|
|
"counter_available": {"type": "float", "default": 0.0},
|
|
"counter_available": {"type": "float", "default": 0.0},
|
|
|
"trailing_anchor": {"type": "float", "default": 0.0},
|
|
"trailing_anchor": {"type": "float", "default": 0.0},
|
|
|
"cooldown_remaining": {"type": "int", "default": 0},
|
|
"cooldown_remaining": {"type": "int", "default": 0},
|
|
|
|
|
+ "last_order_at": {"type": "float", "default": 0.0},
|
|
|
|
|
+ "last_order_price": {"type": "float", "default": 0.0},
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
def init(self):
|
|
def init(self):
|
|
@@ -42,13 +59,15 @@ class Strategy(Strategy):
|
|
|
"last_price": 0.0,
|
|
"last_price": 0.0,
|
|
|
"last_action": "idle",
|
|
"last_action": "idle",
|
|
|
"last_error": "",
|
|
"last_error": "",
|
|
|
- "debug_log": ["init stop loss rebalancer"],
|
|
|
|
|
|
|
+ "debug_log": ["init exposure protector"],
|
|
|
"regimes": {},
|
|
"regimes": {},
|
|
|
"regimes_updated_at": "",
|
|
"regimes_updated_at": "",
|
|
|
"base_available": 0.0,
|
|
"base_available": 0.0,
|
|
|
"counter_available": 0.0,
|
|
"counter_available": 0.0,
|
|
|
"trailing_anchor": 0.0,
|
|
"trailing_anchor": 0.0,
|
|
|
"cooldown_remaining": 0,
|
|
"cooldown_remaining": 0,
|
|
|
|
|
+ "last_order_at": 0.0,
|
|
|
|
|
+ "last_order_price": 0.0,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
def _log(self, message: str) -> None:
|
|
def _log(self, message: str) -> None:
|
|
@@ -65,6 +84,44 @@ class Strategy(Strategy):
|
|
|
def _market_symbol(self) -> str:
|
|
def _market_symbol(self) -> str:
|
|
|
return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
|
|
return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
|
|
|
|
|
|
|
|
|
|
+ def apply_policy(self):
|
|
|
|
|
+ policy = super().apply_policy()
|
|
|
|
|
+ risk = str(policy.get("risk_posture") or "normal").lower()
|
|
|
|
|
+ priority = str(policy.get("priority") or "normal").lower()
|
|
|
|
|
+
|
|
|
|
|
+ trail_map = {"cautious": 0.02, "normal": 0.03, "assertive": 0.04}
|
|
|
|
|
+ step_map = {"cautious": 0.08, "normal": 0.15, "assertive": 0.25}
|
|
|
|
|
+ wait_map = {"cautious": 600, "normal": 300, "assertive": 120}
|
|
|
|
|
+ move_map = {"cautious": 0.02, "normal": 0.01, "assertive": 0.005}
|
|
|
|
|
+
|
|
|
|
|
+ if priority in {"low", "background"}:
|
|
|
|
|
+ trail = trail_map.get("cautious", 0.02)
|
|
|
|
|
+ step = step_map.get("cautious", 0.08)
|
|
|
|
|
+ wait = wait_map.get("cautious", 600)
|
|
|
|
|
+ move = move_map.get("cautious", 0.02)
|
|
|
|
|
+ elif priority in {"high", "urgent"}:
|
|
|
|
|
+ trail = trail_map.get("assertive", 0.04)
|
|
|
|
|
+ step = step_map.get("assertive", 0.25)
|
|
|
|
|
+ wait = wait_map.get("assertive", 120)
|
|
|
|
|
+ move = move_map.get("assertive", 0.005)
|
|
|
|
|
+ else:
|
|
|
|
|
+ trail = trail_map.get(risk, 0.03)
|
|
|
|
|
+ step = step_map.get(risk, 0.15)
|
|
|
|
|
+ wait = wait_map.get(risk, 300)
|
|
|
|
|
+ move = move_map.get(risk, 0.01)
|
|
|
|
|
+
|
|
|
|
|
+ self.config["trail_distance_pct"] = trail
|
|
|
|
|
+ self.config["rebalance_step_ratio"] = step
|
|
|
|
|
+ self.config["min_rebalance_seconds"] = wait
|
|
|
|
|
+ self.config["min_price_move_pct"] = move
|
|
|
|
|
+ self.state["policy_derived"] = {
|
|
|
|
|
+ "trail_distance_pct": trail,
|
|
|
|
|
+ "rebalance_step_ratio": step,
|
|
|
|
|
+ "min_rebalance_seconds": wait,
|
|
|
|
|
+ "min_price_move_pct": move,
|
|
|
|
|
+ }
|
|
|
|
|
+ return policy
|
|
|
|
|
+
|
|
|
def _live_fee_rate(self) -> float:
|
|
def _live_fee_rate(self) -> float:
|
|
|
try:
|
|
try:
|
|
|
payload = self.context.get_fee_rates(self._market_symbol())
|
|
payload = self.context.get_fee_rates(self._market_symbol())
|
|
@@ -78,13 +135,10 @@ class Strategy(Strategy):
|
|
|
return float(payload.get("price") or 0.0)
|
|
return float(payload.get("price") or 0.0)
|
|
|
|
|
|
|
|
def _refresh_regimes(self) -> None:
|
|
def _refresh_regimes(self) -> None:
|
|
|
- regimes: dict[str, dict] = {}
|
|
|
|
|
- for tf in self.config.get("regime_timeframes") or ["1d", "4h", "1h", "15m"]:
|
|
|
|
|
- try:
|
|
|
|
|
- regimes[str(tf)] = self.context.get_regime(self._base_symbol(), str(tf))
|
|
|
|
|
- except Exception as exc:
|
|
|
|
|
- regimes[str(tf)] = {"error": str(exc)}
|
|
|
|
|
- self.state["regimes"] = regimes
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.state["regimes"] = self.context.get_strategy_snapshot().get("fit", {}) if hasattr(self.context, "get_strategy_snapshot") else {}
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ self.state["regimes"] = {}
|
|
|
self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
|
|
def _refresh_balance_snapshot(self) -> None:
|
|
def _refresh_balance_snapshot(self) -> None:
|
|
@@ -111,19 +165,6 @@ class Strategy(Strategy):
|
|
|
if asset == quote:
|
|
if asset == quote:
|
|
|
self.state["counter_available"] = available
|
|
self.state["counter_available"] = available
|
|
|
|
|
|
|
|
- def _regime_strength(self) -> float:
|
|
|
|
|
- regimes = self.state.get("regimes") or {}
|
|
|
|
|
- strengths = []
|
|
|
|
|
- for tf in self.config.get("regime_timeframes") or []:
|
|
|
|
|
- regime = regimes.get(str(tf)) or {}
|
|
|
|
|
- trend = regime.get("trend") or {}
|
|
|
|
|
- strengths.append(float(trend.get("strength") or 0.0))
|
|
|
|
|
- return max(strengths) if strengths else 0.0
|
|
|
|
|
-
|
|
|
|
|
- def _is_trending(self) -> bool:
|
|
|
|
|
- strength = self._regime_strength()
|
|
|
|
|
- return strength >= float(self.config.get("trend_enter_threshold", 0.7) or 0.7)
|
|
|
|
|
-
|
|
|
|
|
def _account_value_ratio(self, price: float) -> float:
|
|
def _account_value_ratio(self, price: float) -> float:
|
|
|
base_value = float(self.state.get("base_available") or 0.0) * price
|
|
base_value = float(self.state.get("base_available") or 0.0) * price
|
|
|
counter_value = float(self.state.get("counter_available") or 0.0)
|
|
counter_value = float(self.state.get("counter_available") or 0.0)
|
|
@@ -182,9 +223,20 @@ class Strategy(Strategy):
|
|
|
self.state["last_action"] = "cooldown"
|
|
self.state["last_action"] = "cooldown"
|
|
|
return {"action": "cooldown", "price": price}
|
|
return {"action": "cooldown", "price": price}
|
|
|
|
|
|
|
|
- if not self._is_trending():
|
|
|
|
|
- self.state["last_action"] = "standby"
|
|
|
|
|
- return {"action": "standby", "price": price}
|
|
|
|
|
|
|
+ now = datetime.now(timezone.utc).timestamp()
|
|
|
|
|
+ last_order_at = float(self.state.get("last_order_at") or 0.0)
|
|
|
|
|
+ min_rebalance_seconds = int(self.config.get("min_rebalance_seconds", 300) or 0)
|
|
|
|
|
+ if last_order_at and min_rebalance_seconds > 0 and (now - last_order_at) < min_rebalance_seconds:
|
|
|
|
|
+ self.state["last_action"] = "hold"
|
|
|
|
|
+ return {"action": "hold", "price": price, "reason": "rebalance cooldown"}
|
|
|
|
|
+
|
|
|
|
|
+ last_order_price = float(self.state.get("last_order_price") or 0.0)
|
|
|
|
|
+ min_price_move_pct = float(self.config.get("min_price_move_pct", 0.01) or 0.0)
|
|
|
|
|
+ if last_order_price > 0 and min_price_move_pct > 0:
|
|
|
|
|
+ move_pct = abs(price - last_order_price) / last_order_price
|
|
|
|
|
+ if move_pct < min_price_move_pct:
|
|
|
|
|
+ self.state["last_action"] = "hold"
|
|
|
|
|
+ return {"action": "hold", "price": price, "reason": "insufficient price move", "move_pct": move_pct}
|
|
|
|
|
|
|
|
side = self._desired_side(price)
|
|
side = self._desired_side(price)
|
|
|
amount = self._suggest_amount(side, price)
|
|
amount = self._suggest_amount(side, price)
|
|
@@ -198,10 +250,10 @@ class Strategy(Strategy):
|
|
|
market = self._market_symbol()
|
|
market = self._market_symbol()
|
|
|
if side == "sell":
|
|
if side == "sell":
|
|
|
self.state["trailing_anchor"] = max(float(self.state.get("trailing_anchor") or 0.0), price)
|
|
self.state["trailing_anchor"] = max(float(self.state.get("trailing_anchor") or 0.0), price)
|
|
|
- order_price = round(price * (1 + trail_distance), 8)
|
|
|
|
|
|
|
+ order_price = round(price * (1 - trail_distance), 8)
|
|
|
else:
|
|
else:
|
|
|
self.state["trailing_anchor"] = min(float(self.state.get("trailing_anchor") or price), price) if self.state.get("trailing_anchor") else price
|
|
self.state["trailing_anchor"] = min(float(self.state.get("trailing_anchor") or price), price) if self.state.get("trailing_anchor") else price
|
|
|
- order_price = round(price * (1 - trail_distance), 8)
|
|
|
|
|
|
|
+ order_price = round(price * (1 + trail_distance), 8)
|
|
|
|
|
|
|
|
if self.config.get("debug_orders", True):
|
|
if self.config.get("debug_orders", True):
|
|
|
self._log(f"{side} rebalance amount={amount:.6g} price={order_price} ratio={self._account_value_ratio(price):.4f}")
|
|
self._log(f"{side} rebalance amount={amount:.6g} price={order_price} ratio={self._account_value_ratio(price):.4f}")
|
|
@@ -214,6 +266,8 @@ class Strategy(Strategy):
|
|
|
market=market,
|
|
market=market,
|
|
|
)
|
|
)
|
|
|
self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
|
|
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_action"] = f"{side}_rebalance"
|
|
self.state["last_action"] = f"{side}_rebalance"
|
|
|
return {"action": side, "price": order_price, "amount": amount, "result": result}
|
|
return {"action": side, "price": order_price, "amount": amount, "result": result}
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
@@ -222,6 +276,37 @@ class Strategy(Strategy):
|
|
|
self.state["last_action"] = "error"
|
|
self.state["last_action"] = "error"
|
|
|
return {"action": "error", "price": price, "error": str(exc)}
|
|
return {"action": "error", "price": price, "error": str(exc)}
|
|
|
|
|
|
|
|
|
|
+ def report(self):
|
|
|
|
|
+ snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
|
|
|
|
|
+ return {
|
|
|
|
|
+ "identity": snapshot.get("identity", {}),
|
|
|
|
|
+ "control": snapshot.get("control", {}),
|
|
|
|
|
+ "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
|
|
|
|
|
+ "position": {
|
|
|
|
|
+ "balances": {
|
|
|
|
|
+ "base_available": self.state.get("base_available", 0.0),
|
|
|
|
|
+ "counter_available": self.state.get("counter_available", 0.0),
|
|
|
|
|
+ },
|
|
|
|
|
+ "open_orders": snapshot.get("orders", {}).get("open_orders", []),
|
|
|
|
|
+ "exposure": "managed",
|
|
|
|
|
+ },
|
|
|
|
|
+ "state": {
|
|
|
|
|
+ "last_price": self.state.get("last_price", 0.0),
|
|
|
|
|
+ "last_action": self.state.get("last_action", "idle"),
|
|
|
|
|
+ "trailing_anchor": self.state.get("trailing_anchor", 0.0),
|
|
|
|
|
+ "cooldown_remaining": self.state.get("cooldown_remaining", 0),
|
|
|
|
|
+ "regimes_updated_at": self.state.get("regimes_updated_at", ""),
|
|
|
|
|
+ },
|
|
|
|
|
+ "assessment": {
|
|
|
|
|
+ "confidence": None,
|
|
|
|
|
+ "uncertainty": None,
|
|
|
|
|
+ "reason": "defensive exposure protection",
|
|
|
|
|
+ "warnings": [],
|
|
|
|
|
+ "policy": dict(self.config.get("policy") or {}),
|
|
|
|
|
+ },
|
|
|
|
|
+ "execution": snapshot.get("execution", {}),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
def render(self):
|
|
def render(self):
|
|
|
return {
|
|
return {
|
|
|
"widgets": [
|
|
"widgets": [
|