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

@@ -72,21 +72,16 @@ By default, CoinHunter prints human-friendly TUI tables. Add `--agent` to any co
# Account
coinhunter account overview
coinhunter account overview --agent
coinhunter account balances --spot --futures
coinhunter account positions --spot
coinhunter account balances
coinhunter account positions
# Market
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt
coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50
# Trade (Spot)
coinhunter trade spot buy BTCUSDT --quote 100 --dry-run
coinhunter trade spot sell BTCUSDT --qty 0.01 --type limit --price 90000
# Trade (Futures)
coinhunter trade futures buy BTCUSDT --qty 0.01 --dry-run
coinhunter trade futures sell BTCUSDT --qty 0.01 --reduce-only
coinhunter trade futures close BTCUSDT
# Trade
coinhunter trade buy BTCUSDT --quote 100 --dry-run
coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000
# Opportunities
coinhunter opportunity portfolio
@@ -110,7 +105,7 @@ CoinHunter V2 uses a flat, direct architecture:
| Layer | Responsibility | Key Files |
|-------|----------------|-----------|
| **CLI** | Single entrypoint, argument parsing | `cli.py` |
| **Binance** | Thin API wrappers with unified error handling | `binance/spot_client.py`, `binance/um_futures_client.py` |
| **Binance** | Thin API wrappers with unified error handling | `binance/spot_client.py` |
| **Services** | Domain logic | `services/account_service.py`, `services/market_service.py`, `services/trade_service.py`, `services/opportunity_service.py` |
| **Config** | TOML config, `.env` secrets, path resolution | `config.py` |
| **Runtime** | Paths, TUI/JSON/compact output | `runtime.py` |

View File

@@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "coinhunter"
version = "2.0.6"
version = "2.0.7"
description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution."
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
dependencies = [
"binance-connector>=3.9.0",
"binance-futures-connector>=4.1.0",
"shtab>=1.7.0",
"tomli>=2.0.1; python_version < '3.11'",
]

View File

@@ -1,73 +0,0 @@
"""Thin wrapper around the official Binance USDT-M Futures connector."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from requests.exceptions import RequestException, SSLError
class UMFuturesClient:
def __init__(
self,
*,
api_key: str,
api_secret: str,
base_url: str,
recv_window: int,
client: Any | None = None,
) -> None:
self.recv_window = recv_window
if client is not None:
self._client = client
return
try:
from binance.um_futures import UMFutures
except ModuleNotFoundError as exc: # pragma: no cover
raise RuntimeError("binance-futures-connector is not installed") from exc
self._client = UMFutures(key=api_key, secret=api_secret, base_url=base_url)
def _call(self, operation: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except SSLError as exc:
raise RuntimeError(
"Binance UM Futures request failed because TLS certificate verification failed. "
"This usually means the local Python trust store is incomplete or a proxy is intercepting HTTPS. "
"Update the local CA trust chain or configure the host environment with the correct corporate/root CA."
) from exc
except RequestException as exc:
raise RuntimeError(f"Binance UM Futures request failed during {operation}: {exc}") from exc
def balance(self) -> list[dict[str, Any]]:
return self._call("balance", self._client.balance, recvWindow=self.recv_window) # type: ignore[no-any-return]
def position_risk(self, symbol: str | None = None) -> list[dict[str, Any]]:
kwargs: dict[str, Any] = {"recvWindow": self.recv_window}
if symbol:
kwargs["symbol"] = symbol
response = self._call("position risk", self._client.position_risk, **kwargs)
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def ticker_24h(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
if not symbols:
response = self._call("24h ticker", self._client.ticker_24hr_price_change)
elif len(symbols) == 1:
response = self._call("24h ticker", self._client.ticker_24hr_price_change, symbol=symbols[0])
else:
response = [self._call("24h ticker", self._client.ticker_24hr_price_change, symbol=symbol) for symbol in symbols]
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def ticker_price(self, symbols: list[str] | None = None) -> list[dict[str, Any]]:
if not symbols:
response = self._call("ticker price", self._client.ticker_price)
elif len(symbols) == 1:
response = self._call("ticker price", self._client.ticker_price, symbol=symbols[0])
else:
response = [self._call("ticker price", self._client.ticker_price, symbol=symbol) for symbol in symbols]
return response if isinstance(response, list) else [response] # type: ignore[no-any-return]
def new_order(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("recvWindow", self.recv_window)
return self._call("new order", self._client.new_order, **kwargs) # type: ignore[no-any-return]

View File

@@ -8,7 +8,6 @@ from typing import Any
from . import __version__
from .binance.spot_client import SpotBinanceClient
from .binance.um_futures_client import UMFuturesClient
from .config import ensure_init_files, get_binance_credentials, load_config
from .runtime import get_runtime_paths, install_shell_completion, print_output, self_upgrade, with_spinner
from .services import account_service, market_service, opportunity_service, trade_service
@@ -16,11 +15,11 @@ from .services import account_service, market_service, opportunity_service, trad
EPILOG = """\
examples:
coinhunter init
coinhunter account overview -sf
coinhunter account overview
coinhunter market tickers BTCUSDT ETHUSDT
coinhunter market klines BTCUSDT -i 1h -l 50
coinhunter trade spot buy BTCUSDT -q 100 -d
coinhunter trade futures sell BTCUSDT -q 0.01 -r
coinhunter trade buy BTCUSDT -q 100 -d
coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000
coinhunter opportunity scan -s BTCUSDT ETHUSDT
coinhunter upgrade
"""
@@ -38,24 +37,6 @@ def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> S
)
def _load_futures_client(config: dict[str, Any], *, client: Any | None = None) -> UMFuturesClient:
credentials = get_binance_credentials()
binance_config = config["binance"]
return UMFuturesClient(
api_key=credentials["api_key"],
api_secret=credentials["api_secret"],
base_url=binance_config["futures_base_url"],
recv_window=int(binance_config["recv_window"]),
client=client,
)
def _resolve_market_flags(args: argparse.Namespace) -> tuple[bool, bool]:
if getattr(args, "spot", False) or getattr(args, "futures", False):
return bool(getattr(args, "spot", False)), bool(getattr(args, "futures", False))
return True, True
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="coinhunter",
@@ -73,14 +54,12 @@ def build_parser() -> argparse.ArgumentParser:
account_parser = subparsers.add_parser("account", help="Account overview, balances, and positions")
account_subparsers = account_parser.add_subparsers(dest="account_command")
account_commands_help = {
"overview": "Total equity and summary across markets",
"overview": "Total equity and summary",
"balances": "List asset balances",
"positions": "List open positions",
}
for name in ("overview", "balances", "positions"):
sub = account_subparsers.add_parser(name, help=account_commands_help[name])
sub.add_argument("-s", "--spot", action="store_true", help="Include spot market")
sub.add_argument("-f", "--futures", action="store_true", help="Include futures market")
account_subparsers.add_parser(name, help=account_commands_help[name])
market_parser = subparsers.add_parser("market", help="Batch market queries")
market_subparsers = market_parser.add_subparsers(dest="market_command")
@@ -91,14 +70,11 @@ def build_parser() -> argparse.ArgumentParser:
klines_parser.add_argument("-i", "--interval", default="1h", help="Kline interval (default: 1h)")
klines_parser.add_argument("-l", "--limit", type=int, default=100, help="Number of candles (default: 100)")
trade_parser = subparsers.add_parser("trade", help="Spot and futures trade execution")
trade_subparsers = trade_parser.add_subparsers(dest="trade_market")
spot_parser = trade_subparsers.add_parser("spot", help="Spot market orders")
spot_subparsers = spot_parser.add_subparsers(dest="trade_action")
spot_side_help = {"buy": "Buy base asset with quote quantity", "sell": "Sell base asset quantity"}
trade_parser = subparsers.add_parser("trade", help="Spot trade execution")
trade_subparsers = trade_parser.add_subparsers(dest="trade_action")
trade_side_help = {"buy": "Buy base asset with quote quantity", "sell": "Sell base asset quantity"}
for side in ("buy", "sell"):
sub = spot_subparsers.add_parser(side, help=spot_side_help[side])
sub = trade_subparsers.add_parser(side, help=trade_side_help[side])
sub.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
sub.add_argument("-q", "--qty", type=float, help="Base asset quantity")
sub.add_argument("-Q", "--quote", type=float, help="Quote asset amount (buy market only)")
@@ -106,21 +82,6 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_argument("-p", "--price", type=float, help="Limit price")
sub.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
futures_parser = trade_subparsers.add_parser("futures", help="USDT-M futures orders")
futures_subparsers = futures_parser.add_subparsers(dest="trade_action")
futures_side_help = {"buy": "Open or add to a LONG position", "sell": "Open or add to a SHORT position"}
for side in ("buy", "sell"):
sub = futures_subparsers.add_parser(side, help=futures_side_help[side])
sub.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
sub.add_argument("-q", "--qty", type=float, required=True, help="Contract quantity")
sub.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
sub.add_argument("-p", "--price", type=float, help="Limit price")
sub.add_argument("-r", "--reduce-only", action="store_true", help="Only reduce position")
sub.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
close_parser = futures_subparsers.add_parser("close", help="Close position at market price")
close_parser.add_argument("symbol", metavar="SYM", help="Trading pair to close")
close_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
opportunity_parser = subparsers.add_parser("opportunity", help="Portfolio analysis and market scanning")
opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command")
opportunity_subparsers.add_parser("portfolio", help="Score current holdings")
@@ -181,45 +142,25 @@ def main(argv: list[str] | None = None) -> int:
config = load_config()
if args.command == "account":
include_spot, include_futures = _resolve_market_flags(args)
spot_client = _load_spot_client(config) if include_spot else None
futures_client = _load_futures_client(config) if include_futures else None
spot_client = _load_spot_client(config)
if args.account_command == "overview":
with with_spinner("Fetching account overview...", enabled=not args.agent):
print_output(
account_service.get_overview(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
account_service.get_overview(config, spot_client=spot_client),
agent=args.agent,
)
return 0
if args.account_command == "balances":
with with_spinner("Fetching balances...", enabled=not args.agent):
print_output(
account_service.get_balances(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
account_service.get_balances(config, spot_client=spot_client),
agent=args.agent,
)
return 0
if args.account_command == "positions":
with with_spinner("Fetching positions...", enabled=not args.agent):
print_output(
account_service.get_positions(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
account_service.get_positions(config, spot_client=spot_client),
agent=args.agent,
)
return 0
@@ -247,55 +188,23 @@ def main(argv: list[str] | None = None) -> int:
parser.error("market requires one of: tickers, klines")
if args.command == "trade":
if args.trade_market == "spot":
spot_client = _load_spot_client(config)
with with_spinner("Placing spot order...", enabled=not args.agent):
print_output(
trade_service.execute_spot_trade(
config,
side=args.trade_action,
symbol=args.symbol,
qty=args.qty,
quote=args.quote,
order_type=args.type,
price=args.price,
dry_run=True if args.dry_run else None,
spot_client=spot_client,
),
agent=args.agent,
)
return 0
if args.trade_market == "futures":
futures_client = _load_futures_client(config)
if args.trade_action == "close":
with with_spinner("Closing futures position...", enabled=not args.agent):
print_output(
trade_service.close_futures_position(
config,
symbol=args.symbol,
dry_run=True if args.dry_run else None,
futures_client=futures_client,
),
agent=args.agent,
)
return 0
with with_spinner("Placing futures order...", enabled=not args.agent):
print_output(
trade_service.execute_futures_trade(
config,
side=args.trade_action,
symbol=args.symbol,
qty=args.qty,
order_type=args.type,
price=args.price,
reduce_only=args.reduce_only,
dry_run=True if args.dry_run else None,
futures_client=futures_client,
),
agent=args.agent,
)
return 0
parser.error("trade requires `spot` or `futures`")
spot_client = _load_spot_client(config)
with with_spinner("Placing order...", enabled=not args.agent):
print_output(
trade_service.execute_spot_trade(
config,
side=args.trade_action,
symbol=args.symbol,
qty=args.qty,
quote=args.quote,
order_type=args.type,
price=args.price,
dry_run=True if args.dry_run else None,
spot_client=spot_client,
),
agent=args.agent,
)
return 0
if args.command == "opportunity":
spot_client = _load_spot_client(config)

View File

@@ -21,7 +21,6 @@ output_format = "tui"
[binance]
spot_base_url = "https://api.binance.com"
futures_base_url = "https://fapi.binance.com"
recv_window = 5000
[market]
@@ -31,7 +30,6 @@ universe_denylist = []
[trading]
spot_enabled = true
futures_enabled = true
dry_run_default = false
dust_usdt_threshold = 10.0

View File

@@ -199,10 +199,8 @@ def _render_tui(payload: Any) -> None:
overview = payload.get("overview", {})
print(f"\n{_BOLD}{_CYAN} ACCOUNT OVERVIEW {_RESET}")
print(f" Total Equity: {_GREEN}{_fmt_number(overview.get('total_equity_usdt', 0))} USDT{_RESET}")
print(f" Spot Equity: {_fmt_number(overview.get('spot_equity_usdt', 0))} USDT")
print(f" Futures Equity: {_fmt_number(overview.get('futures_equity_usdt', 0))} USDT")
print(f" Spot Assets: {_fmt_number(overview.get('spot_asset_count', 0))}")
print(f" Futures Positions: {_fmt_number(overview.get('futures_position_count', 0))}")
print(f" Positions: {_fmt_number(overview.get('spot_position_count', 0))}")
if payload.get("balances"):
print()
_render_tui({"balances": payload["balances"]})

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

View File

@@ -41,14 +41,6 @@ class FakeSpotClient:
return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "BREAK"}]}
class FakeFuturesClient:
def balance(self):
return [{"asset": "USDT", "balance": "250.0", "availableBalance": "200.0"}]
def position_risk(self, symbol=None):
return [{"symbol": "BTCUSDT", "positionAmt": "0.02", "notional": "1200", "entryPrice": "59000", "markPrice": "60000", "unRealizedProfit": "20"}]
class AccountMarketServicesTestCase(unittest.TestCase):
def test_account_overview_and_dust_filter(self):
config = {
@@ -57,13 +49,9 @@ class AccountMarketServicesTestCase(unittest.TestCase):
}
payload = account_service.get_overview(
config,
include_spot=True,
include_futures=True,
spot_client=FakeSpotClient(),
futures_client=FakeFuturesClient(),
)
self.assertEqual(payload["overview"]["spot_equity_usdt"], 720.1)
self.assertEqual(payload["overview"]["futures_equity_usdt"], 250.0)
self.assertEqual(payload["overview"]["total_equity_usdt"], 720.1)
symbols = {item["symbol"] for item in payload["positions"]}
self.assertNotIn("DOGEUSDT", symbols)
self.assertIn("BTCUSDT", symbols)

View File

@@ -17,18 +17,6 @@ class FakeSpotClient:
return {"symbol": kwargs["symbol"], "status": "FILLED", "orderId": 1}
class FakeFuturesClient:
def __init__(self):
self.calls = []
def new_order(self, **kwargs):
self.calls.append(kwargs)
return {"symbol": kwargs["symbol"], "status": "FILLED", "orderId": 2}
def position_risk(self, symbol=None):
return [{"symbol": "BTCUSDT", "positionAmt": "-0.02", "notional": "-1200"}]
class TradeServiceTestCase(unittest.TestCase):
def test_spot_market_buy_dry_run_does_not_call_client(self):
events = []
@@ -66,18 +54,6 @@ class TradeServiceTestCase(unittest.TestCase):
self.assertEqual(payload["trade"]["status"], "FILLED")
self.assertEqual(client.calls[0]["timeInForce"], "GTC")
def test_futures_close_uses_opposite_side(self):
with patch.object(trade_service, "audit_event", return_value=None):
client = FakeFuturesClient()
payload = trade_service.close_futures_position(
{"trading": {"dry_run_default": False}},
symbol="BTCUSDT",
dry_run=False,
futures_client=client,
)
self.assertEqual(payload["trade"]["side"], "BUY")
self.assertEqual(client.calls[0]["reduceOnly"], "true")
def test_spot_market_buy_requires_quote(self):
with patch.object(trade_service, "audit_event", return_value=None):
with self.assertRaisesRegex(RuntimeError, "requires --quote"):