refactor: remove all futures-related capabilities
Delete USDT-M futures support since the user's Binance API key does not support futures trading. This simplifies the CLI to spot-only: - Remove futures client wrapper (um_futures_client.py) - Remove futures trade commands and close position logic - Simplify account service to spot-only (no market_type field) - Remove futures references from opportunity service - Update README and tests to reflect spot-only architecture - Bump version to 2.0.7 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,9 @@ from __future__ import annotations
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from .market_service import normalize_symbol
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetBalance:
|
||||
market_type: str
|
||||
asset: str
|
||||
free: float
|
||||
locked: float
|
||||
@@ -20,13 +17,11 @@ class AssetBalance:
|
||||
|
||||
@dataclass
|
||||
class PositionView:
|
||||
market_type: str
|
||||
symbol: str
|
||||
quantity: float
|
||||
entry_price: float | None
|
||||
mark_price: float
|
||||
notional_usdt: float
|
||||
unrealized_pnl: float | None
|
||||
side: str
|
||||
|
||||
|
||||
@@ -34,9 +29,8 @@ class PositionView:
|
||||
class AccountOverview:
|
||||
total_equity_usdt: float
|
||||
spot_equity_usdt: float
|
||||
futures_equity_usdt: float
|
||||
spot_asset_count: int
|
||||
futures_position_count: int
|
||||
spot_position_count: int
|
||||
|
||||
|
||||
def _spot_price_map(spot_client: Any, quote: str, assets: list[str]) -> dict[str, float]:
|
||||
@@ -62,224 +56,117 @@ def _spot_account_data(spot_client: Any, quote: str) -> tuple[list[dict[str, Any
|
||||
def get_balances(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
include_spot: bool,
|
||||
include_futures: bool,
|
||||
spot_client: Any | None = None,
|
||||
futures_client: Any | None = None,
|
||||
spot_client: Any,
|
||||
) -> dict[str, Any]:
|
||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
if include_spot and spot_client is not None:
|
||||
balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in balances:
|
||||
free = float(item.get("free", 0.0))
|
||||
locked = float(item.get("locked", 0.0))
|
||||
total = free + locked
|
||||
if total <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
rows.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
market_type="spot",
|
||||
asset=asset,
|
||||
free=free,
|
||||
locked=locked,
|
||||
total=total,
|
||||
notional_usdt=total * price_map.get(asset, 0.0),
|
||||
)
|
||||
balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in balances:
|
||||
free = float(item.get("free", 0.0))
|
||||
locked = float(item.get("locked", 0.0))
|
||||
total = free + locked
|
||||
if total <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
rows.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
asset=asset,
|
||||
free=free,
|
||||
locked=locked,
|
||||
total=total,
|
||||
notional_usdt=total * price_map.get(asset, 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
if include_futures and futures_client is not None:
|
||||
for item in futures_client.balance():
|
||||
balance = float(item.get("balance", 0.0))
|
||||
available = float(item.get("availableBalance", balance))
|
||||
if balance <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
rows.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
market_type="futures",
|
||||
asset=asset,
|
||||
free=available,
|
||||
locked=max(balance - available, 0.0),
|
||||
total=balance,
|
||||
notional_usdt=balance if asset == quote else 0.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
)
|
||||
return {"balances": rows}
|
||||
|
||||
|
||||
def get_positions(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
include_spot: bool,
|
||||
include_futures: bool,
|
||||
spot_client: Any | None = None,
|
||||
futures_client: Any | None = None,
|
||||
spot_client: Any,
|
||||
) -> dict[str, Any]:
|
||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||
dust = float(config.get("trading", {}).get("dust_usdt_threshold", 0.0))
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
if include_spot and spot_client is not None:
|
||||
balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in balances:
|
||||
quantity = float(item.get("free", 0.0)) + float(item.get("locked", 0.0))
|
||||
if quantity <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
symbol = quote if asset == quote else f"{asset}{quote}"
|
||||
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
|
||||
notional = quantity * mark_price
|
||||
if notional < dust:
|
||||
continue
|
||||
rows.append(
|
||||
asdict(
|
||||
PositionView(
|
||||
market_type="spot",
|
||||
symbol=symbol,
|
||||
quantity=quantity,
|
||||
entry_price=None,
|
||||
mark_price=mark_price,
|
||||
notional_usdt=notional,
|
||||
unrealized_pnl=None,
|
||||
side="LONG",
|
||||
)
|
||||
balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in balances:
|
||||
quantity = float(item.get("free", 0.0)) + float(item.get("locked", 0.0))
|
||||
if quantity <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
|
||||
notional = quantity * mark_price
|
||||
if notional < dust:
|
||||
continue
|
||||
rows.append(
|
||||
asdict(
|
||||
PositionView(
|
||||
symbol=quote if asset == quote else f"{asset}{quote}",
|
||||
quantity=quantity,
|
||||
entry_price=None,
|
||||
mark_price=mark_price,
|
||||
notional_usdt=notional,
|
||||
side="LONG",
|
||||
)
|
||||
)
|
||||
|
||||
if include_futures and futures_client is not None:
|
||||
for item in futures_client.position_risk():
|
||||
quantity = float(item.get("positionAmt", 0.0))
|
||||
notional = abs(float(item.get("notional", 0.0)))
|
||||
if quantity == 0 or notional < dust:
|
||||
continue
|
||||
side = "LONG" if quantity > 0 else "SHORT"
|
||||
rows.append(
|
||||
asdict(
|
||||
PositionView(
|
||||
market_type="futures",
|
||||
symbol=normalize_symbol(item["symbol"]),
|
||||
quantity=abs(quantity),
|
||||
entry_price=float(item.get("entryPrice", 0.0)),
|
||||
mark_price=float(item.get("markPrice", 0.0)),
|
||||
notional_usdt=notional,
|
||||
unrealized_pnl=float(item.get("unRealizedProfit", 0.0)),
|
||||
side=side,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
)
|
||||
return {"positions": rows}
|
||||
|
||||
|
||||
def get_overview(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
include_spot: bool,
|
||||
include_futures: bool,
|
||||
spot_client: Any | None = None,
|
||||
futures_client: Any | None = None,
|
||||
spot_client: Any,
|
||||
) -> dict[str, Any]:
|
||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||
dust = float(config.get("trading", {}).get("dust_usdt_threshold", 0.0))
|
||||
balances: list[dict[str, Any]] = []
|
||||
positions: list[dict[str, Any]] = []
|
||||
|
||||
if include_spot and spot_client is not None:
|
||||
spot_balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in spot_balances:
|
||||
free = float(item.get("free", 0.0))
|
||||
locked = float(item.get("locked", 0.0))
|
||||
total = free + locked
|
||||
if total <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
balances.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
market_type="spot",
|
||||
asset=asset,
|
||||
free=free,
|
||||
locked=locked,
|
||||
total=total,
|
||||
notional_usdt=total * price_map.get(asset, 0.0),
|
||||
)
|
||||
spot_balances, _, price_map = _spot_account_data(spot_client, quote)
|
||||
for item in spot_balances:
|
||||
free = float(item.get("free", 0.0))
|
||||
locked = float(item.get("locked", 0.0))
|
||||
total = free + locked
|
||||
if total <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
balances.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
asset=asset,
|
||||
free=free,
|
||||
locked=locked,
|
||||
total=total,
|
||||
notional_usdt=total * price_map.get(asset, 0.0),
|
||||
)
|
||||
)
|
||||
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
|
||||
notional = total * mark_price
|
||||
if notional >= dust:
|
||||
positions.append(
|
||||
asdict(
|
||||
PositionView(
|
||||
market_type="spot",
|
||||
symbol=quote if asset == quote else f"{asset}{quote}",
|
||||
quantity=total,
|
||||
entry_price=None,
|
||||
mark_price=mark_price,
|
||||
notional_usdt=notional,
|
||||
unrealized_pnl=None,
|
||||
side="LONG",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if include_futures and futures_client is not None:
|
||||
for item in futures_client.balance():
|
||||
balance = float(item.get("balance", 0.0))
|
||||
available = float(item.get("availableBalance", balance))
|
||||
if balance <= 0:
|
||||
continue
|
||||
asset = item["asset"]
|
||||
balances.append(
|
||||
asdict(
|
||||
AssetBalance(
|
||||
market_type="futures",
|
||||
asset=asset,
|
||||
free=available,
|
||||
locked=max(balance - available, 0.0),
|
||||
total=balance,
|
||||
notional_usdt=balance if asset == quote else 0.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
for item in futures_client.position_risk():
|
||||
quantity = float(item.get("positionAmt", 0.0))
|
||||
notional = abs(float(item.get("notional", 0.0)))
|
||||
if quantity == 0 or notional < dust:
|
||||
continue
|
||||
side = "LONG" if quantity > 0 else "SHORT"
|
||||
)
|
||||
mark_price = price_map.get(asset, 1.0 if asset == quote else 0.0)
|
||||
notional = total * mark_price
|
||||
if notional >= dust:
|
||||
positions.append(
|
||||
asdict(
|
||||
PositionView(
|
||||
market_type="futures",
|
||||
symbol=normalize_symbol(item["symbol"]),
|
||||
quantity=abs(quantity),
|
||||
entry_price=float(item.get("entryPrice", 0.0)),
|
||||
mark_price=float(item.get("markPrice", 0.0)),
|
||||
symbol=quote if asset == quote else f"{asset}{quote}",
|
||||
quantity=total,
|
||||
entry_price=None,
|
||||
mark_price=mark_price,
|
||||
notional_usdt=notional,
|
||||
unrealized_pnl=float(item.get("unRealizedProfit", 0.0)),
|
||||
side=side,
|
||||
side="LONG",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
spot_equity = sum(item["notional_usdt"] for item in balances if item["market_type"] == "spot")
|
||||
futures_equity = sum(item["notional_usdt"] for item in balances if item["market_type"] == "futures")
|
||||
spot_equity = sum(item["notional_usdt"] for item in balances)
|
||||
overview = asdict(
|
||||
AccountOverview(
|
||||
total_equity_usdt=spot_equity + futures_equity,
|
||||
total_equity_usdt=spot_equity,
|
||||
spot_equity_usdt=spot_equity,
|
||||
futures_equity_usdt=futures_equity,
|
||||
spot_asset_count=sum(1 for item in balances if item["market_type"] == "spot"),
|
||||
futures_position_count=sum(1 for item in positions if item["market_type"] == "futures"),
|
||||
spot_asset_count=len(balances),
|
||||
spot_position_count=len(positions),
|
||||
)
|
||||
)
|
||||
return {"overview": overview, "balances": balances, "positions": positions}
|
||||
|
||||
@@ -93,8 +93,8 @@ def _action_for(score: float, concentration: float) -> tuple[str, list[str]]:
|
||||
def analyze_portfolio(config: dict[str, Any], *, spot_client: Any) -> dict[str, Any]:
|
||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||
weights = config.get("opportunity", {}).get("weights", {})
|
||||
positions = get_positions(config, include_spot=True, include_futures=False, spot_client=spot_client)["positions"]
|
||||
positions = [item for item in positions if item["market_type"] == "spot" and item["symbol"] != quote]
|
||||
positions = get_positions(config, spot_client=spot_client)["positions"]
|
||||
positions = [item for item in positions if item["symbol"] != quote]
|
||||
total_notional = sum(item["notional_usdt"] for item in positions) or 1.0
|
||||
recommendations = []
|
||||
for position in positions:
|
||||
@@ -157,11 +157,10 @@ def scan_opportunities(
|
||||
scan_limit = int(opportunity_config.get("scan_limit", 50))
|
||||
top_n = int(opportunity_config.get("top_n", 10))
|
||||
quote = str(config.get("market", {}).get("default_quote", "USDT")).upper()
|
||||
held_positions = get_positions(config, include_spot=True, include_futures=False, spot_client=spot_client)["positions"]
|
||||
held_positions = get_positions(config, spot_client=spot_client)["positions"]
|
||||
concentration_map = {
|
||||
normalize_symbol(item["symbol"]): float(item["notional_usdt"])
|
||||
for item in held_positions
|
||||
if item["market_type"] == "spot"
|
||||
}
|
||||
total_held = sum(concentration_map.values()) or 1.0
|
||||
|
||||
|
||||
@@ -148,116 +148,3 @@ def execute_spot_trade(
|
||||
)
|
||||
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response})
|
||||
return {"trade": result}
|
||||
|
||||
|
||||
def execute_futures_trade(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
qty: float,
|
||||
order_type: str,
|
||||
price: float | None,
|
||||
reduce_only: bool,
|
||||
dry_run: bool | None,
|
||||
futures_client: Any,
|
||||
) -> dict[str, Any]:
|
||||
normalized_symbol = normalize_symbol(symbol)
|
||||
order_type = order_type.upper()
|
||||
side = side.upper()
|
||||
is_dry_run = _default_dry_run(config, dry_run)
|
||||
if qty <= 0:
|
||||
raise RuntimeError("Futures orders require a positive --qty")
|
||||
if order_type == "LIMIT" and price is None:
|
||||
raise RuntimeError("Futures limit orders require --price")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"symbol": normalized_symbol,
|
||||
"side": side,
|
||||
"type": order_type,
|
||||
"quantity": qty,
|
||||
"reduceOnly": "true" if reduce_only else "false",
|
||||
}
|
||||
if price is not None:
|
||||
payload["price"] = price
|
||||
payload["timeInForce"] = "GTC"
|
||||
|
||||
intent = TradeIntent(
|
||||
market_type="futures",
|
||||
symbol=normalized_symbol,
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
qty=qty,
|
||||
quote_amount=None,
|
||||
price=price,
|
||||
reduce_only=reduce_only,
|
||||
dry_run=is_dry_run,
|
||||
)
|
||||
|
||||
audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"))
|
||||
if is_dry_run:
|
||||
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
|
||||
result = asdict(
|
||||
TradeResult(
|
||||
market_type="futures",
|
||||
symbol=normalized_symbol,
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
status="DRY_RUN",
|
||||
dry_run=True,
|
||||
request_payload=payload,
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response})
|
||||
return {"trade": result}
|
||||
|
||||
try:
|
||||
response = futures_client.new_order(**payload)
|
||||
except Exception as exc:
|
||||
audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)))
|
||||
raise RuntimeError(f"Futures order failed: {exc}") from exc
|
||||
|
||||
result = asdict(
|
||||
TradeResult(
|
||||
market_type="futures",
|
||||
symbol=normalized_symbol,
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
status=str(response.get("status", "UNKNOWN")),
|
||||
dry_run=False,
|
||||
request_payload=payload,
|
||||
response_payload=response,
|
||||
)
|
||||
)
|
||||
audit_event("trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response})
|
||||
return {"trade": result}
|
||||
|
||||
|
||||
def close_futures_position(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
symbol: str,
|
||||
dry_run: bool | None,
|
||||
futures_client: Any,
|
||||
) -> dict[str, Any]:
|
||||
normalized_symbol = normalize_symbol(symbol)
|
||||
positions = futures_client.position_risk(normalized_symbol)
|
||||
target = next((item for item in positions if normalize_symbol(item["symbol"]) == normalized_symbol), None)
|
||||
if target is None:
|
||||
raise RuntimeError(f"No futures position found for {normalized_symbol}")
|
||||
position_amt = float(target.get("positionAmt", 0.0))
|
||||
if position_amt == 0:
|
||||
raise RuntimeError(f"No open futures position for {normalized_symbol}")
|
||||
side = "SELL" if position_amt > 0 else "BUY"
|
||||
return execute_futures_trade(
|
||||
config,
|
||||
side=side,
|
||||
symbol=normalized_symbol,
|
||||
qty=abs(position_amt),
|
||||
order_type="MARKET",
|
||||
price=None,
|
||||
reduce_only=True,
|
||||
dry_run=dry_run,
|
||||
futures_client=futures_client,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user