Files
bn-pc/tests/test_position_closer.py
yhydev 48d31cd1d0 移除 Redis、精简配置,新增 Docker 支持
- position_closer: 去掉 Redis 依赖,平仓条件仅名义+未实现盈亏
- requirements: 移除 redis
- settings.toml: 仅保留实际使用的配置项
- 新增 Dockerfile(仅安装依赖)、docker-compose(挂载代码与配置)
- 新增 .dockerignore、.gitignore(含 nohup.log)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 10:06:30 +08:00

407 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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