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 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 18:19:19 +08:00
parent d629c25232
commit 3855477155
7 changed files with 123 additions and 165 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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