grid_trader.py 44 KB

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