diff --git a/README.md b/README.md index a01aaa0..c5726e2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ pipx install coinhunter coinhunter --help ``` +You can also use the shorter `coin` alias: + +```bash +coin --help +``` + Check the installed version: ```bash @@ -68,28 +74,45 @@ Override the default home directory with `COINHUNTER_HOME`. 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). +Add `--doc` to any command to see its output schema and field descriptions (great for AI agents): + ```bash -# Account +coin buy --doc +coin market klines --doc +``` + +### Examples + +```bash +# Account (aliases: a, acc) coinhunter account overview coinhunter account overview --agent -coinhunter account balances -coinhunter account positions +coin a ov +coin acc bal +coin a pos -# Market +# Market (aliases: m) coinhunter market tickers BTCUSDT ETH/USDT sol-usdt coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50 +coin m tk BTCUSDT ETHUSDT +coin m k BTCUSDT -i 1h -l 50 -# Trade -coinhunter trade buy BTCUSDT --quote 100 --dry-run -coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000 +# Trade (buy / sell are now top-level commands) +coinhunter buy BTCUSDT --quote 100 --dry-run +coinhunter sell BTCUSDT --qty 0.01 --type limit --price 90000 +coin b BTCUSDT -Q 100 -d +coin s BTCUSDT -q 0.01 -t limit -p 90000 -# Opportunities +# Opportunities (aliases: opp, o) coinhunter opportunity portfolio coinhunter opportunity scan coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT +coin opp pf +coin o scan -s BTCUSDT ETHUSDT # Self-upgrade coinhunter upgrade +coin upgrade # Shell completion (manual) coinhunter completion zsh > ~/.zsh/completions/_coinhunter diff --git a/pyproject.toml b/pyproject.toml index 487c22e..e007493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "coinhunter" -version = "2.0.8" +version = "2.1.0" description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution." readme = "README.md" license = {text = "MIT"} @@ -27,6 +27,7 @@ dev = [ [project.scripts] coinhunter = "coinhunter.cli:main" +coin = "coinhunter.cli:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index 60bbe75..68f597a 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -14,16 +14,178 @@ from .services import account_service, market_service, opportunity_service, trad EPILOG = """\ examples: - coinhunter init - coinhunter account overview - coinhunter market tickers BTCUSDT ETHUSDT - coinhunter market klines BTCUSDT -i 1h -l 50 - 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 + coin init + coin acc ov + coin m tk BTCUSDT ETHUSDT + coin m k BTCUSDT -i 1h -l 50 + coin buy BTCUSDT -Q 100 -d + coin sell BTCUSDT --qty 0.01 --type limit --price 90000 + coin opp scan -s BTCUSDT ETHUSDT + coin upgrade """ +COMMAND_DOCS: dict[str, str] = { + "init": """\ +Output: JSON +{ + "root": "~/.coinhunter", + "files_created": ["config.toml", ".env"], + "completion": {"shell": "zsh", "installed": true} +} +Fields: + root – runtime directory path + files_created – list of generated files + completion – shell completion installation status +""", + "account/overview": """\ +Output: JSON +{ + "total_btc": 1.234, + "total_usdt": 50000.0, + "assets": [{"asset": "BTC", "free": 0.5, "locked": 0.1}] +} +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 +""", + "market/tickers": """\ +Output: JSON object keyed by normalized symbol +{ + "BTCUSDT": {"lastPrice": "70000.00", "priceChangePercent": "2.5", "volume": "12345.67"} +} +Fields: + lastPrice – latest traded price + priceChangePercent – 24h change % + volume – 24h base volume +""", + "market/klines": """\ +Output: JSON object keyed by symbol, value is array of OHLCV candles +{ + "BTCUSDT": [ + {"open_time": 1713000000000, "open": 69000.0, "high": 69500.0, "low": 68800.0, "close": 69200.0, "volume": 100.5} + ] +} +Fields per candle: + open_time – candle open timestamp (ms) + open/high/low/close – OHLC prices + volume – traded base volume +""", + "buy": """\ +Output: JSON +{ + "trade": { + "market_type": "spot", + "symbol": "BTCUSDT", + "side": "BUY", + "order_type": "MARKET", + "status": "DRY_RUN", + "dry_run": true, + "request_payload": {...}, + "response_payload": {...} + } +} +Fields: + market_type – "spot" + side – "BUY" + order_type – MARKET or LIMIT + status – order status from exchange (or DRY_RUN) + dry_run – whether simulated + request_payload – normalized order sent to Binance + response_payload – raw exchange response +""", + "sell": """\ +Output: JSON +{ + "trade": { + "market_type": "spot", + "symbol": "BTCUSDT", + "side": "SELL", + "order_type": "LIMIT", + "status": "FILLED", + "dry_run": false, + "request_payload": {...}, + "response_payload": {...} + } +} +Fields: + market_type – "spot" + side – "SELL" + order_type – MARKET or LIMIT + status – order status from exchange (or DRY_RUN) + dry_run – whether simulated + request_payload – normalized order sent to Binance + response_payload – raw exchange response +""", + "opportunity/portfolio": """\ +Output: JSON +{ + "scores": [ + {"asset": "BTC", "score": 0.75, "metrics": {"volatility": 0.02, "trend": 0.01}} + ] +} +Fields: + asset – scored asset + score – composite opportunity score (0-1) + metrics – breakdown of contributing signals +""", + "opportunity/scan": """\ +Output: JSON +{ + "opportunities": [ + {"symbol": "ETHUSDT", "score": 0.82, "signals": ["momentum", "volume_spike"]} + ] +} +Fields: + symbol – trading pair scanned + score – opportunity score (0-1) + signals – list of triggered signal names +""", + "upgrade": """\ +Output: JSON +{ + "command": "pip install --upgrade coinhunter", + "returncode": 0, + "stdout": "...", + "stderr": "" +} +Fields: + command – shell command executed + returncode – process exit code (0 = success) + stdout – command standard output + stderr – command standard error +""", + "completion": """\ +Output: shell script text (not JSON) +# bash/zsh completion script for coinhunter +... +Fields: + (raw shell script suitable for sourcing) +""", +} + + def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> SpotBinanceClient: credentials = get_binance_credentials() @@ -46,45 +208,54 @@ def build_parser() -> argparse.ArgumentParser: ) 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)") + 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") 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_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, help=account_commands_help[name]) + account_subparsers.add_parser(name, aliases=account_aliases[name], help=account_commands_help[name]) - market_parser = subparsers.add_parser("market", help="Batch market queries") + market_parser = subparsers.add_parser("market", aliases=["m"], help="Batch market queries") market_subparsers = market_parser.add_subparsers(dest="market_command") - tickers_parser = market_subparsers.add_parser("tickers", help="Fetch 24h ticker data") + tickers_parser = market_subparsers.add_parser("tickers", aliases=["tk", "t"], 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 = market_subparsers.add_parser("klines", aliases=["k"], 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 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 = 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)") - 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") + buy_parser = subparsers.add_parser("buy", aliases=["b"], help="Buy base asset") + 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") - opportunity_parser = subparsers.add_parser("opportunity", help="Portfolio analysis and market scanning") + sell_parser = subparsers.add_parser("sell", aliases=["s"], help="Sell base asset") + 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") + + opportunity_parser = subparsers.add_parser("opportunity", aliases=["opp", "o"], help="Portfolio analysis and market scanning") opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command") - opportunity_subparsers.add_parser("portfolio", help="Score current holdings") + opportunity_subparsers.add_parser("portfolio", aliases=["pf", "p"], 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") @@ -96,9 +267,49 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _reorder_agent_flag(argv: list[str]) -> list[str]: - """Move -a/--agent from after subcommands to before them so argparse can parse it.""" - agent_flags = {"-a", "--agent"} +_CANONICAL_COMMANDS = { + "b": "buy", + "s": "sell", + "acc": "account", + "a": "account", + "m": "market", + "opp": "opportunity", + "o": "opportunity", +} + +_CANONICAL_SUBCOMMANDS = { + "ov": "overview", + "bal": "balances", + "b": "balances", + "pos": "positions", + "p": "positions", + "tk": "tickers", + "t": "tickers", + "k": "klines", + "pf": "portfolio", + "p": "portfolio", +} + +_COMMANDS_WITH_SUBCOMMANDS = {"account", "market", "opportunity"} + + +def _get_doc_key(argv: list[str]) -> str | None: + """Infer command/subcommand from argv for --doc lookup.""" + tokens = [a for a in argv if a != "--doc" and not a.startswith("-")] + if not tokens: + return None + cmd = _CANONICAL_COMMANDS.get(tokens[0], tokens[0]) + if cmd in _COMMANDS_WITH_SUBCOMMANDS and len(tokens) > 1: + sub = _CANONICAL_SUBCOMMANDS.get(tokens[1], tokens[1]) + return f"{cmd}/{sub}" + return cmd + + +def _reorder_flag(argv: list[str], flag: str, short_flag: str | None = None) -> list[str]: + """Move a global flag from after subcommands to before them so argparse can parse it.""" + flags = {flag} + if short_flag: + flags.add(short_flag) subcommand_idx: int | None = None for i, arg in enumerate(argv): if not arg.startswith("-"): @@ -107,21 +318,41 @@ def _reorder_agent_flag(argv: list[str]) -> list[str]: if subcommand_idx is None: return argv new_argv: list[str] = [] - agent_present = False + present = False for i, arg in enumerate(argv): - if i >= subcommand_idx and arg in agent_flags: - agent_present = True + if i >= subcommand_idx and arg in flags: + present = True continue new_argv.append(arg) - if agent_present: - new_argv.insert(subcommand_idx, "--agent") + if present: + new_argv.insert(subcommand_idx, flag) return new_argv def main(argv: list[str] | None = None) -> int: - parser = build_parser() raw_argv = argv if argv is not None else sys.argv[1:] - args = parser.parse_args(_reorder_agent_flag(raw_argv)) + + if "--doc" in raw_argv: + doc_key = _get_doc_key(raw_argv) + if doc_key is None: + print("Available docs: " + ", ".join(sorted(COMMAND_DOCS.keys()))) + return 0 + doc = COMMAND_DOCS.get(doc_key, f"No documentation available for {doc_key}.") + print(doc) + return 0 + + parser = build_parser() + raw_argv = _reorder_flag(raw_argv, "--agent", "-a") + args = parser.parse_args(raw_argv) + + # Normalize aliases to canonical command names + if args.command: + args.command = _CANONICAL_COMMANDS.get(args.command, args.command) + for attr in ("account_command", "market_command", "opportunity_command"): + val = getattr(args, attr, None) + if val: + setattr(args, attr, _CANONICAL_SUBCOMMANDS.get(val, val)) + try: if not args.command: parser.print_help() @@ -187,13 +418,13 @@ def main(argv: list[str] | None = None) -> int: return 0 parser.error("market requires one of: tickers, klines") - if args.command == "trade": + if args.command == "buy": 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, + side="buy", symbol=args.symbol, qty=args.qty, quote=args.quote, @@ -206,6 +437,25 @@ def main(argv: list[str] | None = None) -> int: ) return 0 + if args.command == "sell": + spot_client = _load_spot_client(config) + with with_spinner("Placing order...", enabled=not args.agent): + print_output( + trade_service.execute_spot_trade( + config, + side="sell", + symbol=args.symbol, + qty=args.qty, + quote=None, + 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) if args.opportunity_command == "portfolio": diff --git a/tests/test_cli.py b/tests/test_cli.py index e073b45..83d9c6a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,7 +15,10 @@ class CLITestCase(unittest.TestCase): help_text = parser.format_help() self.assertIn("init", help_text) self.assertIn("account", help_text) + self.assertIn("buy", help_text) + self.assertIn("sell", help_text) self.assertIn("opportunity", help_text) + self.assertIn("--doc", help_text) def test_init_dispatches(self): captured = {} @@ -40,6 +43,48 @@ class CLITestCase(unittest.TestCase): self.assertEqual(result, 1) self.assertIn("error: boom", stderr.getvalue()) + def test_buy_dispatches(self): + captured = {} + with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object( + cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"} + ), patch.object( + cli, "SpotBinanceClient" + ), patch.object( + cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}} + ), patch.object( + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) + ): + result = cli.main(["buy", "BTCUSDT", "-Q", "100"]) + self.assertEqual(result, 0) + self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN") + + def test_sell_dispatches(self): + captured = {} + with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object( + cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"} + ), patch.object( + cli, "SpotBinanceClient" + ), patch.object( + cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}} + ), patch.object( + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) + ): + result = cli.main(["sell", "BTCUSDT", "-q", "0.01"]) + self.assertEqual(result, 0) + self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN") + + def test_doc_flag_prints_documentation(self): + import io + from unittest.mock import patch + + stdout = io.StringIO() + with patch("sys.stdout", stdout): + result = cli.main(["market", "tickers", "--doc"]) + self.assertEqual(result, 0) + output = stdout.getvalue() + self.assertIn("lastPrice", output) + self.assertIn("BTCUSDT", output) + def test_upgrade_dispatches(self): captured = {} with patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}), patch.object(