Merge remote-tracking branch 'origin/main' into main
This commit is contained in:
39
README.md
39
README.md
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user