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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user