diff --git a/README.md b/README.md index 519ef1f..a01aaa0 100644 --- a/README.md +++ b/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` | diff --git a/pyproject.toml b/pyproject.toml index 9908790..2ab9807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'", ] diff --git a/src/coinhunter/binance/um_futures_client.py b/src/coinhunter/binance/um_futures_client.py deleted file mode 100644 index e80ebab..0000000 --- a/src/coinhunter/binance/um_futures_client.py +++ /dev/null @@ -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] diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index ee155c3..60bbe75 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -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) diff --git a/src/coinhunter/config.py b/src/coinhunter/config.py index 40ca385..cc02560 100644 --- a/src/coinhunter/config.py +++ b/src/coinhunter/config.py @@ -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 diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py index 3a0f55c..6772e9f 100644 --- a/src/coinhunter/runtime.py +++ b/src/coinhunter/runtime.py @@ -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"]}) diff --git a/src/coinhunter/services/account_service.py b/src/coinhunter/services/account_service.py index 8953671..1fece40 100644 --- a/src/coinhunter/services/account_service.py +++ b/src/coinhunter/services/account_service.py @@ -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} diff --git a/src/coinhunter/services/opportunity_service.py b/src/coinhunter/services/opportunity_service.py index feb8383..70afbc3 100644 --- a/src/coinhunter/services/opportunity_service.py +++ b/src/coinhunter/services/opportunity_service.py @@ -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 diff --git a/src/coinhunter/services/trade_service.py b/src/coinhunter/services/trade_service.py index f9e0915..4dd36cb 100644 --- a/src/coinhunter/services/trade_service.py +++ b/src/coinhunter/services/trade_service.py @@ -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, - ) diff --git a/tests/test_account_market_services.py b/tests/test_account_market_services.py index e1967b1..9aaa602 100644 --- a/tests/test_account_market_services.py +++ b/tests/test_account_market_services.py @@ -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) diff --git a/tests/test_trade_service.py b/tests/test_trade_service.py index ed67446..f971e91 100644 --- a/tests/test_trade_service.py +++ b/tests/test_trade_service.py @@ -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"):