grid_trader.py 45 KB

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