Ver código fonte

Share conservative sizing across strategies

Lukas Goldschmidt 1 mês atrás
pai
commit
98e7bb4840
2 arquivos alterados com 126 adições e 54 exclusões
  1. 115 2
      src/trader_mcp/strategy_context.py
  2. 11 52
      strategies/grid_trader.py

+ 115 - 2
src/trader_mcp/strategy_context.py

@@ -56,6 +56,26 @@ class StrategyContext:
     def get_account_info(self) -> Any:
         return get_account_info(self.account_id)
 
+    def _available_balance(self, asset_code: str) -> float:
+        try:
+            info = self.get_account_info()
+        except Exception:
+            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 get_account_fees(self, market_symbol: str | None = None) -> Any:
         return get_account_fees(self.account_id, market_symbol)
 
@@ -64,15 +84,108 @@ class StrategyContext:
         if not isinstance(payload, dict):
             return {"maker": 0.0, "taker": 0.0}
         fees = payload.get("fees") if isinstance(payload.get("fees"), dict) else {}
+
+        def _normalize_fee(value: object) -> float:
+            try:
+                rate = float(value or 0.0)
+            except Exception:
+                return 0.0
+            if rate > 0.1:
+                rate /= 100.0
+            return rate
+
         try:
-            maker = float(fees.get("maker") or 0.0)
+            maker = _normalize_fee(fees.get("maker"))
         except Exception:
             maker = 0.0
         try:
-            taker = float(fees.get("taker") or 0.0)
+            taker = _normalize_fee(fees.get("taker"))
         except Exception:
             taker = 0.0
         return {"maker": maker, "taker": taker}
 
+    def suggest_order_amount(
+        self,
+        *,
+        side: str,
+        price: float,
+        levels: int,
+        min_notional: float,
+        fee_rate: float,
+        max_notional_per_order: float = 0.0,
+        dust_collect: bool = False,
+        inventory_cap_pct: float = 0.0,
+        order_size: float = 0.0,
+        safety: float = 0.995,
+    ) -> float:
+        """Return a conservative per-order amount for this venue/account.
+
+        The returned amount is exchange-aware but strategy-agnostic, so other
+        strategies can reuse the same sizing rules.
+        """
+        if levels <= 0 or price <= 0:
+            return 0.0
+
+        side = str(side or "").strip().lower()
+        fee_rate = max(float(fee_rate or 0.0), 0.0)
+        max_notional_per_order = float(max_notional_per_order or 0.0)
+        inventory_cap_pct = float(inventory_cap_pct or 0.0)
+        order_size = float(order_size or 0.0)
+        min_amount = (min_notional / price) if min_notional > 0 else 0.0
+
+        if side == "buy":
+            quote = self.counter_currency or "USD"
+            quote_available = self._available_balance(quote) if hasattr(self, "_available_balance") else 0.0
+            spendable_quote = quote_available * safety
+            quote_cap = spendable_quote if max_notional_per_order <= 0 else min(spendable_quote, max_notional_per_order)
+
+            if 0.0 < inventory_cap_pct < 1.0:
+                base = self.base_currency or (self.market_symbol or "XRP")
+                base_available = self._available_balance(base) if hasattr(self, "_available_balance") else 0.0
+                base_value = base_available * price
+                total_value = base_value + quote_available
+                max_base_value = total_value * inventory_cap_pct
+                remaining_base_value = max(max_base_value - base_value, 0.0)
+                if remaining_base_value <= 0:
+                    return 0.0
+                quote_cap = min(quote_cap, remaining_base_value * (1 + fee_rate))
+
+            if dust_collect and max_notional_per_order > 0:
+                leftover_quote = max(spendable_quote - max_notional_per_order, 0.0)
+                if 0.0 < leftover_quote < min_notional:
+                    quote_cap = spendable_quote
+            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 = per_order_quote / (price * (1 + fee_rate))
+        else:
+            base = self.base_currency or (self.market_symbol or "XRP")
+            base_available = self._available_balance(base) if hasattr(self, "_available_balance") else 0.0
+            spendable_base = base_available * safety
+            if max_notional_per_order > 0 and price > 0:
+                base_cap = max_notional_per_order / price
+                if dust_collect:
+                    leftover_base = max(spendable_base - base_cap, 0.0)
+                    if 0.0 < leftover_base * price < min_notional:
+                        spendable_base = spendable_base
+                    else:
+                        spendable_base = min(spendable_base, base_cap)
+                else:
+                    spendable_base = min(spendable_base, base_cap)
+            if spendable_base <= 0:
+                return 0.0
+            amount = spendable_base / max(levels, 1)
+
+        if amount < min_amount:
+            return 0.0
+        if order_size > 0:
+            if order_size < min_amount:
+                return 0.0
+            amount = min(amount, order_size)
+        return max(amount, 0.0)
+
     def get_news(self, **kwargs: Any) -> Any:
         return call_news_tool("search", kwargs)

+ 11 - 52
strategies/grid_trader.py

@@ -355,58 +355,17 @@ class Strategy(Strategy):
         return {"buy", "sell"}
 
     def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
-        """Derive a per-order amount from the currently available balance.
-
-        This helper is used when the grid seeds, tops up, or replaces an order.
-        It folds in the live available balance, fee cushion, per-order caps, and
-        the exchange minimum notional. If the wallet cannot support a valid order,
-        it returns 0.0 instead of forcing an impossible minimum size.
-        """
-        if levels <= 0 or price <= 0:
-            return 0.0
-        safety = 0.995
-        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":
-            quote = self.context.counter_currency or "USD"
-            quote_available = self._available_balance(quote)
-            self.state["counter_available"] = quote_available
-            spendable_quote = quote_available * safety
-            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 = 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
-            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
-            per_order_base = spendable_base / max(levels, 1)
-            if per_order_base < min_amount:
-                return 0.0
-            amount = per_order_base
-
-        if amount < min_amount:
-            return 0.0
-        if manual > 0:
-            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)
+        return self.context.suggest_order_amount(
+            side=side,
+            price=price,
+            levels=levels,
+            min_notional=min_notional,
+            fee_rate=self._live_fee_rate(),
+            max_notional_per_order=float(self.config.get("max_notional_per_order", 0.0) or 0.0),
+            dust_collect=bool(self.config.get("dust_collect", False)),
+            inventory_cap_pct=float(self.config.get("inventory_cap_pct", 0.0) or 0.0),
+            order_size=float(self.config.get("order_size", 0.0) or 0.0),
+        )
 
     def _place_grid(self, center: float) -> None:
         center = self._maybe_refresh_center(center)