trend_follower.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  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 = "Trend Follower"
  7. STRATEGY_PROFILE = {
  8. "expects": {
  9. "trend": "strong",
  10. "volatility": "moderate",
  11. "event_risk": "low",
  12. "liquidity": "normal",
  13. },
  14. "avoids": {
  15. "trend": "range",
  16. "volatility": "chaotic",
  17. "event_risk": "high",
  18. "liquidity": "thin",
  19. },
  20. "risk_profile": "growth",
  21. "capabilities": ["directional_continuation", "momentum_following", "inventory_accumulation"],
  22. "role": "primary",
  23. "inventory_behavior": "accumulative_long",
  24. "requires_rebalance_before_start": False,
  25. "requires_rebalance_before_stop": False,
  26. "safe_when_unbalanced": True,
  27. "can_run_with": ["exposure_protector"],
  28. }
  29. TICK_MINUTES = 0.5
  30. CONFIG_SCHEMA = {
  31. "trade_side": {"type": "string", "default": "both"},
  32. "balance_target": {"type": "float", "default": 1.0, "min": 0.0, "max": 1.0},
  33. "entry_offset_pct": {"type": "float", "default": 0.003, "min": 0.0, "max": 1.0},
  34. "exit_offset_pct": {"type": "float", "default": 0.002, "min": 0.0, "max": 1.0},
  35. "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
  36. "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
  37. "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
  38. "debug_orders": {"type": "bool", "default": True},
  39. }
  40. STATE_SCHEMA = {
  41. "last_price": {"type": "float", "default": 0.0},
  42. "last_action": {"type": "string", "default": "idle"},
  43. "last_error": {"type": "string", "default": ""},
  44. "debug_log": {"type": "list", "default": []},
  45. "trade_side": {"type": "string", "default": "both"},
  46. "cooldown_remaining": {"type": "int", "default": 0},
  47. "last_order_at": {"type": "float", "default": 0.0},
  48. "last_order_price": {"type": "float", "default": 0.0},
  49. "base_available": {"type": "float", "default": 0.0},
  50. "counter_available": {"type": "float", "default": 0.0},
  51. }
  52. def init(self):
  53. return {
  54. "last_price": 0.0,
  55. "last_action": "idle",
  56. "last_error": "",
  57. "debug_log": ["init trend follower"],
  58. "trade_side": "both",
  59. "cooldown_remaining": 0,
  60. "last_order_at": 0.0,
  61. "last_order_price": 0.0,
  62. "base_available": 0.0,
  63. "counter_available": 0.0,
  64. }
  65. def _log(self, message: str) -> None:
  66. state = getattr(self, "state", {}) or {}
  67. log = list(state.get("debug_log") or [])
  68. log.append(message)
  69. state["debug_log"] = log[-12:]
  70. self.state = state
  71. log_event("trend", message)
  72. def _base_symbol(self) -> str:
  73. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  74. def _market_symbol(self) -> str:
  75. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  76. def _trade_side(self) -> str:
  77. side = str(self.config.get("trade_side") or "both").strip().lower()
  78. return side if side in {"buy", "sell", "both"} else "both"
  79. def _price(self) -> float:
  80. payload = self.context.get_price(self._base_symbol())
  81. return float(payload.get("price") or 0.0)
  82. def _live_fee_rate(self) -> float:
  83. try:
  84. payload = self.context.get_fee_rates(self._market_symbol())
  85. return float(payload.get("maker") or payload.get("taker") or 0.0)
  86. except Exception as exc:
  87. self._log(f"fee lookup failed: {exc}")
  88. return 0.0
  89. def _refresh_balance_snapshot(self) -> None:
  90. try:
  91. info = self.context.get_account_info()
  92. except Exception as exc:
  93. self._log(f"balance refresh failed: {exc}")
  94. return
  95. balances = info.get("balances") if isinstance(info, dict) else []
  96. if not isinstance(balances, list):
  97. return
  98. base = self._base_symbol()
  99. quote = str(self.context.counter_currency or "USD").upper()
  100. for balance in balances:
  101. if not isinstance(balance, dict):
  102. continue
  103. asset = str(balance.get("asset_code") or "").upper()
  104. try:
  105. available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  106. except Exception:
  107. continue
  108. if asset == base:
  109. self.state["base_available"] = available
  110. if asset == quote:
  111. self.state["counter_available"] = available
  112. def _supervision(self) -> dict:
  113. last_error = str(self.state.get("last_error") or "")
  114. side = self._trade_side()
  115. pressure = "balanced" if side in {"buy", "sell"} else "unknown"
  116. balance_target = self._balance_target()
  117. base_ratio = self._account_value_ratio(float(self.state.get("last_price") or 0.0))
  118. quote_ratio = max(0.0, 1.0 - base_ratio)
  119. entry_offset_pct = float(self.config.get("entry_offset_pct") or 0.003)
  120. exit_offset_pct = float(self.config.get("exit_offset_pct") or 0.002)
  121. last_order_at = float(self.state.get("last_order_at") or 0.0)
  122. now_ts = datetime.now(timezone.utc).timestamp()
  123. last_order_age_seconds = round(max(now_ts - last_order_at, 0.0), 3) if last_order_at > 0 else None
  124. if entry_offset_pct <= 0.0015:
  125. chasing_risk = "elevated"
  126. elif entry_offset_pct <= 0.0035:
  127. chasing_risk = "moderate"
  128. else:
  129. chasing_risk = "low"
  130. concerns = []
  131. if side == "both":
  132. concerns.append("generic trend instance relies on Hermes for direction")
  133. if chasing_risk == "elevated":
  134. concerns.append("entry offset is tight and may chase price")
  135. return {
  136. "health": "degraded" if last_error else "healthy",
  137. "degraded": bool(last_error),
  138. "inventory_pressure": pressure,
  139. "capacity_available": side in {"buy", "sell"},
  140. "trade_side": side,
  141. "balance_target": round(balance_target, 6),
  142. "base_ratio": round(base_ratio, 6),
  143. "quote_ratio": round(quote_ratio, 6),
  144. "entry_offset_pct": round(entry_offset_pct, 6),
  145. "exit_offset_pct": round(exit_offset_pct, 6),
  146. "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
  147. "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
  148. "last_order_age_seconds": last_order_age_seconds,
  149. "last_order_price": float(self.state.get("last_order_price") or 0.0),
  150. "chasing_risk": chasing_risk,
  151. "concerns": concerns,
  152. "last_reason": last_error or f"trade_side={side}",
  153. }
  154. def apply_policy(self):
  155. policy = super().apply_policy()
  156. quote_notional = float(self.config.get("order_notional_quote") or 0.0)
  157. max_quote_notional = float(self.config.get("max_order_notional_quote") or 0.0)
  158. self.state["policy_derived"] = {
  159. "trade_side": self._trade_side(),
  160. "balance_target": self._balance_target(),
  161. "entry_offset_pct": float(self.config.get("entry_offset_pct") or 0.003),
  162. "exit_offset_pct": float(self.config.get("exit_offset_pct") or 0.002),
  163. "cooldown_ticks": int(self.config.get("cooldown_ticks") or 2),
  164. "order_notional_quote": quote_notional,
  165. "max_order_notional_quote": max_quote_notional,
  166. }
  167. return policy
  168. def _balance_target(self) -> float:
  169. try:
  170. target = float(self.config.get("balance_target") if self.config.get("balance_target") is not None else 1.0)
  171. except Exception:
  172. return 1.0
  173. return min(max(target, 0.0), 1.0)
  174. def _account_value_ratio(self, price: float) -> float:
  175. if price <= 0:
  176. return 0.5
  177. base_value = float(self.state.get("base_available") or 0.0) * price
  178. counter_value = float(self.state.get("counter_available") or 0.0)
  179. total = base_value + counter_value
  180. if total <= 0:
  181. return 0.5
  182. return base_value / total
  183. def _balance_target_reached(self, side: str, price: float) -> bool:
  184. target = self._balance_target()
  185. if target >= 1.0:
  186. return False
  187. base_ratio = self._account_value_ratio(price)
  188. if side == "buy":
  189. return base_ratio >= target
  190. if side == "sell":
  191. return (1.0 - base_ratio) >= target
  192. return False
  193. def _suggest_amount(self, price: float, side: str) -> float:
  194. min_notional = float(self.context.minimum_order_value or 0.0)
  195. quote_notional = float(self.config.get("order_notional_quote") or 0.0)
  196. max_quote_notional = float(self.config.get("max_order_notional_quote") or 0.0)
  197. if hasattr(self.context, "suggest_order_amount"):
  198. kwargs = {
  199. "side": side,
  200. "price": price,
  201. "levels": 1,
  202. "min_notional": min_notional,
  203. "fee_rate": self._live_fee_rate(),
  204. "quote_notional": quote_notional,
  205. "max_notional_per_order": max_quote_notional,
  206. }
  207. try:
  208. return float(self.context.suggest_order_amount(**kwargs) or 0.0)
  209. except TypeError:
  210. kwargs.pop("quote_notional", None)
  211. return float(self.context.suggest_order_amount(**kwargs) or 0.0)
  212. if quote_notional <= 0:
  213. return 0.0
  214. amount = quote_notional / price
  215. if max_quote_notional > 0:
  216. amount = min(amount, max_quote_notional / price)
  217. return max(amount, 0.0)
  218. def on_tick(self, tick):
  219. self.state["last_error"] = ""
  220. self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
  221. self._refresh_balance_snapshot()
  222. price = self._price()
  223. self.state["last_price"] = price
  224. if int(self.state.get("cooldown_remaining") or 0) > 0:
  225. self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
  226. self.state["last_action"] = "cooldown"
  227. return {"action": "cooldown", "price": price}
  228. side = self._trade_side()
  229. if side not in {"buy", "sell"}:
  230. self.state["last_action"] = "hold"
  231. return {"action": "hold", "price": price, "reason": "trade_side must be buy or sell"}
  232. if self._balance_target_reached(side, price):
  233. self.state["last_action"] = "target_reached"
  234. return {"action": "hold", "price": price, "reason": "balance target reached"}
  235. amount = self._suggest_amount(price, side)
  236. if amount <= 0:
  237. self.state["last_action"] = "hold"
  238. return {"action": "hold", "price": price, "reason": "no usable size"}
  239. offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
  240. if side == "buy":
  241. order_price = round(price * (1 + offset), 8)
  242. else:
  243. order_price = round(price * (1 - offset), 8)
  244. try:
  245. if self.config.get("debug_orders", True):
  246. self._log(f"{side} trend amount={amount:.6g} price={order_price}")
  247. result = self.context.place_order(
  248. side=side,
  249. order_type="limit",
  250. amount=amount,
  251. price=order_price,
  252. market=self._market_symbol(),
  253. )
  254. self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
  255. self.state["last_order_at"] = datetime.now(timezone.utc).timestamp()
  256. self.state["last_order_price"] = order_price
  257. self.state["last_action"] = f"{side}_trend"
  258. return {"action": side, "price": order_price, "amount": amount, "result": result}
  259. except Exception as exc:
  260. self.state["last_error"] = str(exc)
  261. self._log(f"trend order failed: {exc}")
  262. self.state["last_action"] = "error"
  263. return {"action": "error", "price": price, "error": str(exc)}
  264. def report(self):
  265. snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
  266. return {
  267. "identity": snapshot.get("identity", {}),
  268. "control": snapshot.get("control", {}),
  269. "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
  270. "position": snapshot.get("position", {}),
  271. "state": {
  272. "last_price": self.state.get("last_price", 0.0),
  273. "last_action": self.state.get("last_action", "idle"),
  274. "trade_side": self._trade_side(),
  275. "balance_target": self._balance_target(),
  276. "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
  277. "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
  278. "cooldown_remaining": self.state.get("cooldown_remaining", 0),
  279. "base_available": self.state.get("base_available", 0.0),
  280. "counter_available": self.state.get("counter_available", 0.0),
  281. },
  282. "assessment": {
  283. "confidence": None,
  284. "uncertainty": None,
  285. "reason": "trend capture",
  286. "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
  287. "policy": dict(self.config.get("policy") or {}),
  288. },
  289. "execution": snapshot.get("execution", {}),
  290. "supervision": self._supervision(),
  291. }
  292. def render(self):
  293. return {
  294. "widgets": [
  295. {"type": "metric", "label": "market", "value": self._market_symbol()},
  296. {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  297. {"type": "metric", "label": "side", "value": self._trade_side()},
  298. {"type": "metric", "label": "balance target", "value": round(self._balance_target(), 6)},
  299. {"type": "metric", "label": "quote notional", "value": round(float(self.config.get("order_notional_quote") or 0.0), 6)},
  300. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  301. {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
  302. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  303. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  304. ]
  305. }