stop_loss_trader.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  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 = "Stop Loss Rebalancer"
  7. TICK_MINUTES = 0.2
  8. CONFIG_SCHEMA = {
  9. "regime_timeframes": {"type": "list", "default": ["1d", "4h", "1h", "15m"]},
  10. "trend_enter_threshold": {"type": "float", "default": 0.7, "min": 0.0, "max": 1.0},
  11. "trend_exit_threshold": {"type": "float", "default": 0.45, "min": 0.0, "max": 1.0},
  12. "trail_distance_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 1.0},
  13. "rebalance_target_ratio": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0},
  14. "rebalance_step_ratio": {"type": "float", "default": 0.15, "min": 0.0, "max": 1.0},
  15. "min_order_size": {"type": "float", "default": 0.0, "min": 0.0},
  16. "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
  17. "order_spacing_ticks": {"type": "int", "default": 1, "min": 0, "max": 1000},
  18. "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
  19. "balance_tolerance": {"type": "float", "default": 0.05, "min": 0.0, "max": 1.0},
  20. "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
  21. "debug_orders": {"type": "bool", "default": True},
  22. }
  23. STATE_SCHEMA = {
  24. "last_price": {"type": "float", "default": 0.0},
  25. "last_action": {"type": "string", "default": "idle"},
  26. "last_error": {"type": "string", "default": ""},
  27. "debug_log": {"type": "list", "default": []},
  28. "regimes": {"type": "dict", "default": {}},
  29. "regimes_updated_at": {"type": "string", "default": ""},
  30. "base_available": {"type": "float", "default": 0.0},
  31. "counter_available": {"type": "float", "default": 0.0},
  32. "trailing_anchor": {"type": "float", "default": 0.0},
  33. "cooldown_remaining": {"type": "int", "default": 0},
  34. }
  35. def init(self):
  36. return {
  37. "last_price": 0.0,
  38. "last_action": "idle",
  39. "last_error": "",
  40. "debug_log": ["init stop loss rebalancer"],
  41. "regimes": {},
  42. "regimes_updated_at": "",
  43. "base_available": 0.0,
  44. "counter_available": 0.0,
  45. "trailing_anchor": 0.0,
  46. "cooldown_remaining": 0,
  47. }
  48. def _log(self, message: str) -> None:
  49. state = getattr(self, "state", {}) or {}
  50. log = list(state.get("debug_log") or [])
  51. log.append(message)
  52. state["debug_log"] = log[-12:]
  53. self.state = state
  54. log_event("stoploss", message)
  55. def _base_symbol(self) -> str:
  56. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  57. def _market_symbol(self) -> str:
  58. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  59. def _live_fee_rate(self) -> float:
  60. try:
  61. payload = self.context.get_fee_rates(self._market_symbol())
  62. return float(payload.get("maker") or 0.0)
  63. except Exception as exc:
  64. self._log(f"fee lookup failed: {exc}")
  65. return float(self.config.get("fee_rate", 0.0025) or 0.0)
  66. def _price(self) -> float:
  67. payload = self.context.get_price(self._base_symbol())
  68. return float(payload.get("price") or 0.0)
  69. def _refresh_regimes(self) -> None:
  70. regimes: dict[str, dict] = {}
  71. for tf in self.config.get("regime_timeframes") or ["1d", "4h", "1h", "15m"]:
  72. try:
  73. regimes[str(tf)] = self.context.get_regime(self._base_symbol(), str(tf))
  74. except Exception as exc:
  75. regimes[str(tf)] = {"error": str(exc)}
  76. self.state["regimes"] = regimes
  77. self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
  78. def _refresh_balance_snapshot(self) -> None:
  79. try:
  80. info = self.context.get_account_info()
  81. except Exception as exc:
  82. self._log(f"balance refresh failed: {exc}")
  83. return
  84. balances = info.get("balances") if isinstance(info, dict) else []
  85. if not isinstance(balances, list):
  86. return
  87. base = self._base_symbol()
  88. quote = str(self.context.counter_currency or "USD").upper()
  89. for balance in balances:
  90. if not isinstance(balance, dict):
  91. continue
  92. asset = str(balance.get("asset_code") or "").upper()
  93. try:
  94. available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  95. except Exception:
  96. continue
  97. if asset == base:
  98. self.state["base_available"] = available
  99. if asset == quote:
  100. self.state["counter_available"] = available
  101. def _regime_strength(self) -> float:
  102. regimes = self.state.get("regimes") or {}
  103. strengths = []
  104. for tf in self.config.get("regime_timeframes") or []:
  105. regime = regimes.get(str(tf)) or {}
  106. trend = regime.get("trend") or {}
  107. strengths.append(float(trend.get("strength") or 0.0))
  108. return max(strengths) if strengths else 0.0
  109. def _is_trending(self) -> bool:
  110. strength = self._regime_strength()
  111. return strength >= float(self.config.get("trend_enter_threshold", 0.7) or 0.7)
  112. def _account_value_ratio(self, price: float) -> float:
  113. base_value = float(self.state.get("base_available") or 0.0) * price
  114. counter_value = float(self.state.get("counter_available") or 0.0)
  115. total = base_value + counter_value
  116. if total <= 0:
  117. return 0.5
  118. return base_value / total
  119. def _desired_side(self, price: float) -> str:
  120. # If base dominates, sell some into strength, otherwise buy some back.
  121. ratio = self._account_value_ratio(price)
  122. target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
  123. return "sell" if ratio > target else "buy"
  124. def _suggest_amount(self, side: str, price: float) -> float:
  125. fee_rate = self._live_fee_rate()
  126. step_ratio = float(self.config.get("rebalance_step_ratio", 0.15) or 0.0)
  127. target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
  128. min_order = float(self.config.get("min_order_size", 0.0) or 0.0)
  129. max_order = float(self.config.get("max_order_size", 0.0) or 0.0)
  130. balance_tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.0)
  131. base_value = float(self.state.get("base_available") or 0.0) * price
  132. counter_value = float(self.state.get("counter_available") or 0.0)
  133. total = base_value + counter_value
  134. if total <= 0 or price <= 0:
  135. return 0.0
  136. current = base_value / total
  137. drift = abs(current - target)
  138. if drift <= balance_tolerance:
  139. return 0.0
  140. notional = total * min(drift, step_ratio)
  141. if side == "sell":
  142. amount = notional / (price * (1 + fee_rate))
  143. amount = min(amount, float(self.state.get("base_available") or 0.0))
  144. else:
  145. amount = notional / (price * (1 + fee_rate))
  146. amount = min(amount, float(self.state.get("counter_available") or 0.0) / price if price > 0 else 0.0)
  147. if min_order > 0:
  148. amount = max(amount, min_order)
  149. if max_order > 0:
  150. amount = min(amount, max_order)
  151. return max(amount, 0.0)
  152. def on_tick(self, tick):
  153. self.state["last_error"] = ""
  154. self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
  155. self._refresh_balance_snapshot()
  156. self._refresh_regimes()
  157. price = self._price()
  158. self.state["last_price"] = price
  159. if int(self.state.get("cooldown_remaining") or 0) > 0:
  160. self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
  161. self.state["last_action"] = "cooldown"
  162. return {"action": "cooldown", "price": price}
  163. if not self._is_trending():
  164. self.state["last_action"] = "standby"
  165. return {"action": "standby", "price": price}
  166. side = self._desired_side(price)
  167. amount = self._suggest_amount(side, price)
  168. trail_distance = float(self.config.get("trail_distance_pct", 0.03) or 0.03)
  169. if amount <= 0:
  170. self.state["last_action"] = "hold"
  171. return {"action": "hold", "price": price}
  172. try:
  173. market = self._market_symbol()
  174. if side == "sell":
  175. self.state["trailing_anchor"] = max(float(self.state.get("trailing_anchor") or 0.0), price)
  176. order_price = round(price * (1 + trail_distance), 8)
  177. else:
  178. self.state["trailing_anchor"] = min(float(self.state.get("trailing_anchor") or price), price) if self.state.get("trailing_anchor") else price
  179. order_price = round(price * (1 - trail_distance), 8)
  180. if self.config.get("debug_orders", True):
  181. self._log(f"{side} rebalance amount={amount:.6g} price={order_price} ratio={self._account_value_ratio(price):.4f}")
  182. result = self.context.place_order(
  183. side=side,
  184. order_type="limit",
  185. amount=amount,
  186. price=order_price,
  187. market=market,
  188. )
  189. self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
  190. self.state["last_action"] = f"{side}_rebalance"
  191. return {"action": side, "price": order_price, "amount": amount, "result": result}
  192. except Exception as exc:
  193. self.state["last_error"] = str(exc)
  194. self._log(f"rebalance failed: {exc}")
  195. self.state["last_action"] = "error"
  196. return {"action": "error", "price": price, "error": str(exc)}
  197. def render(self):
  198. return {
  199. "widgets": [
  200. {"type": "metric", "label": "market", "value": self._market_symbol()},
  201. {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  202. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  203. {"type": "metric", "label": "base avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
  204. {"type": "metric", "label": "counter avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
  205. {"type": "metric", "label": "ratio", "value": round(self._account_value_ratio(float(self.state.get("last_price") or 0.0) or 1.0), 4)},
  206. {"type": "metric", "label": "trailing anchor", "value": round(float(self.state.get("trailing_anchor") or 0.0), 6)},
  207. {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
  208. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  209. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  210. ]
  211. }