Forráskód Böngészése

Tighten grid sizing and fee lookup

Lukas Goldschmidt 1 hónapja
szülő
commit
026df93913
2 módosított fájl, 87 hozzáadás és 27 törlés
  1. 7 0
      src/trader_mcp/exec_client.py
  2. 80 27
      strategies/grid_trader.py

+ 7 - 0
src/trader_mcp/exec_client.py

@@ -49,6 +49,13 @@ def get_account_info(account_id: str) -> Any:
     return _mcp.call_tool("get_account_info", {"account_id": account_id})
 
 
+def get_account_fees(account_id: str, market_symbol: str | None = None) -> Any:
+    args: dict[str, Any] = {"account_id": account_id}
+    if market_symbol is not None:
+        args["market_symbol"] = market_symbol
+    return _mcp.call_tool("get_account_fees", args)
+
+
 def list_markets() -> list[dict[str, Any]]:
     payload = _mcp.call_tool("list_markets", {})
     if isinstance(payload, list):

+ 80 - 27
strategies/grid_trader.py

@@ -9,7 +9,7 @@ from src.trader_mcp.logging_utils import log_event
 
 class Strategy(Strategy):
     LABEL = "Grid Trader"
-    TICK_MINUTES = 0.2
+    TICK_MINUTES = 1.0
     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},
@@ -26,6 +26,7 @@ class Strategy(Strategy):
         "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},
+        "dust_collect": {"type": "bool", "default": False},
         "order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
         "enable_trend_guard": {"type": "bool", "default": True},
         "trend_guard_reversal_max": {"type": "float", "default": 0.25, "min": 0.0, "max": 1.0},
@@ -46,6 +47,8 @@ class Strategy(Strategy):
         "trend_guard_active": {"type": "bool", "default": False},
         "regimes_updated_at": {"type": "string", "default": ""},
         "account_snapshot_updated_at": {"type": "string", "default": ""},
+        "last_balance_log_signature": {"type": "string", "default": ""},
+        "last_balance_log_at": {"type": "string", "default": ""},
         "grid_refresh_pending_until": {"type": "string", "default": ""},
         "mismatch_ticks": {"type": "int", "default": 0},
         "recovery_cooldown_until": {"type": "string", "default": ""},
@@ -66,6 +69,8 @@ class Strategy(Strategy):
             "trend_guard_active": False,
             "regimes_updated_at": "",
             "account_snapshot_updated_at": "",
+            "last_balance_log_signature": "",
+            "last_balance_log_at": "",
             "grid_refresh_pending_until": "",
             "mismatch_ticks": 0,
             "recovery_cooldown_until": "",
@@ -79,6 +84,12 @@ class Strategy(Strategy):
         self.state = state
         log_event("grid", message)
 
+    def _log_decision(self, action: str, **fields) -> None:
+        parts = [action]
+        for key, value in fields.items():
+            parts.append(f"{key}={value}")
+        self._log(", ".join(parts))
+
     def _set_grid_refresh_pause(self, seconds: float = 30.0) -> None:
         self.state["grid_refresh_pending_until"] = (datetime.now(timezone.utc).timestamp() + max(seconds, 0.0))
 
@@ -141,8 +152,8 @@ class Strategy(Strategy):
             return fallback, fallback
 
     def _live_fee_rate(self) -> float:
-        maker, _taker = self._live_fee_rates()
-        return maker
+        _maker, taker = self._live_fee_rates()
+        return taker
 
     def _mode(self) -> str:
         return getattr(self.context, "mode", "active") or "active"
@@ -286,6 +297,30 @@ class Strategy(Strategy):
             if asset == str(quote).upper():
                 self.state["counter_available"] = available
         self.state["account_snapshot_updated_at"] = datetime.now(timezone.utc).isoformat()
+        signature = f"{base}:{self.state.get('base_available', 0.0):.8f}|{quote}:{self.state.get('counter_available', 0.0):.8f}"
+        last_signature = str(self.state.get("last_balance_log_signature") or "")
+        last_logged_at = str(self.state.get("last_balance_log_at") or "")
+        now_iso = self.state["account_snapshot_updated_at"]
+        should_log = signature != last_signature or not last_logged_at
+        if not should_log:
+            try:
+                from datetime import datetime as _dt
+
+                elapsed = (_dt.fromisoformat(now_iso) - _dt.fromisoformat(last_logged_at)).total_seconds()
+                should_log = elapsed >= 60
+            except Exception:
+                should_log = True
+        if should_log:
+            self.state["last_balance_log_signature"] = signature
+            self.state["last_balance_log_at"] = now_iso
+            self._log_decision(
+                "balance snapshot",
+                base=base,
+                base_available=f"{self.state.get('base_available', 0.0):.6g}",
+                quote=quote,
+                quote_available=f"{self.state.get('counter_available', 0.0):.6g}",
+                updated_at=now_iso,
+            )
 
     def _supported_levels(self, side: str, price: float, min_notional: float) -> int:
         if min_notional <= 0 or price <= 0:
@@ -330,8 +365,9 @@ class Strategy(Strategy):
         if levels <= 0 or price <= 0:
             return 0.0
         safety = 0.995
-        fee_rate = self._live_fee_rate()
+        fee_rate = max(self._live_fee_rate(), 0.0)
         max_notional = float(self.config.get("max_notional_per_order", 0.0) or 0.0)
+        dust_collect = bool(self.config.get("dust_collect", False))
         manual = float(self.config.get("order_size", 0.0) or 0.0)
         min_amount = (min_notional / price) if min_notional > 0 else 0.0
         if side == "buy":
@@ -339,30 +375,37 @@ class Strategy(Strategy):
             quote_available = self._available_balance(quote)
             self.state["counter_available"] = quote_available
             spendable_quote = quote_available * safety
-            max_affordable = spendable_quote / (price * (1 + fee_rate))
-            if max_affordable < min_amount:
+            quote_cap = spendable_quote if (dust_collect or max_notional <= 0) else min(spendable_quote, max_notional)
+            if quote_cap <= 0:
+                return 0.0
+            per_order_quote = quote_cap / max(levels, 1)
+            min_quote_needed = min_notional * (1 + fee_rate)
+            if per_order_quote < min_quote_needed:
                 return 0.0
-            amount = min(spendable_quote / (max(levels, 1) * price * (1 + fee_rate)), max_affordable)
+            amount = per_order_quote / (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)
-            max_affordable = spendable_base
-            if max_affordable < min_amount:
+            spendable_base = base_available * safety
+            if not dust_collect and max_notional > 0 and price > 0:
+                spendable_base = min(spendable_base, max_notional / price)
+            if spendable_base <= 0:
                 return 0.0
-            amount = min(spendable_base / max(levels, 1), max_affordable)
+            per_order_base = spendable_base / max(levels, 1)
+            if per_order_base < min_amount:
+                return 0.0
+            amount = per_order_base
 
-        amount = max(amount, min_amount * 1.05)
-        if max_notional > 0 and price > 0:
-            amount = min(amount, max_notional / (price * (1 + fee_rate)))
+        if amount < min_amount:
+            return 0.0
         if manual > 0:
-            if manual >= min_amount:
-                amount = min(amount, manual)
-            else:
+            if manual < min_amount:
                 self._log(
                     f"manual order_size below minimum: order_size={manual:.6g} min_amount={min_amount:.6g} price={price} min_notional={min_notional}"
                 )
+                return 0.0
+            amount = min(amount, manual)
         return max(amount, 0.0)
 
     def _place_grid(self, center: float) -> None:
@@ -449,8 +492,13 @@ class Strategy(Strategy):
             max_affordable_amount = (quote_available * safety) / (center * (1 + fee_rate)) if center > 0 else 0.0
             min_amount = (min_notional / center) if center > 0 and min_notional > 0 else 0.0
             if max_affordable_amount < min_amount:
-                self._log(
-                    f"skip side buy: insufficient counter balance quote={quote_available:.6g} max_affordable_amount={max_affordable_amount:.6g} min_amount={min_amount:.6g} fee_rate={fee_rate}"
+                self._log_decision(
+                    f"skip side {side}",
+                    reason="insufficient_counter_balance",
+                    quote=f"{quote_available:.6g}",
+                    max_affordable_amount=f"{max_affordable_amount:.6g}",
+                    min_amount=f"{min_amount:.6g}",
+                    fee_rate=f"{fee_rate:.6g}",
                 )
                 return
             amount = min(amount, max_affordable_amount)
@@ -461,33 +509,38 @@ class Strategy(Strategy):
                 side_levels = 1
                 self._log(f"side {side} restored to 1 level because amount clears minimum: amount={amount:.6g} min_amount={min_amount:.6g}")
         self._log(
-            f"prepare side {side}: market={market} center={center} levels={side_levels} amount={amount:.6g} min_notional={min_notional} existing_ids={order_ids}"
+            f"prepare side {side}, market={market}, center={center}, levels={side_levels}, amount={amount:.6g}, min_notional={min_notional}, existing_ids={order_ids}"
         )
 
         for i in range(start_level, levels + 1):
             price = round(center * (1 - (step * i)) if side == "buy" else center * (1 + (step * i)), 8)
             min_size = (min_notional / price) if price > 0 else 0.0
             if i > side_levels or amount < min_size:
-                self._log(
-                    f"skip side {side} level {i}: amount={amount:.6g} below min_size={min_size:.6g} min_notional={min_notional} price={price}"
+                self._log_decision(
+                    f"skip side {side} level {i}",
+                    reason="below_min_size",
+                    amount=f"{amount:.6g}",
+                    min_size=f"{min_size:.6g}",
+                    min_notional=min_notional,
+                    price=price,
                 )
                 continue
             try:
-                self._log(f"place side {side} level {i}: price={price} amount={amount:.6g}")
+                self._log_decision(f"place side {side} level {i}", price=price, amount=f"{amount:.6g}")
                 result = self.context.place_order(side=side, order_type="limit", amount=amount, price=price, market=market)
                 status = None
                 order_id = None
                 if isinstance(result, dict):
                     status = result.get("status")
                     order_id = result.get("bitstamp_order_id") or result.get("order_id") or result.get("id") or result.get("client_order_id")
-                self._log(f"place side {side} level {i} result status={status} order_id={order_id} raw={result}")
+                self._log_decision(f"place side {side} level {i} result", status=status, order_id=order_id)
                 orders.append({"side": side, "price": price, "amount": amount, "result": result})
                 if order_id is not None:
                     order_ids.append(str(order_id))
-                self._log(f"seed side {side} level {i}: {price} amount {amount:.6g}")
+                self._log_decision(f"seed side {side} level {i}", price=price, amount=f"{amount:.6g}")
             except Exception as exc:
                 self.state["last_error"] = str(exc)
-                self._log(f"seed side {side} level {i} failed: {exc}")
+                self._log_decision(f"seed side {side} level {i} failed", error=str(exc))
                 continue
 
             delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
@@ -496,7 +549,7 @@ class Strategy(Strategy):
 
         self.state["orders"] = orders
         self.state["order_ids"] = order_ids
-        self._log(f"side {side} placement complete: tracked_ids={order_ids}")
+        self._log_decision(f"side {side} placement complete", tracked_ids=order_ids)
         self._set_grid_refresh_pause()
 
     def _top_up_missing_levels(self, center: float, live_orders: list[dict]) -> None: