From 9395978440765e74f0e944724feb61d24a13b0c9 Mon Sep 17 00:00:00 2001 From: Tacit Lab Date: Thu, 16 Apr 2026 18:36:23 +0800 Subject: [PATCH] feat: human-friendly TUI output with --agent flag for JSON/compact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace default JSON output with styled TUI tables and ANSI colors. - Add -a/--agent global flag: small payloads → JSON, large → pipe-delimited compact. - Update README to reflect new output behavior and remove JSON-first references. - Bump version to 2.0.2. Co-Authored-By: Claude Opus 4.6 --- README.md | 7 +- pyproject.toml | 2 +- src/coinhunter/cli.py | 127 ++++++++++------- src/coinhunter/runtime.py | 282 ++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 4 +- 5 files changed, 366 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 046abd5..8580f24 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Typing SVG + Typing SVG

@@ -64,9 +64,12 @@ Override the default home directory with `COINHUNTER_HOME`. ## Commands +By default, CoinHunter prints human-friendly TUI tables. Add `--agent` to any command to get JSON output (or compact pipe-delimited tables for large datasets). + ```bash # Account coinhunter account overview +coinhunter account overview --agent coinhunter account balances --spot --futures coinhunter account positions --spot @@ -104,7 +107,7 @@ CoinHunter V2 uses a flat, direct architecture: | **Binance** | Thin API wrappers with unified error handling | `binance/spot_client.py`, `binance/um_futures_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, JSON helpers | `runtime.py` | +| **Runtime** | Paths, TUI/JSON/compact output | `runtime.py` | | **Audit** | Structured JSONL logging | `audit.py` | ## Logging diff --git a/pyproject.toml b/pyproject.toml index 238ac0f..7bf2d63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "coinhunter" -version = "2.0.1" +version = "2.0.2" description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution." readme = "README.md" license = {text = "MIT"} diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index c19f385..467abf7 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -10,9 +10,21 @@ 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, print_json, self_update +from .runtime import get_runtime_paths, print_output, self_update from .services import account_service, market_service, opportunity_service, trade_service +EPILOG = """\ +examples: + coinhunter init + coinhunter account overview -sf + 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 opportunity scan -s BTCUSDT ETHUSDT + coinhunter update +""" + def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> SpotBinanceClient: credentials = get_binance_credentials() @@ -45,62 +57,68 @@ def _resolve_market_flags(args: argparse.Namespace) -> tuple[bool, bool]: def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="coinhunter", description="CoinHunter V2 Binance-first trading CLI") - parser.add_argument("--version", action="version", version=__version__) + parser = argparse.ArgumentParser( + prog="coinhunter", + description="CoinHunter V2 Binance-first trading CLI", + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-v", "--version", action="version", version=__version__) + parser.add_argument("-a", "--agent", action="store_true", help="Output in agent-friendly format (JSON or compact)") subparsers = parser.add_subparsers(dest="command") init_parser = subparsers.add_parser("init", help="Generate config.toml, .env, and log directory") - init_parser.add_argument("--force", action="store_true") + init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing files") account_parser = subparsers.add_parser("account", help="Account overview, balances, and positions") account_subparsers = account_parser.add_subparsers(dest="account_command") for name in ("overview", "balances", "positions"): sub = account_subparsers.add_parser(name) - sub.add_argument("--spot", action="store_true") - sub.add_argument("--futures", action="store_true") + sub.add_argument("-s", "--spot", action="store_true", help="Include spot market") + sub.add_argument("-f", "--futures", action="store_true", help="Include futures market") market_parser = subparsers.add_parser("market", help="Batch market queries") market_subparsers = market_parser.add_subparsers(dest="market_command") - tickers_parser = market_subparsers.add_parser("tickers") - tickers_parser.add_argument("symbols", nargs="+") - klines_parser = market_subparsers.add_parser("klines") - klines_parser.add_argument("symbols", nargs="+") - klines_parser.add_argument("--interval", default="1h") - klines_parser.add_argument("--limit", type=int, default=100) + tickers_parser = market_subparsers.add_parser("tickers", help="Fetch 24h ticker data") + tickers_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query (e.g. BTCUSDT ETH/USDT)") + klines_parser = market_subparsers.add_parser("klines", help="Fetch OHLCV klines") + 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)") 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") + spot_parser = trade_subparsers.add_parser("spot", help="Spot market orders") spot_subparsers = spot_parser.add_subparsers(dest="trade_action") for side in ("buy", "sell"): sub = spot_subparsers.add_parser(side) - sub.add_argument("symbol") - sub.add_argument("--qty", type=float) - sub.add_argument("--quote", type=float) - sub.add_argument("--type", choices=["market", "limit"], default="market") - sub.add_argument("--price", type=float) - sub.add_argument("--dry-run", action="store_true") + 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)") + 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("-d", "--dry-run", action="store_true", help="Simulate without sending") - futures_parser = trade_subparsers.add_parser("futures") + futures_parser = trade_subparsers.add_parser("futures", help="USDT-M futures orders") futures_subparsers = futures_parser.add_subparsers(dest="trade_action") for side in ("buy", "sell"): sub = futures_subparsers.add_parser(side) - sub.add_argument("symbol") - sub.add_argument("--qty", type=float, required=True) - sub.add_argument("--type", choices=["market", "limit"], default="market") - sub.add_argument("--price", type=float) - sub.add_argument("--reduce-only", action="store_true") - sub.add_argument("--dry-run", action="store_true") - close_parser = futures_subparsers.add_parser("close") - close_parser.add_argument("symbol") - close_parser.add_argument("--dry-run", action="store_true") + 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") - scan_parser = opportunity_subparsers.add_parser("scan") - scan_parser.add_argument("--symbols", nargs="*") + opportunity_subparsers.add_parser("portfolio", help="Score current holdings") + scan_parser = opportunity_subparsers.add_parser("scan", help="Scan market for opportunities") + scan_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols") subparsers.add_parser("update", help="Upgrade coinhunter to the latest version") @@ -116,7 +134,7 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "init": - print_json(ensure_init_files(get_runtime_paths(), force=args.force)) + print_output(ensure_init_files(get_runtime_paths(), force=args.force), agent=args.agent) return 0 config = load_config() @@ -126,36 +144,39 @@ def main(argv: list[str] | None = None) -> int: spot_client = _load_spot_client(config) if include_spot else None futures_client = _load_futures_client(config) if include_futures else None if args.account_command == "overview": - print_json( + print_output( account_service.get_overview( config, include_spot=include_spot, include_futures=include_futures, spot_client=spot_client, futures_client=futures_client, - ) + ), + agent=args.agent, ) return 0 if args.account_command == "balances": - print_json( + print_output( account_service.get_balances( config, include_spot=include_spot, include_futures=include_futures, spot_client=spot_client, futures_client=futures_client, - ) + ), + agent=args.agent, ) return 0 if args.account_command == "positions": - print_json( + print_output( account_service.get_positions( config, include_spot=include_spot, include_futures=include_futures, spot_client=spot_client, futures_client=futures_client, - ) + ), + agent=args.agent, ) return 0 parser.error("account requires one of: overview, balances, positions") @@ -163,17 +184,18 @@ def main(argv: list[str] | None = None) -> int: if args.command == "market": spot_client = _load_spot_client(config) if args.market_command == "tickers": - print_json(market_service.get_tickers(config, args.symbols, spot_client=spot_client)) + print_output(market_service.get_tickers(config, args.symbols, spot_client=spot_client), agent=args.agent) return 0 if args.market_command == "klines": - print_json( + print_output( market_service.get_klines( config, args.symbols, interval=args.interval, limit=args.limit, spot_client=spot_client, - ) + ), + agent=args.agent, ) return 0 parser.error("market requires one of: tickers, klines") @@ -181,7 +203,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "trade": if args.trade_market == "spot": spot_client = _load_spot_client(config) - print_json( + print_output( trade_service.execute_spot_trade( config, side=args.trade_action, @@ -192,22 +214,24 @@ def main(argv: list[str] | None = None) -> int: 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": - print_json( + 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 - print_json( + print_output( trade_service.execute_futures_trade( config, side=args.trade_action, @@ -218,7 +242,8 @@ def main(argv: list[str] | None = None) -> int: 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`") @@ -226,15 +251,15 @@ def main(argv: list[str] | None = None) -> int: if args.command == "opportunity": spot_client = _load_spot_client(config) if args.opportunity_command == "portfolio": - print_json(opportunity_service.analyze_portfolio(config, spot_client=spot_client)) + print_output(opportunity_service.analyze_portfolio(config, spot_client=spot_client), agent=args.agent) return 0 if args.opportunity_command == "scan": - print_json(opportunity_service.scan_opportunities(config, spot_client=spot_client, symbols=args.symbols)) + print_output(opportunity_service.scan_opportunities(config, spot_client=spot_client, symbols=args.symbols), agent=args.agent) return 0 parser.error("opportunity requires `portfolio` or `scan`") if args.command == "update": - print_json(self_update()) + print_output(self_update(), agent=args.agent) return 0 parser.error(f"Unsupported command {args.command}") diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py index fdc80ba..9938f5c 100644 --- a/src/coinhunter/runtime.py +++ b/src/coinhunter/runtime.py @@ -2,8 +2,11 @@ from __future__ import annotations +import csv +import io import json import os +import re import shutil import subprocess import sys @@ -67,3 +70,282 @@ def self_update() -> dict[str, Any]: "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), } + + +# --------------------------------------------------------------------------- +# TUI / Agent output helpers +# --------------------------------------------------------------------------- + +_ANSI_RE = re.compile(r"\033\[[0-9;]*m") +_BOLD = "\033[1m" +_RESET = "\033[0m" +_CYAN = "\033[36m" +_GREEN = "\033[32m" +_YELLOW = "\033[33m" +_RED = "\033[31m" +_DIM = "\033[2m" + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +def _color(text: str, color: str) -> str: + return f"{color}{text}{_RESET}" + + +def _cell_width(text: str) -> int: + return len(_strip_ansi(text)) + + +def _pad(text: str, width: int, align: str = "left") -> str: + pad = width - _cell_width(text) + if align == "right": + return " " * pad + text + return text + " " * pad + + +def _fmt_number(value: Any) -> str: + if value is None: + return "—" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + s = f"{value:,.4f}" + s = s.rstrip("0").rstrip(".") + return s + return str(value) + + +def _is_large_dataset(payload: Any, threshold: int = 8) -> bool: + if isinstance(payload, dict): + for value in payload.values(): + if isinstance(value, list) and len(value) > threshold: + return True + return False + + +def _print_compact(payload: dict[str, Any]) -> None: + target_key = None + target_rows: list[Any] = [] + for key, value in payload.items(): + if isinstance(value, list) and len(value) > len(target_rows): + target_key = key + target_rows = value + + if target_rows and isinstance(target_rows[0], dict): + headers = list(target_rows[0].keys()) + output = io.StringIO() + writer = csv.writer(output, delimiter="|", lineterminator="\n") + writer.writerow(headers) + for row in target_rows: + writer.writerow([str(row.get(h, "")) for h in headers]) + print(f"mode=compact|source={target_key}") + print(output.getvalue().strip()) + else: + for key, value in payload.items(): + print(f"{key}={value}") + + +def _h_line(widths: list[int], left: str, mid: str, right: str) -> str: + parts = ["─" * (w + 2) for w in widths] + return left + mid.join(parts) + right + + +def _print_box_table( + title: str, + headers: list[str], + rows: list[list[str]], + aligns: list[str] | None = None, +) -> None: + if not rows: + print(f"{_BOLD}{_CYAN}{title}{_RESET}") + print(" (empty)") + return + + aligns = aligns or ["left"] * len(headers) + col_widths = [_cell_width(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + col_widths[i] = max(col_widths[i], _cell_width(cell)) + + if title: + print(f"{_BOLD}{_CYAN}{title}{_RESET}") + print(_h_line(col_widths, "┌", "┬", "┐")) + header_cells = [_pad(headers[i], col_widths[i], aligns[i]) for i in range(len(headers))] + print("│ " + " │ ".join(header_cells) + " │") + print(_h_line(col_widths, "├", "┼", "┤")) + for row in rows: + cells = [_pad(row[i], col_widths[i], aligns[i]) for i in range(len(row))] + print("│ " + " │ ".join(cells) + " │") + print(_h_line(col_widths, "└", "┴", "┘")) + + +def _render_tui(payload: Any) -> None: + if not isinstance(payload, dict): + 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 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))}") + 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: + 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)), + ] + ) + _print_box_table( + "BALANCES", + ["Market", "Asset", "Free", "Locked", "Total", "Notional (USDT)"], + table_rows, + aligns=["left", "left", "right", "right", "right", "right"], + ) + return + + if "positions" in payload: + rows = payload["positions"] + table_rows = [] + for r in rows: + entry = _fmt_number(r.get("entry_price")) if r.get("entry_price") is not None else "—" + pnl = _fmt_number(r.get("unrealized_pnl")) if r.get("unrealized_pnl") is not None else "—" + table_rows.append( + [ + r.get("market_type", ""), + r.get("symbol", ""), + r.get("side", ""), + _fmt_number(r.get("quantity", 0)), + entry, + _fmt_number(r.get("mark_price", 0)), + _fmt_number(r.get("notional_usdt", 0)), + pnl, + ] + ) + _print_box_table( + "POSITIONS", + ["Market", "Symbol", "Side", "Qty", "Entry", "Mark", "Notional", "PnL"], + table_rows, + aligns=["left", "left", "left", "right", "right", "right", "right", "right"], + ) + return + + if "tickers" in payload: + rows = payload["tickers"] + table_rows = [] + for r in rows: + pct = r.get("price_change_pct", 0) + pct_str = _color(f"{pct:+.2f}%", _GREEN if pct >= 0 else _RED) + table_rows.append( + [ + r.get("symbol", ""), + _fmt_number(r.get("last_price", 0)), + pct_str, + _fmt_number(r.get("quote_volume", 0)), + ] + ) + _print_box_table( + "24H TICKERS", + ["Symbol", "Last Price", "Change %", "Quote Volume"], + table_rows, + aligns=["left", "right", "right", "right"], + ) + return + + if "klines" in payload: + rows = payload["klines"] + print(f"\n{_BOLD}{_CYAN} KLINES {_RESET} interval={payload.get('interval')} limit={payload.get('limit')} count={len(rows)}") + display_rows = rows[:10] + table_rows = [] + for r in display_rows: + table_rows.append( + [ + r.get("symbol", ""), + str(r.get("open_time", ""))[:10], + _fmt_number(r.get("open", 0)), + _fmt_number(r.get("high", 0)), + _fmt_number(r.get("low", 0)), + _fmt_number(r.get("close", 0)), + _fmt_number(r.get("volume", 0)), + ] + ) + _print_box_table( + "", + ["Symbol", "Time", "Open", "High", "Low", "Close", "Vol"], + table_rows, + aligns=["left", "left", "right", "right", "right", "right", "right"], + ) + if len(rows) > 10: + print(f" {_DIM}... and {len(rows) - 10} more rows{_RESET}") + return + + if "trade" in payload: + t = payload["trade"] + status = t.get("status", "UNKNOWN") + status_color = _GREEN if status == "FILLED" else _YELLOW if status == "DRY_RUN" else _CYAN + print(f"\n{_BOLD}{_CYAN} TRADE RESULT {_RESET}") + print(f" Market: {t.get('market_type', '').upper()}") + print(f" Symbol: {t.get('symbol', '')}") + print(f" Side: {t.get('side', '')}") + print(f" Type: {t.get('order_type', '')}") + print(f" Status: {_color(status, status_color)}") + print(f" Dry Run: {_fmt_number(t.get('dry_run', False))}") + return + + if "recommendations" in payload: + rows = payload["recommendations"] + print(f"\n{_BOLD}{_CYAN} RECOMMENDATIONS {_RESET} count={len(rows)}") + for i, r in enumerate(rows, 1): + score = r.get("score", 0) + action = r.get("action", "") + action_color = _GREEN if action == "add" else _YELLOW if action == "hold" else _RED if action == "exit" else _CYAN + print(f" {i}. {_BOLD}{r.get('symbol', '')}{_RESET} action={_color(action, action_color)} score={score:.4f}") + for reason in r.get("reasons", []): + print(f" · {reason}") + metrics = r.get("metrics", {}) + if metrics: + metric_str = " ".join(f"{k}={v}" for k, v in metrics.items()) + print(f" {_DIM}{metric_str}{_RESET}") + return + + # Generic fallback for single-list payloads + if len(payload) == 1: + key, value = next(iter(payload.items())) + if isinstance(value, list) and value and isinstance(value[0], dict): + _render_tui({key: value}) + return + + # Simple key-value fallback + print(f"\n{_BOLD}{_CYAN} RESULT {_RESET}") + for key, value in payload.items(): + print(f" {key}: {value}") + + +def print_output(payload: Any, *, agent: bool = False) -> None: + if agent: + if _is_large_dataset(payload): + _print_compact(payload) + else: + print_json(payload) + else: + _render_tui(payload) diff --git a/tests/test_cli.py b/tests/test_cli.py index e9f13dc..908cd3a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,7 +20,7 @@ class CLITestCase(unittest.TestCase): def test_init_dispatches(self): captured = {} with patch.object(cli, "ensure_init_files", return_value={"force": True, "root": "/tmp/ch"}), patch.object( - cli, "print_json", side_effect=lambda payload: captured.setdefault("payload", payload) + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ): result = cli.main(["init", "--force"]) self.assertEqual(result, 0) @@ -40,7 +40,7 @@ class CLITestCase(unittest.TestCase): def test_update_dispatches(self): captured = {} with patch.object(cli, "self_update", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}), patch.object( - cli, "print_json", side_effect=lambda payload: captured.setdefault("payload", payload) + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) ): result = cli.main(["update"]) self.assertEqual(result, 0)