feat: add CLI aliases, flatten trade commands, and introduce --doc
- Add `coin` entry-point alias alongside `coinhunter` - Add short aliases for all commands (e.g., a/acc, m, opp/o, b, s) - Flatten `buy` and `sell` to top-level commands; remove `trade` parent - Add `--doc` flag to print output schema and field descriptions per command - Update README and tests - Bump version to 2.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
39
README.md
39
README.md
@@ -28,6 +28,12 @@ pipx install coinhunter
|
|||||||
coinhunter --help
|
coinhunter --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also use the shorter `coin` alias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
coin --help
|
||||||
|
```
|
||||||
|
|
||||||
Check the installed version:
|
Check the installed version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -68,28 +74,45 @@ 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).
|
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
|
```bash
|
||||||
# Account
|
coin buy --doc
|
||||||
|
coin market klines --doc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Account (aliases: a, acc)
|
||||||
coinhunter account overview
|
coinhunter account overview
|
||||||
coinhunter account overview --agent
|
coinhunter account overview --agent
|
||||||
coinhunter account balances
|
coin a ov
|
||||||
coinhunter account positions
|
coin acc bal
|
||||||
|
coin a pos
|
||||||
|
|
||||||
# Market
|
# Market (aliases: m)
|
||||||
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt
|
coinhunter market tickers BTCUSDT ETH/USDT sol-usdt
|
||||||
coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50
|
coinhunter market klines BTCUSDT ETHUSDT --interval 1h --limit 50
|
||||||
|
coin m tk BTCUSDT ETHUSDT
|
||||||
|
coin m k BTCUSDT -i 1h -l 50
|
||||||
|
|
||||||
# Trade
|
# Trade (buy / sell are now top-level commands)
|
||||||
coinhunter trade buy BTCUSDT --quote 100 --dry-run
|
coinhunter buy BTCUSDT --quote 100 --dry-run
|
||||||
coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000
|
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 portfolio
|
||||||
coinhunter opportunity scan
|
coinhunter opportunity scan
|
||||||
coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT
|
coinhunter opportunity scan --symbols BTCUSDT ETHUSDT SOLUSDT
|
||||||
|
coin opp pf
|
||||||
|
coin o scan -s BTCUSDT ETHUSDT
|
||||||
|
|
||||||
# Self-upgrade
|
# Self-upgrade
|
||||||
coinhunter upgrade
|
coinhunter upgrade
|
||||||
|
coin upgrade
|
||||||
|
|
||||||
# Shell completion (manual)
|
# Shell completion (manual)
|
||||||
coinhunter completion zsh > ~/.zsh/completions/_coinhunter
|
coinhunter completion zsh > ~/.zsh/completions/_coinhunter
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "coinhunter"
|
name = "coinhunter"
|
||||||
version = "2.0.8"
|
version = "2.1.0"
|
||||||
description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution."
|
description = "Binance-first trading CLI for balances, market data, opportunity scanning, and execution."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
@@ -27,6 +27,7 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
coinhunter = "coinhunter.cli:main"
|
coinhunter = "coinhunter.cli:main"
|
||||||
|
coin = "coinhunter.cli:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
|||||||
@@ -14,16 +14,178 @@ from .services import account_service, market_service, opportunity_service, trad
|
|||||||
|
|
||||||
EPILOG = """\
|
EPILOG = """\
|
||||||
examples:
|
examples:
|
||||||
coinhunter init
|
coin init
|
||||||
coinhunter account overview
|
coin acc ov
|
||||||
coinhunter market tickers BTCUSDT ETHUSDT
|
coin m tk BTCUSDT ETHUSDT
|
||||||
coinhunter market klines BTCUSDT -i 1h -l 50
|
coin m k BTCUSDT -i 1h -l 50
|
||||||
coinhunter trade buy BTCUSDT -q 100 -d
|
coin buy BTCUSDT -Q 100 -d
|
||||||
coinhunter trade sell BTCUSDT --qty 0.01 --type limit --price 90000
|
coin sell BTCUSDT --qty 0.01 --type limit --price 90000
|
||||||
coinhunter opportunity scan -s BTCUSDT ETHUSDT
|
coin opp scan -s BTCUSDT ETHUSDT
|
||||||
coinhunter upgrade
|
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:
|
def _load_spot_client(config: dict[str, Any], *, client: Any | None = None) -> SpotBinanceClient:
|
||||||
credentials = get_binance_credentials()
|
credentials = get_binance_credentials()
|
||||||
@@ -46,45 +208,54 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
parser.add_argument("-v", "--version", action="version", version=__version__)
|
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("-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")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
init_parser = subparsers.add_parser("init", help="Generate config.toml, .env, and log directory")
|
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")
|
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_subparsers = account_parser.add_subparsers(dest="account_command")
|
||||||
account_commands_help = {
|
account_commands_help = {
|
||||||
"overview": "Total equity and summary",
|
"overview": "Total equity and summary",
|
||||||
"balances": "List asset balances",
|
"balances": "List asset balances",
|
||||||
"positions": "List open positions",
|
"positions": "List open positions",
|
||||||
}
|
}
|
||||||
|
account_aliases = {
|
||||||
|
"overview": ["ov"],
|
||||||
|
"balances": ["bal", "b"],
|
||||||
|
"positions": ["pos", "p"],
|
||||||
|
}
|
||||||
for name in ("overview", "balances", "positions"):
|
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")
|
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)")
|
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("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("-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)")
|
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")
|
buy_parser = subparsers.add_parser("buy", aliases=["b"], help="Buy base asset")
|
||||||
trade_subparsers = trade_parser.add_subparsers(dest="trade_action")
|
buy_parser.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
|
||||||
trade_side_help = {"buy": "Buy base asset with quote quantity", "sell": "Sell base asset quantity"}
|
buy_parser.add_argument("-q", "--qty", type=float, help="Base asset quantity (limit orders)")
|
||||||
for side in ("buy", "sell"):
|
buy_parser.add_argument("-Q", "--quote", type=float, help="Quote asset amount (market buy only)")
|
||||||
sub = trade_subparsers.add_parser(side, help=trade_side_help[side])
|
buy_parser.add_argument("-t", "--type", choices=["market", "limit"], default="market", help="Order type (default: market)")
|
||||||
sub.add_argument("symbol", metavar="SYM", help="Trading pair (e.g. BTCUSDT)")
|
buy_parser.add_argument("-p", "--price", type=float, help="Limit price")
|
||||||
sub.add_argument("-q", "--qty", type=float, help="Base asset quantity")
|
buy_parser.add_argument("-d", "--dry-run", action="store_true", help="Simulate without sending")
|
||||||
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")
|
|
||||||
|
|
||||||
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 = 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 = 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")
|
scan_parser.add_argument("-s", "--symbols", nargs="*", metavar="SYM", help="Restrict scan to specific symbols")
|
||||||
|
|
||||||
@@ -96,9 +267,49 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _reorder_agent_flag(argv: list[str]) -> list[str]:
|
_CANONICAL_COMMANDS = {
|
||||||
"""Move -a/--agent from after subcommands to before them so argparse can parse it."""
|
"b": "buy",
|
||||||
agent_flags = {"-a", "--agent"}
|
"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
|
subcommand_idx: int | None = None
|
||||||
for i, arg in enumerate(argv):
|
for i, arg in enumerate(argv):
|
||||||
if not arg.startswith("-"):
|
if not arg.startswith("-"):
|
||||||
@@ -107,21 +318,41 @@ def _reorder_agent_flag(argv: list[str]) -> list[str]:
|
|||||||
if subcommand_idx is None:
|
if subcommand_idx is None:
|
||||||
return argv
|
return argv
|
||||||
new_argv: list[str] = []
|
new_argv: list[str] = []
|
||||||
agent_present = False
|
present = False
|
||||||
for i, arg in enumerate(argv):
|
for i, arg in enumerate(argv):
|
||||||
if i >= subcommand_idx and arg in agent_flags:
|
if i >= subcommand_idx and arg in flags:
|
||||||
agent_present = True
|
present = True
|
||||||
continue
|
continue
|
||||||
new_argv.append(arg)
|
new_argv.append(arg)
|
||||||
if agent_present:
|
if present:
|
||||||
new_argv.insert(subcommand_idx, "--agent")
|
new_argv.insert(subcommand_idx, flag)
|
||||||
return new_argv
|
return new_argv
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = build_parser()
|
|
||||||
raw_argv = argv if argv is not None else sys.argv[1:]
|
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:
|
try:
|
||||||
if not args.command:
|
if not args.command:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
@@ -187,13 +418,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
parser.error("market requires one of: tickers, klines")
|
parser.error("market requires one of: tickers, klines")
|
||||||
|
|
||||||
if args.command == "trade":
|
if args.command == "buy":
|
||||||
spot_client = _load_spot_client(config)
|
spot_client = _load_spot_client(config)
|
||||||
with with_spinner("Placing order...", enabled=not args.agent):
|
with with_spinner("Placing order...", enabled=not args.agent):
|
||||||
print_output(
|
print_output(
|
||||||
trade_service.execute_spot_trade(
|
trade_service.execute_spot_trade(
|
||||||
config,
|
config,
|
||||||
side=args.trade_action,
|
side="buy",
|
||||||
symbol=args.symbol,
|
symbol=args.symbol,
|
||||||
qty=args.qty,
|
qty=args.qty,
|
||||||
quote=args.quote,
|
quote=args.quote,
|
||||||
@@ -206,6 +437,25 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "sell":
|
||||||
|
spot_client = _load_spot_client(config)
|
||||||
|
with with_spinner("Placing order...", enabled=not args.agent):
|
||||||
|
print_output(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
agent=args.agent,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.command == "opportunity":
|
if args.command == "opportunity":
|
||||||
spot_client = _load_spot_client(config)
|
spot_client = _load_spot_client(config)
|
||||||
if args.opportunity_command == "portfolio":
|
if args.opportunity_command == "portfolio":
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ class CLITestCase(unittest.TestCase):
|
|||||||
help_text = parser.format_help()
|
help_text = parser.format_help()
|
||||||
self.assertIn("init", help_text)
|
self.assertIn("init", help_text)
|
||||||
self.assertIn("account", help_text)
|
self.assertIn("account", help_text)
|
||||||
|
self.assertIn("buy", help_text)
|
||||||
|
self.assertIn("sell", help_text)
|
||||||
self.assertIn("opportunity", help_text)
|
self.assertIn("opportunity", help_text)
|
||||||
|
self.assertIn("--doc", help_text)
|
||||||
|
|
||||||
def test_init_dispatches(self):
|
def test_init_dispatches(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
@@ -40,6 +43,48 @@ class CLITestCase(unittest.TestCase):
|
|||||||
self.assertEqual(result, 1)
|
self.assertEqual(result, 1)
|
||||||
self.assertIn("error: boom", stderr.getvalue())
|
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):
|
def test_upgrade_dispatches(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
with patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}), patch.object(
|
with patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}), patch.object(
|
||||||
|
|||||||
Reference in New Issue
Block a user