|
|
@@ -0,0 +1,286 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from datetime import datetime, timezone
|
|
|
+
|
|
|
+from src.trader_mcp.logging_utils import log_event
|
|
|
+from src.trader_mcp.strategy_sdk import Strategy
|
|
|
+from src.trader_mcp.strategy_sizing import suggest_quote_sized_amount
|
|
|
+
|
|
|
+
|
|
|
+class Strategy(Strategy):
|
|
|
+ LABEL = "Dumb Trader"
|
|
|
+ STRATEGY_PROFILE = {
|
|
|
+ "expects": {},
|
|
|
+ "avoids": {},
|
|
|
+ "risk_profile": "neutral",
|
|
|
+ "capabilities": ["side_only_execution", "quote_sizing"],
|
|
|
+ "role": "primary",
|
|
|
+ "inventory_behavior": "unspecified",
|
|
|
+ "requires_rebalance_before_start": False,
|
|
|
+ "requires_rebalance_before_stop": False,
|
|
|
+ "safe_when_unbalanced": True,
|
|
|
+ "can_run_with": ["exposure_protector"],
|
|
|
+ }
|
|
|
+ TICK_MINUTES = 0.5
|
|
|
+ CONFIG_SCHEMA = {
|
|
|
+ "trade_side": {"type": "string", "default": "both"},
|
|
|
+ "entry_offset_pct": {"type": "float", "default": 0.003, "min": 0.0, "max": 1.0},
|
|
|
+ "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
|
|
|
+ "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
|
|
|
+ "dust_collect": {"type": "bool", "default": False},
|
|
|
+ "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
|
|
|
+ "debug_orders": {"type": "bool", "default": True},
|
|
|
+ }
|
|
|
+ STATE_SCHEMA = {
|
|
|
+ "last_price": {"type": "float", "default": 0.0},
|
|
|
+ "last_action": {"type": "string", "default": "idle"},
|
|
|
+ "last_error": {"type": "string", "default": ""},
|
|
|
+ "debug_log": {"type": "list", "default": []},
|
|
|
+ "trade_side": {"type": "string", "default": "both"},
|
|
|
+ "cooldown_remaining": {"type": "int", "default": 0},
|
|
|
+ "last_order_at": {"type": "float", "default": 0.0},
|
|
|
+ "last_order_price": {"type": "float", "default": 0.0},
|
|
|
+ "base_available": {"type": "float", "default": 0.0},
|
|
|
+ "counter_available": {"type": "float", "default": 0.0},
|
|
|
+ "balance_snapshot_ok": {"type": "bool", "default": False},
|
|
|
+ "balance_snapshot_updated_at": {"type": "string", "default": ""},
|
|
|
+ }
|
|
|
+
|
|
|
+ def init(self):
|
|
|
+ return {
|
|
|
+ "last_price": 0.0,
|
|
|
+ "last_action": "idle",
|
|
|
+ "last_error": "",
|
|
|
+ "debug_log": ["init dumb trader"],
|
|
|
+ "trade_side": "both",
|
|
|
+ "cooldown_remaining": 0,
|
|
|
+ "last_order_at": 0.0,
|
|
|
+ "last_order_price": 0.0,
|
|
|
+ "base_available": 0.0,
|
|
|
+ "counter_available": 0.0,
|
|
|
+ "balance_snapshot_ok": False,
|
|
|
+ "balance_snapshot_updated_at": "",
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ log_event("dumb_trader", message)
|
|
|
+
|
|
|
+ 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 _trade_side(self) -> str:
|
|
|
+ side = str(self.config.get("trade_side") or "both").strip().lower()
|
|
|
+ return side if side in {"buy", "sell", "both"} else "both"
|
|
|
+
|
|
|
+ def _price(self) -> float:
|
|
|
+ payload = self.context.get_price(self._base_symbol())
|
|
|
+ return float(payload.get("price") or 0.0)
|
|
|
+
|
|
|
+ def _live_fee_rate(self) -> float:
|
|
|
+ try:
|
|
|
+ payload = self.context.get_fee_rates(self._market_symbol())
|
|
|
+ return float(payload.get("maker") or payload.get("taker") or 0.0)
|
|
|
+ except Exception as exc:
|
|
|
+ self._log(f"fee lookup failed: {exc}")
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ def _refresh_balance_snapshot(self) -> bool:
|
|
|
+ try:
|
|
|
+ info = self.context.get_account_info()
|
|
|
+ except Exception as exc:
|
|
|
+ self._log(f"balance refresh failed: {exc}")
|
|
|
+ self.state["base_available"] = 0.0
|
|
|
+ self.state["counter_available"] = 0.0
|
|
|
+ self.state["balance_snapshot_ok"] = False
|
|
|
+ self.state["balance_snapshot_updated_at"] = ""
|
|
|
+ return False
|
|
|
+ balances = info.get("balances") if isinstance(info, dict) else []
|
|
|
+ if not isinstance(balances, list):
|
|
|
+ self.state["base_available"] = 0.0
|
|
|
+ self.state["counter_available"] = 0.0
|
|
|
+ self.state["balance_snapshot_ok"] = False
|
|
|
+ self.state["balance_snapshot_updated_at"] = ""
|
|
|
+ return False
|
|
|
+ base = self._base_symbol()
|
|
|
+ quote = str(self.context.counter_currency or "USD").upper()
|
|
|
+ for balance in balances:
|
|
|
+ if not isinstance(balance, dict):
|
|
|
+ continue
|
|
|
+ asset = str(balance.get("asset_code") or "").upper()
|
|
|
+ try:
|
|
|
+ available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ if asset == base:
|
|
|
+ self.state["base_available"] = available
|
|
|
+ if asset == quote:
|
|
|
+ self.state["counter_available"] = available
|
|
|
+ self.state["balance_snapshot_ok"] = True
|
|
|
+ self.state["balance_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
+ return True
|
|
|
+
|
|
|
+ def _supervision(self) -> dict:
|
|
|
+ last_error = str(self.state.get("last_error") or "")
|
|
|
+ side = self._trade_side()
|
|
|
+ entry_offset_pct = float(self.config.get("entry_offset_pct") or 0.003)
|
|
|
+ last_order_at = float(self.state.get("last_order_at") or 0.0)
|
|
|
+ now_ts = datetime.now(timezone.utc).timestamp()
|
|
|
+ last_order_age_seconds = round(max(now_ts - last_order_at, 0.0), 3) if last_order_at > 0 else None
|
|
|
+ if entry_offset_pct <= 0.0015:
|
|
|
+ chasing_risk = "elevated"
|
|
|
+ elif entry_offset_pct <= 0.0035:
|
|
|
+ chasing_risk = "moderate"
|
|
|
+ else:
|
|
|
+ chasing_risk = "low"
|
|
|
+ concerns = []
|
|
|
+ if side == "both":
|
|
|
+ concerns.append("side selection is symmetric and requires external direction")
|
|
|
+ if chasing_risk == "elevated":
|
|
|
+ concerns.append("entry offset is tight and may chase price")
|
|
|
+ return {
|
|
|
+ "health": "degraded" if last_error else "healthy",
|
|
|
+ "degraded": bool(last_error),
|
|
|
+ "inventory_pressure": "balanced" if side in {"buy", "sell"} else "unknown",
|
|
|
+ "capacity_available": side in {"buy", "sell"},
|
|
|
+ "trade_side": side,
|
|
|
+ "entry_offset_pct": round(entry_offset_pct, 6),
|
|
|
+ "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
|
|
|
+ "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
|
|
|
+ "dust_collect": bool(self.config.get("dust_collect", False)),
|
|
|
+ "last_order_age_seconds": last_order_age_seconds,
|
|
|
+ "last_order_price": float(self.state.get("last_order_price") or 0.0),
|
|
|
+ "chasing_risk": chasing_risk,
|
|
|
+ "concerns": concerns,
|
|
|
+ "last_reason": last_error or f"trade_side={side}",
|
|
|
+ }
|
|
|
+
|
|
|
+ def apply_policy(self):
|
|
|
+ policy = super().apply_policy()
|
|
|
+ self.state["policy_derived"] = {
|
|
|
+ "trade_side": self._trade_side(),
|
|
|
+ "entry_offset_pct": float(self.config.get("entry_offset_pct") or 0.003),
|
|
|
+ "cooldown_ticks": int(self.config.get("cooldown_ticks") or 2),
|
|
|
+ "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
|
|
|
+ "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
|
|
|
+ "dust_collect": bool(self.config.get("dust_collect", False)),
|
|
|
+ }
|
|
|
+ return policy
|
|
|
+
|
|
|
+ def _suggest_amount(self, price: float, side: str) -> float:
|
|
|
+ min_notional = float(self.context.minimum_order_value or 0.0)
|
|
|
+ fee_rate = self._live_fee_rate()
|
|
|
+ return suggest_quote_sized_amount(
|
|
|
+ self.context,
|
|
|
+ side=side,
|
|
|
+ price=price,
|
|
|
+ levels=1,
|
|
|
+ min_notional=min_notional,
|
|
|
+ fee_rate=fee_rate,
|
|
|
+ order_notional_quote=float(self.config.get("order_notional_quote") or 0.0),
|
|
|
+ max_order_notional_quote=float(self.config.get("max_order_notional_quote") or 0.0),
|
|
|
+ dust_collect=bool(self.config.get("dust_collect", False)),
|
|
|
+ )
|
|
|
+
|
|
|
+ def on_tick(self, tick):
|
|
|
+ self.state["last_error"] = ""
|
|
|
+ self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
|
|
|
+ price = self._price()
|
|
|
+ self.state["last_price"] = price
|
|
|
+ if not self._refresh_balance_snapshot():
|
|
|
+ self.state["last_action"] = "hold"
|
|
|
+ return {"action": "hold", "price": price, "reason": "balance refresh unavailable"}
|
|
|
+
|
|
|
+ if int(self.state.get("cooldown_remaining") or 0) > 0:
|
|
|
+ self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
|
|
|
+ self.state["last_action"] = "cooldown"
|
|
|
+ return {"action": "cooldown", "price": price}
|
|
|
+
|
|
|
+ side = self._trade_side()
|
|
|
+ if side not in {"buy", "sell"}:
|
|
|
+ self.state["last_action"] = "hold"
|
|
|
+ return {"action": "hold", "price": price, "reason": "trade_side must be buy or sell"}
|
|
|
+
|
|
|
+ amount = self._suggest_amount(price, side)
|
|
|
+ if amount <= 0:
|
|
|
+ self.state["last_action"] = "hold"
|
|
|
+ return {"action": "hold", "price": price, "reason": "no usable size"}
|
|
|
+
|
|
|
+ offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
|
|
|
+ if side == "buy":
|
|
|
+ order_price = round(price * (1 + offset), 8)
|
|
|
+ else:
|
|
|
+ order_price = round(price * (1 - offset), 8)
|
|
|
+
|
|
|
+ try:
|
|
|
+ if self.config.get("debug_orders", True):
|
|
|
+ self._log(f"{side} dumb amount={amount:.6g} price={order_price}")
|
|
|
+ result = self.context.place_order(
|
|
|
+ side=side,
|
|
|
+ order_type="limit",
|
|
|
+ amount=amount,
|
|
|
+ price=order_price,
|
|
|
+ market=self._market_symbol(),
|
|
|
+ )
|
|
|
+ self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
|
|
|
+ self.state["last_order_at"] = datetime.now(timezone.utc).timestamp()
|
|
|
+ self.state["last_order_price"] = order_price
|
|
|
+ self.state["last_action"] = f"{side}_dumb"
|
|
|
+ return {"action": side, "price": order_price, "amount": amount, "result": result}
|
|
|
+ except Exception as exc:
|
|
|
+ self.state["last_error"] = str(exc)
|
|
|
+ self._log(f"dumb order failed: {exc}")
|
|
|
+ self.state["last_action"] = "error"
|
|
|
+ return {"action": "error", "price": price, "error": str(exc)}
|
|
|
+
|
|
|
+ def report(self):
|
|
|
+ snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
|
|
|
+ return {
|
|
|
+ "identity": snapshot.get("identity", {}),
|
|
|
+ "control": snapshot.get("control", {}),
|
|
|
+ "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
|
|
|
+ "position": snapshot.get("position", {}),
|
|
|
+ "state": {
|
|
|
+ "last_price": self.state.get("last_price", 0.0),
|
|
|
+ "last_action": self.state.get("last_action", "idle"),
|
|
|
+ "trade_side": self._trade_side(),
|
|
|
+ "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
|
|
|
+ "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
|
|
|
+ "dust_collect": bool(self.config.get("dust_collect", False)),
|
|
|
+ "cooldown_remaining": self.state.get("cooldown_remaining", 0),
|
|
|
+ "base_available": self.state.get("base_available", 0.0),
|
|
|
+ "counter_available": self.state.get("counter_available", 0.0),
|
|
|
+ },
|
|
|
+ "assessment": {
|
|
|
+ "confidence": None,
|
|
|
+ "uncertainty": None,
|
|
|
+ "reason": "side-only execution",
|
|
|
+ "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
|
|
|
+ "policy": dict(self.config.get("policy") or {}),
|
|
|
+ },
|
|
|
+ "execution": snapshot.get("execution", {}),
|
|
|
+ "supervision": self._supervision(),
|
|
|
+ }
|
|
|
+
|
|
|
+ def render(self):
|
|
|
+ return {
|
|
|
+ "widgets": [
|
|
|
+ {"type": "metric", "label": "market", "value": self._market_symbol()},
|
|
|
+ {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
|
|
|
+ {"type": "metric", "label": "side", "value": self._trade_side()},
|
|
|
+ {"type": "metric", "label": "quote notional", "value": round(float(self.config.get("order_notional_quote") or 0.0), 6)},
|
|
|
+ {"type": "metric", "label": "dust collect", "value": bool(self.config.get("dust_collect", False))},
|
|
|
+ {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
|
|
|
+ {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
|
|
|
+ {"type": "metric", "label": "balances", "value": "fresh" if self.state.get("balance_snapshot_ok") else "stale"},
|
|
|
+ {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},
|
|
|
+ {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
|
|
|
+ ]
|
|
|
+ }
|