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 []}, ] }