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:
2026-04-16 20:10:15 +08:00
parent 680bd3d33c
commit 0f862957b0
11 changed files with 112 additions and 549 deletions

View File

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

View File

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

View File

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