grid_trader.py 47 KB

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