grid_trader.py 60 KB

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