grid_trader.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  1. from __future__ import annotations
  2. import time
  3. from datetime import datetime, timezone
  4. from src.trader_mcp.strategy_sdk import Strategy
  5. class Strategy(Strategy):
  6. LABEL = "Grid Trader"
  7. TICK_MINUTES = 0.2
  8. CONFIG_SCHEMA = {
  9. "grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
  10. "grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
  11. "volatility_timeframe": {"type": "string", "default": "1h"},
  12. "volatility_multiplier": {"type": "float", "default": 0.5, "min": 0.0, "max": 10.0},
  13. "grid_step_min_pct": {"type": "float", "default": 0.005, "min": 0.0001, "max": 0.5},
  14. "grid_step_max_pct": {"type": "float", "default": 0.03, "min": 0.0001, "max": 1.0},
  15. "order_size": {"type": "float", "default": 0.0, "min": 0.0},
  16. "inventory_cap_pct": {"type": "float", "default": 0.7, "min": 0.0, "max": 1.0},
  17. "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
  18. "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
  19. "trade_sides": {"type": "string", "default": "both"},
  20. "max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
  21. "order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
  22. "enable_trend_guard": {"type": "bool", "default": True},
  23. "trend_guard_reversal_max": {"type": "float", "default": 0.25, "min": 0.0, "max": 1.0},
  24. "debug_orders": {"type": "bool", "default": True},
  25. "use_all_available": {"type": "bool", "default": True},
  26. }
  27. STATE_SCHEMA = {
  28. "center_price": {"type": "float", "default": 0.0},
  29. "last_price": {"type": "float", "default": 0.0},
  30. "seeded": {"type": "bool", "default": False},
  31. "last_action": {"type": "string", "default": "idle"},
  32. "last_error": {"type": "string", "default": ""},
  33. "orders": {"type": "list", "default": []},
  34. "order_ids": {"type": "list", "default": []},
  35. "debug_log": {"type": "list", "default": []},
  36. "base_available": {"type": "float", "default": 0.0},
  37. "counter_available": {"type": "float", "default": 0.0},
  38. "trend_guard_active": {"type": "bool", "default": False},
  39. "regimes_updated_at": {"type": "string", "default": ""},
  40. "account_snapshot_updated_at": {"type": "string", "default": ""},
  41. "grid_refresh_pending_until": {"type": "string", "default": ""},
  42. }
  43. def init(self):
  44. return {
  45. "center_price": 0.0,
  46. "last_price": 0.0,
  47. "seeded": False,
  48. "last_action": "idle",
  49. "last_error": "",
  50. "orders": [],
  51. "order_ids": [],
  52. "debug_log": ["init cancel all orders"],
  53. "base_available": 0.0,
  54. "counter_available": 0.0,
  55. "trend_guard_active": False,
  56. "regimes_updated_at": "",
  57. "account_snapshot_updated_at": "",
  58. "grid_refresh_pending_until": "",
  59. }
  60. def _log(self, message: str) -> None:
  61. state = getattr(self, "state", {}) or {}
  62. log = list(state.get("debug_log") or [])
  63. log.append(message)
  64. state["debug_log"] = log[-12:]
  65. self.state = state
  66. def _set_grid_refresh_pause(self, seconds: float = 30.0) -> None:
  67. self.state["grid_refresh_pending_until"] = (datetime.now(timezone.utc).timestamp() + max(seconds, 0.0))
  68. def _grid_refresh_paused(self) -> bool:
  69. try:
  70. until = float(self.state.get("grid_refresh_pending_until") or 0.0)
  71. except Exception:
  72. until = 0.0
  73. return until > datetime.now(timezone.utc).timestamp()
  74. def _base_symbol(self) -> str:
  75. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  76. def _market_symbol(self) -> str:
  77. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  78. def _mode(self) -> str:
  79. return getattr(self.context, "mode", "active") or "active"
  80. def _price(self) -> float:
  81. payload = self.context.get_price(self._base_symbol())
  82. return float(payload.get("price") or 0.0)
  83. def _regime_snapshot(self) -> dict:
  84. timeframes = ["1d", "4h", "1h", "15m"]
  85. snapshot = {}
  86. for tf in timeframes:
  87. try:
  88. snapshot[tf] = self.context.get_regime(self._base_symbol(), tf)
  89. except Exception as exc:
  90. snapshot[tf] = {"error": str(exc)}
  91. return snapshot
  92. def _refresh_regimes(self) -> None:
  93. self.state["regimes"] = self._regime_snapshot()
  94. self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
  95. def _trend_guard_status(self) -> tuple[bool, str]:
  96. if not bool(self.config.get("enable_trend_guard", True)):
  97. return False, "disabled"
  98. reversal_max = float(self.config.get("trend_guard_reversal_max", 0.25) or 0.0)
  99. regimes = self.state.get("regimes") or self._regime_snapshot()
  100. d1 = (regimes.get("1d") or {}) if isinstance(regimes, dict) else {}
  101. h4 = (regimes.get("4h") or {}) if isinstance(regimes, dict) else {}
  102. d1_trend = str((d1.get("trend") or {}).get("state") or "unknown")
  103. h4_trend = str((h4.get("trend") or {}).get("state") or "unknown")
  104. d1_rev = float((d1.get("reversal") or {}).get("score") or 0.0)
  105. h4_rev = float((h4.get("reversal") or {}).get("score") or 0.0)
  106. strong_trend = d1_trend in {"bull", "bear"} and d1_trend == h4_trend
  107. weak_reversal = max(d1_rev, h4_rev) <= reversal_max
  108. active = bool(strong_trend and weak_reversal)
  109. reason = f"1d={d1_trend} 4h={h4_trend} rev={max(d1_rev, h4_rev):.3f}"
  110. return active, reason
  111. def _grid_step_pct(self) -> float:
  112. base_step = float(self.config.get("grid_step_pct", 0.012) or 0.012)
  113. tf = str(self.config.get("volatility_timeframe", "1h") or "1h")
  114. multiplier = float(self.config.get("volatility_multiplier", 0.5) or 0.0)
  115. min_step = float(self.config.get("grid_step_min_pct", 0.005) or 0.0)
  116. max_step = float(self.config.get("grid_step_max_pct", 0.03) or 1.0)
  117. try:
  118. regime = self.context.get_regime(self._base_symbol(), tf)
  119. short_regime = self.context.get_regime(self._base_symbol(), "15m")
  120. tf_atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  121. atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  122. short_atr_pct = float((short_regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  123. atr_pct = max(atr_pct, short_atr_pct)
  124. self.state["regimes"] = self._regime_snapshot()
  125. except Exception as exc:
  126. self._log(f"regime fetch failed: {exc}")
  127. tf_atr_pct = 0.0
  128. atr_pct = 0.0
  129. short_atr_pct = 0.0
  130. adaptive = (atr_pct / 100.0) * multiplier if atr_pct > 0 else base_step
  131. step = adaptive if atr_pct > 0 else base_step
  132. step = max(step, min_step)
  133. step = min(step, max_step)
  134. self.state["grid_step_pct"] = step
  135. self.state["atr_percent_tf"] = tf_atr_pct
  136. self.state["atr_percent_15m"] = short_atr_pct
  137. self.state["atr_percent"] = atr_pct
  138. return step
  139. def _available_balance(self, asset_code: str) -> float:
  140. try:
  141. info = self.context.get_account_info()
  142. except Exception as exc:
  143. self._log(f"account info failed: {exc}")
  144. return 0.0
  145. balances = info.get("balances") if isinstance(info, dict) else []
  146. if not isinstance(balances, list):
  147. return 0.0
  148. wanted = str(asset_code or "").upper()
  149. for balance in balances:
  150. if not isinstance(balance, dict):
  151. continue
  152. if str(balance.get("asset_code") or "").upper() != wanted:
  153. continue
  154. try:
  155. return float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  156. except Exception:
  157. return 0.0
  158. return 0.0
  159. def _refresh_balance_snapshot(self) -> None:
  160. try:
  161. info = self.context.get_account_info()
  162. except Exception as exc:
  163. self._log(f"balance refresh failed: {exc}")
  164. return
  165. balances = info.get("balances") if isinstance(info, dict) else []
  166. if not isinstance(balances, list):
  167. return
  168. base = self._base_symbol()
  169. quote = self.context.counter_currency or "USD"
  170. for balance in balances:
  171. if not isinstance(balance, dict):
  172. continue
  173. asset = str(balance.get("asset_code") or "").upper()
  174. try:
  175. available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  176. except Exception:
  177. continue
  178. if asset == base:
  179. self.state["base_available"] = available
  180. if asset == str(quote).upper():
  181. self.state["counter_available"] = available
  182. self.state["account_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
  183. def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
  184. if min_notional <= 0 or price <= 0:
  185. return 0
  186. safety = 0.995
  187. fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
  188. if side == "buy":
  189. quote = self.context.counter_currency or "USD"
  190. quote_available = self._available_balance(quote)
  191. self.state["counter_available"] = quote_available
  192. usable_notional = quote_available * safety
  193. return max(int(usable_notional / min_notional), 0)
  194. base = self._base_symbol()
  195. base_available = self._available_balance(base)
  196. self.state["base_available"] = base_available
  197. usable_notional = base_available * safety * price / (1 + fee_rate)
  198. return max(int(usable_notional / min_notional), 0)
  199. def _side_allowed(self, side: str) -> bool:
  200. selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
  201. if selected == "both":
  202. return True
  203. return selected == side
  204. def _desired_sides(self) -> set[str]:
  205. selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
  206. if selected == "both":
  207. return {"buy", "sell"}
  208. if selected in {"buy", "sell"}:
  209. return {selected}
  210. return {"buy", "sell"}
  211. def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
  212. """Derive a per-order amount from the currently available balance.
  213. This helper is used when the grid seeds, tops up, or replaces an order.
  214. It folds in the live available balance, fee cushion, per-order caps, and
  215. the exchange minimum notional. If the wallet cannot support a valid order,
  216. it returns 0.0 instead of forcing an impossible minimum size.
  217. """
  218. if levels <= 0 or price <= 0:
  219. return 0.0
  220. safety = 0.995
  221. fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
  222. max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
  223. manual = float(self.config.get("order_size", 0.0) or 0.0)
  224. min_amount = (min_notional / price) if min_notional > 0 else 0.0
  225. if side == "buy":
  226. quote = self.context.counter_currency or "USD"
  227. quote_available = self._available_balance(quote)
  228. self.state["counter_available"] = quote_available
  229. spendable_quote = quote_available * safety
  230. max_affordable = spendable_quote / (price * (1 + fee_rate))
  231. if max_affordable < min_amount:
  232. return 0.0
  233. amount = min(spendable_quote / (max(levels, 1) * price * (1 + fee_rate)), max_affordable)
  234. else:
  235. base = self._base_symbol()
  236. base_available = self._available_balance(base)
  237. self.state["base_available"] = base_available
  238. spendable_base = (base_available * safety) / (1 + fee_rate)
  239. max_affordable = spendable_base
  240. if max_affordable < min_amount:
  241. return 0.0
  242. amount = min(spendable_base / max(levels, 1), max_affordable)
  243. amount = max(amount, min_amount * 1.05)
  244. if max_notional > 0 and price > 0:
  245. amount = min(amount, max_notional / (price * (1 + fee_rate)))
  246. if manual > 0:
  247. if manual >= min_amount:
  248. amount = min(amount, manual)
  249. else:
  250. self._log(
  251. f"manual order_size below minimum: order_size={manual:.6g} min_amount={min_amount:.6g} price={price} min_notional={min_notional}"
  252. )
  253. return max(amount, 0.0)
  254. def _place_grid(self, center: float) -> None:
  255. mode = self._mode()
  256. levels = int(self.config.get("grid_levels", 6) or 6)
  257. step = self._grid_step_pct()
  258. min_notional = float(self.context.minimum_order_value or 0.0)
  259. market = self._market_symbol()
  260. orders = []
  261. order_ids = []
  262. def _capture_order_id(result):
  263. if isinstance(result, dict):
  264. return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  265. return None
  266. buy_levels = min(levels, self._supported_levels("buy", center, min_notional)) if (mode == "active" and self._side_allowed("buy")) else (levels if self._side_allowed("buy") else 0)
  267. sell_levels = min(levels, self._supported_levels("sell", center, min_notional)) if (mode == "active" and self._side_allowed("sell")) else (levels if self._side_allowed("sell") else 0)
  268. buy_amount = self._suggest_amount("buy", center, max(buy_levels, 1), min_notional)
  269. sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
  270. for i in range(1, levels + 1):
  271. buy_price = round(center * (1 - (step * i)), 8)
  272. sell_price = round(center * (1 + (step * i)), 8)
  273. if mode != "active":
  274. orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
  275. orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
  276. self._log(f"plan level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
  277. continue
  278. if i > buy_levels and i > sell_levels:
  279. self._log(f"skip level {i}: no capacity on either side")
  280. continue
  281. min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
  282. min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
  283. try:
  284. if i <= buy_levels and buy_amount >= min_size_buy:
  285. buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
  286. orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
  287. buy_id = _capture_order_id(buy)
  288. if buy_id is not None:
  289. order_ids.append(str(buy_id))
  290. if i <= sell_levels and sell_amount >= min_size_sell:
  291. sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
  292. orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
  293. sell_id = _capture_order_id(sell)
  294. if sell_id is not None:
  295. order_ids.append(str(sell_id))
  296. self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
  297. except Exception as exc: # best effort for first draft
  298. self.state["last_error"] = str(exc)
  299. self._log(f"seed level {i} failed: {exc}")
  300. continue
  301. delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
  302. if delay > 0:
  303. time.sleep(delay)
  304. self.state["orders"] = orders
  305. self.state["order_ids"] = order_ids
  306. self.state["last_action"] = "seeded grid"
  307. self._set_grid_refresh_pause()
  308. def _place_side_grid(self, side: str, center: float, *, start_level: int = 1) -> None:
  309. levels = int(self.config.get("grid_levels", 6) or 6)
  310. step = self._grid_step_pct()
  311. min_notional = float(self.context.minimum_order_value or 0.0)
  312. fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
  313. safety = 0.995
  314. market = self._market_symbol()
  315. orders = list(self.state.get("orders") or [])
  316. order_ids = list(self.state.get("order_ids") or [])
  317. side_levels = min(levels, self._supported_levels(side, center, min_notional))
  318. amount = self._suggest_amount(side, center, max(side_levels, 1), min_notional)
  319. if side == "buy":
  320. quote = self.context.counter_currency or "USD"
  321. quote_available = self._available_balance(quote)
  322. max_affordable_amount = (quote_available * safety) / (center * (1 + fee_rate)) if center > 0 else 0.0
  323. min_amount = (min_notional / center) if center > 0 and min_notional > 0 else 0.0
  324. if max_affordable_amount < min_amount:
  325. self._log(
  326. f"skip side buy: insufficient counter balance quote={quote_available:.6g} max_affordable_amount={max_affordable_amount:.6g} min_amount={min_amount:.6g} fee_rate={fee_rate}"
  327. )
  328. return
  329. amount = min(amount, max_affordable_amount)
  330. if side_levels <= 0 and min_notional > 0 and center > 0:
  331. min_amount = min_notional / center
  332. if amount >= min_amount:
  333. side_levels = 1
  334. self._log(f"side {side} restored to 1 level because amount clears minimum: amount={amount:.6g} min_amount={min_amount:.6g}")
  335. self._log(
  336. f"prepare side {side}: market={market} center={center} levels={side_levels} amount={amount:.6g} min_notional={min_notional} existing_ids={order_ids}"
  337. )
  338. for i in range(start_level, levels + 1):
  339. price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
  340. min_size = (min_notional / price) if price > 0 else 0.0
  341. if i > side_levels or amount < min_size:
  342. self._log(
  343. f"skip side {side} level {i}: amount={amount:.6g} below min_size={min_size:.6g} min_notional={min_notional} price={price}"
  344. )
  345. continue
  346. try:
  347. self._log(f"place side {side} level {i}: price={price} amount={amount:.6g}")
  348. result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
  349. status = None
  350. order_id = None
  351. if isinstance(result, dict):
  352. status = result.get("status")
  353. order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  354. self._log(f"place side {side} level {i} result status={status} order_id={order_id} raw={result}")
  355. orders.append({"side": side, "price": price, "amount": amount, "result": result})
  356. if order_id is not None:
  357. order_ids.append(str(order_id))
  358. self._log(f"seed side {side} level {i}: {price} amount {amount:.6g}")
  359. except Exception as exc:
  360. self.state["last_error"] = str(exc)
  361. self._log(f"seed side {side} level {i} failed: {exc}")
  362. continue
  363. delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
  364. if delay > 0:
  365. time.sleep(delay)
  366. self.state["orders"] = orders
  367. self.state["order_ids"] = order_ids
  368. self._log(f"side {side} placement complete: tracked_ids={order_ids}")
  369. self._set_grid_refresh_pause()
  370. def _top_up_missing_levels(self, center: float, live_orders: list[dict]) -> None:
  371. target_levels = int(self.config.get("grid_levels", 6) or 6)
  372. if target_levels <= 0:
  373. return
  374. for side in ("buy", "sell"):
  375. count = 0
  376. for order in live_orders:
  377. if not isinstance(order, dict):
  378. continue
  379. if str(order.get("side") or "").lower() == side:
  380. count += 1
  381. if 0 < count < target_levels:
  382. self._log(f"top up side {side}: have {count}, want {target_levels}")
  383. self._place_side_grid(side, center, start_level=count + 1)
  384. def _cancel_obsolete_side_orders(self, open_orders: list[dict], desired_sides: set[str]) -> list[str]:
  385. removed: list[str] = []
  386. for order in open_orders:
  387. if not isinstance(order, dict):
  388. continue
  389. side = str(order.get("side") or "").lower()
  390. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  391. if not order_id or side in desired_sides:
  392. continue
  393. try:
  394. self.context.cancel_order(order_id)
  395. removed.append(order_id)
  396. self._log(f"cancelled obsolete {side} order {order_id}")
  397. except Exception as exc:
  398. self.state["last_error"] = str(exc)
  399. self._log(f"cancel obsolete {side} order {order_id} failed: {exc}")
  400. return removed
  401. def _cancel_surplus_side_orders(self, open_orders: list[dict], target_levels: int) -> list[str]:
  402. removed: list[str] = []
  403. if target_levels <= 0:
  404. return removed
  405. for side in ("buy", "sell"):
  406. side_orders = [order for order in open_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == side]
  407. if len(side_orders) <= target_levels:
  408. continue
  409. surplus = side_orders[target_levels:]
  410. for order in surplus:
  411. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  412. if not order_id:
  413. continue
  414. try:
  415. self.context.cancel_order(order_id)
  416. removed.append(order_id)
  417. self._log(f"cancelled surplus {side} order {order_id}")
  418. except Exception as exc:
  419. self.state["last_error"] = str(exc)
  420. self._log(f"cancel surplus {side} order {order_id} failed: {exc}")
  421. return removed
  422. def _cancel_duplicate_level_orders(self, open_orders: list[dict]) -> list[str]:
  423. removed: list[str] = []
  424. seen: set[tuple[str, str]] = set()
  425. for order in open_orders:
  426. if not isinstance(order, dict):
  427. continue
  428. side = str(order.get("side") or "").lower()
  429. try:
  430. price_key = f"{float(order.get('price') or 0.0):.8f}"
  431. except Exception:
  432. price_key = str(order.get("price") or "")
  433. key = (side, price_key)
  434. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  435. if not order_id:
  436. continue
  437. if key in seen:
  438. try:
  439. self.context.cancel_order(order_id)
  440. removed.append(order_id)
  441. self._log(f"cancelled duplicate {side} level order {order_id} price={price_key}")
  442. except Exception as exc:
  443. self.state["last_error"] = str(exc)
  444. self._log(f"cancel duplicate {side} order {order_id} failed: {exc}")
  445. continue
  446. seen.add(key)
  447. return removed
  448. def _place_replacement_orders(self, vanished_orders: list[dict], price_hint: float) -> list[str]:
  449. placed: list[str] = []
  450. if not vanished_orders:
  451. return placed
  452. market = self._market_symbol()
  453. for order in vanished_orders:
  454. if not isinstance(order, dict):
  455. continue
  456. side = str(order.get("side") or "").lower()
  457. opposite = "sell" if side == "buy" else "buy" if side == "sell" else ""
  458. if not opposite:
  459. continue
  460. try:
  461. amount = float(order.get("amount") or 0.0)
  462. price = float(order.get("price") or price_hint or 0.0)
  463. except Exception:
  464. continue
  465. if amount <= 0 or price <= 0:
  466. continue
  467. try:
  468. self._log(f"replace filled {side} order with {opposite}: price={price} amount={amount:.6g}")
  469. result = self.context.place_order(side=opposite, order_type="limit", amount=amount, price=price, market=market)
  470. order_id = None
  471. if isinstance(result, dict):
  472. order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  473. if order_id is not None:
  474. placed.append(str(order_id))
  475. except Exception as exc:
  476. self.state["last_error"] = str(exc)
  477. self._log(f"replacement order failed for {side}→{opposite} at {price}: {exc}")
  478. return placed
  479. def _sync_open_orders_state(self) -> list[dict]:
  480. try:
  481. open_orders = self.context.get_open_orders()
  482. except Exception as exc:
  483. self.state["last_error"] = str(exc)
  484. self._log(f"open orders sync failed: {exc}")
  485. return []
  486. if not isinstance(open_orders, list):
  487. open_orders = []
  488. live_orders = [order for order in open_orders if isinstance(order, dict)]
  489. live_ids = [str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "") for order in live_orders]
  490. live_ids = [oid for oid in live_ids if oid]
  491. live_sides = [str(order.get("side") or "").lower() for order in live_orders]
  492. self.state["orders"] = live_orders
  493. self.state["order_ids"] = live_ids
  494. self.state["open_order_count"] = len(live_ids)
  495. self._log(f"sync live orders: count={len(live_ids)} sides={live_sides} ids={live_ids}")
  496. return live_orders
  497. def _cancel_orders(self, order_ids) -> None:
  498. for order_id in order_ids or []:
  499. self._log(f"dropping stale order {order_id} from state")
  500. def _reconcile_after_sync(self, previous_orders: list[dict], live_orders: list[dict], desired_sides: set[str], price: float) -> tuple[list[dict], list[str], int]:
  501. live_ids = list(self.state.get("order_ids") or [])
  502. open_order_count = len(live_ids)
  503. if self._mode() != "active":
  504. return live_orders, live_ids, open_order_count
  505. previous_ids = {
  506. str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  507. for order in previous_orders
  508. if isinstance(order, dict)
  509. }
  510. current_ids = {
  511. str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  512. for order in live_orders
  513. if isinstance(order, dict)
  514. }
  515. vanished_orders = [
  516. order
  517. for order in previous_orders
  518. if isinstance(order, dict)
  519. and str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "") in (previous_ids - current_ids)
  520. ]
  521. if vanished_orders and not self._grid_refresh_paused():
  522. replaced_ids = self._place_replacement_orders(vanished_orders, price)
  523. if replaced_ids:
  524. live_orders = self._sync_open_orders_state()
  525. live_ids = list(self.state.get("order_ids") or [])
  526. open_order_count = len(live_ids)
  527. surplus_cancelled = self._cancel_surplus_side_orders(live_orders, int(self.config.get("grid_levels", 6) or 6))
  528. duplicate_cancelled = self._cancel_duplicate_level_orders(live_orders)
  529. if surplus_cancelled or duplicate_cancelled:
  530. live_orders = self._sync_open_orders_state()
  531. live_ids = list(self.state.get("order_ids") or [])
  532. open_order_count = len(live_ids)
  533. if desired_sides != {"buy", "sell"}:
  534. live_orders = self._sync_open_orders_state()
  535. live_ids = list(self.state.get("order_ids") or [])
  536. open_order_count = len(live_ids)
  537. return live_orders, live_ids, open_order_count
  538. def on_tick(self, tick):
  539. previous_orders = list(self.state.get("orders") or [])
  540. self._refresh_balance_snapshot()
  541. price = self._price()
  542. self.state["last_price"] = price
  543. self.state["last_error"] = ""
  544. self._refresh_regimes()
  545. try:
  546. live_orders = self._sync_open_orders_state()
  547. live_ids = list(self.state.get("order_ids") or [])
  548. open_order_count = len(live_ids)
  549. expected_ids = [str(oid) for oid in (self.state.get("order_ids") or []) if oid]
  550. stale_ids = []
  551. missing_ids = []
  552. except Exception as exc:
  553. open_order_count = -1
  554. live_orders = []
  555. live_ids = []
  556. expected_ids = []
  557. stale_ids = []
  558. missing_ids = []
  559. self.state["last_error"] = str(exc)
  560. self._log(f"open orders check failed: {exc}")
  561. self.state["open_order_count"] = open_order_count
  562. desired_sides = self._desired_sides()
  563. mode = self._mode()
  564. guard_active, guard_reason = self._trend_guard_status()
  565. self.state["trend_guard_active"] = guard_active
  566. if mode == "active" and guard_active:
  567. self._log(f"trend guard active: {guard_reason}")
  568. try:
  569. self.context.cancel_all_orders()
  570. except Exception as exc:
  571. self.state["last_error"] = str(exc)
  572. self._log(f"trend guard cancel failed: {exc}")
  573. self.state["last_action"] = "trend_guard"
  574. return {"action": "guard", "price": price, "reason": guard_reason}
  575. if mode != "active":
  576. if not self.state.get("seeded") or not self.state.get("center_price"):
  577. self.state["center_price"] = price
  578. self._place_grid(price)
  579. self.state["seeded"] = True
  580. self._log(f"planned grid at {price}")
  581. return {"action": "plan", "price": price}
  582. center = float(self.state.get("center_price") or price)
  583. recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
  584. deviation = abs(price - center) / center if center else 0.0
  585. if deviation >= recenter_pct:
  586. self.state["center_price"] = price
  587. self._place_grid(price)
  588. self._log(f"planned recenter to {price}")
  589. return {"action": "plan", "price": price, "deviation": deviation}
  590. self.state["last_action"] = "observe monitor"
  591. self._log(f"observe at {price} dev {deviation:.4f}")
  592. return {"action": "observe", "price": price, "deviation": deviation}
  593. if stale_ids:
  594. self._log(f"stale live orders: {stale_ids}")
  595. self._cancel_orders(stale_ids)
  596. live_ids = [oid for oid in live_ids if oid not in stale_ids]
  597. if missing_ids:
  598. self._log(f"missing tracked orders: {missing_ids}")
  599. self.state["order_ids"] = live_ids
  600. live_orders, live_ids, open_order_count = self._reconcile_after_sync(previous_orders, live_orders, desired_sides, price)
  601. if desired_sides != {"buy", "sell"}:
  602. current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
  603. missing_side = next((side for side in desired_sides if side not in current_sides), None)
  604. if missing_side and self.state.get("center_price"):
  605. self._log(f"adding missing {missing_side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
  606. self._place_side_grid(missing_side, float(self.state.get("center_price") or price))
  607. live_orders = self._sync_open_orders_state()
  608. self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
  609. self.state["last_action"] = f"added {missing_side} side"
  610. return {"action": "add_side", "price": price, "side": missing_side}
  611. if desired_sides == {"buy", "sell"}:
  612. current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
  613. missing_sides = [side for side in ("buy", "sell") if side not in current_sides]
  614. reconciled_sides: list[str] = []
  615. if missing_sides and self.state.get("center_price") and not self._grid_refresh_paused():
  616. for side in missing_sides:
  617. self._log(f"adding missing {side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
  618. self._place_side_grid(side, float(self.state.get("center_price") or price))
  619. reconciled_sides.append(side)
  620. live_orders = self._sync_open_orders_state()
  621. self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
  622. if live_orders and self.state.get("center_price") and not self._grid_refresh_paused():
  623. self._top_up_missing_levels(float(self.state.get("center_price") or price), live_orders)
  624. live_orders = self._sync_open_orders_state()
  625. if reconciled_sides:
  626. self.state["last_action"] = f"reconciled {','.join(reconciled_sides)}"
  627. return {"action": "reconcile", "price": price, "side": ",".join(reconciled_sides)}
  628. if (not self.state.get("seeded") or not self.state.get("center_price")) and not self._grid_refresh_paused():
  629. self.state["center_price"] = price
  630. self._place_grid(price)
  631. live_orders = self._sync_open_orders_state()
  632. self.state["seeded"] = True
  633. mode = self._mode()
  634. self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
  635. return {"action": "seed" if mode == "active" else "plan", "price": price}
  636. if (open_order_count == 0 or (expected_ids and not set(expected_ids).intersection(set(live_ids)))) and not self._grid_refresh_paused():
  637. self._log("no open orders, reseeding grid")
  638. self.state["center_price"] = price
  639. self._place_grid(price)
  640. live_orders = self._sync_open_orders_state()
  641. mode = self._mode()
  642. self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
  643. return {"action": "reseed" if mode == "active" else "plan", "price": price}
  644. center = float(self.state.get("center_price") or price)
  645. recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
  646. deviation = abs(price - center) / center if center else 0.0
  647. if deviation >= recenter_pct and not self._grid_refresh_paused():
  648. try:
  649. self.context.cancel_all_orders()
  650. except Exception as exc:
  651. self.state["last_error"] = str(exc)
  652. self.state["center_price"] = price
  653. self._place_grid(price)
  654. live_orders = self._sync_open_orders_state()
  655. mode = self._mode()
  656. self.state["last_action"] = "recentered" if mode == "active" else f"{mode} monitor"
  657. self._log(f"recentered grid to {price}")
  658. return {"action": "recenter" if mode == "active" else "plan", "price": price, "deviation": deviation}
  659. mode = self._mode()
  660. self.state["last_action"] = "hold" if mode == "active" else f"{mode} monitor"
  661. self._log(f"hold at {price} dev {deviation:.4f}")
  662. return {"action": "hold" if mode == "active" else "plan", "price": price, "deviation": deviation}
  663. def render(self):
  664. # Refresh the market-derived display values on render so the dashboard
  665. # reflects the same inputs the strategy would use on the next tick.
  666. live_step_pct = float(self.state.get("grid_step_pct") or 0.0)
  667. live_atr_pct = float(self.state.get("atr_percent") or 0.0)
  668. try:
  669. self._refresh_balance_snapshot()
  670. live_step_pct = self._grid_step_pct()
  671. live_atr_pct = float(self.state.get("atr_percent") or live_atr_pct)
  672. except Exception as exc:
  673. self._log(f"render refresh failed: {exc}")
  674. return {
  675. "widgets": [
  676. {"type": "metric", "label": "market", "value": self._market_symbol()},
  677. {"type": "metric", "label": "center", "value": round(float(self.state.get("center_price") or 0.0), 6)},
  678. {"type": "metric", "label": "last price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  679. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  680. {"type": "metric", "label": "orders", "value": len(self.state.get("orders") or [])},
  681. {"type": "metric", "label": "open orders", "value": self.state.get("open_order_count", 0)},
  682. {"type": "metric", "label": f"ATR({self.config.get('volatility_timeframe', '1h')}) %", "value": round(live_atr_pct, 4)},
  683. {"type": "metric", "label": "grid step %", "value": round(live_step_pct * 100.0, 4)},
  684. {"type": "metric", "label": "1d", "value": ((self.state.get('regimes') or {}).get('1d') or {}).get('trend', {}).get('state', 'n/a')},
  685. {"type": "metric", "label": "4h", "value": ((self.state.get('regimes') or {}).get('4h') or {}).get('trend', {}).get('state', 'n/a')},
  686. {"type": "metric", "label": "1h", "value": ((self.state.get('regimes') or {}).get('1h') or {}).get('trend', {}).get('state', 'n/a')},
  687. {"type": "metric", "label": "15m", "value": ((self.state.get('regimes') or {}).get('15m') or {}).get('trend', {}).get('state', 'n/a')},
  688. {"type": "metric", "label": f"{self._base_symbol()} avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
  689. {"type": "metric", "label": f"{self.context.counter_currency or 'USD'} avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
  690. *([
  691. {"type": "metric", "label": "trend guard active", "value": "on"},
  692. {"type": "text", "label": "trend guard reason", "value": "higher-timeframe trend conflict"},
  693. ] if self.state.get("trend_guard_active") else []),
  694. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  695. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  696. ]
  697. }