From 3855477155b7ab6d6bc3a56e076799a97c4cf6c7 Mon Sep 17 00:00:00 2001 From: Tacit Lab Date: Fri, 17 Apr 2026 18:19:19 +0800 Subject: [PATCH] refactor: flatten account command to a single balances view Remove overview/balances/positions subcommands in favor of one `account` command that returns all balances with an `is_dust` flag. Add descriptions to every parser and expose -a/--agent and --doc on all leaf commands for better help discoverability. Co-Authored-By: Claude Opus 4.7 --- README.md | 8 +- src/coinhunter/binance/spot_client.py | 2 +- src/coinhunter/cli.py | 162 +++++++++++---------- src/coinhunter/runtime.py | 22 +-- src/coinhunter/services/account_service.py | 63 +------- tests/test_account_market_services.py | 12 +- tests/test_cli.py | 19 +++ 7 files changed, 123 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 2c50fee..c929f12 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,9 @@ coin market klines --doc ```bash # Account (aliases: a, acc) -coinhunter account overview -coinhunter account overview --agent -coin a ov -coin acc bal -coin a pos +coinhunter account +coinhunter account --agent +coin a # Market (aliases: m) coinhunter market tickers BTCUSDT ETH/USDT sol-usdt diff --git a/src/coinhunter/binance/spot_client.py b/src/coinhunter/binance/spot_client.py index 751b925..f4d07af 100644 --- a/src/coinhunter/binance/spot_client.py +++ b/src/coinhunter/binance/spot_client.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from typing import Any -from requests.exceptions import ( # type: ignore[import-untyped] +from requests.exceptions import ( RequestException, SSLError, ) diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index 2ca0c5f..53a2ca7 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -27,7 +27,7 @@ from .services import ( EPILOG = """\ examples: coin init - coin acc ov + coin account coin m tk BTCUSDT ETHUSDT coin m k BTCUSDT -i 1h -l 50 coin buy BTCUSDT -Q 100 -d @@ -49,38 +49,20 @@ Fields: files_created – list of generated files completion – shell completion installation status """, - "account/overview": """\ + "account": """\ Output: JSON { - "total_btc": 1.234, - "total_usdt": 50000.0, - "assets": [{"asset": "BTC", "free": 0.5, "locked": 0.1}] + "balances": [ + {"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false} + ] } Fields: - total_btc – total equity denominated in BTC - total_usdt – total equity denominated in USDT - assets – list of non-zero balances -""", - "account/balances": """\ -Output: JSON array -[ - {"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6} -] -Fields: - asset – asset symbol - free – available balance - locked – frozen/locked balance - total – free + locked -""", - "account/positions": """\ -Output: JSON array -[ - {"symbol": "BTCUSDT", "positionAmt": 0.01, "entryPrice": 90000.0} -] -Fields: - symbol – trading pair - positionAmt – quantity held (positive long, negative short) - entryPrice – average entry price + asset – asset symbol + free – available balance + locked – frozen/locked balance + total – free + locked + notional_usdt – estimated value in USDT + is_dust – true if value is below dust threshold """, "market/tickers": """\ Output: JSON object keyed by normalized symbol @@ -214,7 +196,7 @@ def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> S def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="coinhunter", - description="CoinHunter V2 Binance-first trading CLI", + description="Binance-first trading CLI for account balances, market data, trade execution, and opportunity scanning.", epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -223,64 +205,105 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--doc", action="store_true", help="Show output schema and field descriptions for the command") subparsers = parser.add_subparsers(dest="command") - init_parser = subparsers.add_parser("init", help="Generate config.toml, .env, and log directory") + def _add_global_flags(p: argparse.ArgumentParser) -> None: + p.add_argument("-a", "--agent", action="store_true", help="Output in agent-friendly format (JSON or compact)") + p.add_argument("--doc", action="store_true", help="Show output schema and field descriptions for the command") + + init_parser = subparsers.add_parser( + "init", help="Generate config.toml, .env, and log directory", + description="Initialize the CoinHunter runtime directory with config.toml, .env, and shell completions.", + ) init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing files") + _add_global_flags(init_parser) - account_parser = subparsers.add_parser("account", aliases=["acc", "a"], help="Account overview, balances, and positions") - account_subparsers = account_parser.add_subparsers(dest="account_command") - account_commands_help = { - "overview": "Total equity and summary", - "balances": "List asset balances", - "positions": "List open positions", - } - account_aliases = { - "overview": ["ov"], - "balances": ["bal", "b"], - "positions": ["pos", "p"], - } - for name in ("overview", "balances", "positions"): - account_subparsers.add_parser(name, aliases=account_aliases[name], help=account_commands_help[name]) + account_parser = subparsers.add_parser( + "account", aliases=["acc", "a"], help="List asset balances and notional values", + description="List all non-zero spot balances with free/locked totals, notional USDT value, and dust flag.", + ) + _add_global_flags(account_parser) - market_parser = subparsers.add_parser("market", aliases=["m"], help="Batch market queries") + market_parser = subparsers.add_parser( + "market", aliases=["m"], help="Batch market queries", + description="Query market data: 24h tickers and OHLCV klines for one or more trading pairs.", + ) market_subparsers = market_parser.add_subparsers(dest="market_command") - tickers_parser = market_subparsers.add_parser("tickers", aliases=["tk", "t"], help="Fetch 24h ticker data") + tickers_parser = market_subparsers.add_parser( + "tickers", aliases=["tk", "t"], help="Fetch 24h ticker data", + description="Fetch 24h ticker statistics (last price, change %, volume) for one or more symbols.", + ) tickers_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query (e.g. BTCUSDT ETH/USDT)") - klines_parser = market_subparsers.add_parser("klines", aliases=["k"], help="Fetch OHLCV klines") + _add_global_flags(tickers_parser) + klines_parser = market_subparsers.add_parser( + "klines", aliases=["k"], help="Fetch OHLCV klines", + description="Fetch OHLCV candlestick data for one or more symbols.", + ) klines_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query") 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)") + _add_global_flags(klines_parser) - buy_parser = subparsers.add_parser("buy", aliases=["b"], help="Buy base asset") + buy_parser = subparsers.add_parser( + "buy", aliases=["b"], help="Buy base asset", + description="Place a spot BUY order. Market buys require --quote. Limit buys require --qty and --price.", + ) buy_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)") buy_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity (limit orders)") buy_parser.add_argument("-Q", "--quote", type=float, help="Quote asset amount (market buy only)") buy_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)") buy_parser.add_argument("-p", "--price", type=float, help="Limit price") buy_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending") + _add_global_flags(buy_parser) - sell_parser = subparsers.add_parser("sell", aliases=["s"], help="Sell base asset") + sell_parser = subparsers.add_parser( + "sell", aliases=["s"], help="Sell base asset", + description="Place a spot SELL order. Requires --qty. Limit sells also require --price.", + ) sell_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)") sell_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity") sell_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)") sell_parser.add_argument("-p", "--price", type=float, help="Limit price") sell_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending") + _add_global_flags(sell_parser) - opportunity_parser = subparsers.add_parser("opportunity", aliases=["opp", "o"], help="Portfolio analysis and market scanning") + opportunity_parser = subparsers.add_parser( + "opportunity", aliases=["opp", "o"], help="Portfolio analysis and market scanning", + description="Analyze your portfolio and scan the market for trading opportunities.", + ) opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command") - opportunity_subparsers.add_parser("portfolio", aliases=["pf", "p"], help="Score current holdings") - scan_parser = opportunity_subparsers.add_parser("scan", help="Scan market for opportunities") + portfolio_parser = opportunity_subparsers.add_parser( + "portfolio", aliases=["pf", "p"], help="Score current holdings", + description="Score your current spot holdings and generate add/hold/trim/exit recommendations.", + ) + _add_global_flags(portfolio_parser) + scan_parser = opportunity_subparsers.add_parser( + "scan", help="Scan market for opportunities", + description="Scan the market for trading opportunities and return the top-N candidates with signals.", + ) scan_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols") + _add_global_flags(scan_parser) - subparsers.add_parser("upgrade", help="Upgrade coinhunter to the latest version") + upgrade_parser = subparsers.add_parser( + "upgrade", help="Upgrade coinhunter to the latest version", + description="Upgrade the coinhunter package using pipx (preferred) or pip.", + ) + _add_global_flags(upgrade_parser) - catlog_parser = subparsers.add_parser("catlog", help="Read recent audit log entries") + catlog_parser = subparsers.add_parser( + "catlog", help="Read recent audit log entries", + description="Read recent audit log entries across all days with optional limit and offset pagination.", + ) catlog_parser.add_argument("-n", "--limit", type=int, default=10, help="Number of entries (default: 10)") catlog_parser.add_argument( "-o", "--offset", type=int, default=0, help="Skip the most recent N entries (default: 0)" ) + _add_global_flags(catlog_parser) - completion_parser = subparsers.add_parser("completion", help="Generate shell completion script") + completion_parser = subparsers.add_parser( + "completion", help="Generate shell completion script", + description="Generate a shell completion script for bash or zsh.", + ) completion_parser.add_argument("shell", choices=["bash", "zsh"], help="Target shell") + _add_global_flags(completion_parser) return parser @@ -296,18 +319,13 @@ _CANONICAL_COMMANDS = { } _CANONICAL_SUBCOMMANDS = { - "ov": "overview", - "bal": "balances", - "b": "balances", - "pos": "positions", - "p": "positions", "tk": "tickers", "t": "tickers", "k": "klines", "pf": "portfolio", } -_COMMANDS_WITH_SUBCOMMANDS = {"account", "market", "opportunity"} +_COMMANDS_WITH_SUBCOMMANDS = {"market", "opportunity"} def _get_doc_key(argv: list[str]) -> str | None: @@ -391,22 +409,10 @@ def main(argv: list[str] | None = None) -> int: if args.command == "account": spot_client = _load_spot_client(config) - if args.account_command == "overview": - with with_spinner("Fetching account overview...", enabled=not args.agent): - result = account_service.get_overview(config, spot_client=spot_client) - print_output(result, agent=args.agent) - return 0 - if args.account_command == "balances": - with with_spinner("Fetching balances...", enabled=not args.agent): - result = account_service.get_balances(config, spot_client=spot_client) - print_output(result, agent=args.agent) - return 0 - if args.account_command == "positions": - with with_spinner("Fetching positions...", enabled=not args.agent): - result = account_service.get_positions(config, spot_client=spot_client) - print_output(result, agent=args.agent) - return 0 - parser.error("account requires one of: overview, balances, positions") + with with_spinner("Fetching balances...", enabled=not args.agent): + result = account_service.get_balances(config, spot_client=spot_client) + print_output(result, agent=args.agent) + return 0 if args.command == "market": spot_client = _load_spot_client(config) diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py index 4c7bd8b..4cb6792 100644 --- a/src/coinhunter/runtime.py +++ b/src/coinhunter/runtime.py @@ -213,39 +213,27 @@ def _render_tui(payload: Any) -> None: print(str(payload)) return - if "overview" in payload: - 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 Assets: {_fmt_number(overview.get('spot_asset_count', 0))}") - print(f" Positions: {_fmt_number(overview.get('spot_position_count', 0))}") - if payload.get("balances"): - print() - _render_tui({"balances": payload["balances"]}) - if payload.get("positions"): - print() - _render_tui({"positions": payload["positions"]}) - return - if "balances" in payload: rows = payload["balances"] table_rows: list[list[str]] = [] for r in rows: + is_dust = r.get("is_dust", False) + dust_label = f"{_DIM}dust{_RESET}" if is_dust else "" table_rows.append( [ - r.get("market_type", ""), r.get("asset", ""), _fmt_number(r.get("free", 0)), _fmt_number(r.get("locked", 0)), _fmt_number(r.get("total", 0)), _fmt_number(r.get("notional_usdt", 0)), + dust_label, ] ) _print_box_table( "BALANCES", - ["Market", "Asset", "Free", "Locked", "Total", "Notional (USDT)"], + ["Asset", "Free", "Locked", "Total", "Notional (USDT)", ""], table_rows, - aligns=["left", "left", "right", "right", "right", "right"], + aligns=["left", "right", "right", "right", "right", "left"], ) return diff --git a/src/coinhunter/services/account_service.py b/src/coinhunter/services/account_service.py index 1fece40..dae9493 100644 --- a/src/coinhunter/services/account_service.py +++ b/src/coinhunter/services/account_service.py @@ -13,6 +13,7 @@ class AssetBalance: locked: float total: float notional_usdt: float + is_dust: bool @dataclass @@ -59,6 +60,7 @@ def get_balances( 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]] = [] balances, _, price_map = _spot_account_data(spot_client, quote) for item in balances: @@ -68,6 +70,7 @@ def get_balances( if total <= 0: continue asset = item["asset"] + notional = total * price_map.get(asset, 0.0) rows.append( asdict( AssetBalance( @@ -75,7 +78,8 @@ def get_balances( free=free, locked=locked, total=total, - notional_usdt=total * price_map.get(asset, 0.0), + notional_usdt=notional, + is_dust=notional < dust, ) ) ) @@ -113,60 +117,3 @@ def get_positions( ) ) return {"positions": rows} - - -def get_overview( - config: dict[str, Any], - *, - 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]] = [] - - 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( - symbol=quote if asset == quote else f"{asset}{quote}", - quantity=total, - entry_price=None, - mark_price=mark_price, - notional_usdt=notional, - side="LONG", - ) - ) - ) - - spot_equity = sum(item["notional_usdt"] for item in balances) - overview = asdict( - AccountOverview( - total_equity_usdt=spot_equity, - spot_equity_usdt=spot_equity, - spot_asset_count=len(balances), - spot_position_count=len(positions), - ) - ) - return {"overview": overview, "balances": balances, "positions": positions} diff --git a/tests/test_account_market_services.py b/tests/test_account_market_services.py index a774a3f..e01022f 100644 --- a/tests/test_account_market_services.py +++ b/tests/test_account_market_services.py @@ -69,19 +69,19 @@ class FakeSpotClient: class AccountMarketServicesTestCase(unittest.TestCase): - def test_account_overview_and_dust_filter(self): + def test_get_balances_with_dust_flag(self): config = { "market": {"default_quote": "USDT"}, "trading": {"dust_usdt_threshold": 10.0}, } - payload = account_service.get_overview( + payload = account_service.get_balances( config, spot_client=FakeSpotClient(), ) - 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) + balances = {item["asset"]: item for item in payload["balances"]} + self.assertFalse(balances["USDT"]["is_dust"]) + self.assertFalse(balances["BTC"]["is_dust"]) + self.assertTrue(balances["DOGE"]["is_dust"]) def test_market_tickers_and_scan_universe(self): config = { diff --git a/tests/test_cli.py b/tests/test_cli.py index e57083e..c6e6226 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -91,6 +91,25 @@ class CLITestCase(unittest.TestCase): self.assertIn("lastPrice", output) self.assertIn("BTCUSDT", output) + def test_account_dispatches(self): + captured = {} + with ( + patch.object( + cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "trading": {"dust_usdt_threshold": 10.0}} + ), + patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), + patch.object(cli, "SpotBinanceClient"), + patch.object( + cli.account_service, "get_balances", return_value={"balances": [{"asset": "BTC", "is_dust": False}]} + ), + patch.object( + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) + ), + ): + result = cli.main(["account"]) + self.assertEqual(result, 0) + self.assertEqual(captured["payload"]["balances"][0]["asset"], "BTC") + def test_upgrade_dispatches(self): captured = {} with (