grid_trader.py 59 KB

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