""" 除平仓外的流程单元测试:拉持仓、解析精度、dry_run 不下单。 使用 mock 替代真实 Binance/Redis,验证 get_positions、get_symbol_precision、 place_close_order(dry_run=True) 不调用 futures_create_order。 """ import sys from decimal import Decimal from pathlib import Path from unittest.mock import AsyncMock import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from position_closer import ( filter_nonzero_positions, get_min_unrealized_profit_in_window, get_positions, get_symbol_precision, get_total_unrealized_profit, parse_exchange_info_to_precisions, place_close_order, save_unrealized_profit_history, ) # ----- get_total_unrealized_profit:账户总未实现盈亏 ----- @pytest.mark.asyncio async def test_get_total_unrealized_profit_sums_all_positions() -> None: raw_positions = [ {"symbol": "BTCUSDT", "unrealizedProfit": "10.5"}, {"symbol": "ETHUSDT", "unrealizedProfit": "-2"}, {"symbol": "XRPUSDT", "unrealizedProfit": "0.5"}, ] client = AsyncMock() client.futures_account = AsyncMock(return_value={"positions": raw_positions}) result = await get_total_unrealized_profit(client) assert result == pytest.approx(10.5 - 2 + 0.5) assert client.futures_account.await_count == 1 # ----- get_positions:拉取持仓并过滤为零 ----- @pytest.mark.asyncio async def test_get_positions_returns_only_nonzero() -> None: raw_positions = [ {"symbol": "BTCUSDT", "positionAmt": "0.1", "notional": "5000"}, {"symbol": "ETHUSDT", "positionAmt": "0", "notional": "0"}, {"symbol": "XRPUSDT", "positionAmt": "-1", "notional": "2000"}, ] client = AsyncMock() client.futures_account = AsyncMock(return_value={"positions": raw_positions}) result = await get_positions(client) assert result == filter_nonzero_positions(raw_positions) assert len(result) == 2 assert client.futures_account.await_count == 1 @pytest.mark.asyncio async def test_get_positions_empty_account() -> None: client = AsyncMock() client.futures_account = AsyncMock(return_value={"positions": []}) result = await get_positions(client) assert result == [] assert client.futures_account.await_count == 1 # ----- get_symbol_precision:拉取并解析精度 ----- @pytest.mark.asyncio async def test_get_symbol_precision_parses_exchange_info() -> None: info = { "symbols": [ { "symbol": "BTCUSDT", "filters": [ {"filterType": "LOT_SIZE", "stepSize": "0.001"}, {"filterType": "PRICE_FILTER", "tickSize": "0.1"}, ], } ] } client = AsyncMock() client.futures_exchange_info = AsyncMock(return_value=info) result = await get_symbol_precision(client) assert result == parse_exchange_info_to_precisions(info) assert result["BTCUSDT"] == {"lot_size": "0.001", "price_filter": "0.1"} assert client.futures_exchange_info.await_count == 1 # ----- place_close_order(dry_run=True):不调用下单 API ----- @pytest.mark.asyncio async def test_place_close_order_dry_run_does_not_call_futures_create_order() -> None: client = AsyncMock() client.futures_create_order = AsyncMock() precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.1"}} await place_close_order( client, symbol="BTCUSDT", position_side="LONG", position_amt=0.1, current_price=Decimal("50000"), precisions=precisions, reduce_only=True, dry_run=True, ) client.futures_create_order.assert_not_called() @pytest.mark.asyncio async def test_place_close_order_not_dry_run_calls_futures_create_order() -> None: client = AsyncMock() client.futures_create_order = AsyncMock(return_value={"orderId": 123}) precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.1"}} await place_close_order( client, symbol="BTCUSDT", position_side="LONG", position_amt=0.1, current_price=Decimal("50000"), precisions=precisions, reduce_only=True, dry_run=False, ) client.futures_create_order.assert_called_once() call_kw = client.futures_create_order.call_args[1] assert call_kw["symbol"] == "BTCUSDT" assert call_kw["side"] == "SELL" assert call_kw["quantity"] == "0.1" assert "price" in call_kw # ----- save_unrealized_profit_history / get_min_unrealized_profit_in_window ----- @pytest.mark.asyncio async def test_save_unrealized_profit_history_calls_zadd_and_zremrangebyscore() -> None: from unittest.mock import MagicMock, patch mock_redis = MagicMock() mock_redis.zadd = AsyncMock() mock_redis.zremrangebyscore = AsyncMock() mock_redis.aclose = AsyncMock() with patch("redis.asyncio.Redis") as MockRedis: MockRedis.from_url.return_value = mock_redis await save_unrealized_profit_history( "redis://localhost/0", "test:history", 12.5, 300, ) mock_redis.zadd.assert_called_once() call_args = mock_redis.zadd.call_args[0] assert call_args[0] == "test:history" assert isinstance(call_args[1], dict) member = list(call_args[1].keys())[0] assert ":12.5" in member mock_redis.zremrangebyscore.assert_called_once() mock_redis.aclose.assert_called_once() @pytest.mark.asyncio async def test_get_min_unrealized_profit_in_window_returns_min() -> None: from unittest.mock import MagicMock, patch mock_redis = MagicMock() mock_redis.zrangebyscore = AsyncMock( return_value=["1000.0:5.0", "1001.0:3.0", "1002.0:7.0"] ) mock_redis.aclose = AsyncMock() with patch("redis.asyncio.Redis") as MockRedis: MockRedis.from_url.return_value = mock_redis result = await get_min_unrealized_profit_in_window( "redis://localhost/0", "test:history", 300, ) assert result == 3.0 mock_redis.zrangebyscore.assert_called_once() mock_redis.aclose.assert_called_once() @pytest.mark.asyncio async def test_get_min_unrealized_profit_in_window_empty_returns_none() -> None: from unittest.mock import MagicMock, patch mock_redis = MagicMock() mock_redis.zrangebyscore = AsyncMock(return_value=[]) mock_redis.aclose = AsyncMock() with patch("redis.asyncio.Redis") as MockRedis: MockRedis.from_url.return_value = mock_redis result = await get_min_unrealized_profit_in_window( "redis://localhost/0", "test:history", 300, ) assert result is None