- Add `coinhunter catlog` with limit/offset pagination for audit logs - Optimize audit log reading with deque to avoid loading all history - Allow `-a/--agent` flag after subcommands - Fix upgrade spinner artifact and empty line issues - Render audit log TUI as timeline with low-saturation event colors - Convert audit timestamps to local timezone in TUI - Remove futures-related capabilities - Add conda environment.yml for development - Bump version to 2.0.9 and update README Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
103 lines
3.7 KiB
Python
103 lines
3.7 KiB
Python
"""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 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_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(),
|
|
)
|