- strategy_service.py combines opportunity + portfolio signals into unified buy/sell/hold recommendations - backtest_service.py runs walk-forward backtests on historical datasets with virtual cash and positions - CLI adds `strategy` and `backtest` commands with `--decision-interval` and other tuning parameters - Add tests for both services and CLI dispatch - Update CLAUDE.md with new architecture docs - Optimize model weights via opportunity optimizer Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1372 lines
58 KiB
Python
1372 lines
58 KiB
Python
"""CoinHunter V2 CLI."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
from typing import Any
|
||
|
||
from . import __version__
|
||
from .audit import read_audit_log
|
||
from .binance.spot_client import SpotBinanceClient
|
||
from .config import (
|
||
ensure_init_files,
|
||
get_binance_credentials,
|
||
get_config_value,
|
||
get_runtime_paths,
|
||
load_config,
|
||
set_config_value,
|
||
set_env_value,
|
||
)
|
||
from .runtime import (
|
||
install_shell_completion,
|
||
print_output,
|
||
self_upgrade,
|
||
with_spinner,
|
||
)
|
||
from .services import (
|
||
account_service,
|
||
backtest_service,
|
||
market_service,
|
||
opportunity_dataset_service,
|
||
opportunity_evaluation_service,
|
||
opportunity_service,
|
||
portfolio_service,
|
||
strategy_service,
|
||
trade_service,
|
||
)
|
||
|
||
EPILOG = """\
|
||
examples:
|
||
coin init
|
||
coin account
|
||
coin m tk BTCUSDT ETHUSDT
|
||
coin m k BTCUSDT -i 1h -l 50
|
||
coin buy BTCUSDT -Q 100 -d
|
||
coin sell BTCUSDT --qty 0.01 --type limit --price 90000
|
||
coin opportunity -s BTCUSDT ETHUSDT
|
||
coin opportunity evaluate ~/.coinhunter/datasets/opportunity_dataset.json --agent
|
||
coin opportunity optimize ~/.coinhunter/datasets/opportunity_dataset.json --agent
|
||
coin strategy -s BTCUSDT ETHUSDT
|
||
coin backtest ~/.coinhunter/datasets/opportunity_dataset_20260101T000000Z.json
|
||
coin upgrade
|
||
"""
|
||
|
||
COMMAND_DOCS: dict[str, dict[str, str]] = {
|
||
"init": {
|
||
"tui": """\
|
||
TUI Output:
|
||
INITIALIZED
|
||
Root: ~/.coinhunter
|
||
Config: ~/.coinhunter/config.toml
|
||
Env: ~/.coinhunter/.env
|
||
Logs: ~/.coinhunter/logs
|
||
Files created: config.toml, .env
|
||
|
||
✓ Shell completions installed for zsh
|
||
Path: ~/.zsh/completions/_coinhunter
|
||
Hint: Run 'compinit' or restart your terminal to activate completions
|
||
|
||
JSON Output:
|
||
{
|
||
"root": "~/.coinhunter",
|
||
"files_created": ["config.toml", ".env"],
|
||
"completion": {
|
||
"shell": "zsh",
|
||
"installed": true,
|
||
"path": "~/.zsh/completions/_coinhunter",
|
||
"hint": "Run 'compinit' or restart your terminal to activate completions"
|
||
}
|
||
}
|
||
Fields:
|
||
root – runtime directory path
|
||
files_created – list of generated files (e.g. ["config.toml", ".env"])
|
||
completion – shell completion installation status (installed, path, hint)
|
||
""",
|
||
"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": [
|
||
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false}
|
||
]
|
||
}
|
||
Fields:
|
||
asset – asset symbol (e.g. "BTC", "ETH", "USDT")
|
||
free – available balance (float)
|
||
locked – frozen/locked balance (float)
|
||
total – free + locked (float)
|
||
notional_usdt – estimated value in USDT (float)
|
||
is_dust – true if value is below dust threshold (bool)
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"balances": [
|
||
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6, "notional_usdt": 30000.0, "is_dust": false}
|
||
]
|
||
}
|
||
Fields:
|
||
asset – asset symbol (e.g. "BTC", "ETH", "USDT")
|
||
free – available balance (float)
|
||
locked – frozen/locked balance (float)
|
||
total – free + locked (float)
|
||
notional_usdt – estimated value in USDT (float)
|
||
is_dust – true if value is below dust threshold (bool)
|
||
""",
|
||
},
|
||
"market/tickers": {
|
||
"tui": """\
|
||
TUI Output:
|
||
TICKERS window=1d
|
||
┌─────────┬────────────┬──────────┬──────────────┐
|
||
│ 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}
|
||
],
|
||
"window": "1d"
|
||
}
|
||
Fields:
|
||
symbol – normalized trading pair (e.g. "BTCUSDT")
|
||
last_price – latest traded price (float)
|
||
price_change_pct – change % over the selected window (float, e.g. 2.5 = +2.5%)
|
||
quote_volume – quote volume over the selected window (float)
|
||
window – statistics window (enum: 1m, 2m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 2d, 3d, 5d, 7d, 15d, 30d)
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"tickers": [
|
||
{"symbol": "BTCUSDT", "last_price": 70000.0, "price_change_pct": 2.5, "quote_volume": 123456.0}
|
||
],
|
||
"window": "1d"
|
||
}
|
||
Fields:
|
||
symbol – normalized trading pair (e.g. "BTCUSDT")
|
||
last_price – latest traded price (float)
|
||
price_change_pct – change % over the selected window (float, e.g. 2.5 = +2.5%)
|
||
quote_volume – quote volume over the selected window (float)
|
||
window – statistics window (enum: 1m, 2m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 2d, 3d, 5d, 7d, 15d, 30d)
|
||
""",
|
||
},
|
||
"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: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M)
|
||
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: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M)
|
||
limit – number of candles requested (int)
|
||
symbol – normalized trading pair
|
||
open_time – candle open timestamp in ms (int)
|
||
close_time – candle close timestamp in ms (int)
|
||
open/high/low/close – OHLC prices (float)
|
||
volume – traded base volume (float)
|
||
quote_volume – traded quote volume (float)
|
||
""",
|
||
},
|
||
"buy": {
|
||
"tui": """\
|
||
TUI Output:
|
||
TRADE RESULT
|
||
Market: SPOT
|
||
Symbol: BTCUSDT
|
||
Side: BUY
|
||
Type: MARKET
|
||
Status: DRY_RUN
|
||
Dry Run: true
|
||
|
||
JSON Output:
|
||
{
|
||
"trade": {
|
||
"market_type": "spot",
|
||
"symbol": "BTCUSDT",
|
||
"side": "BUY",
|
||
"order_type": "MARKET",
|
||
"status": "DRY_RUN",
|
||
"dry_run": true,
|
||
"request_payload": {"symbol": "BTCUSDT", "side": "BUY", "type": "MARKET", "quoteOrderQty": 100},
|
||
"response_payload": {"dry_run": true, "status": "DRY_RUN", "request": {...}}
|
||
}
|
||
}
|
||
Fields:
|
||
market_type – always "spot"
|
||
side – always "BUY"
|
||
order_type – enum: "MARKET" | "LIMIT"
|
||
status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED"
|
||
dry_run – true if simulated (bool)
|
||
request_payload – normalized order sent to Binance
|
||
response_payload – raw exchange response (or dry_run stub)
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"trade": {
|
||
"market_type": "spot",
|
||
"symbol": "BTCUSDT",
|
||
"side": "BUY",
|
||
"order_type": "MARKET",
|
||
"status": "DRY_RUN",
|
||
"dry_run": true,
|
||
"request_payload": {"symbol": "BTCUSDT", "side": "BUY", "type": "MARKET", "quoteOrderQty": 100},
|
||
"response_payload": {"dry_run": true, "status": "DRY_RUN", "request": {...}}
|
||
}
|
||
}
|
||
Fields:
|
||
market_type – always "spot"
|
||
side – always "BUY"
|
||
order_type – enum: "MARKET" | "LIMIT"
|
||
status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED"
|
||
dry_run – true if simulated (bool)
|
||
request_payload – normalized order sent to Binance
|
||
response_payload – raw exchange response (or dry_run stub)
|
||
""",
|
||
},
|
||
"sell": {
|
||
"tui": """\
|
||
TUI Output:
|
||
TRADE RESULT
|
||
Market: SPOT
|
||
Symbol: BTCUSDT
|
||
Side: SELL
|
||
Type: LIMIT
|
||
Status: FILLED
|
||
Dry Run: false
|
||
|
||
JSON Output:
|
||
{
|
||
"trade": {
|
||
"market_type": "spot",
|
||
"symbol": "BTCUSDT",
|
||
"side": "SELL",
|
||
"order_type": "LIMIT",
|
||
"status": "FILLED",
|
||
"dry_run": false,
|
||
"request_payload": {"symbol": "BTCUSDT", "side": "SELL", "type": "LIMIT", "quantity": 0.01, "price": 90000, "timeInForce": "GTC"},
|
||
"response_payload": {"symbol": "BTCUSDT", "orderId": 12345, "status": "FILLED", ...}
|
||
}
|
||
}
|
||
Fields:
|
||
market_type – always "spot"
|
||
side – always "SELL"
|
||
order_type – enum: "MARKET" | "LIMIT"
|
||
status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED"
|
||
dry_run – true if simulated (bool)
|
||
request_payload – normalized order sent to Binance
|
||
response_payload – raw exchange response (or dry_run stub)
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"trade": {
|
||
"market_type": "spot",
|
||
"symbol": "BTCUSDT",
|
||
"side": "SELL",
|
||
"order_type": "LIMIT",
|
||
"status": "FILLED",
|
||
"dry_run": false,
|
||
"request_payload": {"symbol": "BTCUSDT", "side": "SELL", "type": "LIMIT", "quantity": 0.01, "price": 90000, "timeInForce": "GTC"},
|
||
"response_payload": {"symbol": "BTCUSDT", "orderId": 12345, "status": "FILLED", ...}
|
||
}
|
||
}
|
||
Fields:
|
||
market_type – always "spot"
|
||
side – always "SELL"
|
||
order_type – enum: "MARKET" | "LIMIT"
|
||
status – enum: "DRY_RUN", "NEW", "PARTIALLY_FILLED", "FILLED", "CANCELED", "REJECTED", "EXPIRED"
|
||
dry_run – true if simulated (bool)
|
||
request_payload – normalized order sent to Binance
|
||
response_payload – raw exchange response (or dry_run stub)
|
||
""",
|
||
},
|
||
"portfolio": {
|
||
"tui": """\
|
||
TUI Output:
|
||
RECOMMENDATIONS count=3
|
||
1. BTCUSDT action=add score=0.7500
|
||
· market signal is strong and position still has room
|
||
trend=1.0 momentum=0.02 breakout=0.85 volume_confirmation=1.2 volatility=0.01 position_weight=0.3
|
||
2. ETHUSDT action=hold score=0.6000
|
||
· market structure remains supportive for holding
|
||
trend=1.0 momentum=0.01 breakout=0.5 volume_confirmation=1.0 volatility=0.02 position_weight=0.2
|
||
3. SOLUSDT action=trim score=-0.2000
|
||
· position weight is above the portfolio risk budget
|
||
trend=-1.0 momentum=-0.01 breakout=0.3 volume_confirmation=0.8 volatility=0.03 position_weight=0.5
|
||
|
||
JSON Output:
|
||
{
|
||
"recommendations": [
|
||
{"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["market signal is strong and position still has room"],
|
||
"metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "position_weight": 0.3}}
|
||
]
|
||
}
|
||
Fields:
|
||
symbol – trading pair (e.g. "BTCUSDT")
|
||
action – enum: "add" | "hold" | "trim" | "exit" | "review"
|
||
score – shared market signal 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)
|
||
position_weight – position weight in portfolio (float, 0-1)
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"recommendations": [
|
||
{"symbol": "BTCUSDT", "action": "add", "score": 0.75, "reasons": ["market signal is strong and position still has room"],
|
||
"metrics": {"trend": 1.0, "momentum": 0.02, "breakout": 0.85, "volume_confirmation": 1.2, "volatility": 0.01, "position_weight": 0.3}}
|
||
]
|
||
}
|
||
Fields:
|
||
symbol – trading pair (e.g. "BTCUSDT")
|
||
action – enum: "add" | "hold" | "trim" | "exit" | "review"
|
||
score – shared market signal 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)
|
||
position_weight – position weight in portfolio (float, 0-1)
|
||
""",
|
||
},
|
||
"opportunity": {
|
||
"tui": """\
|
||
TUI Output:
|
||
RECOMMENDATIONS count=5
|
||
1. ETHUSDT action=entry confidence=74 score=1.7200
|
||
· fresh breakout trigger with clean setup and manageable extension
|
||
· base asset ETH passed liquidity and tradability filters
|
||
setup_score=0.74 trigger_score=0.61 liquidity_score=1.0 extension_penalty=0.0 opportunity_score=1.72 position_weight=0.0
|
||
2. BTCUSDT action=watch confidence=52 score=0.7800
|
||
· setup is constructive but the trigger is not clean enough yet
|
||
· base asset BTC passed liquidity and tradability filters
|
||
· symbol is already held, so the opportunity score is discounted for overlap
|
||
setup_score=0.68 trigger_score=0.25 liquidity_score=1.0 extension_penalty=0.1 opportunity_score=0.96 position_weight=0.3
|
||
|
||
JSON Output:
|
||
{
|
||
"recommendations": [
|
||
{"symbol": "ETHUSDT", "action": "entry", "confidence": 74, "score": 1.72,
|
||
"reasons": ["fresh breakout trigger with clean setup and manageable extension", "base asset ETH passed liquidity and tradability filters"],
|
||
"metrics": {"setup_score": 0.74, "trigger_score": 0.61, "liquidity_score": 1.0, "extension_penalty": 0.0, "opportunity_score": 1.72, "position_weight": 0.0}}
|
||
]
|
||
}
|
||
Fields:
|
||
symbol – trading pair (e.g. "ETHUSDT")
|
||
action – enum: "entry" | "watch" | "avoid"
|
||
confidence – 0..100 confidence index derived from edge_score
|
||
score – opportunity score after extension and overlap/risk discounts
|
||
reasons – list of human-readable explanations (includes liquidity filter note for scan)
|
||
metrics – scoring breakdown
|
||
setup_score – compression, higher-lows, and breakout-proximity quality
|
||
trigger_score – fresh-breakout, volume, and controlled-momentum quality
|
||
liquidity_score – relative quote-volume quality after liquidity filters
|
||
extension_penalty – overextension/chase risk from run-up and MA distance
|
||
opportunity_score – raw opportunity score before overlap discount
|
||
position_weight – current portfolio overlap in the same symbol
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"recommendations": [
|
||
{"symbol": "ETHUSDT", "action": "entry", "confidence": 74, "score": 1.72,
|
||
"reasons": ["fresh breakout trigger with clean setup and manageable extension", "base asset ETH passed liquidity and tradability filters"],
|
||
"metrics": {"setup_score": 0.74, "trigger_score": 0.61, "liquidity_score": 1.0, "extension_penalty": 0.0, "opportunity_score": 1.72, "position_weight": 0.0}}
|
||
]
|
||
}
|
||
Fields:
|
||
symbol – trading pair (e.g. "ETHUSDT")
|
||
action – enum: "entry" | "watch" | "avoid"
|
||
confidence – 0..100 confidence index derived from edge_score
|
||
score – opportunity score after extension and overlap/risk discounts
|
||
reasons – list of human-readable explanations (includes liquidity filter note for scan)
|
||
metrics – scoring breakdown
|
||
setup_score – compression, higher-lows, and breakout-proximity quality
|
||
trigger_score – fresh-breakout, volume, and controlled-momentum quality
|
||
liquidity_score – relative quote-volume quality after liquidity filters
|
||
extension_penalty – overextension/chase risk from run-up and MA distance
|
||
opportunity_score – raw opportunity score before overlap discount
|
||
position_weight – current portfolio overlap in the same symbol
|
||
""",
|
||
},
|
||
"opportunity/dataset": {
|
||
"tui": """\
|
||
TUI Output:
|
||
DATASET COLLECTED
|
||
Path: ~/.coinhunter/datasets/opportunity_dataset_20260421T120000Z.json
|
||
Symbols: BTCUSDT, ETHUSDT
|
||
Window: reference=48.0d simulate=7.0d run=7.0d
|
||
|
||
JSON Output:
|
||
{
|
||
"path": "~/.coinhunter/datasets/opportunity_dataset_20260421T120000Z.json",
|
||
"symbols": ["BTCUSDT", "ETHUSDT"],
|
||
"counts": {"BTCUSDT": {"1h": 1488}},
|
||
"plan": {"reference_days": 48.0, "simulate_days": 7.0, "run_days": 7.0, "total_days": 62.0},
|
||
"external_history": {"provider": "coingecko", "status": "available"}
|
||
}
|
||
Fields:
|
||
path – JSON dataset file written locally
|
||
symbols – symbols included in the dataset
|
||
counts – kline row counts by symbol and interval
|
||
plan – reference/simulation/run windows used for collection
|
||
external_history – external provider historical capability probe result
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"path": "~/.coinhunter/datasets/opportunity_dataset_20260421T120000Z.json",
|
||
"symbols": ["BTCUSDT", "ETHUSDT"],
|
||
"counts": {"BTCUSDT": {"1h": 1488}},
|
||
"plan": {"reference_days": 48.0, "simulate_days": 7.0, "run_days": 7.0, "total_days": 62.0},
|
||
"external_history": {"provider": "coingecko", "status": "available"}
|
||
}
|
||
Fields:
|
||
path – JSON dataset file written locally
|
||
symbols – symbols included in the dataset
|
||
counts – kline row counts by symbol and interval
|
||
plan – reference/simulation/run windows used for collection
|
||
external_history – external provider historical capability probe result
|
||
""",
|
||
},
|
||
"opportunity/evaluate": {
|
||
"tui": """\
|
||
TUI Output:
|
||
SUMMARY
|
||
count=120 correct=76 incorrect=44 accuracy=0.6333
|
||
interval=1h top_n=10 decision_times=24
|
||
|
||
BY ACTION
|
||
trigger count=12 correct=7 accuracy=0.5833 avg_trade_return=0.0062
|
||
setup count=78 correct=52 accuracy=0.6667
|
||
skip count=30 correct=17 accuracy=0.5667
|
||
|
||
JSON Output:
|
||
{
|
||
"summary": {"count": 120, "correct": 76, "incorrect": 44, "accuracy": 0.6333},
|
||
"by_action": {"trigger": {"count": 12, "correct": 7, "accuracy": 0.5833}},
|
||
"trade_simulation": {"trigger_trades": 12, "wins": 7, "losses": 5, "win_rate": 0.5833},
|
||
"rules": {"horizon_hours": 24.0, "take_profit": 0.02, "stop_loss": 0.015, "setup_target": 0.01}
|
||
}
|
||
Fields:
|
||
summary – aggregate walk-forward judgment accuracy
|
||
by_action – accuracy and average returns grouped by trigger/setup/chase/skip
|
||
trade_simulation – trigger-only trade outcome using take-profit/stop-loss rules
|
||
rules – objective evaluation assumptions used for the run
|
||
examples – first evaluated judgments with outcome labels
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"summary": {"count": 120, "correct": 76, "incorrect": 44, "accuracy": 0.6333},
|
||
"by_action": {"trigger": {"count": 12, "correct": 7, "accuracy": 0.5833}},
|
||
"trade_simulation": {"trigger_trades": 12, "wins": 7, "losses": 5, "win_rate": 0.5833},
|
||
"rules": {"horizon_hours": 24.0, "take_profit": 0.02, "stop_loss": 0.015, "setup_target": 0.01}
|
||
}
|
||
Fields:
|
||
summary – aggregate walk-forward judgment accuracy
|
||
by_action – accuracy and average returns grouped by trigger/setup/chase/skip
|
||
trade_simulation – trigger-only trade outcome using take-profit/stop-loss rules
|
||
rules – objective evaluation assumptions used for the run
|
||
examples – first evaluated judgments with outcome labels
|
||
""",
|
||
},
|
||
"opportunity/optimize": {
|
||
"tui": """\
|
||
TUI Output:
|
||
BASELINE
|
||
objective=0.5012 accuracy=0.5970 trigger_win_rate=0.4312
|
||
|
||
BEST
|
||
objective=0.5341 accuracy=0.6214 trigger_win_rate=0.4862
|
||
|
||
JSON Output:
|
||
{
|
||
"baseline": {"objective": 0.5012, "summary": {"accuracy": 0.597}},
|
||
"best": {"objective": 0.5341, "summary": {"accuracy": 0.6214}},
|
||
"improvement": {"accuracy": 0.0244, "trigger_win_rate": 0.055},
|
||
"recommended_config": {"opportunity.model_weights.trigger": 1.5}
|
||
}
|
||
Fields:
|
||
baseline – evaluation snapshot with current model weights
|
||
best – best walk-forward snapshot found by coordinate search
|
||
improvement – deltas from baseline to best
|
||
recommended_config – config keys that can be written with `coin config set`
|
||
search – optimizer metadata; thresholds are fixed
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"baseline": {"objective": 0.5012, "summary": {"accuracy": 0.597}},
|
||
"best": {"objective": 0.5341, "summary": {"accuracy": 0.6214}},
|
||
"improvement": {"accuracy": 0.0244, "trigger_win_rate": 0.055},
|
||
"recommended_config": {"opportunity.model_weights.trigger": 1.5}
|
||
}
|
||
Fields:
|
||
baseline – evaluation snapshot with current model weights
|
||
best – best walk-forward snapshot found by coordinate search
|
||
improvement – deltas from baseline to best
|
||
recommended_config – config keys that can be written with `coin config set`
|
||
search – optimizer metadata; thresholds are fixed
|
||
""",
|
||
},
|
||
"upgrade": {
|
||
"tui": """\
|
||
TUI Output:
|
||
✓ Update completed
|
||
upgraded coinhunter 2.1.1 -> 2.2.0
|
||
|
||
(or on failure)
|
||
✗ Update failed (exit code 1)
|
||
error: could not find a version that satisfies the requirement
|
||
|
||
JSON Output:
|
||
{
|
||
"command": "pipx upgrade coinhunter",
|
||
"returncode": 0,
|
||
"stdout": "upgraded coinhunter 2.1.1 -> 2.2.0",
|
||
"stderr": ""
|
||
}
|
||
Fields:
|
||
command – shell command executed (e.g. "pipx upgrade coinhunter" or "python -m pip install --upgrade coinhunter")
|
||
returncode – process exit code (0 = success, non-zero = failure)
|
||
stdout – command standard output
|
||
stderr – command standard error
|
||
""",
|
||
"json": """\
|
||
JSON Output:
|
||
{
|
||
"command": "pipx upgrade coinhunter",
|
||
"returncode": 0,
|
||
"stdout": "upgraded coinhunter 2.1.1 -> 2.2.0",
|
||
"stderr": ""
|
||
}
|
||
Fields:
|
||
command – shell command executed (e.g. "pipx upgrade coinhunter" or "python -m pip install --upgrade coinhunter")
|
||
returncode – process exit code (0 = success, non-zero = failure)
|
||
stdout – command standard output
|
||
stderr – command standard error
|
||
""",
|
||
},
|
||
"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`
|
||
...
|
||
""",
|
||
},
|
||
}
|
||
|
||
|
||
|
||
def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> SpotBinanceClient:
|
||
credentials = get_binance_credentials()
|
||
binance_config = config["binance"]
|
||
return SpotBinanceClient(
|
||
api_key=credentials["api_key"],
|
||
api_secret=credentials["api_secret"],
|
||
base_url=binance_config["spot_base_url"],
|
||
recv_window=int(binance_config["recv_window"]),
|
||
client=client,
|
||
)
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
prog="coin",
|
||
description="Binance-first trading CLI for account balances, market data, trade execution, and opportunity scanning.",
|
||
epilog=EPILOG,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
)
|
||
parser.add_argument("-v", "--version", action="version", version=__version__)
|
||
parser.add_argument("-a", "--agent", action="store_true", help="Output in agent-friendly format (JSON or compact)")
|
||
parser.add_argument("--doc", action="store_true", help="Show output schema and field descriptions for the command")
|
||
subparsers = parser.add_subparsers(dest="command")
|
||
|
||
def _add_global_flags(p: argparse.ArgumentParser) -> None:
|
||
p.add_argument("-a", "--agent", action="store_true", help="Output in agent-friendly format (JSON or compact)")
|
||
p.add_argument("--doc", action="store_true", help="Show output schema and field descriptions for the command")
|
||
|
||
init_parser = subparsers.add_parser(
|
||
"init", help="Generate config.toml, .env, and log directory",
|
||
description="Initialize the CoinHunter runtime directory with config.toml, .env, and shell completions. "
|
||
"Interactively prompts for Binance API key and secret if missing.",
|
||
)
|
||
init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing files")
|
||
init_parser.add_argument("--no-prompt", action="store_true", help="Skip interactive API key/secret prompt")
|
||
_add_global_flags(init_parser)
|
||
|
||
config_parser = subparsers.add_parser(
|
||
"config", aliases=["cfg", "c"], help="Manage configuration",
|
||
description="Read and write config.toml and .env settings without manual editing.",
|
||
)
|
||
config_subparsers = config_parser.add_subparsers(dest="config_command")
|
||
|
||
config_get_parser = config_subparsers.add_parser(
|
||
"get", help="Read a config value",
|
||
description="Read a value from config.toml using dot notation (e.g. binance.recv_window).",
|
||
)
|
||
config_get_parser.add_argument("key", nargs="?", help="Config key in dot notation (e.g. trading.dry_run_default)")
|
||
_add_global_flags(config_get_parser)
|
||
|
||
config_set_parser = config_subparsers.add_parser(
|
||
"set", help="Write a config value",
|
||
description="Write a value to config.toml using dot notation. "
|
||
"Booleans accept: true/1/yes/on or false/0/no/off. Lists use comma-separated values.",
|
||
)
|
||
config_set_parser.add_argument("key", help="Config key in dot notation (e.g. opportunity.top_n)")
|
||
config_set_parser.add_argument("value", help="Value to set")
|
||
_add_global_flags(config_set_parser)
|
||
|
||
config_key_parser = config_subparsers.add_parser(
|
||
"key", help="Set Binance API key",
|
||
description="Set BINANCE_API_KEY in the .env file. Prompts interactively if no value provided.",
|
||
)
|
||
config_key_parser.add_argument("value", nargs="?", help="API key value (omit to prompt interactively)")
|
||
_add_global_flags(config_key_parser)
|
||
|
||
config_secret_parser = config_subparsers.add_parser(
|
||
"secret", help="Set Binance API secret",
|
||
description="Set BINANCE_API_SECRET in the .env file. Prompts interactively if no value provided.",
|
||
)
|
||
config_secret_parser.add_argument("value", nargs="?", help="API secret value (omit to prompt interactively)")
|
||
_add_global_flags(config_secret_parser)
|
||
|
||
account_parser = subparsers.add_parser(
|
||
"account", aliases=["acc", "a"], help="List asset balances and notional values",
|
||
description="List all non-zero spot balances with free/locked totals, notional USDT value, and dust flag.",
|
||
)
|
||
_add_global_flags(account_parser)
|
||
|
||
market_parser = subparsers.add_parser(
|
||
"market", aliases=["m"], help="Batch market queries",
|
||
description="Query market data: 24h tickers and OHLCV klines for one or more trading pairs.",
|
||
)
|
||
market_subparsers = market_parser.add_subparsers(dest="market_command")
|
||
tickers_parser = market_subparsers.add_parser(
|
||
"tickers", aliases=["tk", "t"], help="Fetch ticker statistics",
|
||
description="Fetch ticker statistics (last price, change %, volume) for one or more symbols with optional window.",
|
||
)
|
||
tickers_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query (e.g. BTCUSDT ETH/USDT)")
|
||
tickers_parser.add_argument(
|
||
"-w", "--window",
|
||
choices=["1m", "2m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "2d", "3d", "5d", "7d", "15d", "30d"],
|
||
default="1d",
|
||
help="Rolling statistics window (default: 1d)",
|
||
)
|
||
_add_global_flags(tickers_parser)
|
||
klines_parser = market_subparsers.add_parser(
|
||
"klines", aliases=["k"], help="Fetch OHLCV klines",
|
||
description="Fetch OHLCV candlestick data for one or more symbols.",
|
||
)
|
||
klines_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query")
|
||
klines_parser.add_argument(
|
||
"-i", "--interval", default="1h",
|
||
help="Kline interval: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M (default: 1h)",
|
||
)
|
||
klines_parser.add_argument("-l", "--limit", type=int, default=100, help="Number of candles (default: 100)")
|
||
_add_global_flags(klines_parser)
|
||
|
||
buy_parser = subparsers.add_parser(
|
||
"buy", aliases=["b"], help="Buy base asset",
|
||
description="Place a spot BUY order. Market buys require --quote. Limit buys require --qty and --price.",
|
||
)
|
||
buy_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
|
||
buy_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity (limit orders)")
|
||
buy_parser.add_argument("-Q", "--quote", type=float, help="Quote asset amount (market buy only)")
|
||
buy_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
|
||
buy_parser.add_argument("-p", "--price", type=float, help="Limit price")
|
||
buy_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
|
||
_add_global_flags(buy_parser)
|
||
|
||
sell_parser = subparsers.add_parser(
|
||
"sell", aliases=["s"], help="Sell base asset",
|
||
description="Place a spot SELL order. Requires --qty. Limit sells also require --price.",
|
||
)
|
||
sell_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
|
||
sell_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity")
|
||
sell_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
|
||
sell_parser.add_argument("-p", "--price", type=float, help="Limit price")
|
||
sell_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
|
||
_add_global_flags(sell_parser)
|
||
|
||
portfolio_parser = subparsers.add_parser(
|
||
"portfolio", aliases=["pf", "p"], help="Score current holdings",
|
||
description="Review current spot holdings and generate add/hold/trim/exit recommendations.",
|
||
)
|
||
_add_global_flags(portfolio_parser)
|
||
|
||
opportunity_parser = subparsers.add_parser(
|
||
"opportunity", aliases=["opp", "o"], help="Scan market for opportunities",
|
||
description="Scan the market for trading opportunities and return the top-N candidates with signals.",
|
||
)
|
||
opportunity_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
|
||
_add_global_flags(opportunity_parser)
|
||
opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command")
|
||
scan_parser = opportunity_subparsers.add_parser(
|
||
"scan", help="Scan market for opportunities",
|
||
description="Scan the market for trading opportunities and return the top-N candidates with signals.",
|
||
)
|
||
scan_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
|
||
_add_global_flags(scan_parser)
|
||
dataset_parser = opportunity_subparsers.add_parser(
|
||
"dataset", aliases=["ds"], help="Collect historical opportunity evaluation dataset",
|
||
description="Collect point-in-time market data for opportunity simulation and evaluation.",
|
||
)
|
||
dataset_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict dataset to symbols")
|
||
dataset_parser.add_argument("--simulate-days", type=float, help="Forward simulation/evaluation window in days")
|
||
dataset_parser.add_argument("--run-days", type=float, help="Continuous scan simulation window in days")
|
||
dataset_parser.add_argument("-o", "--output", help="Output dataset JSON path")
|
||
_add_global_flags(dataset_parser)
|
||
evaluate_parser = opportunity_subparsers.add_parser(
|
||
"evaluate", aliases=["eval", "ev"], help="Evaluate opportunity accuracy from a historical dataset",
|
||
description="Run a walk-forward evaluation over an opportunity dataset using point-in-time candles only.",
|
||
)
|
||
evaluate_parser.add_argument("dataset", help="Path to an opportunity dataset JSON file")
|
||
evaluate_parser.add_argument("--horizon-hours", type=float, help="Forward evaluation horizon in hours")
|
||
evaluate_parser.add_argument("--take-profit-pct", type=float, help="Trigger success take-profit threshold in percent")
|
||
evaluate_parser.add_argument("--stop-loss-pct", type=float, help="Stop-loss threshold in percent")
|
||
evaluate_parser.add_argument("--setup-target-pct", type=float, help="Setup success target threshold in percent")
|
||
evaluate_parser.add_argument("--lookback", type=int, help="Closed candles used for each point-in-time score")
|
||
evaluate_parser.add_argument("--top-n", type=int, help="Evaluate only the top-N ranked symbols at each decision time")
|
||
evaluate_parser.add_argument("--examples", type=int, default=20, help="Number of example judgments to include")
|
||
_add_global_flags(evaluate_parser)
|
||
optimize_parser = opportunity_subparsers.add_parser(
|
||
"optimize", aliases=["opt"], help="Optimize opportunity model weights from a historical dataset",
|
||
description="Coordinate-search normalized model weights while keeping decision thresholds fixed.",
|
||
)
|
||
optimize_parser.add_argument("dataset", help="Path to an opportunity dataset JSON file")
|
||
optimize_parser.add_argument("--horizon-hours", type=float, help="Forward evaluation horizon in hours")
|
||
optimize_parser.add_argument("--take-profit-pct", type=float, help="Trigger success take-profit threshold in percent")
|
||
optimize_parser.add_argument("--stop-loss-pct", type=float, help="Stop-loss threshold in percent")
|
||
optimize_parser.add_argument("--setup-target-pct", type=float, help="Setup success target threshold in percent")
|
||
optimize_parser.add_argument("--lookback", type=int, help="Closed candles used for each point-in-time score")
|
||
optimize_parser.add_argument("--top-n", type=int, help="Evaluate only the top-N ranked symbols at each decision time")
|
||
optimize_parser.add_argument("--passes", type=int, default=2, help="Coordinate-search passes over model weights")
|
||
_add_global_flags(optimize_parser)
|
||
|
||
strategy_parser = subparsers.add_parser(
|
||
"strategy", aliases=["strat", "st"], help="Combined opportunity + portfolio trade signals",
|
||
description="Generate unified buy/sell/hold signals by combining opportunity scanning and portfolio analysis.",
|
||
)
|
||
strategy_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
|
||
_add_global_flags(strategy_parser)
|
||
|
||
backtest_parser = subparsers.add_parser(
|
||
"backtest", aliases=["bt"], help="Backtest combined strategy on historical dataset",
|
||
description="Run a walk-forward backtest using historical kline datasets with virtual cash and positions.",
|
||
)
|
||
backtest_parser.add_argument("dataset", help="Path to an opportunity dataset JSON file")
|
||
backtest_parser.add_argument("--initial-cash", type=float, help="Initial cash allocation (default: 10000)")
|
||
backtest_parser.add_argument("--max-positions", type=int, help="Maximum simultaneous positions (default: 5)")
|
||
backtest_parser.add_argument("--position-size-pct", type=float, help="Cash percentage per position (default: 0.2)")
|
||
backtest_parser.add_argument("--commission-pct", type=float, help="Commission per trade in percent (default: 0.1)")
|
||
backtest_parser.add_argument("--lookback", type=int, help="Closed candles used for each point-in-time score")
|
||
backtest_parser.add_argument("--decision-interval", type=int, help="Minimum minutes between decision points (default: 0 = every candle)")
|
||
_add_global_flags(backtest_parser)
|
||
|
||
upgrade_parser = subparsers.add_parser(
|
||
"upgrade", help="Upgrade coinhunter to the latest version",
|
||
description="Upgrade the coinhunter package using pipx (preferred) or pip.",
|
||
)
|
||
_add_global_flags(upgrade_parser)
|
||
|
||
catlog_parser = subparsers.add_parser(
|
||
"catlog", help="Read recent audit log entries",
|
||
description="Read recent audit log entries across all days with optional limit and offset pagination.",
|
||
)
|
||
catlog_parser.add_argument("-n", "--limit", type=int, default=10, help="Number of entries (default: 10)")
|
||
catlog_parser.add_argument(
|
||
"-o", "--offset", type=int, default=0, help="Skip the most recent N entries (default: 0)"
|
||
)
|
||
catlog_parser.add_argument("-d", "--dry-run", action="store_true", help="Read dry-run audit logs")
|
||
_add_global_flags(catlog_parser)
|
||
|
||
completion_parser = subparsers.add_parser(
|
||
"completion", help="Generate shell completion script",
|
||
description="Generate a shell completion script for bash or zsh.",
|
||
)
|
||
completion_parser.add_argument("shell", choices=["bash", "zsh"], help="Target shell")
|
||
_add_global_flags(completion_parser)
|
||
|
||
return parser
|
||
|
||
|
||
_CANONICAL_COMMANDS = {
|
||
"b": "buy",
|
||
"s": "sell",
|
||
"acc": "account",
|
||
"a": "account",
|
||
"m": "market",
|
||
"pf": "portfolio",
|
||
"p": "portfolio",
|
||
"opp": "opportunity",
|
||
"o": "opportunity",
|
||
"cfg": "config",
|
||
"c": "config",
|
||
"strat": "strategy",
|
||
"st": "strategy",
|
||
"bt": "backtest",
|
||
}
|
||
|
||
_CANONICAL_SUBCOMMANDS = {
|
||
"tk": "tickers",
|
||
"t": "tickers",
|
||
"k": "klines",
|
||
"ds": "dataset",
|
||
"eval": "evaluate",
|
||
"ev": "evaluate",
|
||
"opt": "optimize",
|
||
}
|
||
|
||
_COMMANDS_WITH_SUBCOMMANDS = {"market", "config", "opportunity"}
|
||
|
||
|
||
def _get_doc_key(argv: list[str]) -> str | None:
|
||
"""Infer command/subcommand from argv for --doc lookup."""
|
||
tokens = [a for a in argv if a != "--doc" and not a.startswith("-")]
|
||
if not tokens:
|
||
return None
|
||
cmd = _CANONICAL_COMMANDS.get(tokens[0], tokens[0])
|
||
if cmd in _COMMANDS_WITH_SUBCOMMANDS and len(tokens) > 1:
|
||
sub = _CANONICAL_SUBCOMMANDS.get(tokens[1], tokens[1])
|
||
sub_key = f"{cmd}/{sub}"
|
||
if sub_key in COMMAND_DOCS:
|
||
return sub_key
|
||
return cmd
|
||
|
||
|
||
def _reorder_flag(argv: list[str], flag: str, short_flag: str | None = None) -> list[str]:
|
||
"""Move a global flag from after subcommands to before them so argparse can parse it."""
|
||
flags = {flag}
|
||
if short_flag:
|
||
flags.add(short_flag)
|
||
subcommand_idx: int | None = None
|
||
for i, arg in enumerate(argv):
|
||
if not arg.startswith("-"):
|
||
subcommand_idx = i
|
||
break
|
||
if subcommand_idx is None:
|
||
return argv
|
||
new_argv: list[str] = []
|
||
present = False
|
||
for i, arg in enumerate(argv):
|
||
if i >= subcommand_idx and arg in flags:
|
||
present = True
|
||
continue
|
||
new_argv.append(arg)
|
||
if present:
|
||
new_argv.insert(subcommand_idx, flag)
|
||
return new_argv
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
raw_argv = argv if argv is not None else sys.argv[1:]
|
||
|
||
if "--doc" in raw_argv:
|
||
doc_key = _get_doc_key(raw_argv)
|
||
if doc_key is None:
|
||
print("Available docs: " + ", ".join(sorted(COMMAND_DOCS.keys())))
|
||
return 0
|
||
entry = COMMAND_DOCS.get(doc_key)
|
||
if entry is None:
|
||
print(f"No documentation available for {doc_key}.")
|
||
return 0
|
||
is_agent = "--agent" in raw_argv or "-a" in raw_argv
|
||
doc = entry.get("json" if is_agent else "tui", entry.get("tui", ""))
|
||
print(doc)
|
||
return 0
|
||
|
||
parser = build_parser()
|
||
raw_argv = _reorder_flag(raw_argv, "--agent", "-a")
|
||
args = parser.parse_args(raw_argv)
|
||
args.agent = bool(getattr(args, "agent", False) or "--agent" in raw_argv or "-a" in raw_argv)
|
||
|
||
# Normalize aliases to canonical command names
|
||
if args.command:
|
||
args.command = _CANONICAL_COMMANDS.get(args.command, args.command)
|
||
for attr in ("account_command", "market_command", "config_command", "opportunity_command"):
|
||
val = getattr(args, attr, None)
|
||
if val:
|
||
setattr(args, attr, _CANONICAL_SUBCOMMANDS.get(val, val))
|
||
|
||
try:
|
||
if not args.command:
|
||
parser.print_help()
|
||
return 0
|
||
|
||
if args.command == "init":
|
||
paths = get_runtime_paths()
|
||
init_result = ensure_init_files(paths, force=args.force)
|
||
init_result["completion"] = install_shell_completion(parser)
|
||
|
||
# Interactive prompt for API key/secret if missing and tty available
|
||
if not args.no_prompt and sys.stdin.isatty():
|
||
from .config import load_env_file
|
||
|
||
env_data = load_env_file(paths)
|
||
api_key = env_data.get("BINANCE_API_KEY", "").strip()
|
||
api_secret = env_data.get("BINANCE_API_SECRET", "").strip()
|
||
|
||
if not api_key:
|
||
key_input = input("Binance API Key: ").strip()
|
||
if key_input:
|
||
set_env_value(paths, "BINANCE_API_KEY", key_input)
|
||
init_result["api_key_set"] = True
|
||
if not api_secret:
|
||
import getpass
|
||
|
||
secret_input = getpass.getpass("Binance API Secret: ").strip()
|
||
if secret_input:
|
||
set_env_value(paths, "BINANCE_API_SECRET", secret_input)
|
||
init_result["api_secret_set"] = True
|
||
|
||
print_output(init_result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "config":
|
||
paths = get_runtime_paths()
|
||
if args.config_command == "get":
|
||
if not args.key:
|
||
# List all config keys
|
||
config = load_config()
|
||
print_output(config, agent=args.agent)
|
||
return 0
|
||
config = load_config()
|
||
value = get_config_value(config, args.key)
|
||
if value is None:
|
||
print(f"error: key '{args.key}' not found in config", file=sys.stderr)
|
||
return 1
|
||
print_output({args.key: value}, agent=args.agent)
|
||
return 0
|
||
|
||
if args.config_command == "set":
|
||
set_config_value(paths.config_file, args.key, args.value)
|
||
print_output({"key": args.key, "value": args.value, "status": "updated"}, agent=args.agent)
|
||
return 0
|
||
|
||
if args.config_command == "key":
|
||
value = args.value
|
||
if not value and sys.stdin.isatty():
|
||
value = input("Binance API Key: ").strip()
|
||
if not value:
|
||
print("error: API key is required", file=sys.stderr)
|
||
return 1
|
||
set_env_value(paths, "BINANCE_API_KEY", value)
|
||
print_output({"key": "BINANCE_API_KEY", "status": "updated"}, agent=args.agent)
|
||
return 0
|
||
|
||
if args.config_command == "secret":
|
||
value = args.value
|
||
if not value and sys.stdin.isatty():
|
||
import getpass
|
||
|
||
value = getpass.getpass("Binance API Secret: ").strip()
|
||
if not value:
|
||
print("error: API secret is required", file=sys.stderr)
|
||
return 1
|
||
set_env_value(paths, "BINANCE_API_SECRET", value)
|
||
print_output({"key": "BINANCE_API_SECRET", "status": "updated"}, agent=args.agent)
|
||
return 0
|
||
|
||
parser.error("config requires one of: get, set, key, secret")
|
||
|
||
if args.command == "completion":
|
||
import shtab
|
||
|
||
print(shtab.complete(parser, shell=args.shell, preamble=""))
|
||
return 0
|
||
|
||
if args.command == "upgrade":
|
||
with with_spinner("Upgrading coinhunter...", enabled=not args.agent):
|
||
result = self_upgrade()
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "catlog":
|
||
with with_spinner("Reading audit logs...", enabled=not args.agent):
|
||
entries = read_audit_log(limit=args.limit, offset=args.offset, dry_run=args.dry_run)
|
||
print_output(
|
||
{"entries": entries, "limit": args.limit, "offset": args.offset, "total": len(entries), "dry_run": args.dry_run},
|
||
agent=args.agent,
|
||
)
|
||
return 0
|
||
|
||
config = load_config()
|
||
|
||
if args.command == "account":
|
||
spot_client = _load_spot_client(config)
|
||
with with_spinner("Fetching balances...", enabled=not args.agent):
|
||
result = account_service.get_balances(config, spot_client=spot_client)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "market":
|
||
spot_client = _load_spot_client(config)
|
||
if args.market_command == "tickers":
|
||
with with_spinner("Fetching tickers...", enabled=not args.agent):
|
||
result = market_service.get_tickers(
|
||
config, args.symbols, spot_client=spot_client, window=args.window
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
if args.market_command == "klines":
|
||
with with_spinner("Fetching klines...", enabled=not args.agent):
|
||
result = market_service.get_klines(
|
||
config,
|
||
args.symbols,
|
||
interval=args.interval,
|
||
limit=args.limit,
|
||
spot_client=spot_client,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
parser.error("market requires one of: tickers, klines")
|
||
|
||
if args.command == "buy":
|
||
spot_client = _load_spot_client(config)
|
||
with with_spinner("Placing order...", enabled=not args.agent):
|
||
result = trade_service.execute_spot_trade(
|
||
config,
|
||
side="buy",
|
||
symbol=args.symbol,
|
||
qty=args.qty,
|
||
quote=args.quote,
|
||
order_type=args.type,
|
||
price=args.price,
|
||
dry_run=True if args.dry_run else None,
|
||
spot_client=spot_client,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "sell":
|
||
spot_client = _load_spot_client(config)
|
||
with with_spinner("Placing order...", enabled=not args.agent):
|
||
result = trade_service.execute_spot_trade(
|
||
config,
|
||
side="sell",
|
||
symbol=args.symbol,
|
||
qty=args.qty,
|
||
quote=None,
|
||
order_type=args.type,
|
||
price=args.price,
|
||
dry_run=True if args.dry_run else None,
|
||
spot_client=spot_client,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "portfolio":
|
||
spot_client = _load_spot_client(config)
|
||
with with_spinner("Analyzing portfolio...", enabled=not args.agent):
|
||
result = portfolio_service.analyze_portfolio(config, spot_client=spot_client)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "strategy":
|
||
spot_client = _load_spot_client(config)
|
||
with with_spinner("Generating trade signals...", enabled=not args.agent):
|
||
result = strategy_service.generate_trade_signals(
|
||
config, spot_client=spot_client, symbols=args.symbols
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "backtest":
|
||
with with_spinner("Running backtest...", enabled=not args.agent):
|
||
result = backtest_service.run_backtest(
|
||
config,
|
||
dataset_path=args.dataset,
|
||
initial_cash=args.initial_cash,
|
||
max_positions=args.max_positions,
|
||
position_size_pct=args.position_size_pct / 100.0 if args.position_size_pct is not None else None,
|
||
commission_pct=args.commission_pct / 100.0 if args.commission_pct is not None else None,
|
||
lookback=args.lookback,
|
||
decision_interval_minutes=args.decision_interval,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
if args.command == "opportunity":
|
||
if args.opportunity_command == "optimize":
|
||
with with_spinner("Optimizing opportunity model...", enabled=not args.agent):
|
||
result = opportunity_evaluation_service.optimize_opportunity_model(
|
||
config,
|
||
dataset_path=args.dataset,
|
||
horizon_hours=args.horizon_hours,
|
||
take_profit=args.take_profit_pct / 100.0 if args.take_profit_pct is not None else None,
|
||
stop_loss=args.stop_loss_pct / 100.0 if args.stop_loss_pct is not None else None,
|
||
setup_target=args.setup_target_pct / 100.0 if args.setup_target_pct is not None else None,
|
||
lookback=args.lookback,
|
||
top_n=args.top_n,
|
||
passes=args.passes,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
if args.opportunity_command == "evaluate":
|
||
with with_spinner("Evaluating opportunity dataset...", enabled=not args.agent):
|
||
result = opportunity_evaluation_service.evaluate_opportunity_dataset(
|
||
config,
|
||
dataset_path=args.dataset,
|
||
horizon_hours=args.horizon_hours,
|
||
take_profit=args.take_profit_pct / 100.0 if args.take_profit_pct is not None else None,
|
||
stop_loss=args.stop_loss_pct / 100.0 if args.stop_loss_pct is not None else None,
|
||
setup_target=args.setup_target_pct / 100.0 if args.setup_target_pct is not None else None,
|
||
lookback=args.lookback,
|
||
top_n=args.top_n,
|
||
max_examples=args.examples,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
if args.opportunity_command == "dataset":
|
||
with with_spinner("Collecting opportunity dataset...", enabled=not args.agent):
|
||
result = opportunity_dataset_service.collect_opportunity_dataset(
|
||
config,
|
||
symbols=args.symbols,
|
||
simulate_days=args.simulate_days,
|
||
run_days=args.run_days,
|
||
output_path=args.output,
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
spot_client = _load_spot_client(config)
|
||
with with_spinner("Scanning opportunities...", enabled=not args.agent):
|
||
result = opportunity_service.scan_opportunities(
|
||
config, spot_client=spot_client, symbols=args.symbols
|
||
)
|
||
print_output(result, agent=args.agent)
|
||
return 0
|
||
|
||
parser.error(f"Unsupported command {args.command}")
|
||
return 2
|
||
except Exception as exc:
|
||
print(f"error: {exc}", file=sys.stderr)
|
||
return 1
|