Merge remote-tracking branch 'origin/main' into main

This commit is contained in:
2026-04-17 16:57:40 +08:00
4 changed files with 366 additions and 59 deletions

View File

@@ -28,6 +28,12 @@ pipx install coinhunter
coinhunter --help
```
You can also use the shorter `coin` alias:
```bash
coin --help
```
Check the installed version:
```bash
@@ -68,25 +74,41 @@ Override the default home directory with `COINHUNTER_HOME`.
By default, CoinHunter prints human-friendly TUI tables. Add `--agent` to any command to get JSON output (or compact pipe-delimited tables for large datasets).
Add `--doc` to any command to see its output schema and field descriptions (great for AI agents):
```bash
# Account
coin buy --doc
coin market klines --doc
```
### Examples
```bash
# Account (aliases: a, acc)
coinhunter account overview
coinhunter account overview --agent
coinhunter account balances
coinhunter account positions
coin a ov
coin acc bal
coin a pos
# Market
# Market (aliases: m)
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt
coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50
coin m tk BTCUSDT ETHUSDT
coin m k BTCUSDT -i 1h -l 50
# Trade
coinhunter trade buy BTCUSDT --quote 100 --dry-run
coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000
# Trade (buy / sell are now top-level commands)
coinhunter buy BTCUSDT --quote 100 --dry-run
coinhunter sell BTCUSDT --qty 0.01 --type limit --price 90000
coin b BTCUSDT -Q 100 -d
coin s BTCUSDT -q 0.01 -t limit -p 90000
# Opportunities
# Opportunities (aliases: opp, o)
coinhunter opportunity portfolio
coinhunter opportunity scan
coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT
coin opp pf
coin o scan -s BTCUSDT ETHUSDT
# Audit log
coinhunter catlog
@@ -95,6 +117,7 @@ coinhunter catlog -n 10 -o 10
# Self-upgrade
coinhunter upgrade
coin upgrade
# Shell completion (manual)
coinhunter completion zsh > ~/.zsh/completions/_coinhunter

View File

@@ -14,9 +14,6 @@ dependencies = [
"shtab>=1.7.0",
"tomli>=2.0.1; python_version < '3.11'",
]
authors = [
{name = "Tacit Lab", email = "ouyangcarlos@gmail.com"}
]
[project.optional-dependencies]
dev = [
@@ -27,6 +24,7 @@ dev = [
[project.scripts]
coinhunter = "coinhunter.cli:main"
coin = "coinhunter.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
@@ -36,13 +34,10 @@ where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py310"
line-length = 120
addopts = "-v"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "W"]
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.pydocstyle]
@@ -51,7 +46,5 @@ convention = "google"
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
warn_unused_ignores = true
ignore_missing_imports = true
exclude = [".venv", "build"]

View File

@@ -15,16 +15,178 @@ from .services import account_service, market_service, opportunity_service, trad
EPILOG = """\
examples:
coinhunter init
coinhunter account overview
coinhunter market tickers BTCUSDT ETHUSDT
coinhunter market klines BTCUSDT -i 1h -l 50
coinhunter trade buy BTCUSDT -q 100 -d
coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000
coinhunter opportunity scan -s BTCUSDT ETHUSDT
coinhunter upgrade
coin init
coin acc ov
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 opp scan -s BTCUSDT ETHUSDT
coin upgrade
"""
COMMAND_DOCS: dict[str, str] = {
"init": """\
Output: JSON
{
"root": "~/.coinhunter",
"files_created": ["config.toml", ".env"],
"completion": {"shell": "zsh", "installed": true}
}
Fields:
root runtime directory path
files_created list of generated files
completion shell completion installation status
""",
"account/overview": """\
Output: JSON
{
"total_btc": 1.234,
"total_usdt": 50000.0,
"assets": [{"asset": "BTC", "free": 0.5, "locked": 0.1}]
}
Fields:
total_btc total equity denominated in BTC
total_usdt total equity denominated in USDT
assets list of non-zero balances
""",
"account/balances": """\
Output: JSON array
[
{"asset": "BTC", "free": 0.5, "locked": 0.1, "total": 0.6}
]
Fields:
asset asset symbol
free available balance
locked frozen/locked balance
total free + locked
""",
"account/positions": """\
Output: JSON array
[
{"symbol": "BTCUSDT", "positionAmt": 0.01, "entryPrice": 90000.0}
]
Fields:
symbol trading pair
positionAmt quantity held (positive long, negative short)
entryPrice average entry price
""",
"market/tickers": """\
Output: JSON object keyed by normalized symbol
{
"BTCUSDT": {"lastPrice": "70000.00", "priceChangePercent": "2.5", "volume": "12345.67"}
}
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:
open_time candle open timestamp (ms)
open/high/low/close OHLC prices
volume traded base volume
""",
"buy": """\
Output: JSON
{
"trade": {
"market_type": "spot",
"symbol": "BTCUSDT",
"side": "BUY",
"order_type": "MARKET",
"status": "DRY_RUN",
"dry_run": true,
"request_payload": {...},
"response_payload": {...}
}
}
Fields:
market_type "spot"
side "BUY"
order_type MARKET or LIMIT
status order status from exchange (or DRY_RUN)
dry_run whether simulated
request_payload normalized order sent to Binance
response_payload raw exchange response
""",
"sell": """\
Output: JSON
{
"trade": {
"market_type": "spot",
"symbol": "BTCUSDT",
"side": "SELL",
"order_type": "LIMIT",
"status": "FILLED",
"dry_run": false,
"request_payload": {...},
"response_payload": {...}
}
}
Fields:
market_type "spot"
side "SELL"
order_type MARKET or LIMIT
status order status from exchange (or DRY_RUN)
dry_run whether simulated
request_payload normalized order sent to Binance
response_payload raw exchange response
""",
"opportunity/portfolio": """\
Output: JSON
{
"scores": [
{"asset": "BTC", "score": 0.75, "metrics": {"volatility": 0.02, "trend": 0.01}}
]
}
Fields:
asset scored asset
score composite opportunity score (0-1)
metrics breakdown of contributing signals
""",
"opportunity/scan": """\
Output: JSON
{
"opportunities": [
{"symbol": "ETHUSDT", "score": 0.82, "signals": ["momentum", "volume_spike"]}
]
}
Fields:
symbol trading pair scanned
score opportunity score (0-1)
signals list of triggered signal names
""",
"upgrade": """\
Output: JSON
{
"command": "pip install --upgrade coinhunter",
"returncode": 0,
"stdout": "...",
"stderr": ""
}
Fields:
command shell command executed
returncode process exit code (0 = success)
stdout command standard output
stderr command standard error
""",
"completion": """\
Output: shell script text (not JSON)
# bash/zsh completion script for coinhunter
...
Fields:
(raw shell script suitable for sourcing)
""",
}
def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> SpotBinanceClient:
credentials = get_binance_credentials()
@@ -47,47 +209,54 @@ def build_parser() -> argparse.ArgumentParser:
)
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")
init_parser = subparsers.add_parser("init", help="Generate config.toml, .env, and log directory")
init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing files")
account_parser = subparsers.add_parser("account", help="Account overview, balances, and positions")
account_parser = subparsers.add_parser("account", aliases=["acc", "a"], help="Account overview, balances, and positions")
account_subparsers = account_parser.add_subparsers(dest="account_command")
account_commands_help = {
"overview": "Total equity and summary",
"balances": "List asset balances",
"positions": "List open positions",
}
account_aliases = {
"overview": ["ov"],
"balances": ["bal", "b"],
"positions": ["pos", "p"],
}
for name in ("overview", "balances", "positions"):
account_subparsers.add_parser(name, help=account_commands_help[name])
account_subparsers.add_parser(name, aliases=account_aliases[name], help=account_commands_help[name])
market_parser = subparsers.add_parser("market", help="Batch market queries")
market_parser = subparsers.add_parser("market", aliases=["m"], help="Batch market queries")
market_subparsers = market_parser.add_subparsers(dest="market_command")
tickers_parser = market_subparsers.add_parser("tickers", help="Fetch 24h ticker data")
tickers_parser = market_subparsers.add_parser("tickers", aliases=["tk", "t"], help="Fetch 24h ticker data")
tickers_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query (e.g. BTCUSDT ETH/USDT)")
klines_parser = market_subparsers.add_parser("klines", help="Fetch OHLCV klines")
klines_parser = market_subparsers.add_parser("klines", aliases=["k"], help="Fetch OHLCV klines")
klines_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query")
klines_parser.add_argument("-i", "--interval", default="1h", help="Kline interval (default: 1h)")
klines_parser.add_argument("-l", "--limit", type=int, default=100, help="Number of candles (default: 100)")
trade_parser = subparsers.add_parser("trade", help="Spot trade execution")
trade_subparsers = trade_parser.add_subparsers(dest="trade_action")
trade_side_help = {"buy": "Buy base asset with quote quantity", "sell": "Sell base asset quantity"}
for side in ("buy", "sell"):
sub = trade_subparsers.add_parser(side, help=trade_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)")
sub.add_argument(
"-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)"
)
sub.add_argument("-p", "--price", type=float, help="Limit price")
sub.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
buy_parser = subparsers.add_parser("buy", aliases=["b"], help="Buy base asset")
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")
opportunity_parser = subparsers.add_parser("opportunity", help="Portfolio analysis and market scanning")
sell_parser = subparsers.add_parser("sell", aliases=["s"], help="Sell base asset")
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")
opportunity_parser = subparsers.add_parser("opportunity", aliases=["opp", "o"], help="Portfolio analysis and market scanning")
opportunity_subparsers = opportunity_parser.add_subparsers(dest="opportunity_command")
opportunity_subparsers.add_parser("portfolio", help="Score current holdings")
opportunity_subparsers.add_parser("portfolio", aliases=["pf", "p"], help="Score current holdings")
scan_parser = opportunity_subparsers.add_parser("scan", help="Scan market for opportunities")
scan_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
@@ -105,9 +274,49 @@ def build_parser() -> argparse.ArgumentParser:
return parser
def _reorder_agent_flag(argv: list[str]) -> list[str]:
"""Move -a/--agent from after subcommands to before them so argparse can parse it."""
agent_flags = {"-a", "--agent"}
_CANONICAL_COMMANDS = {
"b": "buy",
"s": "sell",
"acc": "account",
"a": "account",
"m": "market",
"opp": "opportunity",
"o": "opportunity",
}
_CANONICAL_SUBCOMMANDS = {
"ov": "overview",
"bal": "balances",
"b": "balances",
"pos": "positions",
"p": "positions",
"tk": "tickers",
"t": "tickers",
"k": "klines",
"pf": "portfolio",
"p": "portfolio",
}
_COMMANDS_WITH_SUBCOMMANDS = {"account", "market", "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])
return f"{cmd}/{sub}"
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("-"):
@@ -116,21 +325,41 @@ def _reorder_agent_flag(argv: list[str]) -> list[str]:
if subcommand_idx is None:
return argv
new_argv: list[str] = []
agent_present = False
present = False
for i, arg in enumerate(argv):
if i >= subcommand_idx and arg in agent_flags:
agent_present = True
if i >= subcommand_idx and arg in flags:
present = True
continue
new_argv.append(arg)
if agent_present:
new_argv.insert(subcommand_idx, "--agent")
if present:
new_argv.insert(subcommand_idx, flag)
return new_argv
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
raw_argv = argv if argv is not None else sys.argv[1:]
args = parser.parse_args(_reorder_agent_flag(raw_argv))
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
doc = COMMAND_DOCS.get(doc_key, f"No documentation available for {doc_key}.")
print(doc)
return 0
parser = build_parser()
raw_argv = _reorder_flag(raw_argv, "--agent", "-a")
args = parser.parse_args(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", "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()
@@ -189,12 +418,12 @@ def main(argv: list[str] | None = None) -> int:
return 0
parser.error("market requires one of: tickers, klines")
if args.command == "trade":
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=args.trade_action,
side="buy",
symbol=args.symbol,
qty=args.qty,
quote=args.quote,
@@ -206,6 +435,23 @@ def main(argv: list[str] | None = None) -> int:
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 == "opportunity":
spot_client = _load_spot_client(config)
if args.opportunity_command == "portfolio":

View File

@@ -15,7 +15,10 @@ class CLITestCase(unittest.TestCase):
help_text = parser.format_help()
self.assertIn("init", help_text)
self.assertIn("account", help_text)
self.assertIn("buy", help_text)
self.assertIn("sell", help_text)
self.assertIn("opportunity", help_text)
self.assertIn("--doc", help_text)
def test_init_dispatches(self):
captured = {}
@@ -46,6 +49,48 @@ class CLITestCase(unittest.TestCase):
self.assertEqual(result, 1)
self.assertIn("error: boom", stderr.getvalue())
def test_buy_dispatches(self):
captured = {}
with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object(
cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}
), patch.object(
cli, "SpotBinanceClient"
), patch.object(
cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}}
), patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
):
result = cli.main(["buy", "BTCUSDT", "-Q", "100"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
def test_sell_dispatches(self):
captured = {}
with patch.object(cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "trading": {"dry_run_default": True}}), patch.object(
cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}
), patch.object(
cli, "SpotBinanceClient"
), patch.object(
cli.trade_service, "execute_spot_trade", return_value={"trade": {"status": "DRY_RUN"}}
), patch.object(
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
):
result = cli.main(["sell", "BTCUSDT", "-q", "0.01"])
self.assertEqual(result, 0)
self.assertEqual(captured["payload"]["trade"]["status"], "DRY_RUN")
def test_doc_flag_prints_documentation(self):
import io
from unittest.mock import patch
stdout = io.StringIO()
with patch("sys.stdout", stdout):
result = cli.main(["market", "tickers", "--doc"])
self.assertEqual(result, 0)
output = stdout.getvalue()
self.assertIn("lastPrice", output)
self.assertIn("BTCUSDT", output)
def test_upgrade_dispatches(self):
captured = {}
with (