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:
2026-04-16 17:22:29 +08:00
parent 3819e35a7b
commit 52cd76a750
78 changed files with 2023 additions and 5407 deletions

View File

@@ -0,0 +1,80 @@
"""Account and market service tests."""
from __future__ import annotations
import unittest
from coinhunter.services import account_service, market_service
class FakeSpotClient:
def account_info(self):
return {
"balances": [
{"asset": "USDT", "free": "120.0", "locked": "0"},
{"asset": "BTC", "free": "0.01", "locked": "0"},
{"asset": "DOGE", "free": "1", "locked": "0"},
]
}
def ticker_price(self, symbols=None):
prices = {
"BTCUSDT": {"symbol": "BTCUSDT", "price": "60000"},
"DOGEUSDT": {"symbol": "DOGEUSDT", "price": "0.1"},
}
if not symbols:
return list(prices.values())
return [prices[symbol] for symbol in symbols]
def ticker_24h(self, symbols=None):
rows = [
{"symbol": "BTCUSDT", "lastPrice": "60000", "priceChangePercent": "4.5", "quoteVolume": "10000000", "highPrice": "61000", "lowPrice": "58000"},
{"symbol": "ETHUSDT", "lastPrice": "3000", "priceChangePercent": "3.0", "quoteVolume": "8000000", "highPrice": "3050", "lowPrice": "2900"},
{"symbol": "DOGEUSDT", "lastPrice": "0.1", "priceChangePercent": "1.0", "quoteVolume": "200", "highPrice": "0.11", "lowPrice": "0.09"},
]
if not symbols:
return rows
wanted = set(symbols)
return [row for row in rows if row["symbol"] in wanted]
def exchange_info(self):
return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "BREAK"}]}
class FakeFuturesClient:
def balance(self):
return [{"asset": "USDT", "balance": "250.0", "availableBalance": "200.0"}]
def position_risk(self, symbol=None):
return [{"symbol": "BTCUSDT", "positionAmt": "0.02", "notional": "1200", "entryPrice": "59000", "markPrice": "60000", "unRealizedProfit": "20"}]
class AccountMarketServicesTestCase(unittest.TestCase):
def test_account_overview_and_dust_filter(self):
config = {
"market": {"default_quote": "USDT"},
"trading": {"dust_usdt_threshold": 10.0},
}
payload = account_service.get_overview(
config,
include_spot=True,
include_futures=True,
spot_client=FakeSpotClient(),
futures_client=FakeFuturesClient(),
)
self.assertEqual(payload["overview"]["spot_equity_usdt"], 720.1)
self.assertEqual(payload["overview"]["futures_equity_usdt"], 250.0)
symbols = {item["symbol"] for item in payload["positions"]}
self.assertNotIn("DOGEUSDT", symbols)
self.assertIn("BTCUSDT", symbols)
def test_market_tickers_and_scan_universe(self):
config = {
"market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []},
"opportunity": {"min_quote_volume": 1000},
}
tickers = market_service.get_tickers(config, ["btc/usdt", "ETH-USDT"], spot_client=FakeSpotClient())
self.assertEqual([item["symbol"] for item in tickers["tickers"]], ["BTCUSDT", "ETHUSDT"])
universe = market_service.get_scan_universe(config, spot_client=FakeSpotClient())
self.assertEqual([item["symbol"] for item in universe], ["BTCUSDT", "ETHUSDT"])

View File

@@ -1,70 +0,0 @@
"""Tests for check_api command."""
import json
from unittest.mock import MagicMock, patch
from coinhunter.commands import check_api
class TestMain:
def test_missing_api_key(self, monkeypatch, capsys):
monkeypatch.setenv("BINANCE_API_KEY", "")
monkeypatch.setenv("BINANCE_API_SECRET", "secret")
rc = check_api.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert "BINANCE_API_KEY" in out["error"]
def test_missing_api_secret(self, monkeypatch, capsys):
monkeypatch.setenv("BINANCE_API_KEY", "key")
monkeypatch.setenv("BINANCE_API_SECRET", "")
rc = check_api.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert "BINANCE_API_SECRET" in out["error"]
def test_placholder_api_key(self, monkeypatch, capsys):
monkeypatch.setenv("BINANCE_API_KEY", "your_api_key")
monkeypatch.setenv("BINANCE_API_SECRET", "secret")
rc = check_api.main()
assert rc == 1
def test_balance_fetch_failure(self, monkeypatch, capsys):
monkeypatch.setenv("BINANCE_API_KEY", "key")
monkeypatch.setenv("BINANCE_API_SECRET", "secret")
mock_ex = MagicMock()
mock_ex.fetch_balance.side_effect = Exception("Network error")
with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex):
rc = check_api.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert "Failed to connect" in out["error"]
def test_success_with_spot_trading(self, monkeypatch, capsys):
monkeypatch.setenv("BINANCE_API_KEY", "key")
monkeypatch.setenv("BINANCE_API_SECRET", "secret")
mock_ex = MagicMock()
mock_ex.fetch_balance.return_value = {"USDT": 100.0}
mock_ex.sapi_get_account_api_restrictions.return_value = {"enableSpotTrading": True}
with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex):
rc = check_api.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["read_permission"] is True
assert out["spot_trading_enabled"] is True
def test_success_restrictions_query_fails(self, monkeypatch, capsys):
monkeypatch.setenv("BINANCE_API_KEY", "key")
monkeypatch.setenv("BINANCE_API_SECRET", "secret")
mock_ex = MagicMock()
mock_ex.fetch_balance.return_value = {"USDT": 100.0}
mock_ex.sapi_get_account_api_restrictions.side_effect = Exception("no permission")
with patch("coinhunter.commands.check_api.ccxt.binance", return_value=mock_ex):
rc = check_api.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["spot_trading_enabled"] is None
assert "may be null" in out["note"]

View File

@@ -1,58 +1,38 @@
"""Tests for CLI routing and parser behavior."""
"""CLI tests for CoinHunter V2."""
import pytest
from __future__ import annotations
from coinhunter.cli import ALIASES, MODULE_MAP, build_parser, run_python_module
import io
import unittest
from unittest.mock import patch
from coinhunter import cli
class TestAliases:
def test_all_aliases_resolve_to_canonical(self):
for alias, canonical in ALIASES.items():
assert canonical in MODULE_MAP, f"alias {alias!r} points to missing canonical {canonical!r}"
def test_no_alias_is_itself_an_alias_loop(self):
for alias in ALIASES:
assert alias not in ALIASES.values() or alias in MODULE_MAP
class TestModuleMap:
def test_all_modules_exist(self):
import importlib
for command, module_name in MODULE_MAP.items():
full = f"coinhunter.{module_name}"
mod = importlib.import_module(full)
assert hasattr(mod, "main"), f"{full} missing main()"
class TestBuildParser:
def test_help_includes_commands(self):
parser = build_parser()
class CLITestCase(unittest.TestCase):
def test_help_includes_v2_commands(self):
parser = cli.build_parser()
help_text = parser.format_help()
assert "coinhunter diag" in help_text
assert "coinhunter exec" in help_text
self.assertIn("init", help_text)
self.assertIn("account", help_text)
self.assertIn("opportunity", help_text)
def test_parses_command_and_args(self):
parser = build_parser()
ns = parser.parse_args(["exec", "bal"])
assert ns.command == "exec"
assert "bal" in ns.args
def test_init_dispatches(self):
captured = {}
with patch.object(cli, "ensure_init_files", return_value={"force": True, "root": "/tmp/ch"}), patch.object(
cli, "print_json", side_effect=lambda payload: captured.setdefault("payload", payload)
):
result = cli.main(["init", "--force"])
self.assertEqual(result, 0)
self.assertTrue(captured["payload"]["force"])
def test_version_action_exits(self):
parser = build_parser()
with pytest.raises(SystemExit) as exc:
parser.parse_args(["--version"])
assert exc.value.code == 0
def test_old_command_is_rejected(self):
with self.assertRaises(SystemExit):
cli.main(["exec", "bal"])
class TestRunPythonModule:
def test_runs_module_main_and_returns_int(self):
result = run_python_module("commands.paths", [], "coinhunter paths")
assert result == 0
def test_mutates_sys_argv(self):
import sys
original = sys.argv[:]
run_python_module("commands.paths", ["--help"], "coinhunter paths")
assert sys.argv == original
def test_runtime_error_is_rendered_cleanly(self):
stderr = io.StringIO()
with patch.object(cli, "load_config", side_effect=RuntimeError("boom")), patch("sys.stderr", stderr):
result = cli.main(["market", "tickers", "BTCUSDT"])
self.assertEqual(result, 1)
self.assertIn("error: boom", stderr.getvalue())

View File

@@ -0,0 +1,79 @@
"""Config and runtime tests."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from coinhunter.config import ensure_init_files, get_binance_credentials, load_config, load_env_file
from coinhunter.runtime import get_runtime_paths
class ConfigRuntimeTestCase(unittest.TestCase):
def test_init_files_created_in_coinhunter_home(self):
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(os.environ, {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, clear=False):
paths = get_runtime_paths()
payload = ensure_init_files(paths)
self.assertTrue(paths.config_file.exists())
self.assertTrue(paths.env_file.exists())
self.assertTrue(paths.logs_dir.exists())
self.assertEqual(payload["root"], str(paths.root))
def test_load_config_and_env(self):
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
os.environ,
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
clear=False,
):
paths = get_runtime_paths()
ensure_init_files(paths)
paths.env_file.write_text("BINANCE_API_KEY=abc\nBINANCE_API_SECRET=def\n", encoding="utf-8")
config = load_config(paths)
loaded = load_env_file(paths)
self.assertEqual(config["market"]["default_quote"], "USDT")
self.assertEqual(loaded["BINANCE_API_KEY"], "abc")
self.assertEqual(os.environ["BINANCE_API_SECRET"], "def")
def test_env_file_overrides_existing_environment(self):
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
os.environ,
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home"), "BINANCE_API_KEY": "old_key"},
clear=False,
):
paths = get_runtime_paths()
ensure_init_files(paths)
paths.env_file.write_text("BINANCE_API_KEY=new_key\nBINANCE_API_SECRET=new_secret\n", encoding="utf-8")
load_env_file(paths)
self.assertEqual(os.environ["BINANCE_API_KEY"], "new_key")
self.assertEqual(os.environ["BINANCE_API_SECRET"], "new_secret")
def test_missing_credentials_raise(self):
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
os.environ,
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
clear=False,
):
os.environ.pop("BINANCE_API_KEY", None)
os.environ.pop("BINANCE_API_SECRET", None)
paths = get_runtime_paths()
ensure_init_files(paths)
with self.assertRaisesRegex(RuntimeError, "Missing BINANCE_API_KEY"):
get_binance_credentials(paths)
def test_permission_error_is_explained(self):
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
os.environ,
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
clear=False,
):
paths = get_runtime_paths()
with patch("coinhunter.config.ensure_runtime_dirs", side_effect=PermissionError("no write access")):
with self.assertRaisesRegex(RuntimeError, "Set COINHUNTER_HOME to a writable directory"):
ensure_init_files(paths)

View File

@@ -1,98 +0,0 @@
"""Tests for exchange helpers and caching."""
import pytest
from coinhunter.services import exchange_service as es
class TestNormSymbol:
def test_already_normalized(self):
assert es.norm_symbol("BTC/USDT") == "BTC/USDT"
def test_raw_symbol(self):
assert es.norm_symbol("BTCUSDT") == "BTC/USDT"
def test_lowercase(self):
assert es.norm_symbol("btcusdt") == "BTC/USDT"
def test_dash_separator(self):
assert es.norm_symbol("BTC-USDT") == "BTC/USDT"
def test_underscore_separator(self):
assert es.norm_symbol("BTC_USDT") == "BTC/USDT"
def test_unsupported_symbol(self):
with pytest.raises(ValueError):
es.norm_symbol("BTC")
class TestStorageSymbol:
def test_converts_to_flat(self):
assert es.storage_symbol("BTC/USDT") == "BTCUSDT"
def test_raw_input(self):
assert es.storage_symbol("ETHUSDT") == "ETHUSDT"
class TestFloorToStep:
def test_no_step(self):
assert es.floor_to_step(1.234, 0) == 1.234
def test_floors_to_step(self):
assert es.floor_to_step(1.234, 0.1) == pytest.approx(1.2)
def test_exact_multiple(self):
assert es.floor_to_step(12, 1) == 12
class TestGetExchangeCaching:
def test_returns_cached_instance(self, monkeypatch):
monkeypatch.setenv("BINANCE_API_KEY", "test_key")
monkeypatch.setenv("BINANCE_API_SECRET", "test_secret")
class FakeEx:
def load_markets(self):
pass
# Reset cache and patch ccxt.binance
original_cache = es._exchange_cache
original_cached_at = es._exchange_cached_at
try:
es._exchange_cache = None
es._exchange_cached_at = None
monkeypatch.setattr(es.ccxt, "binance", lambda *a, **kw: FakeEx())
first = es.get_exchange()
second = es.get_exchange()
assert first is second
third = es.get_exchange(force_new=True)
assert third is not first
finally:
es._exchange_cache = original_cache
es._exchange_cached_at = original_cached_at
def test_cache_expires_after_ttl(self, monkeypatch):
monkeypatch.setenv("BINANCE_API_KEY", "test_key")
monkeypatch.setenv("BINANCE_API_SECRET", "test_secret")
class FakeEx:
def load_markets(self):
pass
original_cache = es._exchange_cache
original_cached_at = es._exchange_cached_at
try:
es._exchange_cache = None
es._exchange_cached_at = None
monkeypatch.setattr(es.ccxt, "binance", lambda *a, **kw: FakeEx())
monkeypatch.setattr(es, "load_env", lambda: None)
first = es.get_exchange()
# Simulate time passing beyond TTL
es._exchange_cached_at -= es.CACHE_TTL_SECONDS + 1
second = es.get_exchange()
assert first is not second
finally:
es._exchange_cache = original_cache
es._exchange_cached_at = original_cached_at

View File

@@ -1,203 +0,0 @@
"""Tests for external_gate configurable hook."""
import json
from unittest.mock import MagicMock, patch
from coinhunter.commands import external_gate
class TestResolveTriggerCommand:
def test_missing_config_means_disabled(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
# no config file exists
cmd = external_gate._resolve_trigger_command(paths)
assert cmd is None
def test_uses_explicit_list_from_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({
"external_gate": {"trigger_command": ["my-scheduler", "run", "job-123"]}
}), encoding="utf-8")
cmd = external_gate._resolve_trigger_command(paths)
assert cmd == ["my-scheduler", "run", "job-123"]
def test_null_means_disabled(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({
"external_gate": {"trigger_command": None}
}), encoding="utf-8")
cmd = external_gate._resolve_trigger_command(paths)
assert cmd is None
def test_empty_list_means_disabled(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({
"external_gate": {"trigger_command": []}
}), encoding="utf-8")
cmd = external_gate._resolve_trigger_command(paths)
assert cmd is None
def test_string_gets_wrapped(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({
"external_gate": {"trigger_command": "my-script.sh"}
}), encoding="utf-8")
cmd = external_gate._resolve_trigger_command(paths)
assert cmd == ["my-script.sh"]
def test_unexpected_type_returns_none_and_warns(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({
"external_gate": {"trigger_command": 42}
}), encoding="utf-8")
cmd = external_gate._resolve_trigger_command(paths)
assert cmd is None
class TestMain:
def test_already_running(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.state_dir.mkdir(parents=True, exist_ok=True)
# Acquire the lock in this process so main() sees it as busy
import fcntl
with open(paths.external_gate_lock, "w", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
rc = external_gate.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "already_running"
def test_precheck_failure(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
fake_result = MagicMock(returncode=1, stdout="err", stderr="")
with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
rc = external_gate.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "precheck_failed"
def test_precheck_parse_error(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
fake_result = MagicMock(returncode=0, stdout="not-json", stderr="")
with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
rc = external_gate.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "precheck_parse_error"
def test_precheck_not_ok(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
fake_result = MagicMock(returncode=0, stdout=json.dumps({"ok": False}), stderr="")
with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
rc = external_gate.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "precheck_not_ok"
def test_no_trigger(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
fake_result = MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": False}), stderr="")
with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
rc = external_gate.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "no_trigger"
def test_already_queued(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
fake_result = MagicMock(
returncode=0,
stdout=json.dumps({"ok": True, "should_analyze": True, "run_requested": True, "run_requested_at": "2024-01-01T00:00:00Z"}),
stderr="",
)
with patch("coinhunter.commands.external_gate.run_cmd", return_value=fake_result):
rc = external_gate.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "already_queued"
def test_trigger_disabled(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": None}}), encoding="utf-8")
# First call is precheck, second is mark-run-requested
responses = [
MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""),
MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""),
]
with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
rc = external_gate.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "trigger_disabled"
def test_trigger_success(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["echo", "ok"]}}), encoding="utf-8")
responses = [
MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True, "reasons": ["price-move"]}), stderr=""),
MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""),
MagicMock(returncode=0, stdout="triggered", stderr=""),
]
with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
rc = external_gate.main()
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["triggered"] is True
assert out["reason"] == "price-move"
def test_trigger_command_failure(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["false"]}}), encoding="utf-8")
responses = [
MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""),
MagicMock(returncode=0, stdout=json.dumps({"ok": True}), stderr=""),
MagicMock(returncode=1, stdout="", stderr="fail"),
]
with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
rc = external_gate.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "trigger_failed"
def test_mark_run_requested_failure(self, tmp_path, monkeypatch, capsys):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = external_gate._paths()
paths.root.mkdir(parents=True, exist_ok=True)
paths.config_file.write_text(json.dumps({"external_gate": {"trigger_command": ["echo", "ok"]}}), encoding="utf-8")
responses = [
MagicMock(returncode=0, stdout=json.dumps({"ok": True, "should_analyze": True}), stderr=""),
MagicMock(returncode=1, stdout="err", stderr=""),
]
with patch("coinhunter.commands.external_gate.run_cmd", side_effect=responses):
rc = external_gate.main()
assert rc == 1
out = json.loads(capsys.readouterr().out)
assert out["reason"] == "mark_failed"

View File

@@ -0,0 +1,94 @@
"""Opportunity service tests."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from coinhunter.services import opportunity_service
class FakeSpotClient:
def account_info(self):
return {
"balances": [
{"asset": "USDT", "free": "50", "locked": "0"},
{"asset": "BTC", "free": "0.01", "locked": "0"},
{"asset": "ETH", "free": "0.5", "locked": "0"},
{"asset": "DOGE", "free": "1", "locked": "0"},
]
}
def ticker_price(self, symbols=None):
mapping = {
"BTCUSDT": {"symbol": "BTCUSDT", "price": "60000"},
"ETHUSDT": {"symbol": "ETHUSDT", "price": "3000"},
"DOGEUSDT": {"symbol": "DOGEUSDT", "price": "0.1"},
}
return [mapping[symbol] for symbol in symbols]
def ticker_24h(self, symbols=None):
rows = {
"BTCUSDT": {"symbol": "BTCUSDT", "lastPrice": "60000", "priceChangePercent": "5", "quoteVolume": "9000000", "highPrice": "60200", "lowPrice": "55000"},
"ETHUSDT": {"symbol": "ETHUSDT", "lastPrice": "3000", "priceChangePercent": "3", "quoteVolume": "8000000", "highPrice": "3100", "lowPrice": "2800"},
"SOLUSDT": {"symbol": "SOLUSDT", "lastPrice": "150", "priceChangePercent": "8", "quoteVolume": "10000000", "highPrice": "152", "lowPrice": "130"},
"DOGEUSDT": {"symbol": "DOGEUSDT", "lastPrice": "0.1", "priceChangePercent": "1", "quoteVolume": "100", "highPrice": "0.11", "lowPrice": "0.09"},
}
if not symbols:
return list(rows.values())
return [rows[symbol] for symbol in symbols]
def exchange_info(self):
return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "SOLUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "TRADING"}]}
def klines(self, symbol, interval, limit):
curves = {
"BTCUSDT": [50000, 52000, 54000, 56000, 58000, 59000, 60000],
"ETHUSDT": [2600, 2650, 2700, 2800, 2900, 2950, 3000],
"SOLUSDT": [120, 125, 130, 135, 140, 145, 150],
"DOGEUSDT": [0.11, 0.108, 0.105, 0.103, 0.102, 0.101, 0.1],
}[symbol]
rows = []
for index, close in enumerate(curves[-limit:]):
rows.append([index, close * 0.98, close * 1.01, close * 0.97, close, 100 + index * 10, index + 1, close * (100 + index * 10)])
return rows
class OpportunityServiceTestCase(unittest.TestCase):
def setUp(self):
self.config = {
"market": {"default_quote": "USDT", "universe_allowlist": [], "universe_denylist": []},
"trading": {"dust_usdt_threshold": 10.0},
"opportunity": {
"scan_limit": 10,
"top_n": 5,
"min_quote_volume": 1000.0,
"weights": {
"trend": 1.0,
"momentum": 1.0,
"breakout": 0.8,
"volume": 0.7,
"volatility_penalty": 0.5,
"position_concentration_penalty": 0.6,
},
},
}
def test_portfolio_analysis_ignores_dust_and_emits_recommendations(self):
events = []
with patch.object(opportunity_service, "audit_event", side_effect=lambda event, payload: events.append(event)):
payload = opportunity_service.analyze_portfolio(self.config, spot_client=FakeSpotClient())
symbols = [item["symbol"] for item in payload["recommendations"]]
self.assertNotIn("DOGEUSDT", symbols)
self.assertEqual(symbols, ["BTCUSDT", "ETHUSDT"])
self.assertEqual(events, ["opportunity_portfolio_generated"])
def test_scan_is_deterministic(self):
with patch.object(opportunity_service, "audit_event", return_value=None):
payload = opportunity_service.scan_opportunities(self.config | {"opportunity": self.config["opportunity"] | {"top_n": 2}}, spot_client=FakeSpotClient())
self.assertEqual([item["symbol"] for item in payload["recommendations"]], ["SOLUSDT", "BTCUSDT"])
def test_score_candidate_handles_empty_klines(self):
score, metrics = opportunity_service._score_candidate([], [], {"price_change_pct": 1.0}, {}, 0.0)
self.assertEqual(score, 0.0)
self.assertEqual(metrics["trend"], 0.0)

View File

@@ -1,194 +0,0 @@
"""Tests for review_service."""
import json
from pathlib import Path
from unittest.mock import patch
from coinhunter.services import review_service as rs
class TestAnalyzeTrade:
def test_buy_good_when_price_up(self):
ex = None
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
with patch.object(rs, "fetch_current_price", return_value=52000.0):
result = rs.analyze_trade(trade, ex)
assert result["action"] == "BUY"
assert result["pnl_estimate_pct"] == 4.0
assert result["outcome_assessment"] == "good"
def test_buy_bad_when_price_down(self):
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
with patch.object(rs, "fetch_current_price", return_value=48000.0):
result = rs.analyze_trade(trade, None)
assert result["pnl_estimate_pct"] == -4.0
assert result["outcome_assessment"] == "bad"
def test_sell_all_missed_when_price_up(self):
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "SELL_ALL"}
with patch.object(rs, "fetch_current_price", return_value=53000.0):
result = rs.analyze_trade(trade, None)
assert result["pnl_estimate_pct"] == -6.0
assert result["outcome_assessment"] == "missed"
def test_neutral_when_small_move(self):
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
with patch.object(rs, "fetch_current_price", return_value=50100.0):
result = rs.analyze_trade(trade, None)
assert result["outcome_assessment"] == "neutral"
def test_none_when_no_price(self):
trade = {"symbol": "BTC/USDT", "action": "BUY"}
result = rs.analyze_trade(trade, None)
assert result["pnl_estimate_pct"] is None
assert result["outcome_assessment"] == "neutral"
class TestAnalyzeHoldPasses:
def test_finds_passed_opportunities_that_rise(self):
decisions = [
{
"timestamp": "2024-01-01T00:00:00Z",
"decision": "HOLD",
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
}
]
with patch.object(rs, "fetch_current_price", return_value=110.0):
result = rs.analyze_hold_passes(decisions, None)
assert len(result) == 1
assert result[0]["symbol"] == "ETH/USDT"
assert result[0]["change_pct"] == 10.0
def test_ignores_non_pass_verdicts(self):
decisions = [
{
"decision": "HOLD",
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "BUY"}]},
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
}
]
with patch.object(rs, "fetch_current_price", return_value=110.0):
result = rs.analyze_hold_passes(decisions, None)
assert result == []
def test_ignores_small_moves(self):
decisions = [
{
"decision": "HOLD",
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
}
]
with patch.object(rs, "fetch_current_price", return_value=102.0):
result = rs.analyze_hold_passes(decisions, None)
assert result == []
class TestAnalyzeCashMisses:
def test_finds_cash_sit_misses(self):
decisions = [
{
"timestamp": "2024-01-01T00:00:00Z",
"balances": {"USDT": 100.0},
"market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}},
}
]
with patch.object(rs, "fetch_current_price", return_value=54000.0):
result = rs.analyze_cash_misses(decisions, None)
assert len(result) == 1
assert result[0]["symbol"] == "BTCUSDT"
assert result[0]["change_pct"] == 8.0
def test_requires_mostly_usdt(self):
decisions = [
{
"balances": {"USDT": 10.0, "BTC": 90.0},
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
}
]
with patch.object(rs, "fetch_current_price", return_value=200.0):
result = rs.analyze_cash_misses(decisions, None)
assert result == []
def test_dedupes_by_best_change(self):
decisions = [
{
"timestamp": "2024-01-01T00:00:00Z",
"balances": {"USDT": 100.0},
"market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}},
},
{
"timestamp": "2024-01-01T01:00:00Z",
"balances": {"USDT": 100.0},
"market_snapshot": {"BTCUSDT": {"lastPrice": 52000.0}},
},
]
with patch.object(rs, "fetch_current_price", return_value=56000.0):
result = rs.analyze_cash_misses(decisions, None)
assert len(result) == 1
# best change is from 50000 -> 56000 = 12%
assert result[0]["change_pct"] == 12.0
class TestGenerateReview:
def test_empty_period(self, monkeypatch):
monkeypatch.setattr(rs, "get_logs_last_n_hours", lambda log_type, hours: [])
review = rs.generate_review(1)
assert review["total_decisions"] == 0
assert review["total_trades"] == 0
assert any("No decisions or trades" in i for i in review["insights"])
def test_with_trades_and_decisions(self, monkeypatch):
trades = [
{"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY", "timestamp": "2024-01-01T00:00:00Z"}
]
decisions = [
{
"timestamp": "2024-01-01T00:00:00Z",
"decision": "HOLD",
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
}
]
errors = [{"message": "oops"}]
def _logs(log_type, hours):
return {"decisions": decisions, "trades": trades, "errors": errors}.get(log_type, [])
monkeypatch.setattr(rs, "get_logs_last_n_hours", _logs)
monkeypatch.setattr(rs, "fetch_current_price", lambda ex, sym: 110.0 if "ETH" in sym else 52000.0)
review = rs.generate_review(1)
assert review["total_trades"] == 1
assert review["total_decisions"] == 1
assert review["stats"]["good_decisions"] == 1
assert review["stats"]["missed_hold_passes"] == 1
assert any("execution/system errors" in i for i in review["insights"])
class TestSaveReview:
def test_writes_file(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
review = {"review_timestamp": "2024-01-01T00:00:00+08:00", "review_period_hours": 1}
path = rs.save_review(review)
saved = json.loads(Path(path).read_text(encoding="utf-8"))
assert saved["review_period_hours"] == 1
class TestPrintReview:
def test_outputs_report(self, capsys):
review = {
"review_timestamp": "2024-01-01T00:00:00+08:00",
"review_period_hours": 1,
"total_decisions": 2,
"total_trades": 1,
"total_errors": 0,
"stats": {"good_decisions": 1, "neutral_decisions": 0, "bad_decisions": 0, "missed_opportunities": 0},
"insights": ["Insight A"],
"recommendations": ["Rec B"],
}
rs.print_review(review)
captured = capsys.readouterr()
assert "Coin Hunter Review Report" in captured.out
assert "Insight A" in captured.out
assert "Rec B" in captured.out

View File

@@ -1,63 +0,0 @@
"""Tests for runtime path resolution."""
from pathlib import Path
import pytest
from coinhunter.runtime import RuntimePaths, ensure_runtime_dirs, get_runtime_paths, mask_secret
class TestGetRuntimePaths:
def test_defaults_point_to_home_dot_coinhunter(self):
paths = get_runtime_paths()
assert paths.root == Path.home() / ".coinhunter"
def test_respects_coinhunter_home_env(self, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", "~/custom_ch")
paths = get_runtime_paths()
assert paths.root == Path.home() / "custom_ch"
def test_respects_hermes_home_env(self, monkeypatch):
monkeypatch.setenv("HERMES_HOME", "~/custom_hermes")
paths = get_runtime_paths()
assert paths.hermes_home == Path.home() / "custom_hermes"
assert paths.env_file == Path.home() / "custom_hermes" / ".env"
def test_returns_frozen_dataclass(self):
paths = get_runtime_paths()
assert isinstance(paths, RuntimePaths)
with pytest.raises(AttributeError):
paths.root = Path("/tmp")
def test_as_dict_returns_strings(self):
paths = get_runtime_paths()
d = paths.as_dict()
assert isinstance(d, dict)
assert all(isinstance(v, str) for v in d.values())
assert "root" in d
class TestEnsureRuntimeDirs:
def test_creates_directories(self, tmp_path, monkeypatch):
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
paths = get_runtime_paths()
returned = ensure_runtime_dirs(paths)
assert returned.root.exists()
assert returned.state_dir.exists()
assert returned.logs_dir.exists()
assert returned.cache_dir.exists()
assert returned.reviews_dir.exists()
class TestMaskSecret:
def test_empty_string(self):
assert mask_secret("") == ""
def test_short_value(self):
assert mask_secret("ab") == "**"
def test_masks_all_but_tail(self):
assert mask_secret("supersecret", tail=4) == "*******cret"
def test_none_returns_empty(self):
assert mask_secret(None) == ""

View File

@@ -1,98 +0,0 @@
"""Tests for smart executor deduplication and routing."""
from coinhunter.services import exchange_service
from coinhunter.services import smart_executor_service as ses
class FakeArgs:
def __init__(self, command="buy", symbol="BTCUSDT", amount_usdt=50, dry_run=False,
decision_id=None, analysis=None, reasoning=None, order_id=None,
from_symbol=None, to_symbol=None):
self.command = command
self.symbol = symbol
self.amount_usdt = amount_usdt
self.dry_run = dry_run
self.decision_id = decision_id
self.analysis = analysis
self.reasoning = reasoning
self.order_id = order_id
self.from_symbol = from_symbol
self.to_symbol = to_symbol
class TestDeduplication:
def test_skips_duplicate_mutating_action(self, monkeypatch, capsys):
monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="buy"), ["BTCUSDT", "50"]))
monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "success"})
get_exchange_calls = []
monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex")
result = ses.run(["buy", "BTCUSDT", "50"])
assert result == 0
assert get_exchange_calls == []
captured = capsys.readouterr()
assert "already executed successfully" in captured.err
def test_allows_duplicate_read_only_action(self, monkeypatch):
monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="balances"), ["balances"]))
monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "success"})
monkeypatch.setattr(ses, "command_balances", lambda ex: None)
get_exchange_calls = []
monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex")
result = ses.run(["bal"])
assert result == 0
assert len(get_exchange_calls) == 1
def test_allows_retry_after_failure(self, monkeypatch):
monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="buy"), ["BTCUSDT", "50"]))
monkeypatch.setattr(ses, "get_execution_state", lambda did: {"status": "failed"})
monkeypatch.setattr(ses, "record_execution_state", lambda did, state: None)
monkeypatch.setattr(ses, "build_decision_context", lambda ex, action, tail, did: {})
monkeypatch.setattr(ses, "action_buy", lambda *a, **k: {"id": "123"})
monkeypatch.setattr(ses, "print_json", lambda d: None)
get_exchange_calls = []
monkeypatch.setattr(exchange_service, "get_exchange", lambda: get_exchange_calls.append(1) or "ex")
result = ses.run(["buy", "BTCUSDT", "50"])
assert result == 0
assert len(get_exchange_calls) == 1
class TestReadOnlyRouting:
def test_routes_balances(self, monkeypatch):
monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="balances"), ["balances"]))
monkeypatch.setattr(ses, "get_execution_state", lambda did: None)
routed = []
monkeypatch.setattr(ses, "command_balances", lambda ex: routed.append("balances"))
monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex")
result = ses.run(["balances"])
assert result == 0
assert routed == ["balances"]
def test_routes_orders(self, monkeypatch):
monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (FakeArgs(command="orders"), ["orders"]))
monkeypatch.setattr(ses, "get_execution_state", lambda did: None)
routed = []
monkeypatch.setattr(ses, "command_orders", lambda ex: routed.append("orders"))
monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex")
result = ses.run(["orders"])
assert result == 0
assert routed == ["orders"]
def test_routes_order_status(self, monkeypatch):
monkeypatch.setattr(ses, "parse_cli_args", lambda argv: (
FakeArgs(command="order-status", symbol="BTCUSDT", order_id="123"),
["order-status", "BTCUSDT", "123"]
))
monkeypatch.setattr(ses, "get_execution_state", lambda did: None)
routed = []
monkeypatch.setattr(ses, "command_order_status", lambda ex, sym, oid: routed.append((sym, oid)))
monkeypatch.setattr(exchange_service, "get_exchange", lambda: "ex")
result = ses.run(["order-status", "BTCUSDT", "123"])
assert result == 0
assert routed == [("BTCUSDT", "123")]

View File

@@ -1,99 +0,0 @@
"""Tests for state_manager precheck utilities."""
from datetime import timedelta, timezone
from coinhunter.services.state_manager import (
clear_run_request_fields,
sanitize_state_for_stale_triggers,
update_state_after_observation,
)
from coinhunter.services.time_utils import utc_iso, utc_now
class TestClearRunRequestFields:
def test_removes_run_fields(self):
state = {"run_requested_at": utc_iso(), "run_request_note": "test"}
clear_run_request_fields(state)
assert "run_requested_at" not in state
assert "run_request_note" not in state
class TestSanitizeStateForStaleTriggers:
def test_no_changes_when_clean(self):
state = {"pending_trigger": False}
result = sanitize_state_for_stale_triggers(state)
assert result["pending_trigger"] is False
assert result["_stale_recovery_notes"] == []
def test_clears_completed_run_request(self):
state = {
"pending_trigger": True,
"run_requested_at": utc_iso(),
"last_deep_analysis_at": utc_iso(),
}
result = sanitize_state_for_stale_triggers(state)
assert result["pending_trigger"] is False
assert "run_requested_at" not in result
assert any("completed run_requested" in note for note in result["_stale_recovery_notes"])
def test_clears_stale_run_request(self):
old = (utc_now() - timedelta(minutes=60)).replace(tzinfo=timezone.utc).isoformat()
state = {
"pending_trigger": False,
"run_requested_at": old,
}
result = sanitize_state_for_stale_triggers(state)
assert "run_requested_at" not in result
assert any("stale run_requested" in note for note in result["_stale_recovery_notes"])
def test_recovers_stale_pending_trigger(self):
old = (utc_now() - timedelta(minutes=60)).replace(tzinfo=timezone.utc).isoformat()
state = {
"pending_trigger": True,
"last_triggered_at": old,
}
result = sanitize_state_for_stale_triggers(state)
assert result["pending_trigger"] is False
assert any("stale pending_trigger" in note for note in result["_stale_recovery_notes"])
class TestUpdateStateAfterObservation:
def test_updates_last_observed_fields(self):
state = {}
snapshot = {
"generated_at": "2024-01-01T00:00:00Z",
"snapshot_hash": "abc",
"positions_hash": "pos123",
"candidates_hash": "can456",
"portfolio_value_usdt": 100.0,
"market_regime": "neutral",
"positions": [],
"top_candidates": [],
}
analysis = {"should_analyze": False, "details": [], "adaptive_profile": {}}
result = update_state_after_observation(state, snapshot, analysis)
assert result["last_observed_at"] == snapshot["generated_at"]
assert result["last_snapshot_hash"] == "abc"
def test_sets_pending_trigger_when_should_analyze(self):
state = {}
snapshot = {
"generated_at": "2024-01-01T00:00:00Z",
"snapshot_hash": "abc",
"positions_hash": "pos123",
"candidates_hash": "can456",
"portfolio_value_usdt": 100.0,
"market_regime": "neutral",
"positions": [],
"top_candidates": [],
}
analysis = {
"should_analyze": True,
"details": ["price move"],
"adaptive_profile": {},
"hard_reasons": ["moon"],
"signal_delta": 1.5,
}
result = update_state_after_observation(state, snapshot, analysis)
assert result["pending_trigger"] is True
assert result["pending_reasons"] == ["price move"]

View File

@@ -1,160 +0,0 @@
"""Tests for trade execution dry-run paths."""
import pytest
from coinhunter.services import trade_execution as te
from coinhunter.services.trade_common import set_dry_run
class TestMarketSellDryRun:
def test_returns_dry_run_order(self, monkeypatch):
set_dry_run(True)
monkeypatch.setattr(
te, "prepare_sell_quantity",
lambda ex, sym, qty: ("BTC/USDT", 0.5, 60000.0, 30000.0)
)
order = te.market_sell(None, "BTCUSDT", 0.5, "dec-123")
assert order["id"] == "dry-sell-dec-123"
assert order["symbol"] == "BTC/USDT"
assert order["amount"] == 0.5
assert order["status"] == "closed"
set_dry_run(False)
class TestMarketBuyDryRun:
def test_returns_dry_run_order(self, monkeypatch):
set_dry_run(True)
monkeypatch.setattr(
te, "prepare_buy_quantity",
lambda ex, sym, amt: ("ETH/USDT", 1.0, 3000.0, 3000.0)
)
order = te.market_buy(None, "ETHUSDT", 100, "dec-456")
assert order["id"] == "dry-buy-dec-456"
assert order["symbol"] == "ETH/USDT"
assert order["amount"] == 1.0
assert order["status"] == "closed"
set_dry_run(False)
class TestActionSellAll:
def test_raises_when_balance_zero(self, monkeypatch):
monkeypatch.setattr(te, "fetch_balances", lambda ex: {"BTC": 0})
with pytest.raises(RuntimeError, match="balance is zero"):
te.action_sell_all(None, "BTCUSDT", "dec-789", {})
def test_dry_run_does_not_reconcile(self, monkeypatch):
set_dry_run(True)
monkeypatch.setattr(te, "fetch_balances", lambda ex: {"BTC": 0.5})
monkeypatch.setattr(
te, "market_sell",
lambda ex, sym, qty, did: {"id": "dry-1", "amount": qty, "price": 60000.0, "cost": 30000.0, "status": "closed"}
)
monkeypatch.setattr(te, "reconcile_positions_with_exchange", lambda ex, hint=None: ([], {}))
monkeypatch.setattr(te, "log_trade", lambda *a, **k: None)
monkeypatch.setattr(te, "log_decision", lambda *a, **k: None)
result = te.action_sell_all(None, "BTCUSDT", "dec-789", {"analysis": "test"})
assert result["id"] == "dry-1"
set_dry_run(False)
class TestActionBuy:
def test_raises_when_insufficient_usdt(self, monkeypatch):
monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 10.0})
with pytest.raises(RuntimeError, match="Insufficient USDT"):
te.action_buy(None, "BTCUSDT", 50, "dec-abc", {})
def test_dry_run_skips_save(self, monkeypatch):
set_dry_run(True)
monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0})
monkeypatch.setattr(
te, "market_buy",
lambda ex, sym, amt, did: {"id": "dry-2", "amount": 0.01, "price": 50000.0, "cost": 500.0, "status": "closed"}
)
monkeypatch.setattr(te, "load_positions", lambda: [])
monkeypatch.setattr(te, "upsert_position", lambda positions, pos: positions.append(pos))
monkeypatch.setattr(te, "reconcile_positions_with_exchange", lambda ex, hint=None: ([], {}))
monkeypatch.setattr(te, "log_trade", lambda *a, **k: None)
monkeypatch.setattr(te, "log_decision", lambda *a, **k: None)
result = te.action_buy(None, "BTCUSDT", 50, "dec-abc", {})
assert result["id"] == "dry-2"
set_dry_run(False)
class TestCommandBalances:
def test_returns_balances(self, monkeypatch, capsys):
monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0, "BTC": 0.5})
result = te.command_balances(None)
assert result == {"USDT": 100.0, "BTC": 0.5}
captured = capsys.readouterr()
assert '"USDT": 100.0' in captured.out
class TestCommandStatus:
def test_returns_snapshot(self, monkeypatch, capsys):
monkeypatch.setattr(te, "fetch_balances", lambda ex: {"USDT": 100.0})
monkeypatch.setattr(te, "load_positions", lambda: [])
monkeypatch.setattr(te, "build_market_snapshot", lambda ex: {"BTC/USDT": 50000.0})
result = te.command_status(None)
assert result["balances"]["USDT"] == 100.0
captured = capsys.readouterr()
assert '"balances"' in captured.out
class TestCommandOrders:
def test_returns_orders(self, monkeypatch, capsys):
orders = [{"id": "1", "symbol": "BTC/USDT"}]
monkeypatch.setattr(te, "print_json", lambda d: None)
ex = type("Ex", (), {"fetch_open_orders": lambda self: orders})()
result = te.command_orders(ex)
assert result == orders
class TestCommandOrderStatus:
def test_returns_order(self, monkeypatch, capsys):
order = {"id": "123", "status": "open"}
monkeypatch.setattr(te, "print_json", lambda d: None)
ex = type("Ex", (), {"fetch_order": lambda self, oid, sym: order})()
result = te.command_order_status(ex, "BTCUSDT", "123")
assert result == order
class TestCommandCancel:
def test_dry_run_returns_early(self, monkeypatch):
set_dry_run(True)
monkeypatch.setattr(te, "log", lambda msg: None)
result = te.command_cancel(None, "BTCUSDT", "123")
assert result["dry_run"] is True
set_dry_run(False)
def test_cancel_by_order_id(self, monkeypatch):
set_dry_run(False)
monkeypatch.setattr(te, "print_json", lambda d: None)
ex = type("Ex", (), {"cancel_order": lambda self, oid, sym: {"id": oid, "status": "canceled"}})()
result = te.command_cancel(ex, "BTCUSDT", "123")
assert result["status"] == "canceled"
def test_cancel_latest_when_no_order_id(self, monkeypatch):
set_dry_run(False)
monkeypatch.setattr(te, "print_json", lambda d: None)
ex = type("Ex", (), {
"fetch_open_orders": lambda self, sym: [{"id": "999"}, {"id": "888"}],
"cancel_order": lambda self, oid, sym: {"id": oid, "status": "canceled"},
})()
result = te.command_cancel(ex, "BTCUSDT", None)
assert result["id"] == "888"
def test_cancel_raises_when_no_open_orders(self, monkeypatch):
set_dry_run(False)
ex = type("Ex", (), {"fetch_open_orders": lambda self, sym: []})()
with pytest.raises(RuntimeError, match="No open orders"):
te.command_cancel(ex, "BTCUSDT", None)
class TestPrintJson:
def test_outputs_sorted_json(self, capsys):
te.print_json({"b": 2, "a": 1})
captured = capsys.readouterr()
assert '"a": 1' in captured.out
assert '"b": 2' in captured.out

124
tests/test_trade_service.py Normal file
View File

@@ -0,0 +1,124 @@
"""Trade execution tests."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from coinhunter.services import trade_service
class FakeSpotClient:
def __init__(self):
self.calls = []
def new_order(self, **kwargs):
self.calls.append(kwargs)
return {"symbol": kwargs["symbol"], "status": "FILLED", "orderId": 1}
class FakeFuturesClient:
def __init__(self):
self.calls = []
def new_order(self, **kwargs):
self.calls.append(kwargs)
return {"symbol": kwargs["symbol"], "status": "FILLED", "orderId": 2}
def position_risk(self, symbol=None):
return [{"symbol": "BTCUSDT", "positionAmt": "-0.02", "notional": "-1200"}]
class TradeServiceTestCase(unittest.TestCase):
def test_spot_market_buy_dry_run_does_not_call_client(self):
events = []
with patch.object(trade_service, "audit_event", side_effect=lambda event, payload: events.append((event, payload))):
client = FakeSpotClient()
payload = trade_service.execute_spot_trade(
{"trading": {"dry_run_default": False}},
side="buy",
symbol="btc/usdt",
qty=None,
quote=100,
order_type="market",
price=None,
dry_run=True,
spot_client=client,
)
self.assertEqual(payload["trade"]["status"], "DRY_RUN")
self.assertEqual(client.calls, [])
self.assertEqual([event for event, _ in events], ["trade_submitted", "trade_filled"])
def test_spot_limit_sell_maps_payload(self):
with patch.object(trade_service, "audit_event", return_value=None):
client = FakeSpotClient()
payload = trade_service.execute_spot_trade(
{"trading": {"dry_run_default": False}},
side="sell",
symbol="BTCUSDT",
qty=0.1,
quote=None,
order_type="limit",
price=90000,
dry_run=False,
spot_client=client,
)
self.assertEqual(payload["trade"]["status"], "FILLED")
self.assertEqual(client.calls[0]["timeInForce"], "GTC")
def test_futures_close_uses_opposite_side(self):
with patch.object(trade_service, "audit_event", return_value=None):
client = FakeFuturesClient()
payload = trade_service.close_futures_position(
{"trading": {"dry_run_default": False}},
symbol="BTCUSDT",
dry_run=False,
futures_client=client,
)
self.assertEqual(payload["trade"]["side"], "BUY")
self.assertEqual(client.calls[0]["reduceOnly"], "true")
def test_spot_market_buy_requires_quote(self):
with patch.object(trade_service, "audit_event", return_value=None):
with self.assertRaisesRegex(RuntimeError, "requires --quote"):
trade_service.execute_spot_trade(
{"trading": {"dry_run_default": False}},
side="buy",
symbol="BTCUSDT",
qty=None,
quote=None,
order_type="market",
price=None,
dry_run=False,
spot_client=FakeSpotClient(),
)
def test_spot_market_buy_rejects_qty(self):
with patch.object(trade_service, "audit_event", return_value=None):
with self.assertRaisesRegex(RuntimeError, "accepts --quote only"):
trade_service.execute_spot_trade(
{"trading": {"dry_run_default": False}},
side="buy",
symbol="BTCUSDT",
qty=0.1,
quote=100,
order_type="market",
price=None,
dry_run=False,
spot_client=FakeSpotClient(),
)
def test_spot_market_sell_rejects_quote(self):
with patch.object(trade_service, "audit_event", return_value=None):
with self.assertRaisesRegex(RuntimeError, "accepts --qty only"):
trade_service.execute_spot_trade(
{"trading": {"dry_run_default": False}},
side="sell",
symbol="BTCUSDT",
qty=0.1,
quote=100,
order_type="market",
price=None,
dry_run=False,
spot_client=FakeSpotClient(),
)