diff --git a/README.md b/README.md index c929f12..1724548 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ This creates: If you are using **zsh** or **bash**, `init` will also generate and install shell completion scripts automatically, and update your rc file (`~/.zshrc` or `~/.bashrc`) if needed. +`init` interactively prompts for your Binance API key and secret if they are missing. Use `--no-prompt` to skip this. + `config.toml` stores runtime and strategy settings. `.env` stores: ```bash @@ -101,18 +103,32 @@ 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 (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 +# Portfolio (aliases: pf, p) +coinhunter portfolio +coinhunter portfolio --agent +coin pf + +# Opportunity scanning (aliases: o) +coinhunter opportunity +coinhunter opportunity --symbols BTCUSDT ETHUSDT SOLUSDT +coin o -s BTCUSDT ETHUSDT # Audit log coinhunter catlog coinhunter catlog -n 20 coinhunter catlog -n 10 -o 10 +# Configuration management (aliases: cfg, c) +coinhunter config get # show all config +coinhunter config get binance.recv_window +coinhunter config set opportunity.top_n 20 +coinhunter config set trading.dry_run_default true +coinhunter config set market.universe_allowlist BTCUSDT,ETHUSDT +coinhunter config key YOUR_API_KEY # or omit value to prompt interactively +coinhunter config secret YOUR_SECRET # or omit value to prompt interactively +coin c get opportunity.top_n +coin c set trading.dry_run_default false + # Self-upgrade coinhunter upgrade coin upgrade diff --git a/pyproject.toml b/pyproject.toml index ef99b36..ae79550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "binance-connector>=3.9.0", "shtab>=1.7.0", "tomli>=2.0.1; python_version < '3.11'", + "tomli-w>=1.0.0", ] [project.optional-dependencies] diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index 53a2ca7..5e33b42 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -9,9 +9,16 @@ from typing import Any from . import __version__ from .audit import read_audit_log from .binance.spot_client import SpotBinanceClient -from .config import ensure_init_files, get_binance_credentials, load_config -from .runtime import ( +from .config import ( + ensure_init_files, + get_binance_credentials, + get_config_value, get_runtime_paths, + load_config, + set_config_value, + set_env_value, +) +from .runtime import ( install_shell_completion, print_output, self_upgrade, @@ -32,151 +39,598 @@ examples: 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 opportunity -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} -} +COMMAND_DOCS: dict[str, dict[str, str]] = { + "init": { + "tui": """\ +TUI Output: + INITIALIZED + Root: ~/.coinhunter + Config: ~/.coinhunter/config.toml + Env: ~/.coinhunter/.env + Logs: ~/.coinhunter/logs + Files created: config.toml, .env + + ✓ Shell completions installed for zsh + Path: ~/.zsh/completions/_coinhunter + Hint: Run 'compinit' or restart your terminal to activate completions + +JSON Output: + { + "root": "~/.coinhunter", + "files_created": ["config.toml", ".env"], + "completion": { + "shell": "zsh", + "installed": true, + "path": "~/.zsh/completions/_coinhunter", + "hint": "Run 'compinit' or restart your terminal to activate completions" + } + } Fields: root – runtime directory path - files_created – list of generated files - completion – shell completion installation status + files_created – list of generated files (e.g. ["config.toml", ".env"]) + completion – shell completion installation status (installed, path, hint) """, - "account": """\ -Output: JSON -{ - "balances": [ - {"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false} - ] -} -Fields: - 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 -{ - "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": {...} + "json": """\ +JSON Output: + { + "root": "~/.coinhunter", + "files_created": ["config.toml", ".env"], + "completion": { + "shell": "zsh", + "installed": true, + "path": "~/.zsh/completions/_coinhunter", + "hint": "Run 'compinit' or restart your terminal to activate completions" + } } -} 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 + root – runtime directory path + files_created – list of generated files (e.g. ["config.toml", ".env"]) + completion – shell completion installation status (installed, path, hint) """, - "sell": """\ -Output: JSON -{ - "trade": { - "market_type": "spot", - "symbol": "BTCUSDT", - "side": "SELL", - "order_type": "LIMIT", - "status": "FILLED", - "dry_run": false, - "request_payload": {...}, - "response_payload": {...} + }, + "account": { + "tui": """\ +TUI Output: + ┌─────────┬──────┬────────┬───────┬───────────────────┬──────┐ + │ Asset │ Free │ Locked │ Total │ Notional (USDT) │ │ + ├─────────┼──────┼────────┼───────┼───────────────────┼──────┤ + │ BTC │ 0.5 │ 0.1 │ 0.6 │ 30,000 │ │ + │ ETH │ 2.0 │ 0.0 │ 2.0 │ 5,000 │ │ + │ USDT │ 10 │ 0 │ 10 │ 10 │ dust │ + └─────────┴──────┴────────┴───────┴───────────────────┴──────┘ + +JSON Output: + { + "balances": [ + {"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false} + ] } -} 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 + asset – asset symbol (e.g. "BTC", "ETH", "USDT") + free – available balance (float) + locked – frozen/locked balance (float) + total – free + locked (float) + notional_usdt – estimated value in USDT (float) + is_dust – true if value is below dust threshold (bool) """, - "opportunity/portfolio": """\ -Output: JSON -{ - "scores": [ - {"asset": "BTC", "score": 0.75, "metrics": {"volatility": 0.02, "trend": 0.01}} - ] -} + "json": """\ +JSON Output: + { + "balances": [ + {"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false} + ] + } Fields: - asset – scored asset - score – composite opportunity score (0-1) - metrics – breakdown of contributing signals + asset – asset symbol (e.g. "BTC", "ETH", "USDT") + free – available balance (float) + locked – frozen/locked balance (float) + total – free + locked (float) + notional_usdt – estimated value in USDT (float) + is_dust – true if value is below dust threshold (bool) """, - "opportunity/scan": """\ -Output: JSON -{ - "opportunities": [ - {"symbol": "ETHUSDT", "score": 0.82, "signals": ["momentum", "volume_spike"]} - ] -} + }, + "market/tickers": { + "tui": """\ +TUI Output: + ┌─────────┬────────────┬──────────┬──────────────┐ + │ Symbol │ Last Price │ Change % │ Quote Volume │ + ├─────────┼────────────┼──────────┼──────────────┤ + │ BTCUSDT │ 70,000 │ +2.50% │ 123,456 │ + │ ETHUSDT │ 3,500 │ -1.20% │ 89,012 │ + └─────────┴────────────┴──────────┴──────────────┘ + +JSON Output: + { + "tickers": [ + {"symbol": "BTCUSDT", "last_price": 70000.0, "price_change_pct": 2.5, "quote_volume": 123456.0} + ] + } Fields: - symbol – trading pair scanned - score – opportunity score (0-1) - signals – list of triggered signal names + symbol – normalized trading pair (e.g. "BTCUSDT") + last_price – latest traded price (float) + price_change_pct – 24h change % (float, e.g. 2.5 = +2.5%) + quote_volume – 24h quote volume (float) """, - "upgrade": """\ -Output: JSON -{ - "command": "pip install --upgrade coinhunter", - "returncode": 0, - "stdout": "...", - "stderr": "" -} + "json": """\ +JSON Output: + { + "tickers": [ + {"symbol": "BTCUSDT", "last_price": 70000.0, "price_change_pct": 2.5, "quote_volume": 123456.0} + ] + } Fields: - command – shell command executed - returncode – process exit code (0 = success) + symbol – normalized trading pair (e.g. "BTCUSDT") + last_price – latest traded price (float) + price_change_pct – 24h change % (float, e.g. 2.5 = +2.5%) + quote_volume – 24h quote volume (float) +""", + }, + "market/klines": { + "tui": """\ +TUI Output: + KLINES interval=1h limit=100 count=100 + ┌─────────┬────────────┬────────┬────────┬────────┬────────┬───────┐ + │ Symbol │ Time │ Open │ High │ Low │ Close │ Vol │ + ├─────────┼────────────┼────────┼────────┼────────┼────────┼───────┤ + │ BTCUSDT │ 1713000000 │ 69,000 │ 69,500 │ 68,800 │ 69,200 │ 100.5 │ + └─────────┴────────────┴────────┴────────┴────────┴────────┴───────┘ + ... and 99 more rows + +JSON Output: + { + "interval": "1h", + "limit": 100, + "klines": [ + {"symbol": "BTCUSDT", "interval": "1h", "open_time": 1713000000000, "open": 69000.0, "high": 69500.0, + "low": 68800.0, "close": 69200.0, "volume": 100.5, "close_time": 1713003600000, "quote_volume": 6950000.0} + ] + } + +Fields: + interval – candle interval (enum: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w) + limit – number of candles requested (int) + symbol – normalized trading pair + open_time – candle open timestamp in ms (int) + close_time – candle close timestamp in ms (int) + open/high/low/close – OHLC prices (float) + volume – traded base volume (float) + quote_volume – traded quote volume (float) +""", + "json": """\ +JSON Output: + { + "interval": "1h", + "limit": 100, + "klines": [ + {"symbol": "BTCUSDT", "interval": "1h", "open_time": 1713000000000, "open": 69000.0, "high": 69500.0, + "low": 68800.0, "close": 69200.0, "volume": 100.5, "close_time": 1713003600000, "quote_volume": 6950000.0} + ] + } + +Fields: + interval – candle interval (enum: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w) + limit – number of candles requested (int) + symbol – normalized trading pair + open_time – candle open timestamp in ms (int) + close_time – candle close timestamp in ms (int) + open/high/low/close – OHLC prices (float) + volume – traded base volume (float) + quote_volume – traded quote volume (float) +""", + }, + "buy": { + "tui": """\ +TUI Output: + TRADE RESULT + Market: SPOT + Symbol: BTCUSDT + Side: BUY + Type: MARKET + Status: DRY_RUN + Dry Run: true + +JSON Output: + { + "trade": { + "market_type": "spot", + "symbol": "BTCUSDT", + "side": "BUY", + "order_type": "MARKET", + "status": "DRY_RUN", + "dry_run": true, + "request_payload": {"symbol": "BTCUSDT", "side": "BUY", "type": "MARKET", "quoteOrderQty": 100}, + "response_payload": {"dry_run": true, "status": "DRY_RUN", "request": {...}} + } + } +Fields: + market_type – always "spot" + side – always "BUY" + order_type – enum: "MARKET" | "LIMIT" + status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED" + dry_run – true if simulated (bool) + request_payload – normalized order sent to Binance + response_payload – raw exchange response (or dry_run stub) +""", + "json": """\ +JSON Output: + { + "trade": { + "market_type": "spot", + "symbol": "BTCUSDT", + "side": "BUY", + "order_type": "MARKET", + "status": "DRY_RUN", + "dry_run": true, + "request_payload": {"symbol": "BTCUSDT", "side": "BUY", "type": "MARKET", "quoteOrderQty": 100}, + "response_payload": {"dry_run": true, "status": "DRY_RUN", "request": {...}} + } + } +Fields: + market_type – always "spot" + side – always "BUY" + order_type – enum: "MARKET" | "LIMIT" + status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED" + dry_run – true if simulated (bool) + request_payload – normalized order sent to Binance + response_payload – raw exchange response (or dry_run stub) +""", + }, + "sell": { + "tui": """\ +TUI Output: + TRADE RESULT + Market: SPOT + Symbol: BTCUSDT + Side: SELL + Type: LIMIT + Status: FILLED + Dry Run: false + +JSON Output: + { + "trade": { + "market_type": "spot", + "symbol": "BTCUSDT", + "side": "SELL", + "order_type": "LIMIT", + "status": "FILLED", + "dry_run": false, + "request_payload": {"symbol": "BTCUSDT", "side": "SELL", "type": "LIMIT", "quantity": 0.01, "price": 90000, "timeInForce": "GTC"}, + "response_payload": {"symbol": "BTCUSDT", "orderId": 12345, "status": "FILLED", ...} + } + } +Fields: + market_type – always "spot" + side – always "SELL" + order_type – enum: "MARKET" | "LIMIT" + status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED" + dry_run – true if simulated (bool) + request_payload – normalized order sent to Binance + response_payload – raw exchange response (or dry_run stub) +""", + "json": """\ +JSON Output: + { + "trade": { + "market_type": "spot", + "symbol": "BTCUSDT", + "side": "SELL", + "order_type": "LIMIT", + "status": "FILLED", + "dry_run": false, + "request_payload": {"symbol": "BTCUSDT", "side": "SELL", "type": "LIMIT", "quantity": 0.01, "price": 90000, "timeInForce": "GTC"}, + "response_payload": {"symbol": "BTCUSDT", "orderId": 12345, "status": "FILLED", ...} + } + } +Fields: + market_type – always "spot" + side – always "SELL" + order_type – enum: "MARKET" | "LIMIT" + status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED" + dry_run – true if simulated (bool) + request_payload – normalized order sent to Binance + response_payload – raw exchange response (or dry_run stub) +""", + }, + "portfolio": { + "tui": """\ +TUI Output: + RECOMMENDATIONS count=3 + 1. BTCUSDT action=add score=0.7500 + · trend, momentum, and breakout are aligned + trend=1.0 momentum=0.02 breakout=0.85 volume_confirmation=1.2 volatility=0.01 concentration=0.3 + 2. ETHUSDT action=hold score=0.6000 + · trend remains constructive + trend=1.0 momentum=0.01 breakout=0.5 volume_confirmation=1.0 volatility=0.02 concentration=0.2 + 3. SOLUSDT action=trim score=-0.2000 + · position concentration is high + trend=-1.0 momentum=-0.01 breakout=0.3 volume_confirmation=0.8 volatility=0.03 concentration=0.5 + +JSON Output: + { + "recommendations": [ + {"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["trend, momentum, and breakout are aligned"], + "metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "concentration": 0.3}} + ] + } +Fields: + symbol – trading pair (e.g. "BTCUSDT") + action – enum: "add" | "hold" | "trim" | "exit" | "observe" + score – composite score (float, can be negative) + reasons – list of human-readable explanations + metrics – scoring breakdown + trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up) + momentum – price momentum signal (float) + breakout – proximity to recent high, 0-1 (float) + volume_confirmation – volume ratio vs average (float, >1 = above average) + volatility – price range relative to current (float) + concentration – position weight in portfolio (float, 0-1) +""", + "json": """\ +JSON Output: + { + "recommendations": [ + {"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["trend, momentum, and breakout are aligned"], + "metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "concentration": 0.3}} + ] + } +Fields: + symbol – trading pair (e.g. "BTCUSDT") + action – enum: "add" | "hold" | "trim" | "exit" | "observe" + score – composite score (float, can be negative) + reasons – list of human-readable explanations + metrics – scoring breakdown + trend – -1.0 (down) | 0.0 (neutral) | 1.0 (up) + momentum – price momentum signal (float) + breakout – proximity to recent high, 0-1 (float) + volume_confirmation – volume ratio vs average (float, >1 = above average) + volatility – price range relative to current (float) + concentration – position weight in portfolio (float, 0-1) +""", + }, + "opportunity": { + "tui": """\ +TUI Output: + RECOMMENDATIONS count=5 + 1. ETHUSDT action=add score=0.8200 + · trend, momentum, and breakout are aligned + · base asset ETH passed liquidity and tradability filters + trend=1.0 momentum=0.03 breakout=0.9 volume_confirmation=1.5 volatility=0.02 concentration=0.0 + 2. BTCUSDT action=hold score=0.6000 + · trend remains constructive + · base asset BTC passed liquidity and tradability filters + trend=1.0 momentum=0.01 breakout=0.6 volume_confirmation=1.1 volatility=0.01 concentration=0.3 + +JSON Output: + { + "recommendations": [ + {"symbol": "ETHUSDT", "action": "add", "score": 0.82, + "reasons": ["trend, momentum, and breakout are aligned", "base asset ETH passed liquidity and tradability filters"], + "metrics": {"trend": 1.0, "momentum": 0.03, "breakout": 0.9, "volume_confirmation": 1.5, "volatility": 0.02, "concentration": 0.0}} + ] + } +Fields: + symbol – trading pair (e.g. "ETHUSDT") + action – enum: "add" | "hold" | "trim" | "exit" | "observe" + score – composite score (float, can be negative) + reasons – list of human-readable explanations (includes liquidity filter note for scan) + metrics – scoring breakdown (same as portfolio) +""", + "json": """\ +JSON Output: + { + "recommendations": [ + {"symbol": "ETHUSDT", "action": "add", "score": 0.82, + "reasons": ["trend, momentum, and breakout are aligned", "base asset ETH passed liquidity and tradability filters"], + "metrics": {"trend": 1.0, "momentum": 0.03, "breakout": 0.9, "volume_confirmation": 1.5, "volatility": 0.02, "concentration": 0.0}} + ] + } +Fields: + symbol – trading pair (e.g. "ETHUSDT") + action – enum: "add" | "hold" | "trim" | "exit" | "observe" + score – composite score (float, can be negative) + reasons – list of human-readable explanations (includes liquidity filter note for scan) + metrics – scoring breakdown (same as portfolio) +""", + }, + "upgrade": { + "tui": """\ +TUI Output: + ✓ Update completed + upgraded coinhunter 2.1.1 -> 2.2.0 + + (or on failure) + ✗ Update failed (exit code 1) + error: could not find a version that satisfies the requirement + +JSON Output: + { + "command": "pipx upgrade coinhunter", + "returncode": 0, + "stdout": "upgraded coinhunter 2.1.1 -> 2.2.0", + "stderr": "" + } +Fields: + command – shell command executed (e.g. "pipx upgrade coinhunter" or "python -m pip install --upgrade coinhunter") + returncode – process exit code (0 = success, non-zero = failure) stdout – command standard output stderr – command standard error """, - "completion": """\ -Output: shell script text (not JSON) -# bash/zsh completion script for coinhunter -... + "json": """\ +JSON Output: + { + "command": "pipx upgrade coinhunter", + "returncode": 0, + "stdout": "upgraded coinhunter 2.1.1 -> 2.2.0", + "stderr": "" + } Fields: - (raw shell script suitable for sourcing) + command – shell command executed (e.g. "pipx upgrade coinhunter" or "python -m pip install --upgrade coinhunter") + returncode – process exit code (0 = success, non-zero = failure) + stdout – command standard output + stderr – command standard error """, + }, + "catlog": { + "tui": """\ +TUI Output: + AUDIT LOG + + 2026-04-20 12:00:00 trade_submitted + symbol=BTCUSDT side=BUY qty=0.01 order_type=MARKET status=submitted dry_run=true + + 2026-04-20 12:00:01 trade_filled + symbol=BTCUSDT side=BUY qty=0.01 order_type=MARKET status=FILLED dry_run=false + + 2026-04-20 12:05:00 opportunity_scan_generated + mode=scan symbols=["BTCUSDT", "ETHUSDT"] + +JSON Output: + { + "entries": [ + {"timestamp": "2026-04-20T12:00:00Z", "event": "trade_submitted", + "symbol": "BTCUSDT", "side": "BUY", "qty": 0.01, "order_type": "MARKET", "status": "submitted", "dry_run": true} + ], + "limit": 10, + "offset": 0, + "total": 1 + } +Fields: + entries – list of audit events (fields vary by event type) + timestamp – ISO 8601 timestamp + event – enum: "trade_submitted", "trade_filled", "trade_failed", + "opportunity_portfolio_generated", "opportunity_scan_generated" + limit – requested entry limit + offset – skip offset + total – number of entries returned +""", + "json": """\ +JSON Output: + { + "entries": [ + {"timestamp": "2026-04-20T12:00:00Z", "event": "trade_submitted", + "symbol": "BTCUSDT", "side": "BUY", "qty": 0.01, "order_type": "MARKET", "status": "submitted", "dry_run": true} + ], + "limit": 10, + "offset": 0, + "total": 1 + } +Fields: + entries – list of audit events (fields vary by event type) + timestamp – ISO 8601 timestamp + event – enum: "trade_submitted", "trade_filled", "trade_failed", + "opportunity_portfolio_generated", "opportunity_scan_generated" + limit – requested entry limit + offset – skip offset + total – number of entries returned +""", + }, + "config/get": { + "tui": """\ +TUI Output: + CONFIG + binance.recv_window = 5000 + opportunity.top_n = 10 + +JSON Output: + {"binance.recv_window": 5000, "opportunity.top_n": 10} +Fields: + key – dot-notation config key (e.g. "binance.recv_window") + value – current value (type depends on key: bool, int, float, list, str) +""", + "json": """\ +JSON Output: + {"binance.recv_window": 5000, "opportunity.top_n": 10} +Fields: + key – dot-notation config key (e.g. "binance.recv_window") + value – current value (type depends on key: bool, int, float, list, str) +""", + }, + "config/set": { + "tui": """\ +TUI Output: + CONFIG UPDATED + binance.recv_window = 10000 + +JSON Output: + {"updated": true, "key": "binance.recv_window", "value": 10000} +Fields: + updated – true if the config file was written successfully + key – dot-notation config key that was updated + value – new value after type coercion +""", + "json": """\ +JSON Output: + {"updated": true, "key": "binance.recv_window", "value": 10000} +Fields: + updated – true if the config file was written successfully + key – dot-notation config key that was updated + value – new value after type coercion +""", + }, + "config/key": { + "tui": """\ +TUI Output: + API KEY SET + BINANCE_API_KEY = ***...abc + +JSON Output: + {"key": "BINANCE_API_KEY", "set": true} +Fields: + key – always "BINANCE_API_KEY" + set – true if the value was written to ~/.coinhunter/.env +""", + "json": """\ +JSON Output: + {"key": "BINANCE_API_KEY", "set": true} +Fields: + key – always "BINANCE_API_KEY" + set – true if the value was written to ~/.coinhunter/.env +""", + }, + "config/secret": { + "tui": """\ +TUI Output: + API SECRET SET + BINANCE_API_SECRET = ***...xyz + +JSON Output: + {"key": "BINANCE_API_SECRET", "set": true} +Fields: + key – always "BINANCE_API_SECRET" + set – true if the value was written to ~/.coinhunter/.env +""", + "json": """\ +JSON Output: + {"key": "BINANCE_API_SECRET", "set": true} +Fields: + key – always "BINANCE_API_SECRET" + set – true if the value was written to ~/.coinhunter/.env +""", + }, + "completion": { + "tui": """\ +TUI Output: + (raw shell script, not JSON) + #compdef coin + # AUTOMATICALLY GENERATED by `shtab` + ... + +JSON Output: + (same as TUI – shell script text, not JSON) +""", + "json": """\ +JSON Output: + (shell script text, not JSON – suitable for sourcing in your shell rc file) + #compdef coin + # AUTOMATICALLY GENERATED by `shtab` + ... +""", + }, } @@ -195,7 +649,7 @@ def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> S def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="coinhunter", + prog="coin", description="Binance-first trading CLI for account balances, market data, trade execution, and opportunity scanning.", epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, @@ -211,11 +665,49 @@ def build_parser() -> argparse.ArgumentParser: 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.", + description="Initialize the CoinHunter runtime directory with config.toml, .env, and shell completions. " + "Interactively prompts for Binance API key and secret if missing.", ) init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing files") + init_parser.add_argument("--no-prompt", action="store_true", help="Skip interactive API key/secret prompt") _add_global_flags(init_parser) + config_parser = subparsers.add_parser( + "config", aliases=["cfg", "c"], help="Manage configuration", + description="Read and write config.toml and .env settings without manual editing.", + ) + config_subparsers = config_parser.add_subparsers(dest="config_command") + + config_get_parser = config_subparsers.add_parser( + "get", help="Read a config value", + description="Read a value from config.toml using dot notation (e.g. binance.recv_window).", + ) + config_get_parser.add_argument("key", nargs="?", help="Config key in dot notation (e.g. trading.dry_run_default)") + _add_global_flags(config_get_parser) + + config_set_parser = config_subparsers.add_parser( + "set", help="Write a config value", + description="Write a value to config.toml using dot notation. " + "Booleans accept: true/1/yes/on or false/0/no/off. Lists use comma-separated values.", + ) + config_set_parser.add_argument("key", help="Config key in dot notation (e.g. opportunity.top_n)") + config_set_parser.add_argument("value", help="Value to set") + _add_global_flags(config_set_parser) + + config_key_parser = config_subparsers.add_parser( + "key", help="Set Binance API key", + description="Set BINANCE_API_KEY in the .env file. Prompts interactively if no value provided.", + ) + config_key_parser.add_argument("value", nargs="?", help="API key value (omit to prompt interactively)") + _add_global_flags(config_key_parser) + + config_secret_parser = config_subparsers.add_parser( + "secret", help="Set Binance API secret", + description="Set BINANCE_API_SECRET in the .env file. Prompts interactively if no value provided.", + ) + config_secret_parser.add_argument("value", nargs="?", help="API secret value (omit to prompt interactively)") + _add_global_flags(config_secret_parser) + 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.", @@ -265,22 +757,18 @@ def build_parser() -> argparse.ArgumentParser: 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", - description="Analyze your portfolio and scan the market for trading opportunities.", - ) - opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command") - portfolio_parser = opportunity_subparsers.add_parser( + portfolio_parser = 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", + + opportunity_parser = subparsers.add_parser( + "opportunity", aliases=["o"], 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) + opportunity_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols") + _add_global_flags(opportunity_parser) upgrade_parser = subparsers.add_parser( "upgrade", help="Upgrade coinhunter to the latest version", @@ -314,18 +802,20 @@ _CANONICAL_COMMANDS = { "acc": "account", "a": "account", "m": "market", - "opp": "opportunity", + "pf": "portfolio", + "p": "portfolio", "o": "opportunity", + "cfg": "config", + "c": "config", } _CANONICAL_SUBCOMMANDS = { "tk": "tickers", "t": "tickers", "k": "klines", - "pf": "portfolio", } -_COMMANDS_WITH_SUBCOMMANDS = {"market", "opportunity"} +_COMMANDS_WITH_SUBCOMMANDS = {"market", "config"} def _get_doc_key(argv: list[str]) -> str | None: @@ -372,7 +862,12 @@ def main(argv: list[str] | None = None) -> int: 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}.") + entry = COMMAND_DOCS.get(doc_key) + if entry is None: + print(f"No documentation available for {doc_key}.") + return 0 + is_agent = "--agent" in raw_argv or "-a" in raw_argv + doc = entry.get("json" if is_agent else "tui", entry.get("tui", "")) print(doc) return 0 @@ -383,7 +878,7 @@ def main(argv: list[str] | None = None) -> int: # 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"): + for attr in ("account_command", "market_command", "config_command"): val = getattr(args, attr, None) if val: setattr(args, attr, _CANONICAL_SUBCOMMANDS.get(val, val)) @@ -394,11 +889,81 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "init": - init_result = ensure_init_files(get_runtime_paths(), force=args.force) + paths = get_runtime_paths() + init_result = ensure_init_files(paths, force=args.force) init_result["completion"] = install_shell_completion(parser) + + # Interactive prompt for API key/secret if missing and tty available + if not args.no_prompt and sys.stdin.isatty(): + from .config import load_env_file + + env_data = load_env_file(paths) + api_key = env_data.get("BINANCE_API_KEY", "").strip() + api_secret = env_data.get("BINANCE_API_SECRET", "").strip() + + if not api_key: + key_input = input("Binance API Key: ").strip() + if key_input: + set_env_value(paths, "BINANCE_API_KEY", key_input) + init_result["api_key_set"] = True + if not api_secret: + import getpass + + secret_input = getpass.getpass("Binance API Secret: ").strip() + if secret_input: + set_env_value(paths, "BINANCE_API_SECRET", secret_input) + init_result["api_secret_set"] = True + print_output(init_result, agent=args.agent) return 0 + if args.command == "config": + paths = get_runtime_paths() + if args.config_command == "get": + if not args.key: + # List all config keys + config = load_config() + print_output(config, agent=args.agent) + return 0 + config = load_config() + value = get_config_value(config, args.key) + if value is None: + print(f"error: key '{args.key}' not found in config", file=sys.stderr) + return 1 + print_output({args.key: value}, agent=args.agent) + return 0 + + if args.config_command == "set": + set_config_value(paths.config_file, args.key, args.value) + print_output({"key": args.key, "value": args.value, "status": "updated"}, agent=args.agent) + return 0 + + if args.config_command == "key": + value = args.value + if not value and sys.stdin.isatty(): + value = input("Binance API Key: ").strip() + if not value: + print("error: API key is required", file=sys.stderr) + return 1 + set_env_value(paths, "BINANCE_API_KEY", value) + print_output({"key": "BINANCE_API_KEY", "status": "updated"}, agent=args.agent) + return 0 + + if args.config_command == "secret": + value = args.value + if not value and sys.stdin.isatty(): + import getpass + + value = getpass.getpass("Binance API Secret: ").strip() + if not value: + print("error: API secret is required", file=sys.stderr) + return 1 + set_env_value(paths, "BINANCE_API_SECRET", value) + print_output({"key": "BINANCE_API_SECRET", "status": "updated"}, agent=args.agent) + return 0 + + parser.error("config requires one of: get, set, key, secret") + if args.command == "completion": import shtab @@ -468,21 +1033,21 @@ def main(argv: list[str] | None = None) -> int: print_output(result, agent=args.agent) return 0 + if args.command == "portfolio": + spot_client = _load_spot_client(config) + with with_spinner("Analyzing portfolio...", enabled=not args.agent): + result = opportunity_service.analyze_portfolio(config, spot_client=spot_client) + print_output(result, agent=args.agent) + return 0 + if args.command == "opportunity": spot_client = _load_spot_client(config) - if args.opportunity_command == "portfolio": - with with_spinner("Analyzing portfolio...", enabled=not args.agent): - result = opportunity_service.analyze_portfolio(config, spot_client=spot_client) - print_output(result, agent=args.agent) - return 0 - if args.opportunity_command == "scan": - with with_spinner("Scanning opportunities...", enabled=not args.agent): - result = opportunity_service.scan_opportunities( - config, spot_client=spot_client, symbols=args.symbols - ) - print_output(result, agent=args.agent) - return 0 - parser.error("opportunity requires `portfolio` or `scan`") + with with_spinner("Scanning opportunities...", enabled=not args.agent): + result = opportunity_service.scan_opportunities( + config, spot_client=spot_client, symbols=args.symbols + ) + print_output(result, agent=args.agent) + return 0 if args.command == "upgrade": with with_spinner("Upgrading coinhunter...", enabled=not args.agent): diff --git a/src/coinhunter/config.py b/src/coinhunter/config.py index cc02560..69cfb8b 100644 --- a/src/coinhunter/config.py +++ b/src/coinhunter/config.py @@ -13,6 +13,11 @@ try: except ModuleNotFoundError: # pragma: no cover import tomli as tomllib +try: + import tomli_w +except ModuleNotFoundError: # pragma: no cover + tomli_w = None # type: ignore[assignment] + DEFAULT_CONFIG = """[runtime] timezone = "Asia/Shanghai" @@ -128,3 +133,72 @@ def resolve_log_dir(config: dict[str, Any], paths: RuntimePaths | None = None) - raw = config.get("runtime", {}).get("log_dir", "logs") value = Path(raw).expanduser() return value if value.is_absolute() else paths.root / value + + +def get_config_value(config: dict[str, Any], key_path: str) -> Any: + keys = key_path.split(".") + node = config + for key in keys: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + return node + + +def set_config_value(config_file: Path, key_path: str, value: Any) -> None: + if tomli_w is None: + raise RuntimeError("tomli-w is not installed. Run `pip install tomli-w`.") + if not config_file.exists(): + raise RuntimeError(f"Config file not found: {config_file}") + config = tomllib.loads(config_file.read_text(encoding="utf-8")) + keys = key_path.split(".") + node = config + for key in keys[:-1]: + if key not in node: + node[key] = {} + node = node[key] + + # Coerce type from existing value when possible + existing = node.get(keys[-1]) + if isinstance(existing, bool) and isinstance(value, str): + value = value.lower() in ("true", "1", "yes", "on") + elif isinstance(existing, (int, float)) and isinstance(value, str): + try: + value = type(existing)(value) + except (ValueError, TypeError) as exc: + raise RuntimeError( + f"Cannot set {key_path} to {value!r}: expected {type(existing).__name__}, got {value!r}" + ) from exc + elif isinstance(existing, list) and isinstance(value, str): + value = [item.strip() for item in value.split(",") if item.strip()] + + node[keys[-1]] = value + config_file.write_text(tomli_w.dumps(config), encoding="utf-8") + + +def get_env_value(paths: RuntimePaths | None = None, key: str = "") -> str: + paths = paths or get_runtime_paths() + if not paths.env_file.exists(): + return "" + env_data = load_env_file(paths) + return env_data.get(key, "") + + +def set_env_value(paths: RuntimePaths | None = None, key: str = "", value: str = "") -> None: + paths = paths or get_runtime_paths() + if not paths.env_file.exists(): + raise RuntimeError(f"Env file not found: {paths.env_file}. Run `coin init` first.") + + lines = paths.env_file.read_text(encoding="utf-8").splitlines() + found = False + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith(f"{key}=") or stripped.startswith(f"{key} ="): + lines[i] = f"{key}={value}" + found = True + break + if not found: + lines.append(f"{key}={value}") + + paths.env_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + os.environ[key] = value diff --git a/src/coinhunter/runtime.py b/src/coinhunter/runtime.py index 4cb6792..d740363 100644 --- a/src/coinhunter/runtime.py +++ b/src/coinhunter/runtime.py @@ -426,10 +426,7 @@ def _render_tui(payload: Any) -> None: def print_output(payload: Any, *, agent: bool = False) -> None: if agent: - if _is_large_dataset(payload): - _print_compact(payload) - else: - print_json(payload) + print_json(payload) else: _render_tui(payload) @@ -509,6 +506,13 @@ def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]: return {"shell": None, "installed": False, "reason": "unable to detect shell from $SHELL"} script = shtab.complete(parser, shell=shell, preamble="") + # Also register completion for the "coinhunter" alias + prog = parser.prog.replace("-", "_") + func = f"_shtab_{prog}" + if shell == "bash": + script += f"\ncomplete -o filenames -F {func} coinhunter\n" + elif shell == "zsh": + script += f"\ncompdef {func} coinhunter\n" installed_path: Path | None = None hint: str | None = None diff --git a/tests/test_cli.py b/tests/test_cli.py index c6e6226..cadb947 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,6 +17,7 @@ class CLITestCase(unittest.TestCase): self.assertIn("account", help_text) self.assertIn("buy", help_text) self.assertIn("sell", help_text) + self.assertIn("portfolio", help_text) self.assertIn("opportunity", help_text) self.assertIn("--doc", help_text) @@ -79,16 +80,24 @@ class CLITestCase(unittest.TestCase): 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 - + def test_doc_flag_prints_tui_documentation(self): 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("TUI Output", output) + self.assertIn("Last Price", output) + self.assertIn("BTCUSDT", output) + + def test_doc_flag_prints_json_documentation(self): + stdout = io.StringIO() + with patch("sys.stdout", stdout): + result = cli.main(["market", "tickers", "--doc", "--agent"]) + self.assertEqual(result, 0) + output = stdout.getvalue() + self.assertIn("JSON Output", output) + self.assertIn("last_price", output) self.assertIn("BTCUSDT", output) def test_account_dispatches(self): @@ -122,6 +131,46 @@ class CLITestCase(unittest.TestCase): self.assertEqual(result, 0) self.assertEqual(captured["payload"]["returncode"], 0) + def test_portfolio_dispatches(self): + captured = {} + with ( + patch.object( + cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}} + ), + patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), + patch.object(cli, "SpotBinanceClient"), + patch.object( + cli.opportunity_service, "analyze_portfolio", return_value={"scores": [{"asset": "BTC", "score": 0.75}]} + ), + patch.object( + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) + ), + ): + result = cli.main(["portfolio"]) + self.assertEqual(result, 0) + self.assertEqual(captured["payload"]["scores"][0]["asset"], "BTC") + + def test_opportunity_dispatches(self): + captured = {} + with ( + patch.object( + cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}} + ), + patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}), + patch.object(cli, "SpotBinanceClient"), + patch.object( + cli.opportunity_service, + "scan_opportunities", + return_value={"opportunities": [{"symbol": "BTCUSDT", "score": 0.82}]}, + ), + patch.object( + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) + ), + ): + result = cli.main(["opportunity", "-s", "BTCUSDT", "ETHUSDT"]) + self.assertEqual(result, 0) + self.assertEqual(captured["payload"]["opportunities"][0]["symbol"], "BTCUSDT") + def test_catlog_dispatches(self): captured = {} with ( @@ -138,3 +187,64 @@ class CLITestCase(unittest.TestCase): self.assertEqual(captured["payload"]["offset"], 10) self.assertIn("entries", captured["payload"]) self.assertEqual(captured["payload"]["total"], 1) + + def test_config_get_dispatches(self): + captured = {} + with ( + patch.object(cli, "load_config", return_value={"binance": {"recv_window": 5000}}), + patch.object( + cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload) + ), + ): + result = cli.main(["config", "get", "binance.recv_window"]) + self.assertEqual(result, 0) + self.assertEqual(captured["payload"]["binance.recv_window"], 5000) + + def test_config_set_dispatches(self): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write('[binance]\nrecv_window = 5000\n') + tmp_path = f.name + + with patch.object(cli, "get_runtime_paths") as mock_paths: + mock_paths.return_value.config_file = __import__("pathlib").Path(tmp_path) + result = cli.main(["config", "set", "binance.recv_window", "10000"]) + self.assertEqual(result, 0) + + # Verify the file was updated + content = __import__("pathlib").Path(tmp_path).read_text() + self.assertIn("recv_window = 10000", content) + __import__("os").unlink(tmp_path) + + def test_config_key_dispatches(self): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("BINANCE_API_KEY=\n") + tmp_path = f.name + + with patch.object(cli, "get_runtime_paths") as mock_paths: + mock_paths.return_value.env_file = __import__("pathlib").Path(tmp_path) + result = cli.main(["config", "key", "test_key_value"]) + self.assertEqual(result, 0) + + content = __import__("pathlib").Path(tmp_path).read_text() + self.assertIn("BINANCE_API_KEY=test_key_value", content) + __import__("os").unlink(tmp_path) + + def test_config_secret_dispatches(self): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("BINANCE_API_SECRET=\n") + tmp_path = f.name + + with patch.object(cli, "get_runtime_paths") as mock_paths: + mock_paths.return_value.env_file = __import__("pathlib").Path(tmp_path) + result = cli.main(["config", "secret", "test_secret_value"]) + self.assertEqual(result, 0) + + content = __import__("pathlib").Path(tmp_path).read_text() + self.assertIn("BINANCE_API_SECRET=test_secret_value", content) + __import__("os").unlink(tmp_path)