mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 15:22:56 +08:00
420 lines
12 KiB
Python
420 lines
12 KiB
Python
"""Tests for close-quantity resolution (DB + exchange fallback)."""
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
|
|
from app.services.live_trading.position_query import (
|
|
resolve_reduce_only_quantity,
|
|
symbols_equivalent,
|
|
)
|
|
|
|
|
|
def test_symbols_equivalent_compact_and_slash():
|
|
assert symbols_equivalent("DOGEUSDT", "DOGE/USDT")
|
|
assert symbols_equivalent("btc/usdt", "BTCUSDT")
|
|
assert not symbols_equivalent("ETH/USDT", "DOGE/USDT")
|
|
|
|
|
|
def test_resolve_uses_exchange_when_db_missing_only_with_explicit_fallback(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 0.0,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 99.0,
|
|
)
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="DOGE/USDT",
|
|
pos_side="short",
|
|
requested_amount=0.0,
|
|
client=MagicMock(),
|
|
market_type="swap",
|
|
exchange_config={},
|
|
allow_exchange_fallback=True,
|
|
)
|
|
assert amount == 99.0
|
|
assert meta.get("filled_from") == "exchange"
|
|
assert meta.get("db_missing") is True
|
|
|
|
|
|
def test_resolve_rejects_strategy_close_when_db_position_is_missing(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 0.0,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 2.0,
|
|
)
|
|
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
requested_amount=0.0004,
|
|
client=MagicMock(),
|
|
market_type="spot",
|
|
exchange_config={},
|
|
)
|
|
|
|
assert amount == 0.0
|
|
assert meta.get("db_missing") is True
|
|
assert meta.get("blocked_by") == "strategy_position_missing"
|
|
|
|
|
|
def test_resolve_spot_close_never_exceeds_strategy_owned_quantity(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 0.0004,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 2.0,
|
|
)
|
|
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
requested_amount=1.0,
|
|
client=MagicMock(),
|
|
market_type="spot",
|
|
exchange_config={},
|
|
)
|
|
|
|
assert amount == pytest.approx(0.0004)
|
|
assert meta.get("capped_by") == "db"
|
|
|
|
|
|
def test_resolve_caps_to_db_when_smaller(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 50.0,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 99.0,
|
|
)
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="DOGE/USDT",
|
|
pos_side="short",
|
|
requested_amount=80.0,
|
|
client=MagicMock(),
|
|
market_type="swap",
|
|
exchange_config={},
|
|
)
|
|
assert amount == 50.0
|
|
assert meta.get("capped_by") == "db"
|
|
|
|
|
|
def test_resolve_account_surplus_does_not_reduce_strategy_exit(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 0.015,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 0.02,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_allocated_position_size",
|
|
lambda **_k: 0.015,
|
|
)
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
requested_amount=0.015,
|
|
client=MagicMock(),
|
|
market_type="swap",
|
|
exchange_config={},
|
|
user_id=1,
|
|
credential_id=2,
|
|
)
|
|
assert amount == pytest.approx(0.015)
|
|
assert meta["other_strategy_allocated_size"] == 0
|
|
assert meta["exchange_strategy_available"] == pytest.approx(0.02)
|
|
|
|
|
|
def test_resolve_reserves_other_strategy_allocations(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 0.015,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 0.025,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_allocated_position_size",
|
|
lambda **_k: 0.02,
|
|
)
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
requested_amount=0.015,
|
|
client=MagicMock(),
|
|
market_type="spot",
|
|
exchange_config={},
|
|
user_id=1,
|
|
credential_id=2,
|
|
)
|
|
assert amount == pytest.approx(0.015)
|
|
assert meta["other_strategy_allocated_size"] == pytest.approx(0.005)
|
|
assert meta["exchange_strategy_available"] == pytest.approx(0.02)
|
|
|
|
|
|
def test_resolve_caps_exit_when_account_cannot_cover_other_strategies(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_position_size_for_side",
|
|
lambda *_a, **_k: 0.015,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.query_exchange_position_size",
|
|
lambda **_k: 0.012,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.services.live_trading.position_query.fetch_allocated_position_size",
|
|
lambda **_k: 0.02,
|
|
)
|
|
amount, meta = resolve_reduce_only_quantity(
|
|
strategy_id=1,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
requested_amount=0.015,
|
|
client=MagicMock(),
|
|
market_type="spot",
|
|
exchange_config={},
|
|
user_id=1,
|
|
credential_id=2,
|
|
)
|
|
assert amount == pytest.approx(0.007)
|
|
assert meta["capped_by"] == "account_allocation"
|
|
|
|
|
|
def test_spot_position_query_uses_total_inventory_including_locked():
|
|
from app.services.live_trading.binance_spot import BinanceSpotClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
client = MagicMock(spec=BinanceSpotClient)
|
|
client.get_account.return_value = {
|
|
"balances": [{"asset": "BTC", "free": "0.6", "locked": "0.4"}],
|
|
}
|
|
|
|
qty = query_exchange_position_size(
|
|
client=client,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
market_type="spot",
|
|
strict=True,
|
|
)
|
|
|
|
assert qty == pytest.approx(1.0)
|
|
|
|
|
|
def test_strict_spot_position_query_propagates_exchange_snapshot_failure():
|
|
from app.services.live_trading.binance_spot import BinanceSpotClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
client = MagicMock(spec=BinanceSpotClient)
|
|
client.get_account.side_effect = RuntimeError("spot account unavailable")
|
|
|
|
with pytest.raises(RuntimeError, match="spot account unavailable"):
|
|
query_exchange_position_size(
|
|
client=client,
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
market_type="spot",
|
|
strict=True,
|
|
)
|
|
|
|
|
|
def test_okx_net_mode_long_position(monkeypatch):
|
|
from app.services.live_trading.okx import OkxClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeOkx(OkxClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self, *, inst_id: str = "", inst_type: str = "SWAP"):
|
|
return {
|
|
"data": [
|
|
{
|
|
"instId": inst_id,
|
|
"posSide": "net",
|
|
"pos": "10",
|
|
"ctVal": "0.01",
|
|
}
|
|
]
|
|
}
|
|
|
|
qty = query_exchange_position_size(
|
|
client=FakeOkx(),
|
|
symbol="BNB/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
)
|
|
assert qty == pytest.approx(0.1)
|
|
|
|
|
|
def test_okx_net_mode_short_ignored_for_long_query(monkeypatch):
|
|
from app.services.live_trading.okx import OkxClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeOkx(OkxClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self, *, inst_id: str = "", inst_type: str = "SWAP"):
|
|
return {
|
|
"data": [
|
|
{
|
|
"instId": inst_id,
|
|
"posSide": "net",
|
|
"pos": "-10",
|
|
"ctVal": "0.01",
|
|
}
|
|
]
|
|
}
|
|
|
|
qty = query_exchange_position_size(
|
|
client=FakeOkx(),
|
|
symbol="BNB/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
)
|
|
assert qty == 0.0
|
|
|
|
|
|
def test_binance_one_way_long_query(monkeypatch):
|
|
from app.services.live_trading.binance import BinanceFuturesClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeBinance(BinanceFuturesClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self):
|
|
return [
|
|
{"symbol": "BNBUSDT", "positionSide": "BOTH", "positionAmt": "2.5"},
|
|
]
|
|
|
|
qty = query_exchange_position_size(
|
|
client=FakeBinance(),
|
|
symbol="BNB/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
)
|
|
assert qty == pytest.approx(2.5)
|
|
|
|
|
|
def test_binance_one_way_short_not_returned_as_long():
|
|
from app.services.live_trading.binance import BinanceFuturesClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeBinance(BinanceFuturesClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self):
|
|
return [
|
|
{"symbol": "BNBUSDT", "positionSide": "BOTH", "positionAmt": "-2.5"},
|
|
]
|
|
|
|
assert query_exchange_position_size(
|
|
client=FakeBinance(),
|
|
symbol="BNB/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
) == 0.0
|
|
assert query_exchange_position_size(
|
|
client=FakeBinance(),
|
|
symbol="BNB/USDT",
|
|
pos_side="short",
|
|
market_type="swap",
|
|
) == pytest.approx(2.5)
|
|
|
|
|
|
def test_bitget_one_way_total_without_hold_side():
|
|
from app.services.live_trading.bitget import BitgetMixClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeBitget(BitgetMixClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = ""):
|
|
return {
|
|
"data": [
|
|
{"symbol": "BNBUSDT", "side": "buy", "total": "1.8"},
|
|
]
|
|
}
|
|
|
|
qty = query_exchange_position_size(
|
|
client=FakeBitget(),
|
|
symbol="BNB/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
)
|
|
assert qty == pytest.approx(1.8)
|
|
|
|
|
|
def test_gate_flat_position_query_returns_zero_in_strict_mode():
|
|
from app.services.live_trading.gate import GateUsdtFuturesClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeGate(GateUsdtFuturesClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self):
|
|
return []
|
|
|
|
qty = query_exchange_position_size(
|
|
client=FakeGate(),
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
strict=True,
|
|
)
|
|
assert qty == 0.0
|
|
|
|
|
|
def test_gate_dual_short_mode_is_not_misread_as_long():
|
|
from app.services.live_trading.gate import GateUsdtFuturesClient
|
|
from app.services.live_trading.position_query import query_exchange_position_size
|
|
|
|
class FakeGate(GateUsdtFuturesClient):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_positions(self):
|
|
return [
|
|
{
|
|
"contract": "BTC_USDT",
|
|
"mode": "dual_short",
|
|
"size": "2",
|
|
}
|
|
]
|
|
|
|
def get_contract(self, *, contract):
|
|
return {"quanto_multiplier": "0.001"}
|
|
|
|
assert query_exchange_position_size(
|
|
client=FakeGate(),
|
|
symbol="BTC/USDT",
|
|
pos_side="short",
|
|
market_type="swap",
|
|
) == pytest.approx(0.002)
|
|
assert query_exchange_position_size(
|
|
client=FakeGate(),
|
|
symbol="BTC/USDT",
|
|
pos_side="long",
|
|
market_type="swap",
|
|
) == 0.0
|