- position_closer: 去掉 Redis 依赖,平仓条件仅名义+未实现盈亏 - requirements: 移除 redis - settings.toml: 仅保留实际使用的配置项 - 新增 Dockerfile(仅安装依赖)、docker-compose(挂载代码与配置) - 新增 .dockerignore、.gitignore(含 nohup.log) Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
6.6 KiB
Python
217 lines
6.6 KiB
Python
"""
|
||
除平仓外的流程单元测试:拉持仓、解析精度、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
|