grid_trader.py 49 KB

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