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:
2026-04-20 08:43:30 +08:00
parent 3855477155
commit e37993c8b5
6 changed files with 941 additions and 171 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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,62 +39,201 @@ 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",
@@ -96,21 +242,55 @@ 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",
@@ -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:
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: Fields:
asset scored asset symbol trading pair (e.g. "BTCUSDT")
score composite opportunity score (0-1) action enum: "add" | "hold" | "trim" | "exit" | "observe"
metrics breakdown of contributing signals 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/scan": """\ "json": """\
Output: JSON JSON Output:
{ {
"opportunities": [ "recommendations": [
{"symbol": "ETHUSDT", "score": 0.82, "signals": ["momentum", "volume_spike"]} {"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": """\ },
Output: JSON "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:
{ {
"command": "pip install --upgrade coinhunter", "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):

View File

@@ -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

View File

@@ -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

View File

@@ -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)