- 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>
161 lines
6.5 KiB
Python
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
|