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