dumb_trader.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. from __future__ import annotations
  2. from datetime import datetime, timezone
  3. from src.trader_mcp.logging_utils import log_event
  4. from src.trader_mcp.strategy_sdk import Strategy
  5. from src.trader_mcp.strategy_sizing import suggest_quote_sized_amount
  6. class Strategy(Strategy):
  7. LABEL = "Dumb Trader"
  8. STRATEGY_PROFILE = {
  9. "expects": {},
  10. "avoids": {},
  11. "risk_profile": "neutral",
  12. "capabilities": ["side_only_execution", "quote_sizing"],
  13. "role": "primary",
  14. "inventory_behavior": "unspecified",
  15. "requires_rebalance_before_start": False,
  16. "requires_rebalance_before_stop": False,
  17. "safe_when_unbalanced": True,
  18. "can_run_with": ["exposure_protector"],
  19. }
  20. TICK_MINUTES = 0.5
  21. CONFIG_SCHEMA = {
  22. "trade_side": {"type": "string", "default": "both"},
  23. "entry_offset_pct": {"type": "float", "default": 0.003, "min": 0.0, "max": 1.0},
  24. "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
  25. "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
  26. "dust_collect": {"type": "bool", "default": False},
  27. "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
  28. "debug_orders": {"type": "bool", "default": True},
  29. }
  30. STATE_SCHEMA = {
  31. "last_price": {"type": "float", "default": 0.0},
  32. "last_action": {"type": "string", "default": "idle"},
  33. "last_error": {"type": "string", "default": ""},
  34. "debug_log": {"type": "list", "default": []},
  35. "trade_side": {"type": "string", "default": "both"},
  36. "cooldown_remaining": {"type": "int", "default": 0},
  37. "last_order_at": {"type": "float", "default": 0.0},
  38. "last_order_price": {"type": "float", "default": 0.0},
  39. "base_available": {"type": "float", "default": 0.0},
  40. "counter_available": {"type": "float", "default": 0.0},
  41. "balance_snapshot_ok": {"type": "bool", "default": False},
  42. "balance_snapshot_updated_at": {"type": "string", "default": ""},
  43. }
  44. def init(self):
  45. return {
  46. "last_price": 0.0,
  47. "last_action": "idle",
  48. "last_error": "",
  49. "debug_log": ["init dumb trader"],
  50. "trade_side": "both",
  51. "cooldown_remaining": 0,
  52. "last_order_at": 0.0,
  53. "last_order_price": 0.0,
  54. "base_available": 0.0,
  55. "counter_available": 0.0,
  56. "balance_snapshot_ok": False,
  57. "balance_snapshot_updated_at": "",
  58. }
  59. def _log(self, message: str) -> None:
  60. state = getattr(self, "state", {}) or {}
  61. log = list(state.get("debug_log") or [])
  62. log.append(message)
  63. state["debug_log"] = log[-12:]
  64. self.state = state
  65. log_event("dumb_trader", message)
  66. def _base_symbol(self) -> str:
  67. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  68. def _market_symbol(self) -> str:
  69. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  70. def _trade_side(self) -> str:
  71. side = str(self.config.get("trade_side") or "both").strip().lower()
  72. return side if side in {"buy", "sell", "both"} else "both"
  73. def _price(self) -> float:
  74. payload = self.context.get_price(self._base_symbol())
  75. return float(payload.get("price") or 0.0)
  76. def _live_fee_rate(self) -> float:
  77. try:
  78. payload = self.context.get_fee_rates(self._market_symbol())
  79. return float(payload.get("maker") or payload.get("taker") or 0.0)
  80. except Exception as exc:
  81. self._log(f"fee lookup failed: {exc}")
  82. return 0.0
  83. def _refresh_balance_snapshot(self) -> bool:
  84. try:
  85. info = self.context.get_account_info()
  86. except Exception as exc:
  87. self._log(f"balance refresh failed: {exc}")
  88. self.state["base_available"] = 0.0
  89. self.state["counter_available"] = 0.0
  90. self.state["balance_snapshot_ok"] = False
  91. self.state["balance_snapshot_updated_at"] = ""
  92. return False
  93. balances = info.get("balances") if isinstance(info, dict) else []
  94. if not isinstance(balances, list):
  95. self.state["base_available"] = 0.0
  96. self.state["counter_available"] = 0.0
  97. self.state["balance_snapshot_ok"] = False
  98. self.state["balance_snapshot_updated_at"] = ""
  99. return False
  100. base = self._base_symbol()
  101. quote = str(self.context.counter_currency or "USD").upper()
  102. for balance in balances:
  103. if not isinstance(balance, dict):
  104. continue
  105. asset = str(balance.get("asset_code") or "").upper()
  106. try:
  107. available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  108. except Exception:
  109. continue
  110. if asset == base:
  111. self.state["base_available"] = available
  112. if asset == quote:
  113. self.state["counter_available"] = available
  114. self.state["balance_snapshot_ok"] = True
  115. self.state["balance_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
  116. return True
  117. def _supervision(self) -> dict:
  118. last_error = str(self.state.get("last_error") or "")
  119. side = self._trade_side()
  120. entry_offset_pct = float(self.config.get("entry_offset_pct") or 0.003)
  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("side selection is symmetric and requires external 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": "balanced" if side in {"buy", "sell"} else "unknown",
  139. "capacity_available": side in {"buy", "sell"},
  140. "trade_side": side,
  141. "entry_offset_pct": round(entry_offset_pct, 6),
  142. "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
  143. "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
  144. "dust_collect": bool(self.config.get("dust_collect", False)),
  145. "last_order_age_seconds": last_order_age_seconds,
  146. "last_order_price": float(self.state.get("last_order_price") or 0.0),
  147. "chasing_risk": chasing_risk,
  148. "concerns": concerns,
  149. "last_reason": last_error or f"trade_side={side}",
  150. }
  151. def apply_policy(self):
  152. policy = super().apply_policy()
  153. self.state["policy_derived"] = {
  154. "trade_side": self._trade_side(),
  155. "entry_offset_pct": float(self.config.get("entry_offset_pct") or 0.003),
  156. "cooldown_ticks": int(self.config.get("cooldown_ticks") or 2),
  157. "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
  158. "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
  159. "dust_collect": bool(self.config.get("dust_collect", False)),
  160. }
  161. return policy
  162. def _suggest_amount(self, price: float, side: str) -> float:
  163. min_notional = float(self.context.minimum_order_value or 0.0)
  164. fee_rate = self._live_fee_rate()
  165. return suggest_quote_sized_amount(
  166. self.context,
  167. side=side,
  168. price=price,
  169. levels=1,
  170. min_notional=min_notional,
  171. fee_rate=fee_rate,
  172. order_notional_quote=float(self.config.get("order_notional_quote") or 0.0),
  173. max_order_notional_quote=float(self.config.get("max_order_notional_quote") or 0.0),
  174. dust_collect=bool(self.config.get("dust_collect", False)),
  175. )
  176. def on_tick(self, tick):
  177. self.state["last_error"] = ""
  178. self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
  179. price = self._price()
  180. self.state["last_price"] = price
  181. if not self._refresh_balance_snapshot():
  182. self.state["last_action"] = "hold"
  183. return {"action": "hold", "price": price, "reason": "balance refresh unavailable"}
  184. if int(self.state.get("cooldown_remaining") or 0) > 0:
  185. self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
  186. self.state["last_action"] = "cooldown"
  187. return {"action": "cooldown", "price": price}
  188. side = self._trade_side()
  189. if side not in {"buy", "sell"}:
  190. self.state["last_action"] = "hold"
  191. return {"action": "hold", "price": price, "reason": "trade_side must be buy or sell"}
  192. amount = self._suggest_amount(price, side)
  193. if amount <= 0:
  194. self.state["last_action"] = "hold"
  195. return {"action": "hold", "price": price, "reason": "no usable size"}
  196. offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
  197. if side == "buy":
  198. order_price = round(price * (1 + offset), 8)
  199. else:
  200. order_price = round(price * (1 - offset), 8)
  201. try:
  202. if self.config.get("debug_orders", True):
  203. self._log(f"{side} dumb amount={amount:.6g} price={order_price}")
  204. result = self.context.place_order(
  205. side=side,
  206. order_type="limit",
  207. amount=amount,
  208. price=order_price,
  209. market=self._market_symbol(),
  210. )
  211. self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
  212. self.state["last_order_at"] = datetime.now(timezone.utc).timestamp()
  213. self.state["last_order_price"] = order_price
  214. self.state["last_action"] = f"{side}_dumb"
  215. return {"action": side, "price": order_price, "amount": amount, "result": result}
  216. except Exception as exc:
  217. self.state["last_error"] = str(exc)
  218. self._log(f"dumb order failed: {exc}")
  219. self.state["last_action"] = "error"
  220. return {"action": "error", "price": price, "error": str(exc)}
  221. def report(self):
  222. snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
  223. return {
  224. "identity": snapshot.get("identity", {}),
  225. "control": snapshot.get("control", {}),
  226. "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
  227. "position": snapshot.get("position", {}),
  228. "state": {
  229. "last_price": self.state.get("last_price", 0.0),
  230. "last_action": self.state.get("last_action", "idle"),
  231. "trade_side": self._trade_side(),
  232. "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
  233. "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
  234. "dust_collect": bool(self.config.get("dust_collect", False)),
  235. "cooldown_remaining": self.state.get("cooldown_remaining", 0),
  236. "base_available": self.state.get("base_available", 0.0),
  237. "counter_available": self.state.get("counter_available", 0.0),
  238. },
  239. "assessment": {
  240. "confidence": None,
  241. "uncertainty": None,
  242. "reason": "side-only execution",
  243. "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
  244. "policy": dict(self.config.get("policy") or {}),
  245. },
  246. "execution": snapshot.get("execution", {}),
  247. "supervision": self._supervision(),
  248. }
  249. def render(self):
  250. return {
  251. "widgets": [
  252. {"type": "metric", "label": "market", "value": self._market_symbol()},
  253. {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  254. {"type": "metric", "label": "side", "value": self._trade_side()},
  255. {"type": "metric", "label": "quote notional", "value": round(float(self.config.get("order_notional_quote") or 0.0), 6)},
  256. {"type": "metric", "label": "dust collect", "value": bool(self.config.get("dust_collect", False))},
  257. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  258. {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
  259. {"type": "metric", "label": "balances", "value": "fresh" if self.state.get("balance_snapshot_ok") else "stale"},
  260. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  261. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  262. ]
  263. }