Files
coinhunter-cli/tests/test_trade_execution.py
Tacit Lab 62c40a9776 refactor: address high-priority debt and publish to PyPI
- Fix TOCTOU race conditions by wrapping read-modify-write cycles
  under single-file locks in execution_state, portfolio_service,
  precheck_state, state_manager, and precheck_service.
- Add missing test coverage (96 tests total):
  - test_review_service.py (15 tests)
  - test_check_api.py (6 tests)
  - test_external_gate.py main branches (+10 tests)
  - test_trade_execution.py new commands (+8 tests)
- Unify all agent-consumed JSON messages to English.
- Config-ize hardcoded values (volume filter, schema_version) via
  get_user_config with sensible defaults.
- Add 1-hour TTL to exchange cache with force_new override.
- Add ruff and mypy to dev dependencies; fix all type errors.
- Add __all__ declarations to 11 service modules.
- Sync README with new commands, config tuning docs, and PyPI badge.
- Publish package as coinhunter==1.0.0 on PyPI with MIT license.
- Deprecate coinhunter-cli==1.0.1 with runtime warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:21:27 +08:00

161 lines
6.5 KiB
Python

"""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