grid_trader.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040
  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. STRATEGY_PROFILE = {
  9. "expects": {
  10. "trend": "none",
  11. "volatility": "low",
  12. "event_risk": "low",
  13. "liquidity": "normal",
  14. },
  15. "avoids": {
  16. "trend": "strong",
  17. "volatility": "expanding",
  18. "event_risk": "high",
  19. "liquidity": "thin",
  20. },
  21. "risk_profile": "medium",
  22. "capabilities": ["mean_reversion", "range_harvesting", "two_sided_inventory"],
  23. "role": "primary",
  24. "inventory_behavior": "balanced",
  25. "requires_rebalance_before_start": False,
  26. "requires_rebalance_before_stop": False,
  27. "safe_when_unbalanced": False,
  28. "can_run_with": ["exposure_protector"],
  29. }
  30. TICK_MINUTES = 0.50
  31. CONFIG_SCHEMA = {
  32. "grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
  33. "grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
  34. "volatility_timeframe": {"type": "string", "default": "1h"},
  35. "volatility_multiplier": {"type": "float", "default": 0.5, "min": 0.0, "max": 10.0},
  36. "grid_step_min_pct": {"type": "float", "default": 0.005, "min": 0.0001, "max": 0.5},
  37. "grid_step_max_pct": {"type": "float", "default": 0.03, "min": 0.0001, "max": 1.0},
  38. "order_size": {"type": "float", "default": 0.0, "min": 0.0},
  39. "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
  40. "recenter_atr_multiplier": {"type": "float", "default": 0.35, "min": 0.0, "max": 10.0},
  41. "recenter_min_pct": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.5},
  42. "recenter_max_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 0.5},
  43. "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
  44. "trade_sides": {"type": "string", "default": "both"},
  45. "max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
  46. "dust_collect": {"type": "bool", "default": False},
  47. "order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
  48. "debug_orders": {"type": "bool", "default": True},
  49. }
  50. STATE_SCHEMA = {
  51. "center_price": {"type": "float", "default": 0.0},
  52. "last_price": {"type": "float", "default": 0.0},
  53. "seeded": {"type": "bool", "default": False},
  54. "last_action": {"type": "string", "default": "idle"},
  55. "last_error": {"type": "string", "default": ""},
  56. "orders": {"type": "list", "default": []},
  57. "order_ids": {"type": "list", "default": []},
  58. "debug_log": {"type": "list", "default": []},
  59. "base_available": {"type": "float", "default": 0.0},
  60. "counter_available": {"type": "float", "default": 0.0},
  61. "regimes_updated_at": {"type": "string", "default": ""},
  62. "account_snapshot_updated_at": {"type": "string", "default": ""},
  63. "last_balance_log_signature": {"type": "string", "default": ""},
  64. "last_balance_log_at": {"type": "string", "default": ""},
  65. "grid_refresh_pending_until": {"type": "string", "default": ""},
  66. "mismatch_ticks": {"type": "int", "default": 0},
  67. "recovery_cooldown_until": {"type": "string", "default": ""},
  68. }
  69. def init(self):
  70. return {
  71. "center_price": 0.0,
  72. "last_price": 0.0,
  73. "seeded": False,
  74. "last_action": "idle",
  75. "last_error": "",
  76. "orders": [],
  77. "order_ids": [],
  78. "debug_log": ["init cancel all orders"],
  79. "base_available": 0.0,
  80. "counter_available": 0.0,
  81. "regimes_updated_at": "",
  82. "account_snapshot_updated_at": "",
  83. "last_balance_log_signature": "",
  84. "last_balance_log_at": "",
  85. "grid_refresh_pending_until": "",
  86. "mismatch_ticks": 0,
  87. "recovery_cooldown_until": "",
  88. }
  89. def _log(self, message: str) -> None:
  90. state = getattr(self, "state", {}) or {}
  91. log = list(state.get("debug_log") or [])
  92. log.append(message)
  93. state["debug_log"] = log[-12:]
  94. self.state = state
  95. log_event("grid", message)
  96. def _log_decision(self, action: str, **fields) -> None:
  97. parts = [action]
  98. for key, value in fields.items():
  99. parts.append(f"{key}={value}")
  100. self._log(", ".join(parts))
  101. def _set_grid_refresh_pause(self, seconds: float = 30.0) -> None:
  102. self.state["grid_refresh_pending_until"] = (datetime.now(timezone.utc).timestamp() + max(seconds, 0.0))
  103. def _grid_refresh_paused(self) -> bool:
  104. try:
  105. until = float(self.state.get("grid_refresh_pending_until") or 0.0)
  106. except Exception:
  107. until = 0.0
  108. return until > datetime.now(timezone.utc).timestamp()
  109. def _recovery_paused(self) -> bool:
  110. try:
  111. until = float(self.state.get("recovery_cooldown_until") or 0.0)
  112. except Exception:
  113. until = 0.0
  114. return until > datetime.now(timezone.utc).timestamp()
  115. def _trip_recovery_pause(self, seconds: float = 30.0) -> None:
  116. self.state["recovery_cooldown_until"] = (datetime.now(timezone.utc).timestamp() + max(seconds, 0.0))
  117. def _recover_grid(self, price: float) -> None:
  118. self._log(f"recovery mode: cancel all and rebuild from {price}")
  119. try:
  120. self.context.cancel_all_orders()
  121. except Exception as exc:
  122. self.state["last_error"] = str(exc)
  123. self._log(f"recovery cancel-all failed: {exc}")
  124. self.state["orders"] = []
  125. self.state["order_ids"] = []
  126. self.state["open_order_count"] = 0
  127. self.state["center_price"] = price
  128. self.state["seeded"] = True
  129. self._place_grid(price)
  130. self._sync_open_orders_state()
  131. self.state["mismatch_ticks"] = 0
  132. self._trip_recovery_pause()
  133. def _order_count_mismatch(self, tracked_ids: list[str], live_orders: list[dict]) -> bool:
  134. 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)]
  135. live_ids = [oid for oid in live_ids if oid]
  136. if len(live_ids) != len([oid for oid in tracked_ids if oid]):
  137. return True
  138. return False
  139. def _base_symbol(self) -> str:
  140. return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
  141. def _market_symbol(self) -> str:
  142. return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
  143. def _live_fee_rates(self) -> tuple[float, float]:
  144. try:
  145. payload = self.context.get_fee_rates(self._market_symbol())
  146. maker = float(payload.get("maker") or 0.0)
  147. taker = float(payload.get("taker") or 0.0)
  148. return maker, taker
  149. except Exception as exc:
  150. self._log(f"fee lookup failed: {exc}")
  151. fallback = float(self.config.get("fee_rate", 0.0025) or 0.0)
  152. return fallback, fallback
  153. def _live_fee_rate(self) -> float:
  154. _maker, taker = self._live_fee_rates()
  155. return taker
  156. def _mode(self) -> str:
  157. return getattr(self.context, "mode", "active") or "active"
  158. def apply_policy(self):
  159. policy = super().apply_policy()
  160. risk = str(policy.get("risk_posture") or "normal").lower()
  161. priority = str(policy.get("priority") or "normal").lower()
  162. step_map = {"cautious": 0.008, "normal": 0.012, "assertive": 0.018}
  163. recenter_map = {"cautious": 0.035, "normal": 0.05, "assertive": 0.07}
  164. levels_map = {"cautious": 4, "normal": 6, "assertive": 8}
  165. delay_map = {"cautious": 500, "normal": 250, "assertive": 120}
  166. if priority in {"low", "background"}:
  167. risk = "cautious"
  168. elif priority in {"high", "urgent"}:
  169. risk = "assertive"
  170. self.config["grid_step_pct"] = step_map.get(risk, 0.012)
  171. self.config["recenter_pct"] = recenter_map.get(risk, 0.05)
  172. if self.config.get("grid_levels") in {None, "", 0}:
  173. self.config["grid_levels"] = levels_map.get(risk, 6)
  174. else:
  175. try:
  176. self.config["grid_levels"] = max(int(self.config.get("grid_levels") or 0), 1)
  177. except Exception:
  178. self.config["grid_levels"] = levels_map.get(risk, 6)
  179. self.config["order_call_delay_ms"] = delay_map.get(risk, 250)
  180. self.state["policy_derived"] = {
  181. "grid_step_pct": self.config["grid_step_pct"],
  182. "recenter_pct": self.config["recenter_pct"],
  183. "grid_levels": self.config["grid_levels"],
  184. "order_call_delay_ms": self.config["order_call_delay_ms"],
  185. }
  186. return policy
  187. def _price(self) -> float:
  188. payload = self.context.get_price(self._base_symbol())
  189. return float(payload.get("price") or 0.0)
  190. def _regime_snapshot(self) -> dict:
  191. timeframes = ["1d", "4h", "1h", "15m"]
  192. snapshot = {}
  193. for tf in timeframes:
  194. try:
  195. snapshot[tf] = self.context.get_regime(self._base_symbol(), tf)
  196. except Exception as exc:
  197. snapshot[tf] = {"error": str(exc)}
  198. return snapshot
  199. def _refresh_regimes(self) -> None:
  200. self.state["regimes"] = self._regime_snapshot()
  201. self.state["regimes_updated_at"] = datetime.now(timezone.utc).isoformat()
  202. def _recenter_threshold_pct(self) -> float:
  203. base_threshold = float(self.config.get("recenter_pct", 0.05) or 0.05)
  204. atr_multiplier = float(self.config.get("recenter_atr_multiplier", 0.35) or 0.0)
  205. min_threshold = float(self.config.get("recenter_min_pct", 0.0025) or 0.0)
  206. max_threshold = float(self.config.get("recenter_max_pct", 0.03) or 1.0)
  207. try:
  208. tf = str(self.config.get("volatility_timeframe", "1h") or "1h")
  209. regime = self.context.get_regime(self._base_symbol(), tf)
  210. short_regime = self.context.get_regime(self._base_symbol(), "15m")
  211. atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  212. short_atr_pct = float((short_regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  213. atr_pct = max(atr_pct, short_atr_pct)
  214. except Exception:
  215. atr_pct = 0.0
  216. threshold = (atr_pct / 100.0) * atr_multiplier if atr_pct > 0 else base_threshold
  217. threshold = max(threshold, min_threshold)
  218. threshold = min(threshold, max_threshold)
  219. self.state["recenter_pct_live"] = threshold
  220. self.state["recenter_atr_percent"] = atr_pct
  221. return threshold
  222. def _grid_step_pct(self) -> float:
  223. base_step = float(self.config.get("grid_step_pct", 0.012) or 0.012)
  224. tf = str(self.config.get("volatility_timeframe", "1h") or "1h")
  225. multiplier = float(self.config.get("volatility_multiplier", 0.5) or 0.0)
  226. min_step = float(self.config.get("grid_step_min_pct", 0.005) or 0.0)
  227. max_step = float(self.config.get("grid_step_max_pct", 0.03) or 1.0)
  228. try:
  229. regime = self.context.get_regime(self._base_symbol(), tf)
  230. short_regime = self.context.get_regime(self._base_symbol(), "15m")
  231. tf_atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  232. atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  233. short_atr_pct = float((short_regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
  234. atr_pct = max(atr_pct, short_atr_pct)
  235. self.state["regimes"] = self._regime_snapshot()
  236. except Exception as exc:
  237. self._log(f"regime fetch failed: {exc}")
  238. tf_atr_pct = 0.0
  239. atr_pct = 0.0
  240. short_atr_pct = 0.0
  241. adaptive = (atr_pct / 100.0) * multiplier if atr_pct > 0 else base_step
  242. step = adaptive if atr_pct > 0 else base_step
  243. step = max(step, min_step)
  244. step = min(step, max_step)
  245. self.state["grid_step_pct"] = step
  246. self.state["atr_percent_tf"] = tf_atr_pct
  247. self.state["atr_percent_15m"] = short_atr_pct
  248. self.state["atr_percent"] = atr_pct
  249. return step
  250. def _config_warning(self) -> str | None:
  251. recenter_pct = float(self.state.get("recenter_pct_live") or self._recenter_threshold_pct())
  252. grid_step_pct = float(self.state.get("grid_step_pct") or self._grid_step_pct())
  253. if grid_step_pct <= 0:
  254. return None
  255. ratio = recenter_pct / grid_step_pct
  256. # If the recenter threshold is too close to the first step, the grid
  257. # can keep rebuilding before it has a fair chance to trade.
  258. if ratio <= 1.0:
  259. return f"warning: recenter threshold ({recenter_pct:.4f}) is <= grid step ({grid_step_pct:.4f}), it may recenter before trading"
  260. if ratio < 1.5:
  261. return f"warning: recenter threshold ({recenter_pct:.4f}) is only {ratio:.2f}x the grid step ({grid_step_pct:.4f}), consider widening it"
  262. return None
  263. def _inventory_ratio(self, price: float) -> float:
  264. base_value = float(self.state.get("base_available") or 0.0) * price
  265. counter_value = float(self.state.get("counter_available") or 0.0)
  266. total = base_value + counter_value
  267. if total <= 0:
  268. return 0.5
  269. return base_value / total
  270. def _supervision(self) -> dict:
  271. price = float(self.state.get("last_price") or 0.0)
  272. ratio = self._inventory_ratio(price if price > 0 else 1.0)
  273. last_error = str(self.state.get("last_error") or "")
  274. config_warning = self._config_warning()
  275. regime_1h = (((self.state.get("regimes") or {}).get("1h") or {}).get("trend") or {}).get("state")
  276. if ratio >= 0.88:
  277. pressure = "base_side_depleted"
  278. elif ratio <= 0.12:
  279. pressure = "quote_side_depleted"
  280. elif ratio >= 0.65:
  281. pressure = "base_heavy"
  282. elif ratio <= 0.35:
  283. pressure = "quote_heavy"
  284. else:
  285. pressure = "balanced"
  286. side_capacity = {
  287. "buy": pressure not in {"quote_side_depleted"},
  288. "sell": pressure not in {"base_side_depleted"},
  289. }
  290. return {
  291. "health": "degraded" if last_error or config_warning else "healthy",
  292. "degraded": bool(last_error or config_warning),
  293. "inventory_pressure": pressure,
  294. "capacity_available": pressure == "balanced",
  295. "side_capacity": side_capacity,
  296. "last_reason": last_error or config_warning or f"base_ratio={ratio:.3f}, trend_1h={regime_1h or 'unknown'}",
  297. }
  298. def _available_balance(self, asset_code: str) -> float:
  299. try:
  300. info = self.context.get_account_info()
  301. except Exception as exc:
  302. self._log(f"account info failed: {exc}")
  303. # A failed balance read makes this tick unsuitable for shape decisions.
  304. self.state["balance_shape_inconclusive"] = True
  305. return 0.0
  306. balances = info.get("balances") if isinstance(info, dict) else []
  307. if not isinstance(balances, list):
  308. self.state["balance_shape_inconclusive"] = True
  309. return 0.0
  310. wanted = str(asset_code or "").upper()
  311. for balance in balances:
  312. if not isinstance(balance, dict):
  313. continue
  314. if str(balance.get("asset_code") or "").upper() != wanted:
  315. continue
  316. try:
  317. return float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  318. except Exception:
  319. self.state["balance_shape_inconclusive"] = True
  320. return 0.0
  321. self.state["balance_shape_inconclusive"] = True
  322. return 0.0
  323. def _refresh_balance_snapshot(self) -> bool:
  324. try:
  325. info = self.context.get_account_info()
  326. except Exception as exc:
  327. self._log(f"balance refresh failed: {exc}")
  328. self.state["balance_shape_inconclusive"] = True
  329. return False
  330. balances = info.get("balances") if isinstance(info, dict) else []
  331. if not isinstance(balances, list):
  332. self.state["balance_shape_inconclusive"] = True
  333. return False
  334. base = self._base_symbol()
  335. quote = self.context.counter_currency or "USD"
  336. for balance in balances:
  337. if not isinstance(balance, dict):
  338. continue
  339. asset = str(balance.get("asset_code") or "").upper()
  340. try:
  341. available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
  342. except Exception:
  343. self.state["balance_shape_inconclusive"] = True
  344. continue
  345. if asset == base:
  346. self.state["base_available"] = available
  347. if asset == str(quote).upper():
  348. self.state["counter_available"] = available
  349. self.state["account_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
  350. signature = f"{base}:{self.state.get('base_available', 0.0):.8f}|{quote}:{self.state.get('counter_available', 0.0):.8f}"
  351. last_signature = str(self.state.get("last_balance_log_signature") or "")
  352. last_logged_at = str(self.state.get("last_balance_log_at") or "")
  353. now_iso = self.state["account_snapshot_updated_at"]
  354. should_log = signature != last_signature or not last_logged_at
  355. if not should_log:
  356. try:
  357. from datetime import datetime as _dt
  358. elapsed = (_dt.fromisoformat(now_iso) - _dt.fromisoformat(last_logged_at)).total_seconds()
  359. should_log = elapsed >= 60
  360. except Exception:
  361. should_log = True
  362. if should_log:
  363. self.state["last_balance_log_signature"] = signature
  364. self.state["last_balance_log_at"] = now_iso
  365. self._log_decision(
  366. "balance snapshot",
  367. base=base,
  368. base_available=f"{self.state.get('base_available', 0.0):.6g}",
  369. quote=quote,
  370. quote_available=f"{self.state.get('counter_available', 0.0):.6g}",
  371. updated_at=now_iso,
  372. )
  373. return True
  374. def _supported_levels(self, side: str, price: float, min_notional: float, *, balance_total: float | None = None) -> int:
  375. if min_notional <= 0 or price <= 0:
  376. return 0
  377. safety = 0.995
  378. fee_rate = self._live_fee_rate()
  379. if side == "buy":
  380. quote = self.context.counter_currency or "USD"
  381. quote_available = self._available_balance(quote)
  382. self.state["counter_available"] = quote_available
  383. usable_notional = (quote_available if balance_total is None else balance_total) * safety
  384. return max(int(usable_notional / min_notional), 0)
  385. base = self._base_symbol()
  386. base_available = self._available_balance(base)
  387. self.state["base_available"] = base_available
  388. usable_base = base_available if balance_total is None else balance_total
  389. usable_notional = usable_base * safety * price / (1 + fee_rate)
  390. return max(int(usable_notional / min_notional), 0)
  391. def _side_allowed(self, side: str) -> bool:
  392. selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
  393. if selected == "both":
  394. return True
  395. return selected == side
  396. def _desired_sides(self) -> set[str]:
  397. selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
  398. if selected == "both":
  399. return {"buy", "sell"}
  400. if selected in {"buy", "sell"}:
  401. return {selected}
  402. return {"buy", "sell"}
  403. def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
  404. return self.context.suggest_order_amount(
  405. side=side,
  406. price=price,
  407. levels=levels,
  408. min_notional=min_notional,
  409. fee_rate=self._live_fee_rate(),
  410. max_notional_per_order=float(self.config.get("max_notional_per_order", 0.0) or 0.0),
  411. dust_collect=bool(self.config.get("dust_collect", False)),
  412. order_size=float(self.config.get("order_size", 0.0) or 0.0),
  413. )
  414. def _target_levels_for_side(self, side: str, center: float, live_orders: list[dict], balance_total: float, expected_levels: int, min_notional: float) -> int:
  415. if expected_levels <= 0 or center <= 0 or balance_total <= 0:
  416. return 0
  417. side_orders = [
  418. order for order in live_orders
  419. if isinstance(order, dict) and str(order.get("side") or "").lower() == side
  420. ]
  421. amount = 0.0
  422. if side_orders:
  423. amounts = []
  424. for order in side_orders:
  425. try:
  426. amounts.append(float(order.get("amount") or 0.0))
  427. except Exception:
  428. continue
  429. if amounts:
  430. amount = sum(amounts) / len(amounts)
  431. if amount <= 0:
  432. amount = self._suggest_amount(side, center, max(expected_levels, 1), min_notional)
  433. if amount <= 0:
  434. return 0
  435. fee_rate = self._live_fee_rate()
  436. safety = 0.995
  437. step = self._grid_step_pct()
  438. spendable_total = balance_total * safety
  439. for level_count in range(expected_levels, 0, -1):
  440. feasible = True
  441. if side == "buy":
  442. needed = 0.0
  443. for i in range(1, level_count + 1):
  444. level_price = center * (1 - (step * i))
  445. min_size = (min_notional / level_price) if level_price > 0 and min_notional > 0 else 0.0
  446. if amount < min_size:
  447. feasible = False
  448. break
  449. needed += amount * level_price * (1 + fee_rate)
  450. else:
  451. needed = amount * level_count
  452. for i in range(1, level_count + 1):
  453. level_price = center * (1 + (step * i))
  454. min_size = (min_notional / level_price) if level_price > 0 and min_notional > 0 else 0.0
  455. if amount < min_size:
  456. feasible = False
  457. break
  458. if feasible and needed <= spendable_total + 1e-9:
  459. return level_count
  460. return 0
  461. def _place_grid(self, center: float) -> None:
  462. center = self._maybe_refresh_center(center)
  463. mode = self._mode()
  464. levels = int(self.config.get("grid_levels", 6) or 6)
  465. step = self._grid_step_pct()
  466. min_notional = float(self.context.minimum_order_value or 0.0)
  467. market = self._market_symbol()
  468. orders = []
  469. order_ids = []
  470. def _capture_order_id(result):
  471. if isinstance(result, dict):
  472. return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  473. return None
  474. 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)
  475. 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)
  476. buy_amount = self._suggest_amount("buy", center, max(buy_levels, 1), min_notional)
  477. sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
  478. for i in range(1, levels + 1):
  479. buy_price = round(center * (1 - (step * i)), 8)
  480. sell_price = round(center * (1 + (step * i)), 8)
  481. if mode != "active":
  482. orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
  483. orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
  484. self._log(f"plan level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
  485. continue
  486. if i > buy_levels and i > sell_levels:
  487. self._log(f"skip level {i}: no capacity on either side")
  488. continue
  489. min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
  490. min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
  491. try:
  492. if i <= buy_levels and buy_amount >= min_size_buy:
  493. buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
  494. orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
  495. buy_id = _capture_order_id(buy)
  496. if buy_id is not None:
  497. order_ids.append(str(buy_id))
  498. if i <= sell_levels and sell_amount >= min_size_sell:
  499. sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
  500. orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
  501. sell_id = _capture_order_id(sell)
  502. if sell_id is not None:
  503. order_ids.append(str(sell_id))
  504. self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
  505. except Exception as exc: # best effort for first draft
  506. self.state["last_error"] = str(exc)
  507. self._log(f"seed level {i} failed: {exc}")
  508. continue
  509. delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
  510. if delay > 0:
  511. time.sleep(delay)
  512. self._refresh_balance_snapshot()
  513. self.state["orders"] = orders
  514. self.state["order_ids"] = order_ids
  515. self.state["last_action"] = "seeded grid"
  516. self._set_grid_refresh_pause()
  517. def _top_up_grid(self, center: float, live_orders: list[dict]) -> list[str]:
  518. center = self._maybe_refresh_center(center)
  519. levels = int(self.config.get("grid_levels", 6) or 6)
  520. if levels <= 0 or center <= 0:
  521. return []
  522. min_notional = float(self.context.minimum_order_value or 0.0)
  523. step = self._grid_step_pct()
  524. market = self._market_symbol()
  525. placed: list[str] = []
  526. live_by_side: dict[str, int] = {"buy": 0, "sell": 0}
  527. for order in live_orders:
  528. if not isinstance(order, dict):
  529. continue
  530. side = str(order.get("side") or "").lower()
  531. if side in live_by_side:
  532. live_by_side[side] += 1
  533. for side in ("buy", "sell"):
  534. live_count = live_by_side.get(side, 0)
  535. max_levels = self._supported_levels(side, center, min_notional)
  536. target_levels = min(levels, max_levels)
  537. if target_levels <= live_count:
  538. continue
  539. amount = self._suggest_amount(side, center, max(target_levels, 1), min_notional)
  540. for i in range(live_count + 1, target_levels + 1):
  541. price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
  542. min_size = (min_notional / price) if price > 0 else 0.0
  543. if amount < min_size:
  544. self._log_decision(
  545. f"skip top-up {side} level {i}",
  546. reason="below_min_size",
  547. amount=f"{amount:.6g}",
  548. min_size=f"{min_size:.6g}",
  549. price=price,
  550. )
  551. continue
  552. try:
  553. self._log_decision(f"top-up {side} level {i}", price=price, amount=f"{amount:.6g}")
  554. result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
  555. order_id = None
  556. if isinstance(result, dict):
  557. order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
  558. if order_id is not None:
  559. placed.append(str(order_id))
  560. live_orders.append({"side": side, "price": price, "amount": amount, "result": result})
  561. self._refresh_balance_snapshot()
  562. except Exception as exc:
  563. self.state["last_error"] = str(exc)
  564. self._log_decision(f"top-up {side} level {i} failed", error=str(exc))
  565. continue
  566. delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
  567. if delay > 0:
  568. time.sleep(delay)
  569. return placed
  570. def _recenter_and_rebuild_from_fill(self, fill_price: float) -> None:
  571. fill_price = self._maybe_refresh_center(fill_price)
  572. """Treat a fill as the new market anchor and rebuild the full grid from there."""
  573. if fill_price <= 0:
  574. return
  575. self._recenter_and_rebuild_from_price(fill_price, "fill rebuild")
  576. def _recenter_and_rebuild_from_price(self, price: float, reason: str) -> None:
  577. if price <= 0:
  578. return
  579. current = float(self.state.get("center_price") or 0.0)
  580. self._log(f"{reason}: recenter from {current} to {price}")
  581. try:
  582. self.context.cancel_all_orders()
  583. except Exception as exc:
  584. self.state["last_error"] = str(exc)
  585. self._log(f"{reason} cancel-all failed: {exc}")
  586. # Give the exchange a moment to release balance before we rebuild.
  587. time.sleep(3.0)
  588. self._refresh_balance_snapshot()
  589. self.state["center_price"] = price
  590. self.state["seeded"] = True
  591. self._place_grid(price)
  592. self._refresh_balance_snapshot()
  593. self._set_grid_refresh_pause()
  594. def on_stop(self):
  595. self._log("stopping: cancel all open orders")
  596. try:
  597. self.context.cancel_all_orders()
  598. except Exception as exc:
  599. self.state["last_error"] = str(exc)
  600. self._log(f"stop cancel-all failed: {exc}")
  601. self.state["orders"] = []
  602. self.state["order_ids"] = []
  603. self.state["open_order_count"] = 0
  604. self.state["last_action"] = "stopped"
  605. def _maybe_refresh_center(self, price: float) -> float:
  606. if price <= 0:
  607. return price
  608. current = float(self.state.get("center_price") or 0.0)
  609. if current <= 0:
  610. self.state["center_price"] = price
  611. return price
  612. deviation = abs(price - current) / current if current else 0.0
  613. threshold = self._recenter_threshold_pct()
  614. if deviation >= threshold:
  615. self._log(f"recenter anchor from {current} to {price} dev={deviation:.4f} threshold={threshold:.4f}")
  616. self.state["center_price"] = price
  617. return price
  618. return current
  619. def _sync_open_orders_state(self) -> list[dict]:
  620. try:
  621. open_orders = self.context.get_open_orders()
  622. except Exception as exc:
  623. self.state["last_error"] = str(exc)
  624. self._log(f"open orders sync failed: {exc}")
  625. return []
  626. if not isinstance(open_orders, list):
  627. open_orders = []
  628. live_orders = [order for order in open_orders if isinstance(order, dict)]
  629. 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]
  630. live_ids = [oid for oid in live_ids if oid]
  631. live_sides = [str(order.get("side") or "").lower() for order in live_orders]
  632. self.state["orders"] = live_orders
  633. self.state["order_ids"] = live_ids
  634. self.state["open_order_count"] = len(live_ids)
  635. self._log(f"sync live orders: count={len(live_ids)} sides={live_sides} ids={live_ids}")
  636. return live_orders
  637. def _cancel_orders(self, order_ids) -> None:
  638. for order_id in order_ids or []:
  639. self._log(f"dropping stale order {order_id} from state")
  640. 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]:
  641. live_ids = list(self.state.get("order_ids") or [])
  642. open_order_count = len(live_ids)
  643. if self._mode() != "active":
  644. return live_orders, live_ids, open_order_count
  645. previous_ids = {
  646. str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  647. for order in previous_orders
  648. if isinstance(order, dict)
  649. }
  650. current_ids = {
  651. str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  652. for order in live_orders
  653. if isinstance(order, dict)
  654. }
  655. vanished_orders = [
  656. order
  657. for order in previous_orders
  658. if isinstance(order, dict)
  659. 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)
  660. ]
  661. if vanished_orders and not self._grid_refresh_paused():
  662. for order in vanished_orders:
  663. order_id = str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or "")
  664. if not order_id:
  665. continue
  666. try:
  667. payload = self.context.query_order(order_id)
  668. except Exception as exc:
  669. self._log(f"order status query failed for {order_id}: {exc}")
  670. continue
  671. raw = payload.get("raw") if isinstance(payload, dict) else {}
  672. if not isinstance(raw, dict):
  673. raw = {}
  674. status = str(payload.get("status") or raw.get("status") or order.get("status") or "").strip().lower()
  675. if status in {"finished", "filled", "closed"}:
  676. fill_price = 0.0
  677. for candidate in (raw.get("price"), order.get("price"), price):
  678. try:
  679. fill_price = float(candidate or 0.0)
  680. except Exception:
  681. fill_price = 0.0
  682. if fill_price > 0:
  683. break
  684. if fill_price > 0:
  685. self._log(f"filled order {order_id} detected via exec status={status}, recentering at {fill_price}")
  686. self._recenter_and_rebuild_from_fill(fill_price)
  687. live_orders = self._sync_open_orders_state()
  688. live_ids = list(self.state.get("order_ids") or [])
  689. open_order_count = len(live_ids)
  690. return live_orders, live_ids, open_order_count
  691. if status in {"cancelled", "expired", "missing"}:
  692. self._log(f"vanished order {order_id} resolved as {status}")
  693. continue
  694. return live_orders, live_ids, open_order_count
  695. def on_tick(self, tick):
  696. previous_orders = list(self.state.get("orders") or [])
  697. tracked_ids_before_sync = list(self.state.get("order_ids") or [])
  698. rebuild_done = False
  699. self.state["balance_shape_inconclusive"] = False
  700. balance_refresh_ok = self._refresh_balance_snapshot()
  701. price = self._price()
  702. self.state["last_price"] = price
  703. self.state["last_error"] = ""
  704. self._refresh_regimes()
  705. try:
  706. live_orders = self._sync_open_orders_state()
  707. live_ids = list(self.state.get("order_ids") or [])
  708. open_order_count = len(live_ids)
  709. expected_ids = [str(oid) for oid in tracked_ids_before_sync if oid]
  710. stale_ids = []
  711. missing_ids = []
  712. except Exception as exc:
  713. open_order_count = -1
  714. live_orders = []
  715. live_ids = []
  716. expected_ids = []
  717. stale_ids = []
  718. missing_ids = []
  719. self.state["last_error"] = str(exc)
  720. self._log(f"open orders check failed: {exc}")
  721. self.state["open_order_count"] = open_order_count
  722. if not balance_refresh_ok:
  723. self._log("balance refresh unavailable, skipping rebuild checks this tick")
  724. self.state["last_action"] = "hold"
  725. return {"action": "hold", "price": price, "reason": "balance refresh unavailable"}
  726. desired_sides = self._desired_sides()
  727. mode = self._mode()
  728. if mode != "active":
  729. if open_order_count > 0:
  730. self._log("observe mode: cancel all open orders")
  731. try:
  732. self.context.cancel_all_orders()
  733. except Exception as exc:
  734. self.state["last_error"] = str(exc)
  735. self._log(f"observe cancel failed: {exc}")
  736. self.state["orders"] = []
  737. self.state["order_ids"] = []
  738. self.state["open_order_count"] = 0
  739. if not self.state.get("seeded") or not self.state.get("center_price"):
  740. self.state["center_price"] = price
  741. self.state["seeded"] = True
  742. self.state["last_action"] = "observe monitor"
  743. self._log(f"observe at {price} dev 0.0000")
  744. return {"action": "observe", "price": price, "deviation": 0.0}
  745. center = float(self.state.get("center_price") or price)
  746. recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
  747. deviation = abs(price - center) / center if center else 0.0
  748. if deviation >= recenter_pct:
  749. self.state["center_price"] = price
  750. self.state["last_action"] = "observe monitor"
  751. self._log(f"observe at {price} dev {deviation:.4f}")
  752. return {"action": "observe", "price": price, "deviation": deviation}
  753. self.state["last_action"] = "observe monitor"
  754. self._log(f"observe at {price} dev {deviation:.4f}")
  755. return {"action": "observe", "price": price, "deviation": deviation}
  756. if stale_ids:
  757. self._log(f"stale live orders: {stale_ids}")
  758. self._cancel_orders(stale_ids)
  759. live_ids = [oid for oid in live_ids if oid not in stale_ids]
  760. if missing_ids:
  761. self._log(f"missing tracked orders: {missing_ids}")
  762. self.state["order_ids"] = live_ids
  763. missing_tracked = bool(set(expected_ids) - set(live_ids))
  764. center = self._maybe_refresh_center(float(self.state.get("center_price") or price))
  765. recenter_pct = self._recenter_threshold_pct()
  766. deviation = abs(price - center) / center if center else 0.0
  767. if mode == "active" and deviation >= recenter_pct and not self._grid_refresh_paused():
  768. if rebuild_done:
  769. return {"action": "hold", "price": price}
  770. self._log(f"recenter needed at price={price} center={center} dev={deviation:.4f} threshold={recenter_pct:.4f}")
  771. rebuild_done = True
  772. self._recenter_and_rebuild_from_price(price, "recenter")
  773. live_orders = self._sync_open_orders_state()
  774. live_ids = list(self.state.get("order_ids") or [])
  775. open_order_count = len(live_ids)
  776. self.state["last_action"] = "recentered"
  777. return {"action": "recenter", "price": price, "deviation": deviation}
  778. live_orders, live_ids, open_order_count = self._reconcile_after_sync(previous_orders, live_orders, desired_sides, price)
  779. if desired_sides != {"buy", "sell"}:
  780. self._log("single-side mode is disabled for this strategy, forcing full-grid rebuilds only")
  781. current_sides = {str(order.get("side") or "").lower() for order in live_orders if isinstance(order, dict)}
  782. current_buy = sum(1 for order in live_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == "buy")
  783. current_sell = sum(1 for order in live_orders if isinstance(order, dict) and str(order.get("side") or "").lower() == "sell")
  784. expected_levels = int(self.config.get("grid_levels", 6) or 6)
  785. quote_currency = self.context.counter_currency or "USD"
  786. quote_available = self._available_balance(quote_currency)
  787. base_symbol = self._base_symbol()
  788. base_available = self._available_balance(base_symbol)
  789. reserved_quote = sum(
  790. float(order.get("price") or 0.0) * float(order.get("amount") or 0.0)
  791. for order in live_orders
  792. if isinstance(order, dict) and str(order.get("side") or "").lower() == "buy"
  793. )
  794. reserved_base = sum(
  795. float(order.get("amount") or 0.0)
  796. for order in live_orders
  797. if isinstance(order, dict) and str(order.get("side") or "").lower() == "sell"
  798. )
  799. total_quote = quote_available + reserved_quote
  800. total_base = base_available + reserved_base
  801. target_buy = self._target_levels_for_side("buy", price, live_orders, total_quote, expected_levels, float(self.context.minimum_order_value or 0.0))
  802. target_sell = self._target_levels_for_side("sell", price, live_orders, total_base, expected_levels, float(self.context.minimum_order_value or 0.0))
  803. target_total = target_buy + target_sell
  804. balance_shape_inconclusive = bool(self.state.get("balance_shape_inconclusive"))
  805. grid_not_as_expected = (
  806. bool(live_orders)
  807. and (
  808. current_buy != target_buy
  809. or current_sell != target_sell
  810. )
  811. )
  812. can_make_better = target_total > 0 and (current_buy != target_buy or current_sell != target_sell)
  813. if balance_shape_inconclusive:
  814. self._log("balance info not conclusive, skipping grid shape rebuild checks this tick")
  815. elif grid_not_as_expected and can_make_better and not self._grid_refresh_paused():
  816. if rebuild_done:
  817. return {"action": "hold", "price": price}
  818. self._log(
  819. f"grid shape mismatch, rebuilding full grid: live_buy={current_buy} live_sell={current_sell} target_buy={target_buy} target_sell={target_sell}"
  820. )
  821. rebuild_done = True
  822. self.state["center_price"] = price
  823. self._recenter_and_rebuild_from_price(price, "grid shape rebuild")
  824. live_orders = self._sync_open_orders_state()
  825. mode = self._mode()
  826. self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
  827. return {"action": "reseed" if mode == "active" else "plan", "price": price}
  828. if not balance_shape_inconclusive and grid_not_as_expected and not can_make_better:
  829. self._log(
  830. f"grid shape left unchanged, balance cannot improve it: live_buy={current_buy} live_sell={current_sell} target_buy={target_buy} target_sell={target_sell}"
  831. )
  832. if not balance_shape_inconclusive and self._order_count_mismatch(tracked_ids_before_sync, live_orders):
  833. if rebuild_done:
  834. return {"action": "hold", "price": price}
  835. self._log(f"grid mismatch detected, rebuilding full grid: tracked={len(tracked_ids_before_sync)} live={len(live_orders)}")
  836. rebuild_done = True
  837. self.state["center_price"] = price
  838. self._recenter_and_rebuild_from_price(price, "grid mismatch rebuild")
  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. if (not self.state.get("seeded") or not self.state.get("center_price")) and not self._grid_refresh_paused():
  844. self.state["center_price"] = price
  845. self._place_grid(price)
  846. live_orders = self._sync_open_orders_state()
  847. self.state["seeded"] = True
  848. mode = self._mode()
  849. self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
  850. return {"action": "seed" if mode == "active" else "plan", "price": price}
  851. if not balance_shape_inconclusive and ((open_order_count == 0) or missing_tracked) and not self._grid_refresh_paused():
  852. if rebuild_done:
  853. return {"action": "hold", "price": price}
  854. self._log("missing tracked order(s), rebuilding full grid")
  855. rebuild_done = True
  856. self.state["center_price"] = price
  857. self._recenter_and_rebuild_from_price(price, "missing order rebuild")
  858. live_orders = self._sync_open_orders_state()
  859. mode = self._mode()
  860. self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
  861. return {"action": "reseed" if mode == "active" else "plan", "price": price}
  862. mode = self._mode()
  863. self.state["last_action"] = "hold" if mode == "active" else f"{mode} monitor"
  864. self._log(f"hold at {price} dev {deviation:.4f}")
  865. return {"action": "hold" if mode == "active" else "plan", "price": price, "deviation": deviation}
  866. def report(self):
  867. snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
  868. return {
  869. "identity": snapshot.get("identity", {}),
  870. "control": snapshot.get("control", {}),
  871. "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
  872. "position": {
  873. "balances": {
  874. "base_available": self.state.get("base_available", 0.0),
  875. "counter_available": self.state.get("counter_available", 0.0),
  876. },
  877. "open_orders": self.state.get("orders") or [],
  878. "exposure": "grid",
  879. },
  880. "state": {
  881. "center_price": self.state.get("center_price", 0.0),
  882. "last_price": self.state.get("last_price", 0.0),
  883. "last_action": self.state.get("last_action", "idle"),
  884. "open_order_count": self.state.get("open_order_count", 0),
  885. "regimes_updated_at": self.state.get("regimes_updated_at", ""),
  886. },
  887. "assessment": {
  888. "confidence": None,
  889. "uncertainty": None,
  890. "reason": "structure-based grid management",
  891. "warnings": [w for w in [self._config_warning()] if w],
  892. "policy": dict(self.config.get("policy") or {}),
  893. },
  894. "execution": snapshot.get("execution", {}),
  895. "supervision": self._supervision(),
  896. }
  897. def render(self):
  898. # Refresh the market-derived display values on render so the dashboard
  899. # reflects the same inputs the strategy would use on the next tick.
  900. live_step_pct = float(self.state.get("grid_step_pct") or 0.0)
  901. live_atr_pct = float(self.state.get("atr_percent") or 0.0)
  902. try:
  903. self._refresh_balance_snapshot()
  904. live_step_pct = self._grid_step_pct()
  905. live_atr_pct = float(self.state.get("atr_percent") or live_atr_pct)
  906. except Exception as exc:
  907. self._log(f"render refresh failed: {exc}")
  908. return {
  909. "widgets": [
  910. {"type": "metric", "label": "market", "value": self._market_symbol()},
  911. {"type": "metric", "label": "center", "value": round(float(self.state.get("center_price") or 0.0), 6)},
  912. {"type": "metric", "label": "last price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
  913. {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
  914. {"type": "metric", "label": "orders", "value": len(self.state.get("orders") or [])},
  915. {"type": "metric", "label": "open orders", "value": self.state.get("open_order_count", 0)},
  916. {"type": "metric", "label": f"ATR({self.config.get('volatility_timeframe', '1h')}) %", "value": round(live_atr_pct, 4)},
  917. {"type": "metric", "label": "grid step %", "value": round(live_step_pct * 100.0, 4)},
  918. {"type": "metric", "label": "1d", "value": ((self.state.get('regimes') or {}).get('1d') or {}).get('trend', {}).get('state', 'n/a')},
  919. {"type": "metric", "label": "4h", "value": ((self.state.get('regimes') or {}).get('4h') or {}).get('trend', {}).get('state', 'n/a')},
  920. {"type": "metric", "label": "1h", "value": ((self.state.get('regimes') or {}).get('1h') or {}).get('trend', {}).get('state', 'n/a')},
  921. {"type": "metric", "label": "15m", "value": ((self.state.get('regimes') or {}).get('15m') or {}).get('trend', {}).get('state', 'n/a')},
  922. {"type": "metric", "label": f"{self._base_symbol()} avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
  923. {"type": "metric", "label": f"{self.context.counter_currency or 'USD'} avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
  924. *([
  925. {"type": "text", "label": "config warning", "value": warning},
  926. ] if (warning := self._config_warning()) else []),
  927. {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
  928. {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
  929. ]
  930. }