refactor: rewrite to CoinHunter V2 flat architecture
Replace the V1 commands/services split with a flat, direct architecture: - cli.py dispatches directly to service functions - New services: account, market, trade, opportunity - Thin Binance wrappers: spot_client, um_futures_client - Add audit logging, runtime paths, and TOML config - Remove legacy V1 code: commands/, precheck, review engine, smart executor - Add ruff + mypy toolchain and fix edge cases in trade params Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
344
src/coinhunter/cli.py
Executable file → Normal file
344
src/coinhunter/cli.py
Executable file → Normal file
@@ -1,146 +1,238 @@
|
||||
"""CoinHunter unified CLI entrypoint."""
|
||||
"""CoinHunter V2 CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
|
||||
MODULE_MAP = {
|
||||
"check-api": "commands.check_api",
|
||||
"doctor": "commands.doctor",
|
||||
"external-gate": "commands.external_gate",
|
||||
"init": "commands.init_user_state",
|
||||
"market-probe": "commands.market_probe",
|
||||
"paths": "commands.paths",
|
||||
"precheck": "commands.precheck",
|
||||
"review-context": "commands.review_context",
|
||||
"review-engine": "commands.review_engine",
|
||||
"rotate-external-gate-log": "commands.rotate_external_gate_log",
|
||||
"smart-executor": "commands.smart_executor",
|
||||
}
|
||||
|
||||
ALIASES = {
|
||||
"api-check": "check-api",
|
||||
"diag": "doctor",
|
||||
"env": "paths",
|
||||
"gate": "external-gate",
|
||||
"pre": "precheck",
|
||||
"probe": "market-probe",
|
||||
"review": "review-context",
|
||||
"recap": "review-engine",
|
||||
"rotate-gate-log": "rotate-external-gate-log",
|
||||
"rotate-log": "rotate-external-gate-log",
|
||||
"scan": "precheck",
|
||||
"setup": "init",
|
||||
"exec": "smart-executor",
|
||||
}
|
||||
|
||||
COMMAND_HELP = [
|
||||
("api-check", "check-api", "Validate exchange/API connectivity"),
|
||||
("diag", "doctor", "Inspect runtime wiring and diagnostics"),
|
||||
("gate", "external-gate", "Run external gate orchestration"),
|
||||
("setup", "init", "Initialize user runtime state"),
|
||||
("env", "paths", "Print runtime path resolution"),
|
||||
("pre, scan", "precheck", "Run precheck workflow"),
|
||||
("probe", "market-probe", "Query external market data"),
|
||||
("review", "review-context", "Generate review context"),
|
||||
("recap", "review-engine", "Generate review recap/engine output"),
|
||||
("rotate-gate-log, rotate-log", "rotate-external-gate-log", "Rotate external gate logs"),
|
||||
("exec", "smart-executor", "Trading and execution actions"),
|
||||
]
|
||||
from .binance.spot_client import SpotBinanceClient
|
||||
from .binance.um_futures_client import UMFuturesClient
|
||||
from .config import ensure_init_files, get_binance_credentials, load_config
|
||||
from .runtime import get_runtime_paths, print_json
|
||||
from .services import account_service, market_service, opportunity_service, trade_service
|
||||
|
||||
|
||||
def _command_listing() -> str:
|
||||
lines = []
|
||||
for names, canonical, summary in COMMAND_HELP:
|
||||
label = names if canonical is None else f"{names} (alias for {canonical})"
|
||||
lines.append(f" {label:<45} {summary}")
|
||||
return "\n".join(lines)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class VersionAction(argparse.Action):
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
print(__version__)
|
||||
raise SystemExit(0)
|
||||
def _load_futures_client(config: dict[str, Any], *, client: Any | None = None) -> UMFuturesClient:
|
||||
credentials = get_binance_credentials()
|
||||
binance_config = config["binance"]
|
||||
return UMFuturesClient(
|
||||
api_key=credentials["api_key"],
|
||||
api_secret=credentials["api_secret"],
|
||||
base_url=binance_config["futures_base_url"],
|
||||
recv_window=int(binance_config["recv_window"]),
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_market_flags(args: argparse.Namespace) -> tuple[bool, bool]:
|
||||
if getattr(args, "spot", False) or getattr(args, "futures", False):
|
||||
return bool(getattr(args, "spot", False)), bool(getattr(args, "futures", False))
|
||||
return True, True
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="coinhunter",
|
||||
description="CoinHunter trading operations CLI",
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
epilog=(
|
||||
"Commands:\n"
|
||||
f"{_command_listing()}\n\n"
|
||||
"Examples:\n"
|
||||
" coinhunter diag\n"
|
||||
" coinhunter env\n"
|
||||
" coinhunter setup\n"
|
||||
" coinhunter api-check\n"
|
||||
" coinhunter exec bal\n"
|
||||
" coinhunter exec overview\n"
|
||||
" coinhunter exec hold\n"
|
||||
" coinhunter exec --analysis '...' --reasoning '...' buy ENJUSDT 50\n"
|
||||
" coinhunter exec orders\n"
|
||||
" coinhunter exec order-status ENJUSDT 123456\n"
|
||||
" coinhunter exec cancel ENJUSDT 123456\n"
|
||||
" coinhunter pre\n"
|
||||
" coinhunter pre --ack 'Analysis complete: HOLD'\n"
|
||||
" coinhunter gate\n"
|
||||
" coinhunter review 12\n"
|
||||
" coinhunter recap 12\n"
|
||||
" coinhunter probe bybit-ticker BTCUSDT\n"
|
||||
"\n"
|
||||
"Preferred exec verbs are bal, overview, hold, buy, flat, rotate, orders, order-status, and cancel.\n"
|
||||
"Legacy command names remain supported for backward compatibility.\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--version", nargs=0, action=VersionAction, help="Print installed version and exit")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
nargs="?",
|
||||
metavar="COMMAND",
|
||||
help="Command to run. Use --help to see canonical names and short aliases.",
|
||||
)
|
||||
parser.add_argument("args", nargs=argparse.REMAINDER)
|
||||
parser = argparse.ArgumentParser(prog="coinhunter", description="CoinHunter V2 Binance-first trading CLI")
|
||||
parser.add_argument("--version", action="version", version=__version__)
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
init_parser = subparsers.add_parser("init", help="Generate config.toml, .env, and log directory")
|
||||
init_parser.add_argument("--force", action="store_true")
|
||||
|
||||
account_parser = subparsers.add_parser("account", help="Account overview, balances, and positions")
|
||||
account_subparsers = account_parser.add_subparsers(dest="account_command")
|
||||
for name in ("overview", "balances", "positions"):
|
||||
sub = account_subparsers.add_parser(name)
|
||||
sub.add_argument("--spot", action="store_true")
|
||||
sub.add_argument("--futures", action="store_true")
|
||||
|
||||
market_parser = subparsers.add_parser("market", help="Batch market queries")
|
||||
market_subparsers = market_parser.add_subparsers(dest="market_command")
|
||||
tickers_parser = market_subparsers.add_parser("tickers")
|
||||
tickers_parser.add_argument("symbols", nargs="+")
|
||||
klines_parser = market_subparsers.add_parser("klines")
|
||||
klines_parser.add_argument("symbols", nargs="+")
|
||||
klines_parser.add_argument("--interval", default="1h")
|
||||
klines_parser.add_argument("--limit", type=int, default=100)
|
||||
|
||||
trade_parser = subparsers.add_parser("trade", help="Spot and futures trade execution")
|
||||
trade_subparsers = trade_parser.add_subparsers(dest="trade_market")
|
||||
|
||||
spot_parser = trade_subparsers.add_parser("spot")
|
||||
spot_subparsers = spot_parser.add_subparsers(dest="trade_action")
|
||||
for side in ("buy", "sell"):
|
||||
sub = spot_subparsers.add_parser(side)
|
||||
sub.add_argument("symbol")
|
||||
sub.add_argument("--qty", type=float)
|
||||
sub.add_argument("--quote", type=float)
|
||||
sub.add_argument("--type", choices=["market", "limit"], default="market")
|
||||
sub.add_argument("--price", type=float)
|
||||
sub.add_argument("--dry-run", action="store_true")
|
||||
|
||||
futures_parser = trade_subparsers.add_parser("futures")
|
||||
futures_subparsers = futures_parser.add_subparsers(dest="trade_action")
|
||||
for side in ("buy", "sell"):
|
||||
sub = futures_subparsers.add_parser(side)
|
||||
sub.add_argument("symbol")
|
||||
sub.add_argument("--qty", type=float, required=True)
|
||||
sub.add_argument("--type", choices=["market", "limit"], default="market")
|
||||
sub.add_argument("--price", type=float)
|
||||
sub.add_argument("--reduce-only", action="store_true")
|
||||
sub.add_argument("--dry-run", action="store_true")
|
||||
close_parser = futures_subparsers.add_parser("close")
|
||||
close_parser.add_argument("symbol")
|
||||
close_parser.add_argument("--dry-run", action="store_true")
|
||||
|
||||
opportunity_parser = subparsers.add_parser("opportunity", help="Portfolio analysis and market scanning")
|
||||
opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command")
|
||||
opportunity_subparsers.add_parser("portfolio")
|
||||
scan_parser = opportunity_subparsers.add_parser("scan")
|
||||
scan_parser.add_argument("--symbols", nargs="*")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def run_python_module(module_name: str, argv: list[str], display_name: str) -> int:
|
||||
module = importlib.import_module(f".{module_name}", package="coinhunter")
|
||||
if not hasattr(module, "main"):
|
||||
raise RuntimeError(f"Module {module_name} has no main()")
|
||||
old_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = [display_name, *argv]
|
||||
result = module.main()
|
||||
return int(result) if isinstance(result, int) else 0
|
||||
except SystemExit as exc:
|
||||
return exc.code if isinstance(exc.code, int) else 0
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
parsed = parser.parse_args()
|
||||
if not parsed.command:
|
||||
parser.print_help()
|
||||
return 0
|
||||
command = ALIASES.get(parsed.command, parsed.command)
|
||||
if command not in MODULE_MAP:
|
||||
parser.error(
|
||||
f"invalid command: {parsed.command!r}. Use `coinhunter --help` to see supported commands and aliases."
|
||||
)
|
||||
module_name = MODULE_MAP[command]
|
||||
argv = list(parsed.args)
|
||||
if argv and argv[0] == "--":
|
||||
argv = argv[1:]
|
||||
return run_python_module(module_name, argv, f"coinhunter {parsed.command}")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
if args.command == "init":
|
||||
print_json(ensure_init_files(get_runtime_paths(), force=args.force))
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
config = load_config()
|
||||
|
||||
if args.command == "account":
|
||||
include_spot, include_futures = _resolve_market_flags(args)
|
||||
spot_client = _load_spot_client(config) if include_spot else None
|
||||
futures_client = _load_futures_client(config) if include_futures else None
|
||||
if args.account_command == "overview":
|
||||
print_json(
|
||||
account_service.get_overview(
|
||||
config,
|
||||
include_spot=include_spot,
|
||||
include_futures=include_futures,
|
||||
spot_client=spot_client,
|
||||
futures_client=futures_client,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.account_command == "balances":
|
||||
print_json(
|
||||
account_service.get_balances(
|
||||
config,
|
||||
include_spot=include_spot,
|
||||
include_futures=include_futures,
|
||||
spot_client=spot_client,
|
||||
futures_client=futures_client,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.account_command == "positions":
|
||||
print_json(
|
||||
account_service.get_positions(
|
||||
config,
|
||||
include_spot=include_spot,
|
||||
include_futures=include_futures,
|
||||
spot_client=spot_client,
|
||||
futures_client=futures_client,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
parser.error("account requires one of: overview, balances, positions")
|
||||
|
||||
if args.command == "market":
|
||||
spot_client = _load_spot_client(config)
|
||||
if args.market_command == "tickers":
|
||||
print_json(market_service.get_tickers(config, args.symbols, spot_client=spot_client))
|
||||
return 0
|
||||
if args.market_command == "klines":
|
||||
print_json(
|
||||
market_service.get_klines(
|
||||
config,
|
||||
args.symbols,
|
||||
interval=args.interval,
|
||||
limit=args.limit,
|
||||
spot_client=spot_client,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
parser.error("market requires one of: tickers, klines")
|
||||
|
||||
if args.command == "trade":
|
||||
if args.trade_market == "spot":
|
||||
spot_client = _load_spot_client(config)
|
||||
print_json(
|
||||
trade_service.execute_spot_trade(
|
||||
config,
|
||||
side=args.trade_action,
|
||||
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,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
if args.trade_market == "futures":
|
||||
futures_client = _load_futures_client(config)
|
||||
if args.trade_action == "close":
|
||||
print_json(
|
||||
trade_service.close_futures_position(
|
||||
config,
|
||||
symbol=args.symbol,
|
||||
dry_run=True if args.dry_run else None,
|
||||
futures_client=futures_client,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
print_json(
|
||||
trade_service.execute_futures_trade(
|
||||
config,
|
||||
side=args.trade_action,
|
||||
symbol=args.symbol,
|
||||
qty=args.qty,
|
||||
order_type=args.type,
|
||||
price=args.price,
|
||||
reduce_only=args.reduce_only,
|
||||
dry_run=True if args.dry_run else None,
|
||||
futures_client=futures_client,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
parser.error("trade requires `spot` or `futures`")
|
||||
|
||||
if args.command == "opportunity":
|
||||
spot_client = _load_spot_client(config)
|
||||
if args.opportunity_command == "portfolio":
|
||||
print_json(opportunity_service.analyze_portfolio(config, spot_client=spot_client))
|
||||
return 0
|
||||
if args.opportunity_command == "scan":
|
||||
print_json(opportunity_service.scan_opportunities(config, spot_client=spot_client, symbols=args.symbols))
|
||||
return 0
|
||||
parser.error("opportunity requires `portfolio` or `scan`")
|
||||
|
||||
parser.error(f"Unsupported command {args.command}")
|
||||
return 2
|
||||
except Exception as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user