grid_trader.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  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. if levels <= 0 or price <= 0:
  213. return 0.0
  214. safety = 0.995
  215. fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
  216. max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
  217. manual = float(self.config.get("order_size", 0.0) or 0.0)
  218. min_amount = (min_notional / price) if min_notional > 0 else 0.0
  219. if side == "buy":
  220. quote = self.context.counter_currency or "USD"
  221. quote_available = self._available_balance(quote)
  222. self.state["counter_available"] = quote_available
  223. spendable_quote = quote_available * safety
  224. amount = spendable_quote / (max(levels, 1) * price * (1 + fee_rate))
  225. else:
  226. base = self._base_symbol()
  227. base_available = self._available_balance(base)
  228. self.state["base_available"] = base_available
  229. spendable_base = (base_available * safety) / (1 + fee_rate)
  230. amount = spendable_base / max(levels, 1)
  231. amount = max(amount, min_amount * 1.05)
  232. if max_notional > 0 and price > 0:
  233. amount = min(amount, max_notional / (price * (1 + fee_rate)))
  234. if manual > 0:
  235. if manual >= min_amount:
  236. amount = min(amount, manual)
  237. else:
  238. self._log(
  239. f"manual order_size below minimum: order_size={manual:.6g} min_amount={min_amount:.6g} price={price} min_notional={min_notional}"
  240. )
  241. return max(amount, 0.0)
  242. def _place_grid(self, center: float) -> None:
  243. mode = self._mode()
  244. levels = int(self.config.get("grid_levels", 6) or 6)
  245. step = self._grid_step_pct()
  246. min_notional = float(self.context.minimum_order_value or 0.0)
  247. market = self._market_symbol()
  248. orders = []
  249. order_ids = []
  250. def _capture_order_id(result):
  251. if isinstance(result, dict):
  252. return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  253. return None
  254. 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)
  255. 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)
  256. buy_amount = self._suggest_amount("buy", center, max(buy_levels, 1), min_notional)
  257. sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
  258. for i in range(1, levels + 1):
  259. buy_price = round(center * (1 - (step * i)), 8)
  260. sell_price = round(center * (1 + (step * i)), 8)
  261. if mode != "active":
  262. orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
  263. orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
  264. self._log(f"plan level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
  265. continue
  266. if i > buy_levels and i > sell_levels:
  267. self._log(f"skip level {i}: no capacity on either side")
  268. continue
  269. min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
  270. min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
  271. try:
  272. if i <= buy_levels and buy_amount >= min_size_buy:
  273. buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
  274. orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
  275. buy_id = _capture_order_id(buy)
  276. if buy_id is not None:
  277. order_ids.append(str(buy_id))
  278. if i <= sell_levels and sell_amount >= min_size_sell:
  279. sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
  280. orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
  281. sell_id = _capture_order_id(sell)
  282. if sell_id is not None:
  283. order_ids.append(str(sell_id))
  284. self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
  285. except Exception as exc: # best effort for first draft
  286. self.state["last_error"] = str(exc)
  287. self._log(f"seed level {i} failed: {exc}")
  288. continue
  289. delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
  290. if delay > 0:
  291. time.sleep(delay)
  292. self.state["orders"] = orders
  293. self.state["order_ids"] = order_ids
  294. self.state["last_action"] = "seeded grid"
  295. self._set_grid_refresh_pause()
  296. def _place_side_grid(self, side: str, center: float, *, start_level: int = 1) -> None:
  297. levels = int(self.config.get("grid_levels", 6) or 6)
  298. step = self._grid_step_pct()
  299. min_notional = float(self.context.minimum_order_value or 0.0)
  300. fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
  301. safety = 0.995
  302. market = self._market_symbol()
  303. orders = list(self.state.get("orders") or [])
  304. order_ids = list(self.state.get("order_ids") or [])
  305. side_levels = min(levels, self._supported_levels(side, center, min_notional))
  306. amount = self._suggest_amount(side, center, max(side_levels, 1), min_notional)
  307. if side == "buy":
  308. quote = self.context.counter_currency or "USD"
  309. quote_available = self._available_balance(quote)
  310. max_affordable_amount = (quote_available * safety) / (center * (1 + fee_rate)) if center > 0 else 0.0
  311. min_amount = (min_notional / center) if center > 0 and min_notional > 0 else 0.0
  312. if max_affordable_amount < min_amount:
  313. self._log(
  314. 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}"
  315. )
  316. return
  317. amount = min(amount, max_affordable_amount)
  318. if side_levels <= 0 and min_notional > 0 and center > 0:
  319. min_amount = min_notional / center
  320. if amount >= min_amount:
  321. side_levels = 1
  322. self._log(f"side {side} restored to 1 level because amount clears minimum: amount={amount:.6g} min_amount={min_amount:.6g}")
  323. self._log(
  324. f"prepare side {side}: market={market} center={center} levels={side_levels} amount={amount:.6g} min_notional={min_notional} existing_ids={order_ids}"
  325. )
  326. for i in range(start_level, levels + 1):
  327. price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
  328. min_size = (min_notional / price) if price > 0 else 0.0
  329. if i > side_levels or amount < min_size:
  330. self._log(
  331. f"skip side {side} level {i}: amount={amount:.6g} below min_size={min_size:.6g} min_notional={min_notional} price={price}"
  332. )
  333. continue
  334. try:
  335. self._log(f"place side {side} level {i}: price={price} amount={amount:.6g}")
  336. result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
  337. status = None
  338. order_id = None
  339. if isinstance(result, dict):
  340. status = result.get("status")
  341. order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  342. self._log(f"place side {side} level {i} result status={status} order_id={order_id} raw={result}")
  343. orders.append({"side": side, "price": price, "amount": amount, "result": result})
  344. if order_id is not None:
  345. order_ids.append(str(order_id))
  346. self._log(f"seed side {side} level {i}: {price} amount {amount:.6g}")
  347. except Exception as exc:
  348. self.state["last_error"] = str(exc)
  349. self._log(f"seed side {side} level {i} failed: {exc}")
  350. continue
  351. delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
  352. if delay > 0:
  353. time.sleep(delay)
  354. self.state["orders"] = orders
  355. self.state["order_ids"] = order_ids
  356. self._log(f"side {side} placement complete: tracked_ids={order_ids}")
  357. self._set_grid_refresh_pause()
  358. def _top_up_missing_levels(self, center: float, live_orders: list[dict]) -> None:
  359. target_levels = int(self.config.get("grid_levels", 6) or 6)
  360. if target_levels <= 0:
  361. return
  362. for side in ("buy", "sell"):
  363. count = 0
  364. for order in live_orders:
  365. if not isinstance(order, dict):
  366. continue
  367. if str(order.get("side") or "").lower() == side:
  368. count += 1
  369. if 0 < count < target_levels:
  370. self._log(f"top up side {side}: have {count}, want {target_levels}")
  371. self._place_side_grid(side, center, start_level=count + 1)
  372. def _cancel_obsolete_side_orders(self, open_orders: list[dict], desired_sides: set[str]) -> list[str]:
  373. removed: list[str] = []
  374. for order in open_orders:
  375. if not isinstance(order, dict):
  376. continue
  377. side = str(order.get("side") or "").lower()
  378. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  379. if not order_id or side in desired_sides:
  380. continue
  381. try:
  382. self.context.cancel_order(order_id)
  383. removed.append(order_id)
  384. self._log(f"cancelled obsolete {side} order {order_id}")
  385. except Exception as exc:
  386. self.state["last_error"] = str(exc)
  387. self._log(f"cancel obsolete {side} order {order_id} failed: {exc}")
  388. return removed
  389. def _cancel_surplus_side_orders(self, open_orders: list[dict], target_levels: int) -> list[str]:
  390. removed: list[str] = []
  391. if target_levels <= 0:
  392. return removed
  393. for side in ("buy", "sell"):
  394. side_orders = [order for order in open_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == side]
  395. if len(side_orders) <= target_levels:
  396. continue
  397. surplus = side_orders[target_levels:]
  398. for order in surplus:
  399. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  400. if not order_id:
  401. continue
  402. try:
  403. self.context.cancel_order(order_id)
  404. removed.append(order_id)
  405. self._log(f"cancelled surplus {side} order {order_id}")
  406. except Exception as exc:
  407. self.state["last_error"] = str(exc)
  408. self._log(f"cancel surplus {side} order {order_id} failed: {exc}")
  409. return removed
  410. def _cancel_duplicate_level_orders(self, open_orders: list[dict]) -> list[str]:
  411. removed: list[str] = []
  412. seen: set[tuple[str, str]] = set()
  413. for order in open_orders:
  414. if not isinstance(order, dict):
  415. continue
  416. side = str(order.get("side") or "").lower()
  417. try:
  418. price_key = f"{float(order.get('price') or 0.0):.8f}"
  419. except Exception:
  420. price_key = str(order.get("price") or "")
  421. key = (side, price_key)
  422. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  423. if not order_id:
  424. continue
  425. if key in seen:
  426. try:
  427. self.context.cancel_order(order_id)
  428. removed.append(order_id)
  429. self._log(f"cancelled duplicate {side} level order {order_id} price={price_key}")
  430. except Exception as exc:
  431. self.state["last_error"] = str(exc)
  432. self._log(f"cancel duplicate {side} order {order_id} failed: {exc}")
  433. continue
  434. seen.add(key)
  435. return removed
  436. def _place_replacement_orders(self, vanished_orders: list[dict], price_hint: float) -> list[str]:
  437. placed: list[str] = []
  438. if not vanished_orders:
  439. return placed
  440. market = self._market_symbol()
  441. for order in vanished_orders:
  442. if not isinstance(order, dict):
  443. continue
  444. side = str(order.get("side") or "").lower()
  445. opposite = "sell" if side == "buy" else "buy" if side == "sell" else ""
  446. if not opposite:
  447. continue
  448. try:
  449. amount = float(order.get("amount") or 0.0)
  450. price = float(order.get("price") or price_hint or 0.0)
  451. except Exception:
  452. continue
  453. if amount <= 0 or price <= 0:
  454. continue
  455. try:
  456. self._log(f"replace filled {side} order with {opposite}: price={price} amount={amount:.6g}")
  457. result = self.context.place_order(side=opposite, order_type="limit", amount=amount, price=price, market=market)
  458. order_id = None
  459. if isinstance(result, dict):
  460. order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  461. if order_id is not None:
  462. placed.append(str(order_id))
  463. except Exception as exc:
  464. self.state["last_error"] = str(exc)
  465. self._log(f"replacement order failed for {side}→{opposite} at {price}: {exc}")
  466. return placed
  467. def _sync_open_orders_state(self) -> list[dict]:
  468. try:
  469. open_orders = self.context.get_open_orders()
  470. except Exception as exc:
  471. self.state["last_error"] = str(exc)
  472. self._log(f"open orders sync failed: {exc}")
  473. return []
  474. if not isinstance(open_orders, list):
  475. open_orders = []
  476. live_orders = [order for order in open_orders if isinstance(order, dict)]
  477. 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]
  478. live_ids = [oid for oid in live_ids if oid]
  479. live_sides = [str(order.get("side") or "").lower() for order in live_orders]
  480. self.state["orders"] = live_orders
  481. self.state["order_ids"] = live_ids
  482. self.state["open_order_count"] = len(live_ids)
  483. self._log(f"sync live orders: count={len(live_ids)} sides={live_sides} ids={live_ids}")
  484. return live_orders
  485. def _cancel_orders(self, order_ids) -> None:
  486. for order_id in order_ids or []:
  487. self._log(f"dropping stale order {order_id} from state")
  488. def on_tick(self, tick):
  489. previous_orders = list(self.state.get("orders") or [])
  490. self._refresh_balance_snapshot()
  491. price = self._price()
  492. self.state["last_price"] = price
  493. self.state["last_error"] = ""
  494. self._refresh_regimes()
  495. try:
  496. live_orders = self._sync_open_orders_state()
  497. live_ids = list(self.state.get("order_ids") or [])
  498. open_order_count = len(live_ids)
  499. expected_ids = [str(oid) for oid in (self.state.get("order_ids") or []) if oid]
  500. stale_ids = []
  501. missing_ids = []
  502. except Exception as exc:
  503. open_order_count = -1
  504. live_orders = []
  505. live_ids = []
  506. expected_ids = []
  507. stale_ids = []
  508. missing_ids = []
  509. self.state["last_error"] = str(exc)
  510. self._log(f"open orders check failed: {exc}")
  511. self.state["open_order_count"] = open_order_count
  512. desired_sides = self._desired_sides()
  513. mode = self._mode()
  514. guard_active, guard_reason = self._trend_guard_status()
  515. self.state["trend_guard_active"] = guard_active
  516. if mode == "active" and guard_active:
  517. self._log(f"trend guard active: {guard_reason}")
  518. try:
  519. self.context.cancel_all_orders()
  520. except Exception as exc:
  521. self.state["last_error"] = str(exc)
  522. self._log(f"trend guard cancel failed: {exc}")
  523. self.state["last_action"] = "trend_guard"
  524. return {"action": "guard", "price": price, "reason": guard_reason}
  525. if mode != "active":
  526. if not self.state.get("seeded") or not self.state.get("center_price"):
  527. self.state["center_price"] = price
  528. self._place_grid(price)
  529. self.state["seeded"] = True
  530. self._log(f"planned grid at {price}")
  531. return {"action": "plan", "price": price}
  532. center = float(self.state.get("center_price") or price)
  533. recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
  534. deviation = abs(price - center) / center if center else 0.0
  535. if deviation >= recenter_pct:
  536. self.state["center_price"] = price
  537. self._place_grid(price)
  538. self._log(f"planned recenter to {price}")
  539. return {"action": "plan", "price": price, "deviation": deviation}
  540. self.state["last_action"] = "observe monitor"
  541. self._log(f"observe at {price} dev {deviation:.4f}")
  542. return {"action": "observe", "price": price, "deviation": deviation}
  543. if stale_ids:
  544. self._log(f"stale live orders: {stale_ids}")
  545. self._cancel_orders(stale_ids)
  546. live_ids = [oid for oid in live_ids if oid not in stale_ids]
  547. if missing_ids:
  548. self._log(f"missing tracked orders: {missing_ids}")
  549. self.state["order_ids"] = live_ids
  550. cancelled_obsolete = self._cancel_obsolete_side_orders(live_orders, desired_sides)
  551. if cancelled_obsolete:
  552. live_orders = self._sync_open_orders_state()
  553. live_ids = list(self.state.get("order_ids") or [])
  554. open_order_count = len(live_ids)
  555. previous_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 previous_orders if isinstance(order, dict)}
  556. current_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)}
  557. vanished_orders = [order for order in previous_orders if isinstance(order, dict) 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)]
  558. if vanished_orders and self._mode() == "active" and not self._grid_refresh_paused():
  559. replaced_ids = self._place_replacement_orders(vanished_orders, price)
  560. if replaced_ids:
  561. live_orders = self._sync_open_orders_state()
  562. live_ids = list(self.state.get("order_ids") or [])
  563. open_order_count = len(live_ids)
  564. surplus_cancelled = self._cancel_surplus_side_orders(live_orders, int(self.config.get("grid_levels", 6) or 6))
  565. if surplus_cancelled:
  566. live_orders = self._sync_open_orders_state()
  567. live_ids = list(self.state.get("order_ids") or [])
  568. open_order_count = len(live_ids)
  569. duplicate_cancelled = self._cancel_duplicate_level_orders(live_orders)
  570. if duplicate_cancelled:
  571. live_orders = self._sync_open_orders_state()
  572. live_ids = list(self.state.get("order_ids") or [])
  573. open_order_count = len(live_ids)
  574. if desired_sides != {"buy", "sell"}:
  575. current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
  576. missing_side = next((side for side in desired_sides if side not in current_sides), None)
  577. if missing_side and self.state.get("center_price"):
  578. self._log(f"adding missing {missing_side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
  579. self._place_side_grid(missing_side, float(self.state.get("center_price") or price))
  580. live_orders = self._sync_open_orders_state()
  581. self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
  582. self.state["last_action"] = f"added {missing_side} side"
  583. return {"action": "add_side", "price": price, "side": missing_side}
  584. if desired_sides == {"buy", "sell"}:
  585. current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
  586. missing_sides = [side for side in ("buy", "sell") if side not in current_sides]
  587. reconciled_sides: list[str] = []
  588. if missing_sides and self.state.get("center_price") and not self._grid_refresh_paused():
  589. for side in missing_sides:
  590. self._log(f"adding missing {side} side after trade_sides change, live_sides={sorted(current_sides)} live_ids={live_ids}")
  591. self._place_side_grid(side, float(self.state.get("center_price") or price))
  592. reconciled_sides.append(side)
  593. live_orders = self._sync_open_orders_state()
  594. self._log(f"post-add sync: open_order_count={self.state.get('open_order_count', 0)} live_ids={self.state.get('order_ids') or []}")
  595. if live_orders and self.state.get("center_price") and not self._grid_refresh_paused():
  596. self._top_up_missing_levels(float(self.state.get("center_price") or price), live_orders)
  597. live_orders = self._sync_open_orders_state()
  598. if reconciled_sides:
  599. self.state["last_action"] = f"reconciled {','.join(reconciled_sides)}"
  600. return {"action": "reconcile", "price": price, "side": ",".join(reconciled_sides)}
  601. if (not self.state.get("seeded") or not self.state.get("center_price")) and not self._grid_refresh_paused():
  602. self.state["center_price"] = price
  603. self._place_grid(price)
  604. live_orders = self._sync_open_orders_state()
  605. self.state["seeded"] = True
  606. mode = self._mode()
  607. self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
  608. return {"action": "seed" if mode == "active" else "plan", "price": price}
  609. if (open_order_count == 0 or (expected_ids and not set(expected_ids).intersection(set(live_ids)))) and not self._grid_refresh_paused():
  610. self._log("no open orders, reseeding grid")
  611. self.state["center_price"] = price
  612. self._place_grid(price)
  613. live_orders = self._sync_open_orders_state()
  614. mode = self._mode()
  615. self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
  616. return {"action": "reseed" if mode == "active" else "plan", "price": price}
  617. center = float(self.state.get("center_price") or price)
  618. recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
  619. deviation = abs(price - center) / center if center else 0.0
  620. if deviation >= recenter_pct and not self._grid_refresh_paused():
  621. try:
  622. self.context.cancel_all_orders()
  623. except Exception as exc:
  624. self.state["last_error"] = str(exc)
  625. self.state["center_price"] = price
  626. self._place_grid(price)
  627. live_orders = self._sync_open_orders_state()
  628. mode = self._mode()
  629. self.state["last_action"] = "recentered" if mode == "active" else f"{mode} monitor"
  630. self._log(f"recentered grid to {price}")
  631. return {"action": "recenter" if mode == "active" else "plan", "price": price, "deviation": deviation}
  632. mode = self._mode()
  633. self.state["last_action"] = "hold" if mode == "active" else f"{mode} monitor"
  634. self._log(f"hold at {price} dev {deviation:.4f}")
  635. return {"action": "hold" if mode == "active" else "plan", "price": price, "deviation": deviation}
  636. def render(self):
  637. # Refresh the market-derived display values on render so the dashboard
  638. # reflects the same inputs the strategy would use on the next tick.
  639. live_step_pct = float(self.state.get("grid_step_pct") or 0.0)
  640. live_atr_pct = float(self.state.get("atr_percent") or 0.0)
  641. try:
  642. self._refresh_balance_snapshot()
  643. live_step_pct = self._grid_step_pct()
  644. live_atr_pct = float(self.state.get("atr_percent") or live_atr_pct)
  645. except Exception as exc:
  646. self._log(f"render refresh failed: {exc}")
  647. return {
  648. "widgets": [
  649. {"type": "metric", "label": "market", "value": self._market_symbol()},
  650. {"type": "metric", "label": "center", "value": round(float(self.state.get("center_price") or 0.0), 6)},
  651. {"type": "metric", "label": "last price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  652. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  653. {"type": "metric", "label": "orders", "value": len(self.state.get("orders") or [])},
  654. {"type": "metric", "label": "open orders", "value": self.state.get("open_order_count", 0)},
  655. {"type": "metric", "label": f"ATR({self.config.get('volatility_timeframe', '1h')}) %", "value": round(live_atr_pct, 4)},
  656. {"type": "metric", "label": "grid step %", "value": round(live_step_pct * 100.0, 4)},
  657. {"type": "metric", "label": "1d", "value": ((self.state.get('regimes') or {}).get('1d') or {}).get('trend', {}).get('state', 'n/a')},
  658. {"type": "metric", "label": "4h", "value": ((self.state.get('regimes') or {}).get('4h') or {}).get('trend', {}).get('state', 'n/a')},
  659. {"type": "metric", "label": "1h", "value": ((self.state.get('regimes') or {}).get('1h') or {}).get('trend', {}).get('state', 'n/a')},
  660. {"type": "metric", "label": "15m", "value": ((self.state.get('regimes') or {}).get('15m') or {}).get('trend', {}).get('state', 'n/a')},
  661. {"type": "metric", "label": f"{self._base_symbol()} avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
  662. {"type": "metric", "label": f"{self.context.counter_currency or 'USD'} avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
  663. *([
  664. {"type": "metric", "label": "trend guard active", "value": "on"},
  665. {"type": "text", "label": "trend guard reason", "value": "higher-timeframe trend conflict"},
  666. ] if self.state.get("trend_guard_active") else []),
  667. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  668. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  669. ]
  670. }