|
@@ -0,0 +1,383 @@
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import time
|
|
|
|
|
+
|
|
|
|
|
+from src.trader_mcp.strategy_sdk import Strategy
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class Strategy(Strategy):
|
|
|
|
|
+ LABEL = "Grid Trader"
|
|
|
|
|
+ TICK_MINUTES = 0.2
|
|
|
|
|
+ # NOTE:
|
|
|
|
|
+ # This strategy is currently using a protective workaround for stale order state,
|
|
|
|
|
+ # because exec-mcp can temporarily report order records that do not reflect the
|
|
|
|
|
+ # clean post-reset strategy state. The grid prefers its own fresh persisted state
|
|
|
|
|
+ # first, so the real exchange behavior stays testable while exec-mcp is improved.
|
|
|
|
|
+ # Expect the reconciliation behavior to change again once exec-mcp is fixed.
|
|
|
|
|
+ CONFIG_SCHEMA = {
|
|
|
|
|
+ "grid_levels": {"type": "int", "default": 6, "min": 1, "max": 20},
|
|
|
|
|
+ "grid_step_pct": {"type": "float", "default": 0.012, "min": 0.001, "max": 0.1},
|
|
|
|
|
+ "volatility_timeframe": {"type": "string", "default": "1h"},
|
|
|
|
|
+ "volatility_multiplier": {"type": "float", "default": 0.5, "min": 0.0, "max": 10.0},
|
|
|
|
|
+ "grid_step_min_pct": {"type": "float", "default": 0.005, "min": 0.0001, "max": 0.5},
|
|
|
|
|
+ "grid_step_max_pct": {"type": "float", "default": 0.03, "min": 0.0001, "max": 1.0},
|
|
|
|
|
+ "order_size": {"type": "float", "default": 0.0, "min": 0.0},
|
|
|
|
|
+ "inventory_cap_pct": {"type": "float", "default": 0.7, "min": 0.0, "max": 1.0},
|
|
|
|
|
+ "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
|
|
|
|
|
+ "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
|
|
|
|
|
+ "trade_sides": {"type": "string", "default": "both"},
|
|
|
|
|
+ "max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
|
|
|
|
|
+ "order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
|
|
|
|
|
+ "debug_orders": {"type": "bool", "default": True},
|
|
|
|
|
+ "use_all_available": {"type": "bool", "default": True},
|
|
|
|
|
+ }
|
|
|
|
|
+ STATE_SCHEMA = {
|
|
|
|
|
+ "center_price": {"type": "float", "default": 0.0},
|
|
|
|
|
+ "last_price": {"type": "float", "default": 0.0},
|
|
|
|
|
+ "seeded": {"type": "bool", "default": False},
|
|
|
|
|
+ "last_action": {"type": "string", "default": "idle"},
|
|
|
|
|
+ "last_error": {"type": "string", "default": ""},
|
|
|
|
|
+ "orders": {"type": "list", "default": []},
|
|
|
|
|
+ "order_ids": {"type": "list", "default": []},
|
|
|
|
|
+ "debug_log": {"type": "list", "default": []},
|
|
|
|
|
+ "base_available": {"type": "float", "default": 0.0},
|
|
|
|
|
+ "counter_available": {"type": "float", "default": 0.0},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def init(self):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "center_price": 0.0,
|
|
|
|
|
+ "last_price": 0.0,
|
|
|
|
|
+ "seeded": False,
|
|
|
|
|
+ "last_action": "idle",
|
|
|
|
|
+ "last_error": "",
|
|
|
|
|
+ "orders": [],
|
|
|
|
|
+ "order_ids": [],
|
|
|
|
|
+ "debug_log": ["init cancel all orders"],
|
|
|
|
|
+ "base_available": 0.0,
|
|
|
|
|
+ "counter_available": 0.0,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def _log(self, message: str) -> None:
|
|
|
|
|
+ state = getattr(self, "state", {}) or {}
|
|
|
|
|
+ log = list(state.get("debug_log") or [])
|
|
|
|
|
+ log.append(message)
|
|
|
|
|
+ state["debug_log"] = log[-12:]
|
|
|
|
|
+ self.state = state
|
|
|
|
|
+
|
|
|
|
|
+ def _base_symbol(self) -> str:
|
|
|
|
|
+ return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
|
|
|
|
|
+
|
|
|
|
|
+ def _market_symbol(self) -> str:
|
|
|
|
|
+ return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
|
|
|
|
|
+
|
|
|
|
|
+ def _mode(self) -> str:
|
|
|
|
|
+ return getattr(self.context, "mode", "active") or "active"
|
|
|
|
|
+
|
|
|
|
|
+ def _price(self) -> float:
|
|
|
|
|
+ payload = self.context.get_price(self._base_symbol())
|
|
|
|
|
+ return float(payload.get("price") or 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ def _regime_snapshot(self) -> dict:
|
|
|
|
|
+ timeframes = ["1d", "4h", "1h", "15m"]
|
|
|
|
|
+ snapshot = {}
|
|
|
|
|
+ for tf in timeframes:
|
|
|
|
|
+ try:
|
|
|
|
|
+ snapshot[tf] = self.context.get_regime(self._base_symbol(), tf)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ snapshot[tf] = {"error": str(exc)}
|
|
|
|
|
+ return snapshot
|
|
|
|
|
+
|
|
|
|
|
+ def _grid_step_pct(self) -> float:
|
|
|
|
|
+ base_step = float(self.config.get("grid_step_pct", 0.012) or 0.012)
|
|
|
|
|
+ tf = str(self.config.get("volatility_timeframe", "1h") or "1h")
|
|
|
|
|
+ multiplier = float(self.config.get("volatility_multiplier", 0.5) or 0.0)
|
|
|
|
|
+ min_step = float(self.config.get("grid_step_min_pct", 0.005) or 0.0)
|
|
|
|
|
+ max_step = float(self.config.get("grid_step_max_pct", 0.03) or 1.0)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ regime = self.context.get_regime(self._base_symbol(), tf)
|
|
|
|
|
+ short_regime = self.context.get_regime(self._base_symbol(), "15m")
|
|
|
|
|
+ atr_pct = float((regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
|
|
|
|
|
+ short_atr_pct = float((short_regime or {}).get("volatility", {}).get("atr_percent") or 0.0)
|
|
|
|
|
+ atr_pct = max(atr_pct, short_atr_pct)
|
|
|
|
|
+ self.state["regimes"] = self._regime_snapshot()
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ self._log(f"regime fetch failed: {exc}")
|
|
|
|
|
+ atr_pct = 0.0
|
|
|
|
|
+
|
|
|
|
|
+ adaptive = (atr_pct / 100.0) * multiplier if atr_pct > 0 else base_step
|
|
|
|
|
+ step = adaptive if atr_pct > 0 else base_step
|
|
|
|
|
+ step = max(step, min_step)
|
|
|
|
|
+ step = min(step, max_step)
|
|
|
|
|
+ self.state["grid_step_pct"] = step
|
|
|
|
|
+ self.state["atr_percent"] = atr_pct
|
|
|
|
|
+ return step
|
|
|
|
|
+
|
|
|
|
|
+ def _available_balance(self, asset_code: str) -> float:
|
|
|
|
|
+ try:
|
|
|
|
|
+ info = self.context.get_account_info()
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ self._log(f"account info failed: {exc}")
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+
|
|
|
|
|
+ balances = info.get("balances") if isinstance(info, dict) else []
|
|
|
|
|
+ if not isinstance(balances, list):
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+ wanted = str(asset_code or "").upper()
|
|
|
|
|
+ for balance in balances:
|
|
|
|
|
+ if not isinstance(balance, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ if str(balance.get("asset_code") or "").upper() != wanted:
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ return float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+
|
|
|
|
|
+ def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
|
|
|
|
|
+ if min_notional <= 0 or price <= 0:
|
|
|
|
|
+ return 0
|
|
|
|
|
+ safety = 0.995
|
|
|
|
|
+ fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
|
|
|
|
|
+ if side == "buy":
|
|
|
|
|
+ quote = self.context.counter_currency or "USD"
|
|
|
|
|
+ quote_available = self._available_balance(quote)
|
|
|
|
|
+ self.state["counter_available"] = quote_available
|
|
|
|
|
+ usable_notional = quote_available * safety
|
|
|
|
|
+ return max(int(usable_notional / min_notional), 0)
|
|
|
|
|
+
|
|
|
|
|
+ base = self._base_symbol()
|
|
|
|
|
+ base_available = self._available_balance(base)
|
|
|
|
|
+ self.state["base_available"] = base_available
|
|
|
|
|
+ usable_notional = base_available * safety * price / (1 + fee_rate)
|
|
|
|
|
+ return max(int(usable_notional / min_notional), 0)
|
|
|
|
|
+
|
|
|
|
|
+ def _side_allowed(self, side: str) -> bool:
|
|
|
|
|
+ selected = str(self.config.get("trade_sides", "both") or "both").strip().lower()
|
|
|
|
|
+ if selected == "both":
|
|
|
|
|
+ return True
|
|
|
|
|
+ return selected == side
|
|
|
|
|
+
|
|
|
|
|
+ def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
|
|
|
|
|
+ if levels <= 0 or price <= 0:
|
|
|
|
|
+ return 0.0
|
|
|
|
|
+ safety = 0.995
|
|
|
|
|
+ fee_rate = float(self.config.get("fee_rate", 0.0025) or 0.0)
|
|
|
|
|
+ max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
|
|
|
|
|
+ manual = float(self.config.get("order_size", 0.0) or 0.0)
|
|
|
|
|
+ if side == "buy":
|
|
|
|
|
+ quote = self.context.counter_currency or "USD"
|
|
|
|
|
+ quote_available = self._available_balance(quote)
|
|
|
|
|
+ self.state["counter_available"] = quote_available
|
|
|
|
|
+ spendable_quote = quote_available * safety
|
|
|
|
|
+ amount = spendable_quote / (max(levels, 1) * price * (1 + fee_rate))
|
|
|
|
|
+ else:
|
|
|
|
|
+ base = self._base_symbol()
|
|
|
|
|
+ base_available = self._available_balance(base)
|
|
|
|
|
+ self.state["base_available"] = base_available
|
|
|
|
|
+ spendable_base = (base_available * safety) / (1 + fee_rate)
|
|
|
|
|
+ amount = spendable_base / max(levels, 1)
|
|
|
|
|
+
|
|
|
|
|
+ min_size = (min_notional / price) if price > 0 else 0.0
|
|
|
|
|
+ amount = max(amount, min_size * 1.05)
|
|
|
|
|
+ if max_notional > 0 and price > 0:
|
|
|
|
|
+ amount = min(amount, max_notional / (price * (1 + fee_rate)))
|
|
|
|
|
+ if manual > 0:
|
|
|
|
|
+ amount = min(amount, manual)
|
|
|
|
|
+ return max(amount, 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ def _place_grid(self, center: float) -> None:
|
|
|
|
|
+ mode = self._mode()
|
|
|
|
|
+ levels = int(self.config.get("grid_levels", 6) or 6)
|
|
|
|
|
+ step = self._grid_step_pct()
|
|
|
|
|
+ min_notional = float(self.context.minimum_order_value or 0.0)
|
|
|
|
|
+ market = self._market_symbol()
|
|
|
|
|
+ orders = []
|
|
|
|
|
+ order_ids = []
|
|
|
|
|
+
|
|
|
|
|
+ def _capture_order_id(result):
|
|
|
|
|
+ if isinstance(result, dict):
|
|
|
|
|
+ return result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ 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)
|
|
|
|
|
+ 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)
|
|
|
|
|
+ buy_amount = self._suggest_amount("buy", center, max(buy_levels, 1), min_notional)
|
|
|
|
|
+ sell_amount = self._suggest_amount("sell", center, max(sell_levels, 1), min_notional)
|
|
|
|
|
+
|
|
|
|
|
+ for i in range(1, levels + 1):
|
|
|
|
|
+ buy_price = round(center * (1 - (step * i)), 8)
|
|
|
|
|
+ sell_price = round(center * (1 + (step * i)), 8)
|
|
|
|
|
+ if mode != "active":
|
|
|
|
|
+ orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": {"simulated": True}})
|
|
|
|
|
+ orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": {"simulated": True}})
|
|
|
|
|
+ self._log(f"plan level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ if i > buy_levels and i > sell_levels:
|
|
|
|
|
+ self._log(f"skip level {i}: no capacity on either side")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
|
|
|
|
|
+ min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ if i <= buy_levels and buy_amount >= min_size_buy:
|
|
|
|
|
+ buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
|
|
|
|
|
+ orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
|
|
|
|
|
+ buy_id = _capture_order_id(buy)
|
|
|
|
|
+ if buy_id is not None:
|
|
|
|
|
+ order_ids.append(str(buy_id))
|
|
|
|
|
+ if i <= sell_levels and sell_amount >= min_size_sell:
|
|
|
|
|
+ sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
|
|
|
|
|
+ orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
|
|
|
|
|
+ sell_id = _capture_order_id(sell)
|
|
|
|
|
+ if sell_id is not None:
|
|
|
|
|
+ order_ids.append(str(sell_id))
|
|
|
|
|
+ self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
|
|
|
|
|
+ except Exception as exc: # best effort for first draft
|
|
|
|
|
+ self.state["last_error"] = str(exc)
|
|
|
|
|
+ self._log(f"seed level {i} failed: {exc}")
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
|
|
|
|
|
+ if delay > 0:
|
|
|
|
|
+ time.sleep(delay)
|
|
|
|
|
+
|
|
|
|
|
+ self.state["orders"] = orders
|
|
|
|
|
+ self.state["order_ids"] = order_ids
|
|
|
|
|
+ self.state["last_action"] = "seeded grid"
|
|
|
|
|
+
|
|
|
|
|
+ def _cancel_orders(self, order_ids) -> None:
|
|
|
|
|
+ for order_id in order_ids or []:
|
|
|
|
|
+ self._log(f"dropping stale order {order_id} from state")
|
|
|
|
|
+
|
|
|
|
|
+ def on_tick(self, tick):
|
|
|
|
|
+ price = self._price()
|
|
|
|
|
+ self.state["last_price"] = price
|
|
|
|
|
+ self.state["last_error"] = ""
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ open_orders = self.context.get_open_orders()
|
|
|
|
|
+ live_ids = []
|
|
|
|
|
+ if isinstance(open_orders, list):
|
|
|
|
|
+ for order in open_orders:
|
|
|
|
|
+ if isinstance(order, dict):
|
|
|
|
|
+ live_ids.append(str(order.get("bitstamp_order_id") or order.get("order_id") or order.get("id") or order.get("client_order_id") or ""))
|
|
|
|
|
+ live_ids = [oid for oid in live_ids if oid]
|
|
|
|
|
+ open_order_count = len(live_ids)
|
|
|
|
|
+ expected_ids = [str(oid) for oid in (self.state.get("order_ids") or []) if oid]
|
|
|
|
|
+ stale_ids = [oid for oid in live_ids if oid not in expected_ids]
|
|
|
|
|
+ missing_ids = [oid for oid in expected_ids if oid not in live_ids]
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ open_order_count = -1
|
|
|
|
|
+ live_ids = []
|
|
|
|
|
+ expected_ids = []
|
|
|
|
|
+ stale_ids = []
|
|
|
|
|
+ missing_ids = []
|
|
|
|
|
+ self.state["last_error"] = str(exc)
|
|
|
|
|
+ self._log(f"open orders check failed: {exc}")
|
|
|
|
|
+
|
|
|
|
|
+ # Workaround: after a reset, trust the fresh strategy state first.
|
|
|
|
|
+ # This prevents stale exec-mcp records from blocking the next clean test.
|
|
|
|
|
+ if not (self.state.get("order_ids") or []):
|
|
|
|
|
+ live_ids = []
|
|
|
|
|
+ open_order_count = 0
|
|
|
|
|
+ expected_ids = []
|
|
|
|
|
+ stale_ids = []
|
|
|
|
|
+ missing_ids = []
|
|
|
|
|
+
|
|
|
|
|
+ self.state["open_order_count"] = open_order_count
|
|
|
|
|
+
|
|
|
|
|
+ mode = self._mode()
|
|
|
|
|
+
|
|
|
|
|
+ if mode != "active":
|
|
|
|
|
+ if not self.state.get("seeded") or not self.state.get("center_price"):
|
|
|
|
|
+ self.state["center_price"] = price
|
|
|
|
|
+ self._place_grid(price)
|
|
|
|
|
+ self.state["seeded"] = True
|
|
|
|
|
+ self._log(f"planned grid at {price}")
|
|
|
|
|
+ return {"action": "plan", "price": price}
|
|
|
|
|
+
|
|
|
|
|
+ center = float(self.state.get("center_price") or price)
|
|
|
|
|
+ recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
|
|
|
|
|
+ deviation = abs(price - center) / center if center else 0.0
|
|
|
|
|
+ if deviation >= recenter_pct:
|
|
|
|
|
+ self.state["center_price"] = price
|
|
|
|
|
+ self._place_grid(price)
|
|
|
|
|
+ self._log(f"planned recenter to {price}")
|
|
|
|
|
+ return {"action": "plan", "price": price, "deviation": deviation}
|
|
|
|
|
+
|
|
|
|
|
+ self.state["last_action"] = "observe monitor"
|
|
|
|
|
+ self._log(f"observe at {price} dev {deviation:.4f}")
|
|
|
|
|
+ return {"action": "observe", "price": price, "deviation": deviation}
|
|
|
|
|
+
|
|
|
|
|
+ if stale_ids:
|
|
|
|
|
+ self._log(f"stale live orders: {stale_ids}")
|
|
|
|
|
+ self._cancel_orders(stale_ids)
|
|
|
|
|
+ live_ids = [oid for oid in live_ids if oid not in stale_ids]
|
|
|
|
|
+
|
|
|
|
|
+ if missing_ids:
|
|
|
|
|
+ self._log(f"missing tracked orders: {missing_ids}")
|
|
|
|
|
+ self.state["order_ids"] = live_ids
|
|
|
|
|
+
|
|
|
|
|
+ if not self.state.get("seeded") or not self.state.get("center_price"):
|
|
|
|
|
+ self.state["center_price"] = price
|
|
|
|
|
+ self._place_grid(price)
|
|
|
|
|
+ self.state["seeded"] = True
|
|
|
|
|
+ mode = self._mode()
|
|
|
|
|
+ self._log(f"{'seeded' if mode == 'active' else 'planned'} grid at {price}")
|
|
|
|
|
+ return {"action": "seed" if mode == "active" else "plan", "price": price}
|
|
|
|
|
+
|
|
|
|
|
+ if open_order_count == 0 or (expected_ids and not set(expected_ids).intersection(set(live_ids))):
|
|
|
|
|
+ self._log("no open orders, reseeding grid")
|
|
|
|
|
+ self.state["center_price"] = price
|
|
|
|
|
+ self._place_grid(price)
|
|
|
|
|
+ mode = self._mode()
|
|
|
|
|
+ self.state["last_action"] = "reseeded" if mode == "active" else f"{mode} monitor"
|
|
|
|
|
+ return {"action": "reseed" if mode == "active" else "plan", "price": price}
|
|
|
|
|
+
|
|
|
|
|
+ center = float(self.state.get("center_price") or price)
|
|
|
|
|
+ recenter_pct = float(self.config.get("recenter_pct", 0.05) or 0.05)
|
|
|
|
|
+ deviation = abs(price - center) / center if center else 0.0
|
|
|
|
|
+
|
|
|
|
|
+ if deviation >= recenter_pct:
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.context.cancel_all_orders()
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ self.state["last_error"] = str(exc)
|
|
|
|
|
+ self.state["center_price"] = price
|
|
|
|
|
+ self._place_grid(price)
|
|
|
|
|
+ mode = self._mode()
|
|
|
|
|
+ self.state["last_action"] = "recentered" if mode == "active" else f"{mode} monitor"
|
|
|
|
|
+ self._log(f"recentered grid to {price}")
|
|
|
|
|
+ return {"action": "recenter" if mode == "active" else "plan", "price": price, "deviation": deviation}
|
|
|
|
|
+
|
|
|
|
|
+ mode = self._mode()
|
|
|
|
|
+ self.state["last_action"] = "hold" if mode == "active" else f"{mode} monitor"
|
|
|
|
|
+ self._log(f"hold at {price} dev {deviation:.4f}")
|
|
|
|
|
+ return {"action": "hold" if mode == "active" else "plan", "price": price, "deviation": deviation}
|
|
|
|
|
+
|
|
|
|
|
+ def render(self):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "widgets": [
|
|
|
|
|
+ {"type": "metric", "label": "market", "value": self._market_symbol()},
|
|
|
|
|
+ {"type": "metric", "label": "center", "value": round(float(self.state.get("center_price") or 0.0), 6)},
|
|
|
|
|
+ {"type": "metric", "label": "last price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
|
|
|
|
|
+ {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
|
|
|
|
|
+ {"type": "metric", "label": "orders", "value": len(self.state.get("orders") or [])},
|
|
|
|
|
+ {"type": "metric", "label": "open orders", "value": self.state.get("open_order_count", 0)},
|
|
|
|
|
+ {"type": "metric", "label": "ATR %", "value": round(float(self.state.get("atr_percent") or 0.0), 4)},
|
|
|
|
|
+ {"type": "metric", "label": "grid step %", "value": round(float(self.state.get("grid_step_pct") or 0.0) * 100.0, 4)},
|
|
|
|
|
+ {"type": "metric", "label": "1d", "value": ((self.state.get('regimes') or {}).get('1d') or {}).get('trend', {}).get('state', 'n/a')},
|
|
|
|
|
+ {"type": "metric", "label": "4h", "value": ((self.state.get('regimes') or {}).get('4h') or {}).get('trend', {}).get('state', 'n/a')},
|
|
|
|
|
+ {"type": "metric", "label": "1h", "value": ((self.state.get('regimes') or {}).get('1h') or {}).get('trend', {}).get('state', 'n/a')},
|
|
|
|
|
+ {"type": "metric", "label": "15m", "value": ((self.state.get('regimes') or {}).get('15m') or {}).get('trend', {}).get('state', 'n/a')},
|
|
|
|
|
+ {"type": "metric", "label": f"{self._base_symbol()} avail", "value": round(float(self.state.get("base_available") or 0.0), 8)},
|
|
|
|
|
+ {"type": "metric", "label": f"{self.context.counter_currency or 'USD'} avail", "value": round(float(self.state.get("counter_available") or 0.0), 8)},
|
|
|
|
|
+ {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
|
|
|
|
|
+ {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|