|
|
@@ -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)
|