Lukas Goldschmidt 2 settimane fa
parent
commit
e1e5650cd1
2 ha cambiato i file con 343 aggiunte e 0 eliminazioni
  1. 57 0
      strategies/dumb_trader.md
  2. 286 0
      strategies/dumb_trader.py

+ 57 - 0
strategies/dumb_trader.md

@@ -0,0 +1,57 @@
+# Dumb Trader
+
+Side-only execution strategy.
+
+## Best Used When
+- Hermes or the operator wants a simple one-sided executor
+- trend data should be ignored for now
+- the market direction is already decided elsewhere
+
+## Avoid When
+- you want the strategy to infer direction on its own
+- you need adaptive trend following
+
+## How It Works
+- The strategy acts only on the configured `trade_side`.
+- `buy` and `sell` place orders on that side only when the live balance-aware sizing step returns a usable amount that also clears the venue minimum.
+- `both` does not invent direction and therefore holds.
+- `entry_offset_pct` sets the limit price offset for new entries.
+- `cooldown_ticks` pauses the next few ticks after a successful order.
+- If the live balance refresh fails, the strategy now holds for that tick instead of reusing a stale balance snapshot.
+
+## Parameters
+- `trade_side`: Allowed side selection. `buy` and `sell` make it one-sided; `both` means the strategy will hold.
+- `entry_offset_pct`: Limit-price offset used for new entries. Buy orders are placed above the last price, sell orders below it.
+- `order_notional_quote`: Target quote notional per order.
+- `max_order_notional_quote`: Optional hard cap on quote notional per order.
+- `dust_collect`: Lets the shared sizing helper consume leftover size more aggressively when a venue minimum would otherwise strand a small remainder.
+- `cooldown_ticks`: Number of ticks to wait after a successful order before the strategy can act again.
+- `debug_orders`: Enables order-placement debug logging.
+
+## Hermes Policy Mapping
+- Hermes controls whether this side-only trader is active.
+- Hermes may set the quote notional and offsets.
+- `apply_policy()` records the derived values but does not rewrite the config.
+
+## Notes
+- This strategy is intentionally dumb for now.
+- It ignores trend inputs.
+- It still uses live fee rates for sizing.
+- If the account cannot afford an order within the configured size limits, the strategy holds instead of falling back to the requested notional.
+- A failed account-info refresh is treated as an unsafe state, so restart-time stale balances will not drive another order.
+
+## Useful Example
+```json
+{
+  "trade_side": "buy",
+  "entry_offset_pct": 0.0035,
+  "order_notional_quote": 25,
+  "max_order_notional_quote": 40,
+  "dust_collect": true,
+  "cooldown_ticks": 3,
+  "debug_orders": false
+}
+```
+
+This is a practical long-only setup. It keeps buying on the configured side,
+uses a modest entry offset, and avoids oversized last orders.

+ 286 - 0
strategies/dumb_trader.py

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