feat: flatten opportunity commands, add config management, fix completions
- Flatten opportunity into top-level portfolio and opportunity commands - Add interactive config get/set/key/secret with type coercion - Rewrite --doc to show TUI vs JSON schema per command - Unify agent mode output to JSON only - Make init prompt for API key/secret interactively - Fix coin tab completion alias binding - Fix set_config_value reading from wrong path - Fail loudly on invalid numeric config values Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
28
README.md
28
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.
|
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:
|
`config.toml` stores runtime and strategy settings. `.env` stores:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -101,18 +103,32 @@ coinhunter sell BTCUSDT --qty 0.01 --type limit --price 90000
|
|||||||
coin b BTCUSDT -Q 100 -d
|
coin b BTCUSDT -Q 100 -d
|
||||||
coin s BTCUSDT -q 0.01 -t limit -p 90000
|
coin s BTCUSDT -q 0.01 -t limit -p 90000
|
||||||
|
|
||||||
# Opportunities (aliases: opp, o)
|
# Portfolio (aliases: pf, p)
|
||||||
coinhunter opportunity portfolio
|
coinhunter portfolio
|
||||||
coinhunter opportunity scan
|
coinhunter portfolio --agent
|
||||||
coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT
|
coin pf
|
||||||
coin opp pf
|
|
||||||
coin o scan -s BTCUSDT ETHUSDT
|
# Opportunity scanning (aliases: o)
|
||||||
|
coinhunter opportunity
|
||||||
|
coinhunter opportunity --symbols BTCUSDT ETHUSDT SOLUSDT
|
||||||
|
coin o -s BTCUSDT ETHUSDT
|
||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
coinhunter catlog
|
coinhunter catlog
|
||||||
coinhunter catlog -n 20
|
coinhunter catlog -n 20
|
||||||
coinhunter catlog -n 10 -o 10
|
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
|
# Self-upgrade
|
||||||
coinhunter upgrade
|
coinhunter upgrade
|
||||||
coin upgrade
|
coin upgrade
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"binance-connector>=3.9.0",
|
"binance-connector>=3.9.0",
|
||||||
"shtab>=1.7.0",
|
"shtab>=1.7.0",
|
||||||
"tomli>=2.0.1; python_version < '3.11'",
|
"tomli>=2.0.1; python_version < '3.11'",
|
||||||
|
"tomli-w>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ from typing import Any
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .audit import read_audit_log
|
from .audit import read_audit_log
|
||||||
from .binance.spot_client import SpotBinanceClient
|
from .binance.spot_client import SpotBinanceClient
|
||||||
from .config import ensure_init_files, get_binance_credentials, load_config
|
from .config import (
|
||||||
from .runtime import (
|
ensure_init_files,
|
||||||
|
get_binance_credentials,
|
||||||
|
get_config_value,
|
||||||
get_runtime_paths,
|
get_runtime_paths,
|
||||||
|
load_config,
|
||||||
|
set_config_value,
|
||||||
|
set_env_value,
|
||||||
|
)
|
||||||
|
from .runtime import (
|
||||||
install_shell_completion,
|
install_shell_completion,
|
||||||
print_output,
|
print_output,
|
||||||
self_upgrade,
|
self_upgrade,
|
||||||
@@ -32,63 +39,202 @@ examples:
|
|||||||
coin m k BTCUSDT -i 1h -l 50
|
coin m k BTCUSDT -i 1h -l 50
|
||||||
coin buy BTCUSDT -Q 100 -d
|
coin buy BTCUSDT -Q 100 -d
|
||||||
coin sell BTCUSDT --qty 0.01 --type limit --price 90000
|
coin sell BTCUSDT --qty 0.01 --type limit --price 90000
|
||||||
coin opp scan -s BTCUSDT ETHUSDT
|
coin opportunity -s BTCUSDT ETHUSDT
|
||||||
coin upgrade
|
coin upgrade
|
||||||
"""
|
"""
|
||||||
|
|
||||||
COMMAND_DOCS: dict[str, str] = {
|
COMMAND_DOCS: dict[str, dict[str, str]] = {
|
||||||
"init": """\
|
"init": {
|
||||||
Output: JSON
|
"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",
|
"root": "~/.coinhunter",
|
||||||
"files_created": ["config.toml", ".env"],
|
"files_created": ["config.toml", ".env"],
|
||||||
"completion": {"shell": "zsh", "installed": true}
|
"completion": {
|
||||||
}
|
"shell": "zsh",
|
||||||
|
"installed": true,
|
||||||
|
"path": "~/.zsh/completions/_coinhunter",
|
||||||
|
"hint": "Run 'compinit' or restart your terminal to activate completions"
|
||||||
|
}
|
||||||
|
}
|
||||||
Fields:
|
Fields:
|
||||||
root – runtime directory path
|
root – runtime directory path
|
||||||
files_created – list of generated files
|
files_created – list of generated files (e.g. ["config.toml", ".env"])
|
||||||
completion – shell completion installation status
|
completion – shell completion installation status (installed, path, hint)
|
||||||
""",
|
""",
|
||||||
"account": """\
|
"json": """\
|
||||||
Output: 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:
|
||||||
|
root – runtime directory path
|
||||||
|
files_created – list of generated files (e.g. ["config.toml", ".env"])
|
||||||
|
completion – shell completion installation status (installed, path, hint)
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
"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": [
|
"balances": [
|
||||||
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false}
|
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Fields:
|
Fields:
|
||||||
asset – asset symbol
|
asset – asset symbol (e.g. "BTC", "ETH", "USDT")
|
||||||
free – available balance
|
free – available balance (float)
|
||||||
locked – frozen/locked balance
|
locked – frozen/locked balance (float)
|
||||||
total – free + locked
|
total – free + locked (float)
|
||||||
notional_usdt – estimated value in USDT
|
notional_usdt – estimated value in USDT (float)
|
||||||
is_dust – true if value is below dust threshold
|
is_dust – true if value is below dust threshold (bool)
|
||||||
""",
|
""",
|
||||||
"market/tickers": """\
|
"json": """\
|
||||||
Output: JSON object keyed by normalized symbol
|
JSON Output:
|
||||||
{
|
{
|
||||||
"BTCUSDT": {"lastPrice": "70000.00", "priceChangePercent": "2.5", "volume": "12345.67"}
|
"balances": [
|
||||||
}
|
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false}
|
||||||
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:
|
Fields:
|
||||||
open_time – candle open timestamp (ms)
|
asset – asset symbol (e.g. "BTC", "ETH", "USDT")
|
||||||
open/high/low/close – OHLC prices
|
free – available balance (float)
|
||||||
volume – traded base volume
|
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)
|
||||||
""",
|
""",
|
||||||
"buy": """\
|
},
|
||||||
Output: JSON
|
"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 – 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)
|
||||||
|
""",
|
||||||
|
"json": """\
|
||||||
|
JSON Output:
|
||||||
|
{
|
||||||
|
"tickers": [
|
||||||
|
{"symbol": "BTCUSDT", "last_price": 70000.0, "price_change_pct": 2.5, "quote_volume": 123456.0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Fields:
|
||||||
|
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": {
|
"trade": {
|
||||||
"market_type": "spot",
|
"market_type": "spot",
|
||||||
"symbol": "BTCUSDT",
|
"symbol": "BTCUSDT",
|
||||||
@@ -96,22 +242,56 @@ Output: JSON
|
|||||||
"order_type": "MARKET",
|
"order_type": "MARKET",
|
||||||
"status": "DRY_RUN",
|
"status": "DRY_RUN",
|
||||||
"dry_run": true,
|
"dry_run": true,
|
||||||
"request_payload": {...},
|
"request_payload": {"symbol": "BTCUSDT", "side": "BUY", "type": "MARKET", "quoteOrderQty": 100},
|
||||||
"response_payload": {...}
|
"response_payload": {"dry_run": true, "status": "DRY_RUN", "request": {...}}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Fields:
|
Fields:
|
||||||
market_type – "spot"
|
market_type – always "spot"
|
||||||
side – "BUY"
|
side – always "BUY"
|
||||||
order_type – MARKET or LIMIT
|
order_type – enum: "MARKET" | "LIMIT"
|
||||||
status – order status from exchange (or DRY_RUN)
|
status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED"
|
||||||
dry_run – whether simulated
|
dry_run – true if simulated (bool)
|
||||||
request_payload – normalized order sent to Binance
|
request_payload – normalized order sent to Binance
|
||||||
response_payload – raw exchange response
|
response_payload – raw exchange response (or dry_run stub)
|
||||||
""",
|
""",
|
||||||
"sell": """\
|
"json": """\
|
||||||
Output: 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": {
|
"trade": {
|
||||||
"market_type": "spot",
|
"market_type": "spot",
|
||||||
"symbol": "BTCUSDT",
|
"symbol": "BTCUSDT",
|
||||||
@@ -119,64 +299,338 @@ Output: JSON
|
|||||||
"order_type": "LIMIT",
|
"order_type": "LIMIT",
|
||||||
"status": "FILLED",
|
"status": "FILLED",
|
||||||
"dry_run": false,
|
"dry_run": false,
|
||||||
"request_payload": {...},
|
"request_payload": {"symbol": "BTCUSDT", "side": "SELL", "type": "LIMIT", "quantity": 0.01, "price": 90000, "timeInForce": "GTC"},
|
||||||
"response_payload": {...}
|
"response_payload": {"symbol": "BTCUSDT", "orderId": 12345, "status": "FILLED", ...}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Fields:
|
Fields:
|
||||||
market_type – "spot"
|
market_type – always "spot"
|
||||||
side – "SELL"
|
side – always "SELL"
|
||||||
order_type – MARKET or LIMIT
|
order_type – enum: "MARKET" | "LIMIT"
|
||||||
status – order status from exchange (or DRY_RUN)
|
status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED"
|
||||||
dry_run – whether simulated
|
dry_run – true if simulated (bool)
|
||||||
request_payload – normalized order sent to Binance
|
request_payload – normalized order sent to Binance
|
||||||
response_payload – raw exchange response
|
response_payload – raw exchange response (or dry_run stub)
|
||||||
""",
|
""",
|
||||||
"opportunity/portfolio": """\
|
"json": """\
|
||||||
Output: JSON
|
JSON Output:
|
||||||
{
|
{
|
||||||
"scores": [
|
"trade": {
|
||||||
{"asset": "BTC", "score": 0.75, "metrics": {"volatility": 0.02, "trend": 0.01}}
|
"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:
|
Fields:
|
||||||
asset – scored asset
|
market_type – always "spot"
|
||||||
score – composite opportunity score (0-1)
|
side – always "SELL"
|
||||||
metrics – breakdown of contributing signals
|
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)
|
||||||
""",
|
""",
|
||||||
"opportunity/scan": """\
|
},
|
||||||
Output: JSON
|
"portfolio": {
|
||||||
{
|
"tui": """\
|
||||||
"opportunities": [
|
TUI Output:
|
||||||
{"symbol": "ETHUSDT", "score": 0.82, "signals": ["momentum", "volume_spike"]}
|
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:
|
Fields:
|
||||||
symbol – trading pair scanned
|
symbol – trading pair (e.g. "BTCUSDT")
|
||||||
score – opportunity score (0-1)
|
action – enum: "add" | "hold" | "trim" | "exit" | "observe"
|
||||||
signals – list of triggered signal names
|
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)
|
||||||
""",
|
""",
|
||||||
"upgrade": """\
|
"json": """\
|
||||||
Output: JSON
|
JSON Output:
|
||||||
{
|
{
|
||||||
"command": "pip install --upgrade coinhunter",
|
"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,
|
"returncode": 0,
|
||||||
"stdout": "...",
|
"stdout": "upgraded coinhunter 2.1.1 -> 2.2.0",
|
||||||
"stderr": ""
|
"stderr": ""
|
||||||
}
|
}
|
||||||
Fields:
|
Fields:
|
||||||
command – shell command executed
|
command – shell command executed (e.g. "pipx upgrade coinhunter" or "python -m pip install --upgrade coinhunter")
|
||||||
returncode – process exit code (0 = success)
|
returncode – process exit code (0 = success, non-zero = failure)
|
||||||
stdout – command standard output
|
stdout – command standard output
|
||||||
stderr – command standard error
|
stderr – command standard error
|
||||||
""",
|
""",
|
||||||
"completion": """\
|
"json": """\
|
||||||
Output: shell script text (not JSON)
|
JSON Output:
|
||||||
# bash/zsh completion script for coinhunter
|
{
|
||||||
...
|
"command": "pipx upgrade coinhunter",
|
||||||
|
"returncode": 0,
|
||||||
|
"stdout": "upgraded coinhunter 2.1.1 -> 2.2.0",
|
||||||
|
"stderr": ""
|
||||||
|
}
|
||||||
Fields:
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
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.",
|
description="Binance-first trading CLI for account balances, market data, trade execution, and opportunity scanning.",
|
||||||
epilog=EPILOG,
|
epilog=EPILOG,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -211,11 +665,49 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
init_parser = subparsers.add_parser(
|
init_parser = subparsers.add_parser(
|
||||||
"init", help="Generate config.toml, .env, and log directory",
|
"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("-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)
|
_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_parser = subparsers.add_parser(
|
||||||
"account", aliases=["acc", "a"], help="List asset balances and notional values",
|
"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.",
|
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")
|
sell_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
|
||||||
_add_global_flags(sell_parser)
|
_add_global_flags(sell_parser)
|
||||||
|
|
||||||
opportunity_parser = subparsers.add_parser(
|
portfolio_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", aliases=["pf", "p"], help="Score current holdings",
|
"portfolio", aliases=["pf", "p"], help="Score current holdings",
|
||||||
description="Score your current spot holdings and generate add/hold/trim/exit recommendations.",
|
description="Score your current spot holdings and generate add/hold/trim/exit recommendations.",
|
||||||
)
|
)
|
||||||
_add_global_flags(portfolio_parser)
|
_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.",
|
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")
|
opportunity_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
|
||||||
_add_global_flags(scan_parser)
|
_add_global_flags(opportunity_parser)
|
||||||
|
|
||||||
upgrade_parser = subparsers.add_parser(
|
upgrade_parser = subparsers.add_parser(
|
||||||
"upgrade", help="Upgrade coinhunter to the latest version",
|
"upgrade", help="Upgrade coinhunter to the latest version",
|
||||||
@@ -314,18 +802,20 @@ _CANONICAL_COMMANDS = {
|
|||||||
"acc": "account",
|
"acc": "account",
|
||||||
"a": "account",
|
"a": "account",
|
||||||
"m": "market",
|
"m": "market",
|
||||||
"opp": "opportunity",
|
"pf": "portfolio",
|
||||||
|
"p": "portfolio",
|
||||||
"o": "opportunity",
|
"o": "opportunity",
|
||||||
|
"cfg": "config",
|
||||||
|
"c": "config",
|
||||||
}
|
}
|
||||||
|
|
||||||
_CANONICAL_SUBCOMMANDS = {
|
_CANONICAL_SUBCOMMANDS = {
|
||||||
"tk": "tickers",
|
"tk": "tickers",
|
||||||
"t": "tickers",
|
"t": "tickers",
|
||||||
"k": "klines",
|
"k": "klines",
|
||||||
"pf": "portfolio",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_COMMANDS_WITH_SUBCOMMANDS = {"market", "opportunity"}
|
_COMMANDS_WITH_SUBCOMMANDS = {"market", "config"}
|
||||||
|
|
||||||
|
|
||||||
def _get_doc_key(argv: list[str]) -> str | None:
|
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:
|
if doc_key is None:
|
||||||
print("Available docs: " + ", ".join(sorted(COMMAND_DOCS.keys())))
|
print("Available docs: " + ", ".join(sorted(COMMAND_DOCS.keys())))
|
||||||
return 0
|
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)
|
print(doc)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -383,7 +878,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
# Normalize aliases to canonical command names
|
# Normalize aliases to canonical command names
|
||||||
if args.command:
|
if args.command:
|
||||||
args.command = _CANONICAL_COMMANDS.get(args.command, 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)
|
val = getattr(args, attr, None)
|
||||||
if val:
|
if val:
|
||||||
setattr(args, attr, _CANONICAL_SUBCOMMANDS.get(val, val))
|
setattr(args, attr, _CANONICAL_SUBCOMMANDS.get(val, val))
|
||||||
@@ -394,11 +889,81 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "init":
|
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)
|
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)
|
print_output(init_result, agent=args.agent)
|
||||||
return 0
|
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":
|
if args.command == "completion":
|
||||||
import shtab
|
import shtab
|
||||||
|
|
||||||
@@ -468,21 +1033,21 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print_output(result, agent=args.agent)
|
print_output(result, agent=args.agent)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "opportunity":
|
if args.command == "portfolio":
|
||||||
spot_client = _load_spot_client(config)
|
spot_client = _load_spot_client(config)
|
||||||
if args.opportunity_command == "portfolio":
|
|
||||||
with with_spinner("Analyzing portfolio...", enabled=not args.agent):
|
with with_spinner("Analyzing portfolio...", enabled=not args.agent):
|
||||||
result = opportunity_service.analyze_portfolio(config, spot_client=spot_client)
|
result = opportunity_service.analyze_portfolio(config, spot_client=spot_client)
|
||||||
print_output(result, agent=args.agent)
|
print_output(result, agent=args.agent)
|
||||||
return 0
|
return 0
|
||||||
if args.opportunity_command == "scan":
|
|
||||||
|
if args.command == "opportunity":
|
||||||
|
spot_client = _load_spot_client(config)
|
||||||
with with_spinner("Scanning opportunities...", enabled=not args.agent):
|
with with_spinner("Scanning opportunities...", enabled=not args.agent):
|
||||||
result = opportunity_service.scan_opportunities(
|
result = opportunity_service.scan_opportunities(
|
||||||
config, spot_client=spot_client, symbols=args.symbols
|
config, spot_client=spot_client, symbols=args.symbols
|
||||||
)
|
)
|
||||||
print_output(result, agent=args.agent)
|
print_output(result, agent=args.agent)
|
||||||
return 0
|
return 0
|
||||||
parser.error("opportunity requires `portfolio` or `scan`")
|
|
||||||
|
|
||||||
if args.command == "upgrade":
|
if args.command == "upgrade":
|
||||||
with with_spinner("Upgrading coinhunter...", enabled=not args.agent):
|
with with_spinner("Upgrading coinhunter...", enabled=not args.agent):
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ try:
|
|||||||
except ModuleNotFoundError: # pragma: no cover
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
import tomli as tomllib
|
import tomli as tomllib
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomli_w
|
||||||
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
|
tomli_w = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG = """[runtime]
|
DEFAULT_CONFIG = """[runtime]
|
||||||
timezone = "Asia/Shanghai"
|
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")
|
raw = config.get("runtime", {}).get("log_dir", "logs")
|
||||||
value = Path(raw).expanduser()
|
value = Path(raw).expanduser()
|
||||||
return value if value.is_absolute() else paths.root / value
|
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
|
||||||
|
|||||||
@@ -426,9 +426,6 @@ def _render_tui(payload: Any) -> None:
|
|||||||
|
|
||||||
def print_output(payload: Any, *, agent: bool = False) -> None:
|
def print_output(payload: Any, *, agent: bool = False) -> None:
|
||||||
if agent:
|
if agent:
|
||||||
if _is_large_dataset(payload):
|
|
||||||
_print_compact(payload)
|
|
||||||
else:
|
|
||||||
print_json(payload)
|
print_json(payload)
|
||||||
else:
|
else:
|
||||||
_render_tui(payload)
|
_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"}
|
return {"shell": None, "installed": False, "reason": "unable to detect shell from $SHELL"}
|
||||||
|
|
||||||
script = shtab.complete(parser, shell=shell, preamble="")
|
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
|
installed_path: Path | None = None
|
||||||
hint: str | None = None
|
hint: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class CLITestCase(unittest.TestCase):
|
|||||||
self.assertIn("account", help_text)
|
self.assertIn("account", help_text)
|
||||||
self.assertIn("buy", help_text)
|
self.assertIn("buy", help_text)
|
||||||
self.assertIn("sell", help_text)
|
self.assertIn("sell", help_text)
|
||||||
|
self.assertIn("portfolio", help_text)
|
||||||
self.assertIn("opportunity", help_text)
|
self.assertIn("opportunity", help_text)
|
||||||
self.assertIn("--doc", help_text)
|
self.assertIn("--doc", help_text)
|
||||||
|
|
||||||
@@ -79,16 +80,24 @@ class CLITestCase(unittest.TestCase):
|
|||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
|
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
|
||||||
|
|
||||||
def test_doc_flag_prints_documentation(self):
|
def test_doc_flag_prints_tui_documentation(self):
|
||||||
import io
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
stdout = io.StringIO()
|
stdout = io.StringIO()
|
||||||
with patch("sys.stdout", stdout):
|
with patch("sys.stdout", stdout):
|
||||||
result = cli.main(["market", "tickers", "--doc"])
|
result = cli.main(["market", "tickers", "--doc"])
|
||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
output = stdout.getvalue()
|
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)
|
self.assertIn("BTCUSDT", output)
|
||||||
|
|
||||||
def test_account_dispatches(self):
|
def test_account_dispatches(self):
|
||||||
@@ -122,6 +131,46 @@ class CLITestCase(unittest.TestCase):
|
|||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
self.assertEqual(captured["payload"]["returncode"], 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):
|
def test_catlog_dispatches(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
with (
|
with (
|
||||||
@@ -138,3 +187,64 @@ class CLITestCase(unittest.TestCase):
|
|||||||
self.assertEqual(captured["payload"]["offset"], 10)
|
self.assertEqual(captured["payload"]["offset"], 10)
|
||||||
self.assertIn("entries", captured["payload"])
|
self.assertIn("entries", captured["payload"])
|
||||||
self.assertEqual(captured["payload"]["total"], 1)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user