grid_trader.py 49 KB

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