trend_follower.py 9.9 KB

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