trend_follower.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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": ["trend_capture", "momentum_following", "position_persistence"],
  22. }
  23. TICK_MINUTES = 0.5
  24. CONFIG_SCHEMA = {
  25. "trend_timeframe": {"type": "string", "default": "1h"},
  26. "trend_strength_min": {"type": "float", "default": 0.65, "min": 0.0, "max": 1.0},
  27. "entry_offset_pct": {"type": "float", "default": 0.003, "min": 0.0, "max": 1.0},
  28. "exit_offset_pct": {"type": "float", "default": 0.002, "min": 0.0, "max": 1.0},
  29. "order_size": {"type": "float", "default": 0.0, "min": 0.0},
  30. "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
  31. "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
  32. "debug_orders": {"type": "bool", "default": True},
  33. }
  34. STATE_SCHEMA = {
  35. "last_price": {"type": "float", "default": 0.0},
  36. "last_action": {"type": "string", "default": "idle"},
  37. "last_error": {"type": "string", "default": ""},
  38. "debug_log": {"type": "list", "default": []},
  39. "last_signal": {"type": "string", "default": "neutral"},
  40. "last_strength": {"type": "float", "default": 0.0},
  41. "cooldown_remaining": {"type": "int", "default": 0},
  42. "last_order_at": {"type": "float", "default": 0.0},
  43. "last_order_price": {"type": "float", "default": 0.0},
  44. }
  45. def init(self):
  46. return {
  47. "last_price": 0.0,
  48. "last_action": "idle",
  49. "last_error": "",
  50. "debug_log": ["init trend follower"],
  51. "last_signal": "neutral",
  52. "last_strength": 0.0,
  53. "cooldown_remaining": 0,
  54. "last_order_at": 0.0,
  55. "last_order_price": 0.0,
  56. }
  57. def _log(self, message: str) -> None:
  58. state = getattr(self, "state", {}) or {}
  59. log = list(state.get("debug_log") or [])
  60. log.append(message)
  61. state["debug_log"] = log[-12:]
  62. self.state = state
  63. log_event("trend", message)
  64. def _base_symbol(self) -> str:
  65. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  66. def _market_symbol(self) -> str:
  67. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  68. def _price(self) -> float:
  69. payload = self.context.get_price(self._base_symbol())
  70. return float(payload.get("price") or 0.0)
  71. def _trend_snapshot(self) -> dict:
  72. tf = str(self.config.get("trend_timeframe", "1h") or "1h")
  73. try:
  74. return self.context.get_regime(self._base_symbol(), tf)
  75. except Exception as exc:
  76. self._log(f"trend lookup failed: {exc}")
  77. return {"error": str(exc)}
  78. def apply_policy(self):
  79. policy = super().apply_policy()
  80. risk = str(policy.get("risk_posture") or "normal").lower()
  81. priority = str(policy.get("priority") or "normal").lower()
  82. strength_map = {"cautious": 0.8, "normal": 0.65, "assertive": 0.5}
  83. entry_map = {"cautious": 0.002, "normal": 0.003, "assertive": 0.005}
  84. exit_map = {"cautious": 0.0015, "normal": 0.002, "assertive": 0.003}
  85. cooldown_map = {"cautious": 4, "normal": 2, "assertive": 1}
  86. size_map = {"cautious": 0.5, "normal": 1.0, "assertive": 1.5}
  87. if priority in {"low", "background"}:
  88. risk = "cautious"
  89. elif priority in {"high", "urgent"}:
  90. risk = "assertive"
  91. self.config["trend_strength_min"] = strength_map.get(risk, 0.65)
  92. self.config["entry_offset_pct"] = entry_map.get(risk, 0.003)
  93. self.config["exit_offset_pct"] = exit_map.get(risk, 0.002)
  94. self.config["cooldown_ticks"] = cooldown_map.get(risk, 2)
  95. self.config["order_size"] = size_map.get(risk, 1.0)
  96. self.state["policy_derived"] = {
  97. "trend_strength_min": self.config["trend_strength_min"],
  98. "entry_offset_pct": self.config["entry_offset_pct"],
  99. "exit_offset_pct": self.config["exit_offset_pct"],
  100. "cooldown_ticks": self.config["cooldown_ticks"],
  101. "order_size": self.config["order_size"],
  102. }
  103. return policy
  104. def _trend_strength(self) -> tuple[str, float]:
  105. regime = self._trend_snapshot()
  106. trend = regime.get("trend") or {}
  107. direction = str(trend.get("state") or trend.get("direction") or "unknown")
  108. try:
  109. strength = float(trend.get("strength") or 0.0)
  110. except Exception:
  111. strength = 0.0
  112. return direction, strength
  113. def _suggest_amount(self, price: float) -> float:
  114. amount = float(self.config.get("order_size", 0.0) or 0.0)
  115. max_order = float(self.config.get("max_order_size", 0.0) or 0.0)
  116. if max_order > 0:
  117. amount = min(amount, max_order)
  118. return max(amount, 0.0)
  119. def on_tick(self, tick):
  120. self.state["last_error"] = ""
  121. self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
  122. price = self._price()
  123. self.state["last_price"] = price
  124. if int(self.state.get("cooldown_remaining") or 0) > 0:
  125. self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
  126. self.state["last_action"] = "cooldown"
  127. return {"action": "cooldown", "price": price}
  128. direction, strength = self._trend_strength()
  129. self.state["last_signal"] = direction
  130. self.state["last_strength"] = strength
  131. if strength < float(self.config.get("trend_strength_min", 0.65) or 0.65):
  132. self.state["last_action"] = "hold"
  133. return {"action": "hold", "price": price, "reason": "trend too weak", "strength": strength}
  134. amount = self._suggest_amount(price)
  135. if amount <= 0:
  136. self.state["last_action"] = "hold"
  137. return {"action": "hold", "price": price, "reason": "no usable size"}
  138. side = "buy" if direction in {"bull", "up", "long"} else "sell"
  139. offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
  140. if side == "buy":
  141. order_price = round(price * (1 + offset), 8)
  142. else:
  143. order_price = round(price * (1 - offset), 8)
  144. try:
  145. if self.config.get("debug_orders", True):
  146. self._log(f"{side} trend amount={amount:.6g} price={order_price} strength={strength:.3f}")
  147. result = self.context.place_order(
  148. side=side,
  149. order_type="limit",
  150. amount=amount,
  151. price=order_price,
  152. market=self._market_symbol(),
  153. )
  154. self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
  155. self.state["last_order_at"] = datetime.now(timezone.utc).timestamp()
  156. self.state["last_order_price"] = order_price
  157. self.state["last_action"] = f"{side}_trend"
  158. return {"action": side, "price": order_price, "amount": amount, "result": result, "strength": strength}
  159. except Exception as exc:
  160. self.state["last_error"] = str(exc)
  161. self._log(f"trend order failed: {exc}")
  162. self.state["last_action"] = "error"
  163. return {"action": "error", "price": price, "error": str(exc)}
  164. def report(self):
  165. snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
  166. return {
  167. "identity": snapshot.get("identity", {}),
  168. "control": snapshot.get("control", {}),
  169. "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
  170. "position": snapshot.get("position", {}),
  171. "state": {
  172. "last_price": self.state.get("last_price", 0.0),
  173. "last_action": self.state.get("last_action", "idle"),
  174. "last_signal": self.state.get("last_signal", "neutral"),
  175. "last_strength": self.state.get("last_strength", 0.0),
  176. "cooldown_remaining": self.state.get("cooldown_remaining", 0),
  177. },
  178. "assessment": {
  179. "confidence": None,
  180. "uncertainty": None,
  181. "reason": "trend capture",
  182. "warnings": [],
  183. "policy": dict(self.config.get("policy") or {}),
  184. },
  185. "execution": snapshot.get("execution", {}),
  186. }
  187. def render(self):
  188. return {
  189. "widgets": [
  190. {"type": "metric", "label": "market", "value": self._market_symbol()},
  191. {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  192. {"type": "metric", "label": "signal", "value": self.state.get("last_signal", "neutral")},
  193. {"type": "metric", "label": "strength", "value": round(float(self.state.get("last_strength") or 0.0), 4)},
  194. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  195. {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
  196. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  197. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  198. ]
  199. }