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:
17
README.md
17
README.md
@@ -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` |
|
||||
|
||||
@@ -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'",
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user