trend_follower.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. "trend_timeframe": {"type": "string", "default": "1h"},
  32. "trend_strength_min": {"type": "float", "default": 0.65, "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_size": {"type": "float", "default": 0.0, "min": 0.0},
  36. "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
  37. "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
  38. "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
  39. "debug_orders": {"type": "bool", "default": True},
  40. }
  41. STATE_SCHEMA = {
  42. "last_price": {"type": "float", "default": 0.0},
  43. "last_action": {"type": "string", "default": "idle"},
  44. "last_error": {"type": "string", "default": ""},
  45. "debug_log": {"type": "list", "default": []},
  46. "last_signal": {"type": "string", "default": "neutral"},
  47. "last_strength": {"type": "float", "default": 0.0},
  48. "cooldown_remaining": {"type": "int", "default": 0},
  49. "last_order_at": {"type": "float", "default": 0.0},
  50. "last_order_price": {"type": "float", "default": 0.0},
  51. "base_available": {"type": "float", "default": 0.0},
  52. "counter_available": {"type": "float", "default": 0.0},
  53. }
  54. def init(self):
  55. return {
  56. "last_price": 0.0,
  57. "last_action": "idle",
  58. "last_error": "",
  59. "debug_log": ["init trend follower"],
  60. "last_signal": "neutral",
  61. "last_strength": 0.0,
  62. "cooldown_remaining": 0,
  63. "last_order_at": 0.0,
  64. "last_order_price": 0.0,
  65. "base_available": 0.0,
  66. "counter_available": 0.0,
  67. }
  68. def _log(self, message: str) -> None:
  69. state = getattr(self, "state", {}) or {}
  70. log = list(state.get("debug_log") or [])
  71. log.append(message)
  72. state["debug_log"] = log[-12:]
  73. self.state = state
  74. log_event("trend", message)
  75. def _base_symbol(self) -> str:
  76. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  77. def _market_symbol(self) -> str:
  78. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  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 float(self.config.get("fee_rate", 0.0025) or 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 _inventory_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 _supervision(self) -> dict:
  120. price = float(self.state.get("last_price") or 0.0)
  121. ratio = self._inventory_ratio(price if price > 0 else 1.0)
  122. last_error = str(self.state.get("last_error") or "")
  123. strength = float(self.state.get("last_strength") or 0.0)
  124. signal = str(self.state.get("last_signal") or "neutral")
  125. if ratio >= 0.88:
  126. pressure = "base_heavy"
  127. elif ratio <= 0.12:
  128. pressure = "quote_heavy"
  129. elif ratio >= 0.68:
  130. pressure = "base_biased"
  131. elif ratio <= 0.32:
  132. pressure = "quote_biased"
  133. else:
  134. pressure = "balanced"
  135. return {
  136. "health": "degraded" if last_error else "healthy",
  137. "degraded": bool(last_error),
  138. "inventory_pressure": pressure,
  139. "capacity_available": strength >= float(self.config.get("trend_strength_min", 0.65) or 0.65),
  140. "switch_readiness": "ready_to_yield_to_grid" if pressure == "balanced" and strength < float(self.config.get("trend_strength_min", 0.65) or 0.65) else "prefer_hold",
  141. "last_reason": last_error or f"signal={signal}, strength={strength:.3f}, base_ratio={ratio:.3f}",
  142. "desired_companion": "exposure_protector" if pressure != "balanced" else None,
  143. }
  144. def _trend_snapshot(self) -> dict:
  145. tf = str(self.config.get("trend_timeframe", "1h") or "1h")
  146. try:
  147. return self.context.get_regime(self._base_symbol(), tf)
  148. except Exception as exc:
  149. self._log(f"trend lookup failed: {exc}")
  150. return {"error": str(exc)}
  151. def apply_policy(self):
  152. policy = super().apply_policy()
  153. risk = str(policy.get("risk_posture") or "normal").lower()
  154. priority = str(policy.get("priority") or "normal").lower()
  155. strength_map = {"cautious": 0.8, "normal": 0.65, "assertive": 0.5}
  156. entry_map = {"cautious": 0.002, "normal": 0.003, "assertive": 0.005}
  157. exit_map = {"cautious": 0.0015, "normal": 0.002, "assertive": 0.003}
  158. cooldown_map = {"cautious": 4, "normal": 2, "assertive": 1}
  159. size_map = {"cautious": 0.5, "normal": 1.0, "assertive": 1.5}
  160. if priority in {"low", "background"}:
  161. risk = "cautious"
  162. elif priority in {"high", "urgent"}:
  163. risk = "assertive"
  164. self.config["trend_strength_min"] = float(self.config.get("trend_strength_min") or strength_map.get(risk, 0.65))
  165. self.config["entry_offset_pct"] = float(self.config.get("entry_offset_pct") or entry_map.get(risk, 0.003))
  166. self.config["exit_offset_pct"] = float(self.config.get("exit_offset_pct") or exit_map.get(risk, 0.002))
  167. self.config["cooldown_ticks"] = int(self.config.get("cooldown_ticks") or cooldown_map.get(risk, 2))
  168. self.config["order_size"] = float(self.config.get("order_size") or size_map.get(risk, 1.0))
  169. self.state["policy_derived"] = {
  170. "trend_strength_min": self.config["trend_strength_min"],
  171. "entry_offset_pct": self.config["entry_offset_pct"],
  172. "exit_offset_pct": self.config["exit_offset_pct"],
  173. "cooldown_ticks": self.config["cooldown_ticks"],
  174. "order_size": self.config["order_size"],
  175. }
  176. return policy
  177. def _trend_strength(self) -> tuple[str, float]:
  178. regime = self._trend_snapshot()
  179. trend = regime.get("trend") or {}
  180. momentum = regime.get("momentum") or {}
  181. direction = str(trend.get("state") or trend.get("direction") or "unknown")
  182. strength = self._coerce_strength(trend.get("strength"))
  183. if strength is None:
  184. strength = self._derive_strength_from_regime(direction=direction, trend=trend, momentum=momentum, regime=regime)
  185. return direction, strength
  186. def _coerce_strength(self, value) -> float | None:
  187. try:
  188. if value is None:
  189. return None
  190. return max(0.0, min(1.0, float(value)))
  191. except Exception:
  192. return None
  193. def _derive_strength_from_regime(self, *, direction: str, trend: dict, momentum: dict, regime: dict) -> float:
  194. direction = str(direction or "unknown").lower()
  195. score = 0.0
  196. if direction in {"bull", "up", "long"}:
  197. score += 0.45
  198. elif direction in {"bear", "down", "short"}:
  199. score += 0.45
  200. else:
  201. return 0.0
  202. momentum_state = str(momentum.get("state") or "").lower()
  203. if direction in {"bull", "up", "long"} and momentum_state == "bull":
  204. score += 0.2
  205. elif direction in {"bear", "down", "short"} and momentum_state == "bear":
  206. score += 0.2
  207. try:
  208. rsi = float(momentum.get("rsi") or 0.0)
  209. except Exception:
  210. rsi = 0.0
  211. if direction in {"bull", "up", "long"}:
  212. if rsi >= 60:
  213. score += 0.2
  214. elif rsi >= 52:
  215. score += 0.1
  216. else:
  217. if 0 < rsi <= 40:
  218. score += 0.2
  219. elif 0 < rsi <= 48:
  220. score += 0.1
  221. try:
  222. macd_hist = float(momentum.get("macd_histogram") or 0.0)
  223. except Exception:
  224. macd_hist = 0.0
  225. if direction in {"bull", "up", "long"} and macd_hist > 0:
  226. score += 0.1
  227. elif direction in {"bear", "down", "short"} and macd_hist < 0:
  228. score += 0.1
  229. try:
  230. ema_fast = float(trend.get("ema_fast") or 0.0)
  231. ema_slow = float(trend.get("ema_slow") or 0.0)
  232. except Exception:
  233. ema_fast = 0.0
  234. ema_slow = 0.0
  235. if direction in {"bull", "up", "long"} and ema_fast > ema_slow > 0:
  236. score += 0.05
  237. elif direction in {"bear", "down", "short"} and 0 < ema_fast < ema_slow:
  238. score += 0.05
  239. return max(0.0, min(1.0, round(score, 4)))
  240. def _suggest_amount(self, price: float) -> float:
  241. min_notional = float(self.context.minimum_order_value or 0.0)
  242. max_order = float(self.config.get("max_order_size", 0.0) or 0.0)
  243. if hasattr(self.context, "suggest_order_amount"):
  244. fee_rate = self._live_fee_rate()
  245. return float(self.context.suggest_order_amount(
  246. side="buy" if str(self.state.get("last_signal") or "").lower() in {"bull", "up", "long"} else "sell",
  247. price=price,
  248. levels=1,
  249. min_notional=min_notional,
  250. fee_rate=fee_rate,
  251. max_notional_per_order=(max_order * price) if max_order > 0 else 0.0,
  252. order_size=float(self.config.get("order_size", 0.0) or 0.0),
  253. ) or 0.0)
  254. amount = float(self.config.get("order_size", 0.0) or 0.0)
  255. if max_order > 0:
  256. amount = min(amount, max_order)
  257. return max(amount, 0.0)
  258. def on_tick(self, tick):
  259. self.state["last_error"] = ""
  260. self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
  261. self._refresh_balance_snapshot()
  262. price = self._price()
  263. self.state["last_price"] = price
  264. if int(self.state.get("cooldown_remaining") or 0) > 0:
  265. self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
  266. self.state["last_action"] = "cooldown"
  267. return {"action": "cooldown", "price": price}
  268. direction, strength = self._trend_strength()
  269. self.state["last_signal"] = direction
  270. self.state["last_strength"] = strength
  271. if strength < float(self.config.get("trend_strength_min", 0.65) or 0.65):
  272. self.state["last_action"] = "hold"
  273. return {"action": "hold", "price": price, "reason": "trend too weak", "strength": strength}
  274. amount = self._suggest_amount(price)
  275. if amount <= 0:
  276. self.state["last_action"] = "hold"
  277. return {"action": "hold", "price": price, "reason": "no usable size"}
  278. side = "buy" if direction in {"bull", "up", "long"} else "sell"
  279. offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
  280. if side == "buy":
  281. order_price = round(price * (1 + offset), 8)
  282. else:
  283. order_price = round(price * (1 - offset), 8)
  284. try:
  285. if self.config.get("debug_orders", True):
  286. self._log(f"{side} trend amount={amount:.6g} price={order_price} strength={strength:.3f}")
  287. result = self.context.place_order(
  288. side=side,
  289. order_type="limit",
  290. amount=amount,
  291. price=order_price,
  292. market=self._market_symbol(),
  293. )
  294. self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
  295. self.state["last_order_at"] = datetime.now(timezone.utc).timestamp()
  296. self.state["last_order_price"] = order_price
  297. self.state["last_action"] = f"{side}_trend"
  298. return {"action": side, "price": order_price, "amount": amount, "result": result, "strength": strength}
  299. except Exception as exc:
  300. self.state["last_error"] = str(exc)
  301. self._log(f"trend order failed: {exc}")
  302. self.state["last_action"] = "error"
  303. return {"action": "error", "price": price, "error": str(exc)}
  304. def report(self):
  305. snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
  306. return {
  307. "identity": snapshot.get("identity", {}),
  308. "control": snapshot.get("control", {}),
  309. "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
  310. "position": snapshot.get("position", {}),
  311. "state": {
  312. "last_price": self.state.get("last_price", 0.0),
  313. "last_action": self.state.get("last_action", "idle"),
  314. "last_signal": self.state.get("last_signal", "neutral"),
  315. "last_strength": self.state.get("last_strength", 0.0),
  316. "cooldown_remaining": self.state.get("cooldown_remaining", 0),
  317. "base_available": self.state.get("base_available", 0.0),
  318. "counter_available": self.state.get("counter_available", 0.0),
  319. },
  320. "assessment": {
  321. "confidence": None,
  322. "uncertainty": None,
  323. "reason": "trend capture",
  324. "warnings": [],
  325. "policy": dict(self.config.get("policy") or {}),
  326. },
  327. "execution": snapshot.get("execution", {}),
  328. "supervision": self._supervision(),
  329. }
  330. def render(self):
  331. return {
  332. "widgets": [
  333. {"type": "metric", "label": "market", "value": self._market_symbol()},
  334. {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  335. {"type": "metric", "label": "signal", "value": self.state.get("last_signal", "neutral")},
  336. {"type": "metric", "label": "strength", "value": round(float(self.state.get("last_strength") or 0.0), 4)},
  337. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  338. {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
  339. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  340. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  341. ]
  342. }