feat: add Braille spinner, shell completions, and TUI polish

- Add with_spinner context manager with cyan Braille animation for human mode.
- Wrap all query/execution commands in cli.py with loading spinners.
- Integrate shtab: auto-install shell completions during init for zsh/bash.
- Add `completion` subcommand for manual script generation.
- Fix stale output_format default in DEFAULT_CONFIG (json → tui).
- Add help descriptions to all second-level subcommands.
- Version 2.0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 19:11:40 +08:00
parent b857ea33f3
commit 536425e8ea
6 changed files with 257 additions and 79 deletions

View File

@@ -10,7 +10,7 @@ from . import __version__
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_output, self_upgrade
from .runtime import get_runtime_paths, install_shell_completion, print_output, self_upgrade, with_spinner
from .services import account_service, market_service, opportunity_service, trade_service
EPILOG = """\
@@ -72,8 +72,13 @@ def build_parser() -> argparse.ArgumentParser:
account_parser = subparsers.add_parser("account", help="Account overview, balances, and positions")
account_subparsers = account_parser.add_subparsers(dest="account_command")
account_commands_help = {
"overview": "Total equity and summary across markets",
"balances": "List asset balances",
"positions": "List open positions",
}
for name in ("overview", "balances", "positions"):
sub = account_subparsers.add_parser(name)
sub = account_subparsers.add_parser(name, help=account_commands_help[name])
sub.add_argument("-s", "--spot", action="store_true", help="Include spot market")
sub.add_argument("-f", "--futures", action="store_true", help="Include futures market")
@@ -91,8 +96,9 @@ def build_parser() -> argparse.ArgumentParser:
spot_parser = trade_subparsers.add_parser("spot", help="Spot market orders")
spot_subparsers = spot_parser.add_subparsers(dest="trade_action")
spot_side_help = {"buy": "Buy base asset with quote quantity", "sell": "Sell base asset quantity"}
for side in ("buy", "sell"):
sub = spot_subparsers.add_parser(side)
sub = spot_subparsers.add_parser(side, help=spot_side_help[side])
sub.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
sub.add_argument("-q", "--qty", type=float, help="Base asset quantity")
sub.add_argument("-Q", "--quote", type=float, help="Quote asset amount (buy market only)")
@@ -102,8 +108,9 @@ def build_parser() -> argparse.ArgumentParser:
futures_parser = trade_subparsers.add_parser("futures", help="USDT-M futures orders")
futures_subparsers = futures_parser.add_subparsers(dest="trade_action")
futures_side_help = {"buy": "Open or add to a LONG position", "sell": "Open or add to a SHORT position"}
for side in ("buy", "sell"):
sub = futures_subparsers.add_parser(side)
sub = futures_subparsers.add_parser(side, help=futures_side_help[side])
sub.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
sub.add_argument("-q", "--qty", type=float, required=True, help="Contract quantity")
sub.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
@@ -122,6 +129,9 @@ def build_parser() -> argparse.ArgumentParser:
subparsers.add_parser("upgrade", help="Upgrade coinhunter to the latest version")
completion_parser = subparsers.add_parser("completion", help="Generate shell completion script")
completion_parser.add_argument("shell", choices=["bash", "zsh"], help="Target shell")
return parser
@@ -134,7 +144,15 @@ def main(argv: list[str] | None = None) -> int:
return 0
if args.command == "init":
print_output(ensure_init_files(get_runtime_paths(), force=args.force), agent=args.agent)
init_result = ensure_init_files(get_runtime_paths(), force=args.force)
init_result["completion"] = install_shell_completion(parser)
print_output(init_result, agent=args.agent)
return 0
if args.command == "completion":
import shtab
print(shtab.complete(parser, shell=args.shell, preamble=""))
return 0
config = load_config()
@@ -144,117 +162,127 @@ def main(argv: list[str] | None = None) -> int:
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_output(
account_service.get_overview(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
agent=args.agent,
)
with with_spinner("Fetching account overview...", enabled=not args.agent):
print_output(
account_service.get_overview(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
agent=args.agent,
)
return 0
if args.account_command == "balances":
print_output(
account_service.get_balances(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
agent=args.agent,
)
with with_spinner("Fetching balances...", enabled=not args.agent):
print_output(
account_service.get_balances(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
agent=args.agent,
)
return 0
if args.account_command == "positions":
print_output(
account_service.get_positions(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
agent=args.agent,
)
with with_spinner("Fetching positions...", enabled=not args.agent):
print_output(
account_service.get_positions(
config,
include_spot=include_spot,
include_futures=include_futures,
spot_client=spot_client,
futures_client=futures_client,
),
agent=args.agent,
)
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_output(market_service.get_tickers(config, args.symbols, spot_client=spot_client), agent=args.agent)
with with_spinner("Fetching tickers...", enabled=not args.agent):
print_output(market_service.get_tickers(config, args.symbols, spot_client=spot_client), agent=args.agent)
return 0
if args.market_command == "klines":
print_output(
market_service.get_klines(
config,
args.symbols,
interval=args.interval,
limit=args.limit,
spot_client=spot_client,
),
agent=args.agent,
)
with with_spinner("Fetching klines...", enabled=not args.agent):
print_output(
market_service.get_klines(
config,
args.symbols,
interval=args.interval,
limit=args.limit,
spot_client=spot_client,
),
agent=args.agent,
)
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_output(
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,
),
agent=args.agent,
)
with with_spinner("Placing spot order...", enabled=not args.agent):
print_output(
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,
),
agent=args.agent,
)
return 0
if args.trade_market == "futures":
futures_client = _load_futures_client(config)
if args.trade_action == "close":
with with_spinner("Closing futures position...", enabled=not args.agent):
print_output(
trade_service.close_futures_position(
config,
symbol=args.symbol,
dry_run=True if args.dry_run else None,
futures_client=futures_client,
),
agent=args.agent,
)
return 0
with with_spinner("Placing futures order...", enabled=not args.agent):
print_output(
trade_service.close_futures_position(
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,
),
agent=args.agent,
)
return 0
print_output(
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,
),
agent=args.agent,
)
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_output(opportunity_service.analyze_portfolio(config, spot_client=spot_client), agent=args.agent)
with with_spinner("Analyzing portfolio...", enabled=not args.agent):
print_output(opportunity_service.analyze_portfolio(config, spot_client=spot_client), agent=args.agent)
return 0
if args.opportunity_command == "scan":
print_output(opportunity_service.scan_opportunities(config, spot_client=spot_client, symbols=args.symbols), agent=args.agent)
with with_spinner("Scanning opportunities...", enabled=not args.agent):
print_output(opportunity_service.scan_opportunities(config, spot_client=spot_client, symbols=args.symbols), agent=args.agent)
return 0
parser.error("opportunity requires `portfolio` or `scan`")