移除 Redis、精简配置,新增 Docker 支持

- position_closer: 去掉 Redis 依赖,平仓条件仅名义+未实现盈亏
- requirements: 移除 redis
- settings.toml: 仅保留实际使用的配置项
- 新增 Dockerfile(仅安装依赖)、docker-compose(挂载代码与配置)
- 新增 .dockerignore、.gitignore(含 nohup.log)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yhydev
2026-02-04 10:06:30 +08:00
parent 2b72dc40ae
commit 48d31cd1d0
16 changed files with 1245 additions and 31 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests package

8
tests/conftest.py Normal file
View File

@@ -0,0 +1,8 @@
# pytest-asyncio: 自动识别 async 测试
import pytest
pytest_plugins = ("pytest_asyncio",)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "asyncio: mark test as async (pytest-asyncio)")

View File

@@ -0,0 +1,406 @@
"""
position_closer 中纯逻辑函数的单元测试from position_closer import ... 即可,无需 binance/redis
"""
import sys
from decimal import Decimal
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from position_closer import (
LONG_CLOSE_PRICE_RATIO,
SHORT_CLOSE_PRICE_RATIO,
build_close_order_params,
filter_nonzero_positions,
get_notional_by_side,
get_symbols_to_close,
get_unrealized_profit,
group_positions_by_symbol,
parse_exchange_info_to_precisions,
parse_ticker_message,
round_price_to_tick,
round_to_step,
should_close_symbol,
)
# ----- round_to_step -----
@pytest.mark.parametrize(
"value, step, expected",
[
(Decimal("1.234"), "0.01", "1.23"),
(Decimal("1.239"), "0.01", "1.23"),
(Decimal("0.001"), "0.001", "0.001"),
(Decimal("0.0015"), "0.001", "0.001"),
(Decimal("100"), "1", "1E+2"), # normalize() 输出科学计数
(Decimal("1.5"), "0", "1.5"),
(Decimal("1.5"), "0.1", "1.5"),
],
)
def test_round_to_step(value: Decimal, step: str, expected: str) -> None:
assert round_to_step(value, step) == expected
# ----- round_price_to_tick按合约最小价格精度优化平仓价-----
@pytest.mark.parametrize(
"price, tick_size, expected",
[
(Decimal("50150.123"), "0.1", "50150.1"),
(Decimal("50150.123"), "0.01", "50150.12"),
(Decimal("50150"), "1", "50150"),
(Decimal("0.00123"), "0.001", "0.001"),
(Decimal("100"), "0.01", "100"),
(Decimal("0.00001"), "0.00001", "0.00001"),
],
)
def test_round_price_to_tick(price: Decimal, tick_size: str, expected: str) -> None:
assert round_price_to_tick(price, tick_size) == expected
def test_round_price_to_tick_zero_tick_returns_str() -> None:
assert round_price_to_tick(Decimal("100.5"), "0") == "100.5"
# ----- group_positions_by_symbol -----
def test_group_positions_by_symbol_empty() -> None:
assert group_positions_by_symbol([]) == {}
def test_group_positions_by_symbol_single() -> None:
positions = [{"symbol": "BTCUSDT", "positionAmt": "0.1"}]
got = group_positions_by_symbol(positions)
assert list(got.keys()) == ["BTCUSDT"]
assert got["BTCUSDT"] == positions
def test_group_positions_by_symbol_multiple_symbols() -> None:
positions = [
{"symbol": "BTCUSDT", "positionAmt": "0.1"},
{"symbol": "ETHUSDT", "positionAmt": "1"},
{"symbol": "BTCUSDT", "positionAmt": "0.2", "positionSide": "SHORT"},
]
got = group_positions_by_symbol(positions)
assert set(got.keys()) == {"BTCUSDT", "ETHUSDT"}
assert len(got["BTCUSDT"]) == 2
assert len(got["ETHUSDT"]) == 1
def test_group_positions_by_symbol_skips_empty_symbol() -> None:
positions = [{"symbol": "", "positionAmt": "0.1"}, {"symbol": "BTCUSDT", "positionAmt": "0.1"}]
got = group_positions_by_symbol(positions)
assert list(got.keys()) == ["BTCUSDT"]
# ----- filter_nonzero_positions -----
def test_filter_nonzero_positions_empty() -> None:
assert filter_nonzero_positions([]) == []
def test_filter_nonzero_positions_keeps_nonzero() -> None:
positions = [
{"symbol": "BTCUSDT", "positionAmt": "0.1"},
{"symbol": "ETHUSDT", "positionAmt": "-1"},
]
assert len(filter_nonzero_positions(positions)) == 2
def test_filter_nonzero_positions_removes_zero() -> None:
positions = [
{"symbol": "BTCUSDT", "positionAmt": "0"},
{"symbol": "ETHUSDT", "positionAmt": "0.0"},
{"symbol": "XRPUSDT", "positionAmt": ""},
]
assert filter_nonzero_positions(positions) == []
# ----- get_notional_by_side -----
def test_get_notional_by_side_empty() -> None:
assert get_notional_by_side([]) == (0.0, 0.0)
def test_get_notional_by_side_both_long() -> None:
positions = [{"positionAmt": "0.1", "notional": "5000", "positionSide": "BOTH"}]
assert get_notional_by_side(positions) == (5000.0, 0.0)
def test_get_notional_by_side_both_short() -> None:
positions = [{"positionAmt": "-0.1", "notional": "5000", "positionSide": "BOTH"}]
assert get_notional_by_side(positions) == (0.0, 5000.0)
def test_get_notional_by_side_hedge_mode() -> None:
positions = [
{"positionAmt": "0.1", "notional": "5000", "positionSide": "LONG"},
{"positionAmt": "-0.05", "notional": "2500", "positionSide": "SHORT"},
]
assert get_notional_by_side(positions) == (5000.0, 2500.0)
def test_get_notional_by_side_uses_abs_notional() -> None:
positions = [{"positionAmt": "-0.1", "notional": "-5000", "positionSide": "BOTH"}]
assert get_notional_by_side(positions) == (0.0, 5000.0)
# ----- get_unrealized_profit -----
def test_get_unrealized_profit_empty() -> None:
assert get_unrealized_profit([]) == 0.0
def test_get_unrealized_profit_sum() -> None:
positions = [
{"unrealizedProfit": "10.5"},
{"unrealizedProfit": "-2"},
{"unrealizedProfit": ""},
]
assert get_unrealized_profit(positions) == pytest.approx(8.5)
# ----- should_close_symbol小仓位/大仓位/Redis-----
@pytest.mark.parametrize(
"long_n, short_n, unrealized_profit, threshold, in_redis, small_threshold, small_min_profit, large_min_profit, expected",
[
(0, 0, 0, 50, False, 30, 0.03, 0.3, False),
(60, 0, 0.5, 50, False, 30, 0.03, 0.3, True), # 大仓位且盈利 0.5 > 0.3
(60, 0, 0.2, 50, False, 30, 0.03, 0.3, False), # 大仓位但盈利 0.2 <= 0.3 不触发
(60, 0, 0, 50, False, 30, 0.03, 0.3, False),
(40, 0, 0.5, 50, False, 30, 0.03, 0.3, False), # 40 不大仓位
(20, 0, 0.5, 50, False, 30, 0.03, 0.3, True), # 小仓位且盈利 > 0.03
(20, 0, 0.05, 50, False, 30, 0.03, 0.3, True),
(20, 0, 0.02, 50, False, 30, 0.03, 0.3, False),
(20, 0, 0, 50, False, 30, 0.03, 0.3, False),
(0, 0, 0, 50, True, 30, 0.03, 0.3, True), # Redis
(30, 30, 0.5, 50, False, 30, 0.03, 0.3, True), # 大仓位 60>50 且盈利 0.5>0.3
],
)
def test_should_close_symbol(
long_n: float,
short_n: float,
unrealized_profit: float,
threshold: float,
in_redis: bool,
small_threshold: float,
small_min_profit: float,
large_min_profit: float,
expected: bool,
) -> None:
assert (
should_close_symbol(
long_n, short_n, unrealized_profit, threshold, in_redis,
small_threshold, small_min_profit, large_min_profit,
)
is expected
)
# ----- get_symbols_to_close -----
def test_get_symbols_to_close_empty() -> None:
symbols, filtered = get_symbols_to_close({}, 50, set())
assert symbols == set()
assert filtered == {}
def test_get_symbols_to_close_by_threshold_and_profit() -> None:
# 大仓位(>50且盈利>0.3 触发;小仓位且盈利>0.03 触发
by_symbol = {
"BTCUSDT": [
{"positionAmt": "0.1", "notional": "15000", "positionSide": "BOTH", "unrealizedProfit": "10"},
],
"ETHUSDT": [
{"positionAmt": "1", "notional": "3000", "positionSide": "BOTH", "unrealizedProfit": "0"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, set(), 30, 0.03, 0.3)
assert symbols == {"BTCUSDT"} # 大仓位且盈利 10 > 0.3ETHUSDT 盈利 0 不触发
assert set(filtered.keys()) == {"BTCUSDT"}
def test_get_symbols_to_close_by_redis() -> None:
by_symbol = {
"ETHUSDT": [
{"positionAmt": "1", "notional": "3000", "positionSide": "BOTH", "unrealizedProfit": "0"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, {"ETHUSDT"})
assert symbols == {"ETHUSDT"}
assert filtered["ETHUSDT"] == by_symbol["ETHUSDT"]
def test_get_symbols_to_close_small_and_profit() -> None:
# 小仓位且盈利 > 0.03 触发平仓
by_symbol = {
"XRPUSDT": [
{"positionAmt": "10", "notional": "20", "positionSide": "BOTH", "unrealizedProfit": "0.5"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, set(), small_threshold=30, small_min_profit=0.03)
assert symbols == {"XRPUSDT"}
assert list(filtered.keys()) == ["XRPUSDT"]
def test_get_symbols_to_close_small_profit_below_min() -> None:
# 小仓位但盈利 <= 0.03 不触发
by_symbol = {
"XRPUSDT": [
{"positionAmt": "10", "notional": "20", "positionSide": "BOTH", "unrealizedProfit": "0.02"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, set(), small_threshold=30, small_min_profit=0.03)
assert symbols == set()
assert filtered == {}
# ----- parse_ticker_message -----
def test_parse_ticker_message_valid() -> None:
msg = {"stream": "btcusdt@ticker", "data": {"c": "50000.5", "s": "BTCUSDT"}}
symbol, price = parse_ticker_message(msg)
assert symbol == "BTCUSDT"
assert price == Decimal("50000.5")
def test_parse_ticker_message_no_stream() -> None:
msg = {"data": {"c": "50000"}}
symbol, price = parse_ticker_message(msg)
assert symbol is None
assert price is None
def test_parse_ticker_message_no_price() -> None:
msg = {"stream": "btcusdt@ticker", "data": {"s": "BTCUSDT"}}
symbol, price = parse_ticker_message(msg)
assert symbol == "BTCUSDT"
assert price is None
def test_parse_ticker_message_invalid_price() -> None:
msg = {"stream": "btcusdt@ticker", "data": {"c": "not-a-number"}}
symbol, price = parse_ticker_message(msg)
assert symbol == "BTCUSDT"
assert price is None
# ----- parse_exchange_info_to_precisions -----
def test_parse_exchange_info_to_precisions_empty() -> None:
assert parse_exchange_info_to_precisions({}) == {}
assert parse_exchange_info_to_precisions({"symbols": []}) == {}
def test_parse_exchange_info_to_precisions_defaults() -> None:
info = {"symbols": [{"symbol": "BTCUSDT", "filters": []}]}
got = parse_exchange_info_to_precisions(info)
assert got["BTCUSDT"] == {"lot_size": "0.01", "price_filter": "0.01"}
def test_parse_exchange_info_to_precisions_with_filters() -> None:
info = {
"symbols": [
{
"symbol": "BTCUSDT",
"filters": [
{"filterType": "LOT_SIZE", "stepSize": "0.001"},
{"filterType": "PRICE_FILTER", "tickSize": "0.1"},
],
}
]
}
got = parse_exchange_info_to_precisions(info)
assert got["BTCUSDT"] == {"lot_size": "0.001", "price_filter": "0.1"}
def test_parse_exchange_info_skips_empty_symbol() -> None:
info = {"symbols": [{"symbol": "", "filters": []}]}
assert parse_exchange_info_to_precisions(info) == {}
# ----- build_close_order_params -----
def test_build_close_order_params_long() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.1"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0.1,
current_price=Decimal("50000"),
precisions=precisions,
reduce_only=True,
)
assert params is not None
assert params["symbol"] == "BTCUSDT"
assert params["side"] == "SELL"
assert params["positionSide"] == "LONG"
assert params["quantity"] == "0.1"
assert Decimal(params["price"]) == Decimal("50150") # 50000 * 1.003
assert params.get("reduceOnly") == "true"
def test_build_close_order_params_short() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.1"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="SHORT",
position_amt=-0.1,
current_price=Decimal("50000"),
precisions=precisions,
reduce_only=True,
)
assert params is not None
assert params["side"] == "BUY"
assert params["positionSide"] == "SHORT"
assert Decimal(params["price"]) == Decimal("49850") # 50000 * 0.997
def test_build_close_order_params_both_long() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.01"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="BOTH",
position_amt=0.05,
current_price=Decimal("50000"),
precisions=precisions,
reduce_only=True,
)
assert params is not None
assert params["side"] == "SELL"
assert params["quantity"] == "0.05"
assert "positionSide" not in params or params.get("positionSide") == "BOTH"
assert params.get("reduceOnly") == "true"
def test_build_close_order_params_zero_amt_returns_none() -> None:
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0,
current_price=Decimal("50000"),
precisions={},
)
assert params is None
def test_build_close_order_params_tiny_amt_rounds_to_zero_returns_none() -> None:
# lot_size 0.1 时 0.05 会舍成 0
precisions = {"BTCUSDT": {"lot_size": "0.1", "price_filter": "0.01"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0.05,
current_price=Decimal("50000"),
precisions=precisions,
)
assert params is None
def test_build_close_order_params_custom_ratios() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.01"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0.1,
current_price=Decimal("50000"),
precisions=precisions,
long_ratio=Decimal("1.01"),
short_ratio=Decimal("0.99"),
)
assert params is not None
assert Decimal(params["price"]) == Decimal("50500") # 50000 * 1.01

View File

@@ -0,0 +1,216 @@
"""
除平仓外的流程单元测试拉持仓、解析精度、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

View File

@@ -0,0 +1,374 @@
"""
position_closer 模块中纯逻辑函数的单元测试。
"""
import sys
from decimal import Decimal
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from position_closer import (
LONG_CLOSE_PRICE_RATIO,
SHORT_CLOSE_PRICE_RATIO,
build_close_order_params,
filter_nonzero_positions,
get_notional_by_side,
get_symbols_to_close,
get_unrealized_profit,
group_positions_by_symbol,
parse_exchange_info_to_precisions,
parse_ticker_message,
round_to_step,
should_close_symbol,
)
# ----- round_to_step -----
@pytest.mark.parametrize(
"value, step, expected",
[
(Decimal("1.234"), "0.01", "1.23"),
(Decimal("1.239"), "0.01", "1.23"),
(Decimal("0.001"), "0.001", "0.001"),
(Decimal("0.0015"), "0.001", "0.001"),
(Decimal("100"), "1", "1E+2"), # normalize() 输出科学计数
(Decimal("1.5"), "0", "1.5"),
(Decimal("1.5"), "0.1", "1.5"),
],
)
def test_round_to_step(value: Decimal, step: str, expected: str) -> None:
assert round_to_step(value, step) == expected
# ----- group_positions_by_symbol -----
def test_group_positions_by_symbol_empty() -> None:
assert group_positions_by_symbol([]) == {}
def test_group_positions_by_symbol_single() -> None:
positions = [{"symbol": "BTCUSDT", "positionAmt": "0.1"}]
got = group_positions_by_symbol(positions)
assert list(got.keys()) == ["BTCUSDT"]
assert got["BTCUSDT"] == positions
def test_group_positions_by_symbol_multiple_symbols() -> None:
positions = [
{"symbol": "BTCUSDT", "positionAmt": "0.1"},
{"symbol": "ETHUSDT", "positionAmt": "1"},
{"symbol": "BTCUSDT", "positionAmt": "0.2", "positionSide": "SHORT"},
]
got = group_positions_by_symbol(positions)
assert set(got.keys()) == {"BTCUSDT", "ETHUSDT"}
assert len(got["BTCUSDT"]) == 2
assert len(got["ETHUSDT"]) == 1
def test_group_positions_by_symbol_skips_empty_symbol() -> None:
positions = [{"symbol": "", "positionAmt": "0.1"}, {"symbol": "BTCUSDT", "positionAmt": "0.1"}]
got = group_positions_by_symbol(positions)
assert list(got.keys()) == ["BTCUSDT"]
# ----- filter_nonzero_positions -----
def test_filter_nonzero_positions_empty() -> None:
assert filter_nonzero_positions([]) == []
def test_filter_nonzero_positions_keeps_nonzero() -> None:
positions = [
{"symbol": "BTCUSDT", "positionAmt": "0.1"},
{"symbol": "ETHUSDT", "positionAmt": "-1"},
]
assert len(filter_nonzero_positions(positions)) == 2
def test_filter_nonzero_positions_removes_zero() -> None:
positions = [
{"symbol": "BTCUSDT", "positionAmt": "0"},
{"symbol": "ETHUSDT", "positionAmt": "0.0"},
{"symbol": "XRPUSDT", "positionAmt": ""},
]
assert filter_nonzero_positions(positions) == []
# ----- get_notional_by_side -----
def test_get_notional_by_side_empty() -> None:
assert get_notional_by_side([]) == (0.0, 0.0)
def test_get_notional_by_side_both_long() -> None:
positions = [{"positionAmt": "0.1", "notional": "5000", "positionSide": "BOTH"}]
assert get_notional_by_side(positions) == (5000.0, 0.0)
def test_get_notional_by_side_both_short() -> None:
positions = [{"positionAmt": "-0.1", "notional": "5000", "positionSide": "BOTH"}]
assert get_notional_by_side(positions) == (0.0, 5000.0)
def test_get_notional_by_side_hedge_mode() -> None:
positions = [
{"positionAmt": "0.1", "notional": "5000", "positionSide": "LONG"},
{"positionAmt": "-0.05", "notional": "2500", "positionSide": "SHORT"},
]
assert get_notional_by_side(positions) == (5000.0, 2500.0)
def test_get_notional_by_side_uses_abs_notional() -> None:
positions = [{"positionAmt": "-0.1", "notional": "-5000", "positionSide": "BOTH"}]
assert get_notional_by_side(positions) == (0.0, 5000.0)
# ----- get_unrealized_profit -----
def test_get_unrealized_profit_empty() -> None:
assert get_unrealized_profit([]) == 0.0
# ----- should_close_symbol -----
@pytest.mark.parametrize(
"long_n, short_n, unrealized_profit, threshold, in_redis, small_threshold, small_min_profit, large_min_profit, expected",
[
(0, 0, 0, 50, False, 30, 0.03, 0.3, False),
(60, 0, 0.5, 50, False, 30, 0.03, 0.3, True), # 大仓位且盈利 > 0.3
(60, 0, 0.2, 50, False, 30, 0.03, 0.3, False),
(60, 0, 0, 50, False, 30, 0.03, 0.3, False),
(40, 0, 0.5, 50, False, 30, 0.03, 0.3, False),
(20, 0, 0.5, 50, False, 30, 0.03, 0.3, True),
(20, 0, 0.05, 50, False, 30, 0.03, 0.3, True),
(20, 0, 0.02, 50, False, 30, 0.03, 0.3, False),
(20, 0, 0, 50, False, 30, 0.03, 0.3, False),
(0, 0, 0, 50, True, 30, 0.03, 0.3, True),
(30, 30, 0.5, 50, False, 30, 0.03, 0.3, True), # 大仓位且盈利>0.3
],
)
def test_should_close_symbol(
long_n: float,
short_n: float,
unrealized_profit: float,
threshold: float,
in_redis: bool,
small_threshold: float,
small_min_profit: float,
large_min_profit: float,
expected: bool,
) -> None:
assert (
should_close_symbol(
long_n, short_n, unrealized_profit, threshold, in_redis,
small_threshold, small_min_profit, large_min_profit,
)
is expected
)
# ----- get_symbols_to_close -----
def test_get_symbols_to_close_empty() -> None:
symbols, filtered = get_symbols_to_close({}, 50, set())
assert symbols == set()
assert filtered == {}
def test_get_symbols_to_close_by_threshold() -> None:
# 大仓位且盈利>0.3 触发
by_symbol = {
"BTCUSDT": [
{"positionAmt": "0.1", "notional": "15000", "positionSide": "BOTH", "unrealizedProfit": "10"},
],
"ETHUSDT": [
{"positionAmt": "1", "notional": "3000", "positionSide": "BOTH", "unrealizedProfit": "0"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, set(), 30, 0.03, 0.3)
assert symbols == {"BTCUSDT"}
assert set(filtered.keys()) == {"BTCUSDT"}
def test_get_symbols_to_close_by_redis() -> None:
by_symbol = {
"ETHUSDT": [
{"positionAmt": "1", "notional": "3000", "positionSide": "BOTH", "unrealizedProfit": "0"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, {"ETHUSDT"})
assert symbols == {"ETHUSDT"}
assert filtered["ETHUSDT"] == by_symbol["ETHUSDT"]
def test_get_symbols_to_close_small_and_profit() -> None:
by_symbol = {
"XRPUSDT": [
{"positionAmt": "10", "notional": "20", "positionSide": "BOTH", "unrealizedProfit": "0.5"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, set(), small_threshold=30, small_min_profit=0.03)
assert symbols == {"XRPUSDT"}
assert list(filtered.keys()) == ["XRPUSDT"]
def test_get_symbols_to_close_small_profit_below_min() -> None:
by_symbol = {
"XRPUSDT": [
{"positionAmt": "10", "notional": "20", "positionSide": "BOTH", "unrealizedProfit": "0.02"},
],
}
symbols, filtered = get_symbols_to_close(by_symbol, 50, set(), small_threshold=30, small_min_profit=0.03)
assert symbols == set()
assert filtered == {}
# ----- parse_ticker_message -----
def test_parse_ticker_message_valid() -> None:
msg = {"stream": "btcusdt@ticker", "data": {"c": "50000.5", "s": "BTCUSDT"}}
symbol, price = parse_ticker_message(msg)
assert symbol == "BTCUSDT"
assert price == Decimal("50000.5")
def test_parse_ticker_message_no_stream() -> None:
msg = {"data": {"c": "50000"}}
symbol, price = parse_ticker_message(msg)
assert symbol is None
assert price is None
def test_parse_ticker_message_no_price() -> None:
msg = {"stream": "btcusdt@ticker", "data": {"s": "BTCUSDT"}}
symbol, price = parse_ticker_message(msg)
assert symbol == "BTCUSDT"
assert price is None
def test_parse_ticker_message_invalid_price() -> None:
msg = {"stream": "btcusdt@ticker", "data": {"c": "not-a-number"}}
symbol, price = parse_ticker_message(msg)
assert symbol == "BTCUSDT"
assert price is None
# ----- parse_exchange_info_to_precisions -----
def test_parse_exchange_info_to_precisions_empty() -> None:
assert parse_exchange_info_to_precisions({}) == {}
assert parse_exchange_info_to_precisions({"symbols": []}) == {}
def test_parse_exchange_info_to_precisions_defaults() -> None:
info = {"symbols": [{"symbol": "BTCUSDT", "filters": []}]}
got = parse_exchange_info_to_precisions(info)
assert got["BTCUSDT"] == {"lot_size": "0.01", "price_filter": "0.01"}
def test_parse_exchange_info_to_precisions_with_filters() -> None:
info = {
"symbols": [
{
"symbol": "BTCUSDT",
"filters": [
{"filterType": "LOT_SIZE", "stepSize": "0.001"},
{"filterType": "PRICE_FILTER", "tickSize": "0.1"},
],
}
]
}
got = parse_exchange_info_to_precisions(info)
assert got["BTCUSDT"] == {"lot_size": "0.001", "price_filter": "0.1"}
def test_parse_exchange_info_skips_empty_symbol() -> None:
info = {"symbols": [{"symbol": "", "filters": []}]}
assert parse_exchange_info_to_precisions(info) == {}
# ----- build_close_order_params -----
def test_build_close_order_params_long() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.1"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0.1,
current_price=Decimal("50000"),
precisions=precisions,
reduce_only=True,
)
assert params is not None
assert params["symbol"] == "BTCUSDT"
assert params["side"] == "SELL"
assert params["positionSide"] == "LONG"
assert params["quantity"] == "0.1"
assert Decimal(params["price"]) == Decimal("50150") # 50000 * 1.003
assert params.get("reduceOnly") == "true"
def test_build_close_order_params_short() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.1"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="SHORT",
position_amt=-0.1,
current_price=Decimal("50000"),
precisions=precisions,
reduce_only=True,
)
assert params is not None
assert params["side"] == "BUY"
assert params["positionSide"] == "SHORT"
assert Decimal(params["price"]) == Decimal("49850") # 50000 * 0.997
def test_build_close_order_params_both_long() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.01"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="BOTH",
position_amt=0.05,
current_price=Decimal("50000"),
precisions=precisions,
reduce_only=True,
)
assert params is not None
assert params["side"] == "SELL"
assert params["quantity"] == "0.05"
assert "positionSide" not in params or params.get("positionSide") == "BOTH"
assert params.get("reduceOnly") == "true"
def test_build_close_order_params_zero_amt_returns_none() -> None:
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0,
current_price=Decimal("50000"),
precisions={},
)
assert params is None
def test_build_close_order_params_tiny_amt_rounds_to_zero_returns_none() -> None:
# lot_size 0.1 时 0.05 会舍成 0
precisions = {"BTCUSDT": {"lot_size": "0.1", "price_filter": "0.01"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0.05,
current_price=Decimal("50000"),
precisions=precisions,
)
assert params is None
def test_build_close_order_params_custom_ratios() -> None:
precisions = {"BTCUSDT": {"lot_size": "0.001", "price_filter": "0.01"}}
params = build_close_order_params(
symbol="BTCUSDT",
position_side="LONG",
position_amt=0.1,
current_price=Decimal("50000"),
precisions=precisions,
long_ratio=Decimal("1.01"),
short_ratio=Decimal("0.99"),
)
assert params is not None
assert Decimal(params["price"]) == Decimal("50500") # 50000 * 1.01