exposure_protector.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. from __future__ import annotations
  2. from datetime import datetime, timezone
  3. from src.trader_mcp.strategy_sdk import Strategy
  4. from src.trader_mcp.logging_utils import log_event
  5. class Strategy(Strategy):
  6. LABEL = "Exposure Protector"
  7. STRATEGY_PROFILE = {
  8. "expects": {
  9. "trend": "mixed",
  10. "volatility": "moderate",
  11. "event_risk": "low",
  12. "liquidity": "normal",
  13. },
  14. "avoids": {
  15. "volatility": "chaotic",
  16. "event_risk": "high",
  17. "liquidity": "thin",
  18. },
  19. "risk_profile": "defensive",
  20. "capabilities": ["inventory_rebalancing", "exposure_trim", "companion_defense"],
  21. "role": "defensive",
  22. "inventory_behavior": "rebalancing",
  23. "requires_rebalance_before_start": False,
  24. "requires_rebalance_before_stop": False,
  25. "safe_when_unbalanced": True,
  26. "can_run_with": ["grid_trader", "trend_follower"],
  27. }
  28. TICK_MINUTES = 0.2
  29. CONFIG_SCHEMA = {
  30. "trail_distance_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 1.0},
  31. "rebalance_target_ratio": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0},
  32. "rebalance_step_ratio": {"type": "float", "default": 0.15, "min": 0.0, "max": 1.0},
  33. "min_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
  34. "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
  35. "order_spacing_ticks": {"type": "int", "default": 1, "min": 0, "max": 1000},
  36. "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
  37. "min_rebalance_seconds": {"type": "int", "default": 180, "min": 0, "max": 86400},
  38. "min_price_move_pct": {"type": "float", "default": 0.005, "min": 0.0, "max": 1.0},
  39. "balance_tolerance": {"type": "float", "default": 0.05, "min": 0.0, "max": 1.0},
  40. "debug_orders": {"type": "bool", "default": True},
  41. }
  42. STATE_SCHEMA = {
  43. "last_price": {"type": "float", "default": 0.0},
  44. "last_action": {"type": "string", "default": "idle"},
  45. "last_error": {"type": "string", "default": ""},
  46. "debug_log": {"type": "list", "default": []},
  47. "regimes": {"type": "dict", "default": {}},
  48. "regimes_updated_at": {"type": "string", "default": ""},
  49. "base_available": {"type": "float", "default": 0.0},
  50. "counter_available": {"type": "float", "default": 0.0},
  51. "trailing_anchor": {"type": "float", "default": 0.0},
  52. "cooldown_remaining": {"type": "int", "default": 0},
  53. "last_order_at": {"type": "float", "default": 0.0},
  54. "last_order_price": {"type": "float", "default": 0.0},
  55. }
  56. def init(self):
  57. return {
  58. "last_price": 0.0,
  59. "last_action": "idle",
  60. "last_error": "",
  61. "debug_log": ["init exposure protector"],
  62. "regimes": {},
  63. "regimes_updated_at": "",
  64. "base_available": 0.0,
  65. "counter_available": 0.0,
  66. "trailing_anchor": 0.0,
  67. "cooldown_remaining": 0,
  68. "last_order_at": 0.0,
  69. "last_order_price": 0.0,
  70. }
  71. def _log(self, message: str) -> None:
  72. state = getattr(self, "state", {}) or {}
  73. log = list(state.get("debug_log") or [])
  74. log.append(message)
  75. state["debug_log"] = log[-12:]
  76. self.state = state
  77. log_event("stoploss", message)
  78. def _base_symbol(self) -> str:
  79. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  80. def _market_symbol(self) -> str:
  81. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  82. def apply_policy(self):
  83. policy = super().apply_policy()
  84. risk = str(policy.get("risk_posture") or "normal").lower()
  85. priority = str(policy.get("priority") or "normal").lower()
  86. trail_map = {"cautious": 0.02, "normal": 0.03, "assertive": 0.04}
  87. step_map = {"cautious": 0.08, "normal": 0.15, "assertive": 0.25}
  88. wait_map = {"cautious": 420, "normal": 180, "assertive": 90}
  89. move_map = {"cautious": 0.01, "normal": 0.005, "assertive": 0.003}
  90. if priority in {"low", "background"}:
  91. trail = trail_map.get("cautious", 0.02)
  92. step = step_map.get("cautious", 0.08)
  93. wait = wait_map.get("cautious", 600)
  94. move = move_map.get("cautious", 0.02)
  95. elif priority in {"high", "urgent"}:
  96. trail = trail_map.get("assertive", 0.04)
  97. step = step_map.get("assertive", 0.25)
  98. wait = wait_map.get("assertive", 120)
  99. move = move_map.get("assertive", 0.005)
  100. else:
  101. trail = trail_map.get(risk, 0.03)
  102. step = step_map.get(risk, 0.15)
  103. wait = wait_map.get(risk, 300)
  104. move = move_map.get(risk, 0.01)
  105. self.config["trail_distance_pct"] = trail
  106. self.config["rebalance_step_ratio"] = step
  107. self.config["min_rebalance_seconds"] = wait
  108. self.config["min_price_move_pct"] = move
  109. self.state["policy_derived"] = {
  110. "trail_distance_pct": trail,
  111. "rebalance_step_ratio": step,
  112. "min_rebalance_seconds": wait,
  113. "min_price_move_pct": move,
  114. }
  115. return policy
  116. def _live_fee_rate(self) -> float:
  117. try:
  118. payload = self.context.get_fee_rates(self._market_symbol())
  119. return float(payload.get("maker") or 0.0)
  120. except Exception as exc:
  121. self._log(f"fee lookup failed: {exc}")
  122. return 0.0
  123. def _price(self) -> float:
  124. payload = self.context.get_price(self._base_symbol())
  125. return float(payload.get("price") or 0.0)
  126. def _refresh_regimes(self) -> None:
  127. try:
  128. self.state["regimes"] = self.context.get_strategy_snapshot().get("fit", {}) if hasattr(self.context, "get_strategy_snapshot") else {}
  129. except Exception:
  130. self.state["regimes"] = {}
  131. self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
  132. def _refresh_balance_snapshot(self) -> None:
  133. try:
  134. info = self.context.get_account_info()
  135. except Exception as exc:
  136. self._log(f"balance refresh failed: {exc}")
  137. return
  138. balances = info.get("balances") if isinstance(info, dict) else []
  139. if not isinstance(balances, list):
  140. return
  141. base = self._base_symbol()
  142. quote = str(self.context.counter_currency or "USD").upper()
  143. for balance in balances:
  144. if not isinstance(balance, dict):
  145. continue
  146. asset = str(balance.get("asset_code") or "").upper()
  147. try:
  148. available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  149. except Exception:
  150. continue
  151. if asset == base:
  152. self.state["base_available"] = available
  153. if asset == quote:
  154. self.state["counter_available"] = available
  155. def _account_value_ratio(self, price: float) -> float:
  156. base_value = float(self.state.get("base_available") or 0.0) * price
  157. counter_value = float(self.state.get("counter_available") or 0.0)
  158. total = base_value + counter_value
  159. if total <= 0:
  160. return 0.5
  161. return base_value / total
  162. def _supervision(self) -> dict:
  163. price = float(self.state.get("last_price") or 0.0)
  164. ratio = self._account_value_ratio(price if price > 0 else 1.0)
  165. target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
  166. tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.05)
  167. drift = abs(ratio - target)
  168. last_error = str(self.state.get("last_error") or "")
  169. repair_progress = max(0.0, 1.0 - min(drift / max(tolerance, 0.35), 1.0))
  170. concerns = []
  171. if drift > tolerance:
  172. concerns.append(f"inventory drift {drift:.3f} still above tolerance {tolerance:.3f}")
  173. if drift >= 0.35:
  174. concerns.append("inventory imbalance is critical")
  175. if last_error:
  176. concerns.append(last_error)
  177. if drift >= 0.35:
  178. pressure = "critical"
  179. elif drift > tolerance:
  180. pressure = "elevated"
  181. else:
  182. pressure = "contained"
  183. return {
  184. "health": "degraded" if last_error else "healthy",
  185. "degraded": bool(last_error),
  186. "inventory_pressure": pressure,
  187. "capacity_available": drift > tolerance,
  188. "rebalance_needed": drift > tolerance,
  189. "drift": round(drift, 6),
  190. "target_ratio": target,
  191. "repair_progress": round(repair_progress, 6),
  192. "concerns": concerns,
  193. "last_reason": last_error or f"base_ratio={ratio:.3f}, target={target:.3f}, drift={drift:.3f}",
  194. }
  195. def _desired_side(self, price: float) -> str:
  196. # If base dominates, sell some into strength, otherwise buy some back.
  197. ratio = self._account_value_ratio(price)
  198. target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
  199. step_ratio = float(self.config.get("rebalance_step_ratio", 0.15) or 0.0)
  200. tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.0)
  201. hysteresis = max(tolerance, step_ratio * 0.5, 0.02)
  202. last_side = str(self.state.get("last_rebalance_side") or "").lower()
  203. if last_side == "sell":
  204. if ratio <= target - hysteresis:
  205. return "buy"
  206. if ratio >= target + hysteresis:
  207. return "sell"
  208. return ""
  209. if last_side == "buy":
  210. if ratio >= target + hysteresis:
  211. return "sell"
  212. if ratio <= target - hysteresis:
  213. return "buy"
  214. return ""
  215. if ratio > target + hysteresis:
  216. return "sell"
  217. if ratio < target - hysteresis:
  218. return "buy"
  219. return ""
  220. def _suggest_amount(self, side: str, price: float) -> float:
  221. fee_rate = self._live_fee_rate()
  222. step_ratio = float(self.config.get("rebalance_step_ratio", 0.15) or 0.0)
  223. target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
  224. min_order_quote = float(self.config.get("min_order_notional_quote") or self.config.get("min_order_size") or 0.0)
  225. max_order_quote = float(self.config.get("max_order_notional_quote") or self.config.get("max_order_size") or 0.0)
  226. balance_tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.0)
  227. base_value = float(self.state.get("base_available") or 0.0) * price
  228. counter_value = float(self.state.get("counter_available") or 0.0)
  229. total = base_value + counter_value
  230. if total <= 0 or price <= 0:
  231. return 0.0
  232. current = base_value / total
  233. drift = abs(current - target)
  234. if drift <= balance_tolerance:
  235. return 0.0
  236. notional = total * min(drift, step_ratio)
  237. if side == "sell":
  238. amount = notional / (price * (1 + fee_rate))
  239. amount = min(amount, float(self.state.get("base_available") or 0.0))
  240. else:
  241. amount = notional / (price * (1 + fee_rate))
  242. amount = min(amount, float(self.state.get("counter_available") or 0.0) / price if price > 0 else 0.0)
  243. if min_order_quote > 0 and price > 0:
  244. amount = max(amount, min_order_quote / price)
  245. if max_order_quote > 0 and price > 0:
  246. amount = min(amount, max_order_quote / price)
  247. return max(amount, 0.0)
  248. def on_tick(self, tick):
  249. self.state["last_error"] = ""
  250. self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
  251. self._refresh_balance_snapshot()
  252. self._refresh_regimes()
  253. price = self._price()
  254. self.state["last_price"] = price
  255. if int(self.state.get("cooldown_remaining") or 0) > 0:
  256. self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
  257. self.state["last_action"] = "cooldown"
  258. return {"action": "cooldown", "price": price}
  259. now = datetime.now(timezone.utc).timestamp()
  260. last_order_at = float(self.state.get("last_order_at") or 0.0)
  261. min_rebalance_seconds = int(self.config.get("min_rebalance_seconds", 300) or 0)
  262. if last_order_at and min_rebalance_seconds > 0 and (now - last_order_at) < min_rebalance_seconds:
  263. self.state["last_action"] = "hold"
  264. return {"action": "hold", "price": price, "reason": "rebalance cooldown"}
  265. last_order_price = float(self.state.get("last_order_price") or 0.0)
  266. min_price_move_pct = float(self.config.get("min_price_move_pct", 0.01) or 0.0)
  267. if last_order_price > 0 and min_price_move_pct > 0:
  268. move_pct = abs(price - last_order_price) / last_order_price
  269. if move_pct < min_price_move_pct:
  270. self.state["last_action"] = "hold"
  271. return {"action": "hold", "price": price, "reason": "insufficient price move", "move_pct": move_pct}
  272. side = self._desired_side(price)
  273. if not side:
  274. self.state["last_action"] = "hold"
  275. return {"action": "hold", "price": price, "reason": "within rebalance hysteresis"}
  276. amount = self._suggest_amount(side, price)
  277. trail_distance = float(self.config.get("trail_distance_pct", 0.03) or 0.03)
  278. if amount <= 0:
  279. self.state["last_action"] = "hold"
  280. return {"action": "hold", "price": price}
  281. try:
  282. market = self._market_symbol()
  283. if side == "sell":
  284. self.state["trailing_anchor"] = max(float(self.state.get("trailing_anchor") or 0.0), price)
  285. order_price = round(price * (1 - trail_distance), 8)
  286. else:
  287. self.state["trailing_anchor"] = min(float(self.state.get("trailing_anchor") or price), price) if self.state.get("trailing_anchor") else price
  288. order_price = round(price * (1 + trail_distance), 8)
  289. if self.config.get("debug_orders", True):
  290. self._log(f"{side} rebalance amount={amount:.6g} price={order_price} ratio={self._account_value_ratio(price):.4f}")
  291. result = self.context.place_order(
  292. side=side,
  293. order_type="limit",
  294. amount=amount,
  295. price=order_price,
  296. market=market,
  297. )
  298. self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
  299. self.state["last_order_at"] = now
  300. self.state["last_order_price"] = order_price
  301. self.state["last_rebalance_side"] = side
  302. self.state["last_action"] = f"{side}_rebalance"
  303. return {"action": side, "price": order_price, "amount": amount, "result": result}
  304. except Exception as exc:
  305. self.state["last_error"] = str(exc)
  306. self._log(f"rebalance failed: {exc}")
  307. self.state["last_action"] = "error"
  308. return {"action": "error", "price": price, "error": str(exc)}
  309. def report(self):
  310. snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
  311. return {
  312. "identity": snapshot.get("identity", {}),
  313. "control": snapshot.get("control", {}),
  314. "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
  315. "position": {
  316. "balances": {
  317. "base_available": self.state.get("base_available", 0.0),
  318. "counter_available": self.state.get("counter_available", 0.0),
  319. },
  320. "open_orders": snapshot.get("orders", {}).get("open_orders", []),
  321. "exposure": "managed",
  322. },
  323. "state": {
  324. "last_price": self.state.get("last_price", 0.0),
  325. "last_action": self.state.get("last_action", "idle"),
  326. "trailing_anchor": self.state.get("trailing_anchor", 0.0),
  327. "cooldown_remaining": self.state.get("cooldown_remaining", 0),
  328. "regimes_updated_at": self.state.get("regimes_updated_at", ""),
  329. },
  330. "assessment": {
  331. "confidence": None,
  332. "uncertainty": None,
  333. "reason": "defensive exposure protection",
  334. "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
  335. "policy": dict(self.config.get("policy") or {}),
  336. },
  337. "execution": snapshot.get("execution", {}),
  338. "supervision": self._supervision(),
  339. }
  340. def render(self):
  341. return {
  342. "widgets": [
  343. {"type": "metric", "label": "market", "value": self._market_symbol()},
  344. {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  345. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  346. {"type": "metric", "label": "base avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
  347. {"type": "metric", "label": "counter avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
  348. {"type": "metric", "label": "ratio", "value": round(self._account_value_ratio(float(self.state.get("last_price") or 0.0) or 1.0), 4)},
  349. {"type": "metric", "label": "trailing anchor", "value": round(float(self.state.get("trailing_anchor") or 0.0), 6)},
  350. {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
  351. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  352. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  353. ]
  354. }