grid_trader.py 46 KB

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