build 039: complete Quotes Feed migration foundation
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError, replace
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||
map_dzengi_exchange_info_to_instruments,
|
||||
map_dzengi_symbol_to_instrument,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiExchangeInfoPayload,
|
||||
DzengiExchangeInfoResponse,
|
||||
DzengiExchangeInfoSymbol,
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiUnknownFilter,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceMappingError,
|
||||
)
|
||||
|
||||
|
||||
def _complete_symbol() -> DzengiExchangeInfoSymbol:
|
||||
return DzengiExchangeInfoSymbol(
|
||||
symbol="ETH/EUR_LEVERAGE",
|
||||
name="ETH/EUR",
|
||||
status="TRADING",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
base_asset="ETH",
|
||||
base_asset_precision=3,
|
||||
quote_asset="EUR",
|
||||
quote_asset_id="EUR_LEVERAGE",
|
||||
quote_precision=3,
|
||||
order_types=("LIMIT", "MARKET", "STOP"),
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.001",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
),
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="2",
|
||||
),
|
||||
),
|
||||
market_modes=("REGULAR",),
|
||||
market_type="LEVERAGE",
|
||||
country="",
|
||||
sector="",
|
||||
industry="",
|
||||
trading_hours="UTC; Mon - 21:00, 21:05 -",
|
||||
tick_size=0.01,
|
||||
tick_value=18.3415,
|
||||
trading_fee=0.06,
|
||||
exchange_fee=None,
|
||||
long_rate=-0.01,
|
||||
short_rate=0.01,
|
||||
swap_charge_interval=480,
|
||||
min_sl_gap=0,
|
||||
max_sl_gap=50.0,
|
||||
min_tp_gap=0,
|
||||
max_tp_gap=50.0,
|
||||
)
|
||||
|
||||
|
||||
def _response(
|
||||
*symbols: DzengiExchangeInfoSymbol,
|
||||
) -> DzengiExchangeInfoResponse:
|
||||
return DzengiExchangeInfoResponse(
|
||||
payload=DzengiExchangeInfoPayload(
|
||||
timezone="UTC",
|
||||
server_time=1783537921471,
|
||||
rate_limits=(),
|
||||
exchange_filters=(),
|
||||
symbols=tuple(symbols),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_map_complete_dzengi_symbol_to_instrument() -> None:
|
||||
instrument = map_dzengi_symbol_to_instrument(
|
||||
_complete_symbol()
|
||||
)
|
||||
|
||||
assert instrument.symbol == "ETH/EUR_LEVERAGE"
|
||||
assert instrument.name == "ETH/EUR"
|
||||
assert instrument.status == "TRADING"
|
||||
|
||||
assert instrument.base_asset == "ETH"
|
||||
assert instrument.quote_asset == "EUR"
|
||||
assert instrument.asset_type == "CRYPTOCURRENCY"
|
||||
|
||||
assert instrument.market_type == "LEVERAGE"
|
||||
assert instrument.market_modes == ("REGULAR",)
|
||||
assert instrument.order_types == ("LIMIT", "MARKET", "STOP")
|
||||
|
||||
assert instrument.base_asset_precision == 3
|
||||
assert instrument.quote_asset_precision == 3
|
||||
|
||||
assert instrument.tick_size == Decimal("0.01")
|
||||
assert instrument.tick_value == Decimal("18.3415")
|
||||
|
||||
assert instrument.step_size == Decimal("0.001")
|
||||
assert instrument.min_qty == Decimal("0.001")
|
||||
assert instrument.max_qty == Decimal("1000")
|
||||
assert instrument.min_notional == Decimal("2")
|
||||
|
||||
assert instrument.country is None
|
||||
assert instrument.sector is None
|
||||
assert instrument.industry is None
|
||||
assert instrument.trading_hours == "UTC; Mon - 21:00, 21:05 -"
|
||||
|
||||
|
||||
def test_map_exchange_info_to_instruments() -> None:
|
||||
first = _complete_symbol()
|
||||
second = replace(
|
||||
_complete_symbol(),
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
name="BTC/USD",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
)
|
||||
|
||||
instruments = map_dzengi_exchange_info_to_instruments(
|
||||
_response(first, second)
|
||||
)
|
||||
|
||||
assert isinstance(instruments, tuple)
|
||||
assert len(instruments) == 2
|
||||
assert instruments[0].symbol == "ETH/EUR_LEVERAGE"
|
||||
assert instruments[1].symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_map_numeric_values_to_decimal_exactly() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
tick_size=0.00000001,
|
||||
tick_value=0,
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.00000001",
|
||||
max_qty=10000000,
|
||||
step_size="0.00000001",
|
||||
),
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="0.00000069",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.tick_size == Decimal("1E-8")
|
||||
assert instrument.tick_value == Decimal("0")
|
||||
assert instrument.min_qty == Decimal("1E-8")
|
||||
assert instrument.max_qty == Decimal("10000000")
|
||||
assert instrument.step_size == Decimal("1E-8")
|
||||
assert instrument.min_notional == Decimal("6.9E-7")
|
||||
|
||||
|
||||
def test_map_symbol_without_filters() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
filters=(),
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.step_size is None
|
||||
assert instrument.min_qty is None
|
||||
assert instrument.max_qty is None
|
||||
assert instrument.min_notional is None
|
||||
|
||||
|
||||
def test_map_symbol_with_missing_optional_numeric_values() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
tick_size=None,
|
||||
tick_value=None,
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty=None,
|
||||
max_qty=None,
|
||||
step_size=None,
|
||||
),
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.tick_size is None
|
||||
assert instrument.tick_value is None
|
||||
assert instrument.step_size is None
|
||||
assert instrument.min_qty is None
|
||||
assert instrument.max_qty is None
|
||||
assert instrument.min_notional is None
|
||||
|
||||
|
||||
def test_mapper_ignores_unknown_filters() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
filters=(
|
||||
DzengiUnknownFilter(
|
||||
filter_type="FUTURE_FILTER",
|
||||
fields=(
|
||||
("enabled", True),
|
||||
("limit", 10),
|
||||
),
|
||||
),
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.001",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.min_qty == Decimal("0.001")
|
||||
assert instrument.max_qty == Decimal("1000")
|
||||
assert instrument.step_size == Decimal("0.001")
|
||||
assert instrument.min_notional is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("asset_type", ""),
|
||||
("asset_type", " "),
|
||||
("country", ""),
|
||||
("country", " "),
|
||||
("sector", ""),
|
||||
("industry", " "),
|
||||
("trading_hours", ""),
|
||||
],
|
||||
)
|
||||
def test_mapper_converts_empty_optional_text_to_none(
|
||||
field: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
**{field: value},
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert getattr(instrument, field) is None
|
||||
|
||||
|
||||
def test_mapper_strips_non_empty_optional_text() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
asset_type=" CRYPTOCURRENCY ",
|
||||
country=" DE ",
|
||||
sector=" Technology ",
|
||||
industry=" Software ",
|
||||
trading_hours=" UTC; Mon 07:00 - 15:30 ",
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.asset_type == "CRYPTOCURRENCY"
|
||||
assert instrument.country == "DE"
|
||||
assert instrument.sector == "Technology"
|
||||
assert instrument.industry == "Software"
|
||||
assert instrument.trading_hours == "UTC; Mon 07:00 - 15:30"
|
||||
|
||||
|
||||
def test_mapper_preserves_market_modes_order() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
market_modes=("REGULAR", "CLOSE_ONLY", "EXTENDED"),
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.market_modes == (
|
||||
"REGULAR",
|
||||
"CLOSE_ONLY",
|
||||
"EXTENDED",
|
||||
)
|
||||
|
||||
|
||||
def test_mapper_preserves_order_types_order() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
order_types=("MARKET", "LIMIT", "STOP"),
|
||||
)
|
||||
|
||||
instrument = map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
assert instrument.order_types == (
|
||||
"MARKET",
|
||||
"LIMIT",
|
||||
"STOP",
|
||||
)
|
||||
|
||||
|
||||
def test_mapper_rejects_duplicate_lot_size_filters() -> None:
|
||||
lot_size = DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.001",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
)
|
||||
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
filters=(
|
||||
lot_size,
|
||||
lot_size,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceMappingError,
|
||||
match=r"несколько фильтров LOT_SIZE",
|
||||
):
|
||||
map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
|
||||
def test_mapper_rejects_duplicate_min_notional_filters() -> None:
|
||||
min_notional = DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="2",
|
||||
)
|
||||
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
filters=(
|
||||
min_notional,
|
||||
min_notional,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceMappingError,
|
||||
match=r"несколько фильтров MIN_NOTIONAL",
|
||||
):
|
||||
map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "invalid_value"),
|
||||
[
|
||||
("tick_size", float("nan")),
|
||||
("tick_value", float("inf")),
|
||||
],
|
||||
)
|
||||
def test_mapper_rejects_non_finite_direct_numeric_value(
|
||||
field_name: str,
|
||||
invalid_value: float,
|
||||
) -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
**{field_name: invalid_value},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceMappingError,
|
||||
match=r"должно быть конечным числом",
|
||||
):
|
||||
map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
|
||||
def test_mapper_rejects_invalid_filter_numeric_value() -> None:
|
||||
symbol = replace(
|
||||
_complete_symbol(),
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="not-a-number",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceMappingError,
|
||||
match=r"minQty.*невозможно преобразовать в Decimal",
|
||||
):
|
||||
map_dzengi_symbol_to_instrument(symbol)
|
||||
|
||||
|
||||
def test_mapped_instrument_is_immutable() -> None:
|
||||
instrument = map_dzengi_symbol_to_instrument(
|
||||
_complete_symbol()
|
||||
)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
instrument.status = "BREAK" # type: ignore[misc]
|
||||
@@ -0,0 +1,206 @@
|
||||
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiExchangeInfoPayload,
|
||||
DzengiExchangeInfoResponse,
|
||||
DzengiExchangeInfoSymbol,
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiUnknownFilter,
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_info_response_stores_complete_raw_contract() -> None:
|
||||
lot_size = DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.001",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
)
|
||||
min_notional = DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="2",
|
||||
)
|
||||
|
||||
symbol = DzengiExchangeInfoSymbol(
|
||||
symbol="ETH/EUR_LEVERAGE",
|
||||
name="ETH/EUR",
|
||||
status="TRADING",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
base_asset="ETH",
|
||||
base_asset_precision=3,
|
||||
quote_asset="EUR",
|
||||
quote_asset_id="EUR_LEVERAGE",
|
||||
quote_precision=3,
|
||||
order_types=("LIMIT", "MARKET", "STOP"),
|
||||
filters=(lot_size, min_notional),
|
||||
market_modes=("REGULAR",),
|
||||
market_type="LEVERAGE",
|
||||
country="",
|
||||
sector="",
|
||||
industry="",
|
||||
trading_hours="UTC; Mon - 21:00, 21:05 -",
|
||||
tick_size=0.01,
|
||||
tick_value=18.3415,
|
||||
trading_fee=0.06,
|
||||
exchange_fee=None,
|
||||
long_rate=-0.01,
|
||||
short_rate=0.01,
|
||||
swap_charge_interval=480,
|
||||
min_sl_gap=0,
|
||||
max_sl_gap=50.0,
|
||||
min_tp_gap=0,
|
||||
max_tp_gap=50.0,
|
||||
)
|
||||
|
||||
payload = DzengiExchangeInfoPayload(
|
||||
timezone="UTC",
|
||||
server_time=1783537921471,
|
||||
rate_limits=(
|
||||
DzengiRateLimit(
|
||||
interval="MINUTE",
|
||||
interval_num=1,
|
||||
limit=1200,
|
||||
rate_limit_type="REQUEST_WEIGHT",
|
||||
),
|
||||
),
|
||||
exchange_filters=(),
|
||||
symbols=(symbol,),
|
||||
)
|
||||
|
||||
response = DzengiExchangeInfoResponse(payload=payload)
|
||||
|
||||
assert response.status is None
|
||||
assert response.correlation_id is None
|
||||
|
||||
assert response.payload.timezone == "UTC"
|
||||
assert response.payload.server_time == 1783537921471
|
||||
assert len(response.payload.symbols) == 1
|
||||
|
||||
parsed_symbol = response.payload.symbols[0]
|
||||
|
||||
assert parsed_symbol.symbol == "ETH/EUR_LEVERAGE"
|
||||
assert parsed_symbol.filters == (lot_size, min_notional)
|
||||
assert parsed_symbol.tick_size == 0.01
|
||||
assert parsed_symbol.trading_fee == 0.06
|
||||
assert parsed_symbol.exchange_fee is None
|
||||
|
||||
|
||||
def test_exchange_info_response_supports_wrapped_api_metadata() -> None:
|
||||
payload = DzengiExchangeInfoPayload(
|
||||
timezone="UTC",
|
||||
server_time=1628193845310,
|
||||
rate_limits=(),
|
||||
exchange_filters=(),
|
||||
symbols=(),
|
||||
)
|
||||
|
||||
response = DzengiExchangeInfoResponse(
|
||||
status="OK",
|
||||
correlation_id="2",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert response.status == "OK"
|
||||
assert response.correlation_id == "2"
|
||||
assert response.payload.symbols == ()
|
||||
|
||||
|
||||
def test_exchange_info_symbol_accepts_optional_transport_fields() -> None:
|
||||
symbol = DzengiExchangeInfoSymbol(
|
||||
symbol="TUI1.",
|
||||
name="TUI - EUR",
|
||||
status="TRADING",
|
||||
asset_type="EQUITY",
|
||||
base_asset="TUI1.",
|
||||
base_asset_precision=3,
|
||||
quote_asset="EUR",
|
||||
quote_asset_id="EUR_LEVERAGE",
|
||||
quote_precision=3,
|
||||
order_types=("LIMIT", "MARKET", "STOP"),
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.1",
|
||||
max_qty="33000",
|
||||
step_size="0.1",
|
||||
),
|
||||
),
|
||||
market_modes=("REGULAR",),
|
||||
market_type="LEVERAGE",
|
||||
country="DE",
|
||||
sector="Cyclical Consumer Goods & Services",
|
||||
industry="Leisure & Recreation",
|
||||
trading_hours="UTC; Mon 07:00 - 15:30",
|
||||
tick_size=0.005,
|
||||
tick_value=None,
|
||||
trading_fee=0,
|
||||
exchange_fee=None,
|
||||
long_rate=-0.0165933,
|
||||
short_rate=-0.0056289,
|
||||
swap_charge_interval=1440,
|
||||
min_sl_gap=0,
|
||||
max_sl_gap=30.0,
|
||||
min_tp_gap=0,
|
||||
max_tp_gap=30.0,
|
||||
)
|
||||
|
||||
assert symbol.tick_value is None
|
||||
assert symbol.exchange_fee is None
|
||||
assert len(symbol.filters) == 1
|
||||
|
||||
|
||||
def test_unknown_filter_preserves_unrecognized_scalar_fields() -> None:
|
||||
unknown_filter = DzengiUnknownFilter(
|
||||
filter_type="FUTURE_FILTER",
|
||||
fields=(
|
||||
("enabled", True),
|
||||
("limit", 10),
|
||||
("mode", "STRICT"),
|
||||
("description", None),
|
||||
),
|
||||
)
|
||||
|
||||
assert unknown_filter.filter_type == "FUTURE_FILTER"
|
||||
assert unknown_filter.fields == (
|
||||
("enabled", True),
|
||||
("limit", 10),
|
||||
("mode", "STRICT"),
|
||||
("description", None),
|
||||
)
|
||||
|
||||
|
||||
def test_raw_models_use_immutable_sequences() -> None:
|
||||
payload = DzengiExchangeInfoPayload(
|
||||
timezone=None,
|
||||
server_time=None,
|
||||
rate_limits=(),
|
||||
exchange_filters=(),
|
||||
symbols=(),
|
||||
)
|
||||
|
||||
assert isinstance(payload.rate_limits, tuple)
|
||||
assert isinstance(payload.exchange_filters, tuple)
|
||||
assert isinstance(payload.symbols, tuple)
|
||||
|
||||
|
||||
def test_raw_models_are_immutable() -> None:
|
||||
payload = DzengiExchangeInfoPayload(
|
||||
timezone="UTC",
|
||||
server_time=1783537921471,
|
||||
rate_limits=(),
|
||||
exchange_filters=(),
|
||||
symbols=(),
|
||||
)
|
||||
|
||||
response = DzengiExchangeInfoResponse(payload=payload)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
response.status = "OK" # type: ignore[misc]
|
||||
@@ -0,0 +1,405 @@
|
||||
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_parser.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiUnknownFilter,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||
parse_exchange_info,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceParseError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
ValidatedExchangeInfoDocument,
|
||||
)
|
||||
|
||||
|
||||
def _validated_document(
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
is_wrapped: bool = False,
|
||||
status: object | None = None,
|
||||
correlation_id: object | None = None,
|
||||
) -> ValidatedExchangeInfoDocument:
|
||||
return ValidatedExchangeInfoDocument(
|
||||
payload=MappingProxyType(payload),
|
||||
is_wrapped=is_wrapped,
|
||||
status=status,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
|
||||
def _complete_symbol() -> dict[str, object]:
|
||||
return {
|
||||
"symbol": "ETH/EUR_LEVERAGE",
|
||||
"name": "ETH/EUR",
|
||||
"status": "TRADING",
|
||||
"assetType": "CRYPTOCURRENCY",
|
||||
"baseAsset": "ETH",
|
||||
"baseAssetPrecision": 3,
|
||||
"quoteAsset": "EUR",
|
||||
"quoteAssetId": "EUR_LEVERAGE",
|
||||
"quotePrecision": 3,
|
||||
"orderTypes": ["LIMIT", "MARKET", "STOP"],
|
||||
"filters": [
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"minQty": "0.001",
|
||||
"maxQty": "1000",
|
||||
"stepSize": "0.001",
|
||||
},
|
||||
{
|
||||
"filterType": "MIN_NOTIONAL",
|
||||
"minNotional": "2",
|
||||
},
|
||||
],
|
||||
"marketModes": ["REGULAR"],
|
||||
"marketType": "LEVERAGE",
|
||||
"country": "",
|
||||
"sector": "",
|
||||
"industry": "",
|
||||
"tradingHours": "UTC; Mon - 21:00, 21:05 -",
|
||||
"tickSize": 0.01,
|
||||
"tickValue": 18.3415,
|
||||
"tradingFee": 0.06,
|
||||
"longRate": -0.01,
|
||||
"shortRate": 0.01,
|
||||
"swapChargeInterval": 480,
|
||||
"minSLGap": 0,
|
||||
"maxSLGap": 50.0,
|
||||
"minTPGap": 0,
|
||||
"maxTPGap": 50.0,
|
||||
}
|
||||
|
||||
|
||||
def test_parse_complete_unwrapped_exchange_info() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1783537921471,
|
||||
"rateLimits": [
|
||||
{
|
||||
"interval": "MINUTE",
|
||||
"intervalNum": 1,
|
||||
"limit": 1200,
|
||||
"rateLimitType": "REQUEST_WEIGHT",
|
||||
}
|
||||
],
|
||||
"exchangeFilters": [],
|
||||
"symbols": [_complete_symbol()],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
|
||||
assert response.status is None
|
||||
assert response.correlation_id is None
|
||||
assert response.payload.timezone == "UTC"
|
||||
assert response.payload.server_time == 1783537921471
|
||||
assert len(response.payload.rate_limits) == 1
|
||||
assert len(response.payload.symbols) == 1
|
||||
|
||||
symbol = response.payload.symbols[0]
|
||||
|
||||
assert symbol.symbol == "ETH/EUR_LEVERAGE"
|
||||
assert symbol.name == "ETH/EUR"
|
||||
assert symbol.status == "TRADING"
|
||||
assert symbol.asset_type == "CRYPTOCURRENCY"
|
||||
assert symbol.base_asset == "ETH"
|
||||
assert symbol.quote_asset == "EUR"
|
||||
assert symbol.order_types == ("LIMIT", "MARKET", "STOP")
|
||||
assert symbol.market_modes == ("REGULAR",)
|
||||
assert symbol.tick_size == 0.01
|
||||
assert symbol.tick_value == 18.3415
|
||||
assert symbol.trading_fee == 0.06
|
||||
assert symbol.exchange_fee is None
|
||||
|
||||
|
||||
def test_parse_wrapped_exchange_info_metadata() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1628193845310,
|
||||
"symbols": [],
|
||||
},
|
||||
is_wrapped=True,
|
||||
status="OK",
|
||||
correlation_id="2",
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
|
||||
assert response.status == "OK"
|
||||
assert response.correlation_id == "2"
|
||||
assert response.payload.symbols == ()
|
||||
|
||||
|
||||
def test_parse_known_instrument_filters() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [_complete_symbol()],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
filters = response.payload.symbols[0].filters
|
||||
|
||||
assert isinstance(filters[0], DzengiLotSizeFilter)
|
||||
assert filters[0].min_qty == "0.001"
|
||||
assert filters[0].max_qty == "1000"
|
||||
assert filters[0].step_size == "0.001"
|
||||
|
||||
assert isinstance(filters[1], DzengiMinNotionalFilter)
|
||||
assert filters[1].min_notional == "2"
|
||||
|
||||
|
||||
def test_parse_unknown_instrument_filter() -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol["filters"] = [
|
||||
{
|
||||
"filterType": "FUTURE_FILTER",
|
||||
"enabled": True,
|
||||
"limit": 10,
|
||||
"mode": "STRICT",
|
||||
}
|
||||
]
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
parsed_filter = response.payload.symbols[0].filters[0]
|
||||
|
||||
assert isinstance(parsed_filter, DzengiUnknownFilter)
|
||||
assert parsed_filter.filter_type == "FUTURE_FILTER"
|
||||
assert parsed_filter.fields == (
|
||||
("enabled", True),
|
||||
("limit", 10),
|
||||
("mode", "STRICT"),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_exchange_filters_as_unknown_filters() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"exchangeFilters": [
|
||||
{
|
||||
"filterType": "GLOBAL_LIMIT",
|
||||
"enabled": True,
|
||||
"limit": 100,
|
||||
}
|
||||
],
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
exchange_filter = response.payload.exchange_filters[0]
|
||||
|
||||
assert exchange_filter.filter_type == "GLOBAL_LIMIT"
|
||||
assert exchange_filter.fields == (
|
||||
("enabled", True),
|
||||
("limit", 100),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_exchange_filter_without_filter_type() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"exchangeFilters": [
|
||||
{
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
|
||||
assert response.payload.exchange_filters[0].filter_type == ""
|
||||
assert response.payload.exchange_filters[0].fields == (
|
||||
("enabled", True),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_symbol_with_missing_optional_fields() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "TEST/USD",
|
||||
"name": "Test",
|
||||
"status": "BREAK",
|
||||
"baseAsset": "TEST",
|
||||
"quoteAsset": "USD",
|
||||
"marketType": "SPOT",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
symbol = response.payload.symbols[0]
|
||||
|
||||
assert symbol.asset_type is None
|
||||
assert symbol.base_asset_precision is None
|
||||
assert symbol.quote_asset_id is None
|
||||
assert symbol.quote_precision is None
|
||||
assert symbol.order_types == ()
|
||||
assert symbol.filters == ()
|
||||
assert symbol.market_modes == ()
|
||||
assert symbol.country is None
|
||||
assert symbol.tick_size is None
|
||||
assert symbol.min_sl_gap is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
"symbol",
|
||||
"name",
|
||||
"status",
|
||||
"baseAsset",
|
||||
"quoteAsset",
|
||||
"marketType",
|
||||
],
|
||||
)
|
||||
def test_reject_missing_required_symbol_field(field: str) -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol.pop(field)
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceParseError,
|
||||
match=rf"\.{field} должен быть строкой",
|
||||
):
|
||||
parse_exchange_info(document)
|
||||
|
||||
|
||||
def test_reject_invalid_required_string_type() -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol["symbol"] = 123
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceParseError,
|
||||
match=r"\.symbol должен быть строкой",
|
||||
):
|
||||
parse_exchange_info(document)
|
||||
|
||||
|
||||
def test_reject_invalid_json_number_type() -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol["tickSize"] = "0.01"
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceParseError,
|
||||
match=r"\.tickSize должен быть JSON-числом",
|
||||
):
|
||||
parse_exchange_info(document)
|
||||
|
||||
|
||||
def test_reject_bool_as_json_number() -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol["tickSize"] = True
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceParseError,
|
||||
match=r"\.tickSize должен быть JSON-числом",
|
||||
):
|
||||
parse_exchange_info(document)
|
||||
|
||||
|
||||
def test_reject_float_as_integer_field() -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol["baseAssetPrecision"] = 3.0
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceParseError,
|
||||
match=r"\.baseAssetPrecision должен быть целым числом",
|
||||
):
|
||||
parse_exchange_info(document)
|
||||
|
||||
|
||||
def test_reject_nested_unknown_filter_value() -> None:
|
||||
symbol = _complete_symbol()
|
||||
symbol["filters"] = [
|
||||
{
|
||||
"filterType": "FUTURE_FILTER",
|
||||
"settings": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
document = _validated_document(
|
||||
{
|
||||
"symbols": [symbol],
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceParseError,
|
||||
match=r"\.settings должен быть скалярным JSON-значением",
|
||||
):
|
||||
parse_exchange_info(document)
|
||||
|
||||
|
||||
def test_parse_result_uses_immutable_sequences() -> None:
|
||||
document = _validated_document(
|
||||
{
|
||||
"rateLimits": [],
|
||||
"exchangeFilters": [],
|
||||
"symbols": [_complete_symbol()],
|
||||
}
|
||||
)
|
||||
|
||||
response = parse_exchange_info(document)
|
||||
symbol = response.payload.symbols[0]
|
||||
|
||||
assert isinstance(response.payload.rate_limits, tuple)
|
||||
assert isinstance(response.payload.exchange_filters, tuple)
|
||||
assert isinstance(response.payload.symbols, tuple)
|
||||
assert isinstance(symbol.order_types, tuple)
|
||||
assert isinstance(symbol.filters, tuple)
|
||||
assert isinstance(symbol.market_modes, tuple)
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||
map_dzengi_ticker_to_quote,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiTicker24hrResponse,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import QuoteMappingError
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
|
||||
def _response(**overrides: object) -> DzengiTicker24hrResponse:
|
||||
values: dict[str, object] = {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"last_price": "64159.45",
|
||||
"bid_price": "64159.45",
|
||||
"ask_price": "64159.55",
|
||||
"close_time": 1783887270312,
|
||||
}
|
||||
values.update(overrides)
|
||||
return DzengiTicker24hrResponse(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_mapper_returns_canonical_quote() -> None:
|
||||
received_at = datetime(2026, 7, 12, 18, 0, tzinfo=timezone.utc)
|
||||
|
||||
result = map_dzengi_ticker_to_quote(
|
||||
_response(),
|
||||
received_at=received_at,
|
||||
)
|
||||
|
||||
assert result == Quote(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price=Decimal("64159.45"),
|
||||
bid_price=Decimal("64159.45"),
|
||||
ask_price=Decimal("64159.55"),
|
||||
exchange_timestamp=datetime.fromtimestamp(
|
||||
1783887270312 / 1000,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
received_at=received_at,
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
def test_mapper_preserves_decimal_precision() -> None:
|
||||
result = map_dzengi_ticker_to_quote(
|
||||
_response(
|
||||
last_price="0.123456789123456789",
|
||||
bid_price="0.123456789123456788",
|
||||
ask_price="0.123456789123456790",
|
||||
),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
assert result.last_price == Decimal("0.123456789123456789")
|
||||
assert result.bid_price == Decimal("0.123456789123456788")
|
||||
assert result.ask_price == Decimal("0.123456789123456790")
|
||||
|
||||
|
||||
def test_mapper_strips_symbol_outer_spaces() -> None:
|
||||
result = map_dzengi_ticker_to_quote(
|
||||
_response(symbol=" BTC/USD_LEVERAGE "),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
assert result.symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_mapper_rejects_non_finite_price() -> None:
|
||||
with pytest.raises(
|
||||
QuoteMappingError,
|
||||
match=r"lastPrice.*конечным числом",
|
||||
):
|
||||
map_dzengi_ticker_to_quote(
|
||||
_response(last_price="NaN"),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_mapper_wraps_invalid_price_conversion() -> None:
|
||||
with pytest.raises(
|
||||
QuoteMappingError,
|
||||
match=r"bidPrice.*Decimal",
|
||||
):
|
||||
map_dzengi_ticker_to_quote(
|
||||
_response(bid_price="not-a-number"),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_mapper_rejects_naive_received_at() -> None:
|
||||
with pytest.raises(
|
||||
QuoteMappingError,
|
||||
match=r"received_at.*timezone-aware",
|
||||
):
|
||||
map_dzengi_ticker_to_quote(
|
||||
_response(),
|
||||
received_at=datetime(2026, 7, 12, 18, 0),
|
||||
)
|
||||
|
||||
|
||||
def test_mapper_preserves_received_at_timezone() -> None:
|
||||
received_at = datetime.fromisoformat("2026-07-12T21:00:00+03:00")
|
||||
|
||||
result = map_dzengi_ticker_to_quote(
|
||||
_response(),
|
||||
received_at=received_at,
|
||||
)
|
||||
|
||||
assert result.received_at is received_at
|
||||
|
||||
|
||||
def test_mapper_wraps_invalid_close_time() -> None:
|
||||
with pytest.raises(
|
||||
QuoteMappingError,
|
||||
match=r"closeTime.*UTC datetime",
|
||||
):
|
||||
map_dzengi_ticker_to_quote(
|
||||
_response(close_time=10**30),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiTicker24hrResponse,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import parse_quote
|
||||
from src.market_data.acquisition.exceptions import QuoteParseError
|
||||
from src.market_data.acquisition.validation.schema import validate_quote_schema
|
||||
|
||||
|
||||
def _document() -> dict[str, object]:
|
||||
return {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"lastPrice": "64159.45",
|
||||
"bidPrice": "64159.45",
|
||||
"askPrice": "64159.55",
|
||||
"closeTime": 1783887270312,
|
||||
"highPrice": "64261.45",
|
||||
"volume": "9.6002",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_quote_builds_dzengi_transport_model() -> None:
|
||||
validated = validate_quote_schema(_document())
|
||||
|
||||
result = parse_quote(validated)
|
||||
|
||||
assert result == DzengiTicker24hrResponse(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price="64159.45",
|
||||
bid_price="64159.45",
|
||||
ask_price="64159.55",
|
||||
close_time=1783887270312,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_quote_ignores_unrelated_24hr_statistics() -> None:
|
||||
document = _document()
|
||||
document["openPrice"] = "63785.75"
|
||||
document["weightedAvgPrice"] = "64159.50"
|
||||
|
||||
result = parse_quote(validate_quote_schema(document))
|
||||
|
||||
assert result.symbol == "BTC/USD_LEVERAGE"
|
||||
assert not hasattr(result, "open_price")
|
||||
assert not hasattr(result, "weighted_avg_price")
|
||||
|
||||
|
||||
def test_parse_quote_accepts_json_numbers_for_prices() -> None:
|
||||
document = _document()
|
||||
document["lastPrice"] = 64159.45
|
||||
document["bidPrice"] = 64159
|
||||
document["askPrice"] = 64160
|
||||
|
||||
result = parse_quote(validate_quote_schema(document))
|
||||
|
||||
assert result.last_price == 64159.45
|
||||
assert result.bid_price == 64159
|
||||
assert result.ask_price == 64160
|
||||
|
||||
|
||||
def test_parse_quote_rejects_boolean_price() -> None:
|
||||
document = _document()
|
||||
document["lastPrice"] = True
|
||||
|
||||
with pytest.raises(QuoteParseError, match="lastPrice"):
|
||||
parse_quote(validate_quote_schema(document))
|
||||
|
||||
|
||||
def test_parse_quote_rejects_non_integer_close_time() -> None:
|
||||
document = _document()
|
||||
document["closeTime"] = "1783887270312"
|
||||
|
||||
with pytest.raises(QuoteParseError, match="closeTime"):
|
||||
parse_quote(validate_quote_schema(document))
|
||||
@@ -0,0 +1,371 @@
|
||||
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_rest.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.integrations.exchange.exceptions import (
|
||||
ExchangeConnectionError,
|
||||
ExchangeResponseError,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
DzengiInstrumentDocumentSource,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceTransportError,
|
||||
)
|
||||
from src.market_data.acquisition.protocol import (
|
||||
InstrumentDocumentSource,
|
||||
)
|
||||
|
||||
|
||||
class StubRestClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
result: object = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.error = error
|
||||
self.calls: list[str] = []
|
||||
|
||||
def get_payload(
|
||||
self,
|
||||
path: str,
|
||||
params: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
del params
|
||||
del headers
|
||||
|
||||
self.calls.append(path)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.result
|
||||
|
||||
|
||||
def test_source_satisfies_instrument_document_source_protocol() -> None:
|
||||
source = DzengiInstrumentDocumentSource(
|
||||
client=StubRestClient(
|
||||
result={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(source, InstrumentDocumentSource)
|
||||
|
||||
|
||||
def test_fetch_instrument_document_calls_exchange_info_endpoint() -> None:
|
||||
client = StubRestClient(
|
||||
result={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
source = DzengiInstrumentDocumentSource(client=client)
|
||||
|
||||
result = source.fetch_instrument_document()
|
||||
|
||||
assert result == {
|
||||
"symbols": [],
|
||||
}
|
||||
assert client.calls == [
|
||||
"/api/v1/exchangeInfo",
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_instrument_document_returns_dict_without_changes() -> None:
|
||||
document = {
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1783537921471,
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
source = DzengiInstrumentDocumentSource(
|
||||
client=StubRestClient(result=document)
|
||||
)
|
||||
|
||||
result = source.fetch_instrument_document()
|
||||
|
||||
assert result is document
|
||||
|
||||
|
||||
def test_fetch_instrument_document_returns_list_without_changes() -> None:
|
||||
document = [
|
||||
{
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
}
|
||||
]
|
||||
|
||||
source = DzengiInstrumentDocumentSource(
|
||||
client=StubRestClient(result=document)
|
||||
)
|
||||
|
||||
result = source.fetch_instrument_document()
|
||||
|
||||
assert result is document
|
||||
|
||||
|
||||
def test_source_uses_injected_client() -> None:
|
||||
client = StubRestClient(
|
||||
result={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
source = DzengiInstrumentDocumentSource(client=client)
|
||||
|
||||
source.fetch_instrument_document()
|
||||
source.fetch_instrument_document()
|
||||
|
||||
assert client.calls == [
|
||||
"/api/v1/exchangeInfo",
|
||||
"/api/v1/exchangeInfo",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ExchangeConnectionError("Network error."),
|
||||
ExchangeResponseError("Invalid response."),
|
||||
RuntimeError("Unexpected transport failure."),
|
||||
],
|
||||
)
|
||||
def test_transport_errors_are_wrapped(
|
||||
error: Exception,
|
||||
) -> None:
|
||||
source = DzengiInstrumentDocumentSource(
|
||||
client=StubRestClient(error=error)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceTransportError,
|
||||
match=r"Не удалось получить Instrument Reference Data от Dzengi",
|
||||
) as exc_info:
|
||||
source.fetch_instrument_document()
|
||||
|
||||
assert exc_info.value.__cause__ is error
|
||||
assert str(error) in str(exc_info.value)
|
||||
|
||||
|
||||
def test_client_creation_error_is_wrapped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original_error = RuntimeError("EXCHANGE_BASE_URL is invalid.")
|
||||
|
||||
def raise_client_creation_error() -> None:
|
||||
raise original_error
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient",
|
||||
raise_client_creation_error,
|
||||
)
|
||||
|
||||
source = DzengiInstrumentDocumentSource()
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceTransportError,
|
||||
match=r"Не удалось получить Instrument Reference Data от Dzengi",
|
||||
) as exc_info:
|
||||
source.fetch_instrument_document()
|
||||
|
||||
assert exc_info.value.__cause__ is original_error
|
||||
|
||||
|
||||
def test_adapter_does_not_transform_returned_document() -> None:
|
||||
document = {
|
||||
"status": "OK",
|
||||
"payload": {
|
||||
"symbols": [],
|
||||
},
|
||||
}
|
||||
|
||||
source = DzengiInstrumentDocumentSource(
|
||||
client=StubRestClient(result=document)
|
||||
)
|
||||
|
||||
result = source.fetch_instrument_document()
|
||||
|
||||
assert result is document
|
||||
assert result == {
|
||||
"status": "OK",
|
||||
"payload": {
|
||||
"symbols": [],
|
||||
},
|
||||
}
|
||||
|
||||
# Quotes Feed REST source tests.
|
||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
DzengiQuoteDocumentSource,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import QuoteTransportError
|
||||
from src.market_data.acquisition.protocol import QuoteDocumentSource
|
||||
|
||||
|
||||
class RecordingQuoteRestClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
result: object = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.error = error
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def get_payload(
|
||||
self,
|
||||
path: str,
|
||||
params: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
self.calls.append(
|
||||
{
|
||||
"path": path,
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
}
|
||||
)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.result
|
||||
|
||||
|
||||
def test_quote_source_satisfies_quote_document_source_protocol() -> None:
|
||||
source = DzengiQuoteDocumentSource(
|
||||
client=RecordingQuoteRestClient(result={})
|
||||
)
|
||||
|
||||
assert isinstance(source, QuoteDocumentSource)
|
||||
|
||||
|
||||
def test_fetch_quote_document_calls_ticker_endpoint_with_symbol() -> None:
|
||||
client = RecordingQuoteRestClient(result={})
|
||||
source = DzengiQuoteDocumentSource(client=client)
|
||||
|
||||
source.fetch_quote_document("BTC/USD_LEVERAGE")
|
||||
|
||||
assert client.calls == [
|
||||
{
|
||||
"path": "/api/v1/ticker/24hr",
|
||||
"params": {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
},
|
||||
"headers": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_quote_document_passes_symbol_without_changes() -> None:
|
||||
client = RecordingQuoteRestClient(result={})
|
||||
source = DzengiQuoteDocumentSource(client=client)
|
||||
|
||||
source.fetch_quote_document(" btc/usd_leverage ")
|
||||
|
||||
assert client.calls[0]["params"] == {
|
||||
"symbol": " btc/usd_leverage ",
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_quote_document_returns_payload_without_changes() -> None:
|
||||
document = {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"lastPrice": "64159.45",
|
||||
"bidPrice": "64159.45",
|
||||
"askPrice": "64159.55",
|
||||
"closeTime": 1783887270312,
|
||||
}
|
||||
source = DzengiQuoteDocumentSource(
|
||||
client=RecordingQuoteRestClient(result=document)
|
||||
)
|
||||
|
||||
result = source.fetch_quote_document("BTC/USD_LEVERAGE")
|
||||
|
||||
assert result is document
|
||||
|
||||
|
||||
def test_quote_source_uses_injected_client_once() -> None:
|
||||
client = RecordingQuoteRestClient(result={})
|
||||
source = DzengiQuoteDocumentSource(client=client)
|
||||
|
||||
source.fetch_quote_document("BTC/USD_LEVERAGE")
|
||||
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
def test_quote_source_creates_default_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = RecordingQuoteRestClient(result={})
|
||||
client_creation_count = 0
|
||||
|
||||
def create_client() -> RecordingQuoteRestClient:
|
||||
nonlocal client_creation_count
|
||||
client_creation_count += 1
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient",
|
||||
create_client,
|
||||
)
|
||||
|
||||
source = DzengiQuoteDocumentSource()
|
||||
source.fetch_quote_document("BTC/USD_LEVERAGE")
|
||||
|
||||
assert client_creation_count == 1
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ExchangeConnectionError("Network error."),
|
||||
ExchangeResponseError("Invalid response."),
|
||||
RuntimeError("Unexpected transport failure."),
|
||||
],
|
||||
)
|
||||
def test_quote_transport_errors_are_wrapped(
|
||||
error: Exception,
|
||||
) -> None:
|
||||
source = DzengiQuoteDocumentSource(
|
||||
client=RecordingQuoteRestClient(error=error)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
QuoteTransportError,
|
||||
match=r"Не удалось получить текущую котировку от Dzengi",
|
||||
) as exc_info:
|
||||
source.fetch_quote_document("BTC/USD_LEVERAGE")
|
||||
|
||||
assert exc_info.value.__cause__ is error
|
||||
assert str(error) in str(exc_info.value)
|
||||
|
||||
|
||||
def test_quote_client_creation_error_is_wrapped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original_error = RuntimeError("EXCHANGE_BASE_URL is invalid.")
|
||||
|
||||
def raise_client_creation_error() -> None:
|
||||
raise original_error
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.market_data.acquisition.adapters.dzengi.rest.ExchangeRestClient",
|
||||
raise_client_creation_error,
|
||||
)
|
||||
|
||||
source = DzengiQuoteDocumentSource()
|
||||
|
||||
with pytest.raises(QuoteTransportError) as exc_info:
|
||||
source.fetch_quote_document("BTC/USD_LEVERAGE")
|
||||
|
||||
assert exc_info.value.__cause__ is original_error
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||
DzengiWebSocketQuoteAdapter,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import QuoteValueError
|
||||
|
||||
|
||||
def test_adapter_maps_document_to_quote() -> None:
|
||||
received_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
|
||||
result = DzengiWebSocketQuoteAdapter().map_message(
|
||||
{
|
||||
"Payload": {
|
||||
"symbolName": "BTC/USD",
|
||||
"bids": [["10", "1"]],
|
||||
"asks": [["12", "1"]],
|
||||
"timestamp": 1000,
|
||||
}
|
||||
},
|
||||
received_at=received_at,
|
||||
)
|
||||
assert str(result.last_price) == "11"
|
||||
assert result.received_at is received_at
|
||||
|
||||
|
||||
def test_adapter_preserves_layer_error() -> None:
|
||||
with pytest.raises(QuoteValueError):
|
||||
DzengiWebSocketQuoteAdapter().map_message(
|
||||
{"symbol": "BTC/USD", "bid": "12", "ask": "11"}
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||
map_dzengi_websocket_quote_to_quote,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.models import DzengiWebSocketQuoteResponse
|
||||
from src.market_data.acquisition.exceptions import QuoteMappingError
|
||||
|
||||
|
||||
def _response(timestamp: int | None = 1000) -> DzengiWebSocketQuoteResponse:
|
||||
return DzengiWebSocketQuoteResponse(
|
||||
symbol="BTC/USD",
|
||||
bid_price="10.1",
|
||||
ask_price="10.3",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def test_maps_midpoint_and_timestamps() -> None:
|
||||
received_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
|
||||
result = map_dzengi_websocket_quote_to_quote(_response(), received_at=received_at)
|
||||
assert result.last_price == Decimal("10.2")
|
||||
assert result.bid_price == Decimal("10.1")
|
||||
assert result.ask_price == Decimal("10.3")
|
||||
assert result.exchange_timestamp == datetime.fromtimestamp(1, tz=timezone.utc)
|
||||
assert result.received_at is received_at
|
||||
assert result.source == "dzengi"
|
||||
|
||||
|
||||
def test_allows_missing_exchange_timestamp() -> None:
|
||||
result = map_dzengi_websocket_quote_to_quote(
|
||||
_response(None),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
assert result.exchange_timestamp is None
|
||||
|
||||
|
||||
def test_rejects_naive_received_at() -> None:
|
||||
with pytest.raises(QuoteMappingError):
|
||||
map_dzengi_websocket_quote_to_quote(
|
||||
_response(),
|
||||
received_at=datetime(2026, 7, 13),
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||
parse_dzengi_websocket_quote,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import QuoteParseError
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
validate_dzengi_websocket_quote_schema,
|
||||
)
|
||||
|
||||
|
||||
def _parse(document: object):
|
||||
return parse_dzengi_websocket_quote(
|
||||
validate_dzengi_websocket_quote_schema(document)
|
||||
)
|
||||
|
||||
|
||||
def test_parses_direct_quote() -> None:
|
||||
result = _parse(
|
||||
{"symbolName": "BTC/USD", "bid": "10", "ofr": "11", "timestamp": 1000}
|
||||
)
|
||||
assert result.symbol == "BTC/USD"
|
||||
assert result.bid_price == "10"
|
||||
assert result.ask_price == "11"
|
||||
assert result.timestamp == 1000
|
||||
|
||||
|
||||
def test_parses_depth_list_entries() -> None:
|
||||
result = _parse(
|
||||
{"symbol": "BTC/USD", "bids": [["10", "2"]], "asks": [["11", "3"]]}
|
||||
)
|
||||
assert result.bid_price == "10"
|
||||
assert result.ask_price == "11"
|
||||
assert result.timestamp is None
|
||||
|
||||
|
||||
def test_parses_depth_dict_aliases() -> None:
|
||||
result = _parse(
|
||||
{"symbol": "BTC/USD", "bids": [{"p": "10"}], "asks": [{"askPrice": "11"}]}
|
||||
)
|
||||
assert result.bid_price == "10"
|
||||
assert result.ask_price == "11"
|
||||
|
||||
|
||||
def test_rejects_invalid_depth_item() -> None:
|
||||
validated = validate_dzengi_websocket_quote_schema(
|
||||
{"symbol": "BTC/USD", "bids": ["10"], "asks": [["11"]]}
|
||||
)
|
||||
with pytest.raises(QuoteParseError):
|
||||
parse_dzengi_websocket_quote(validated)
|
||||
@@ -0,0 +1,328 @@
|
||||
# app/tests/unit/market_data/acquisition/feeds/test_instrument_feed.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceTransportError,
|
||||
InstrumentReferenceValueError,
|
||||
)
|
||||
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
|
||||
|
||||
|
||||
def _instrument(
|
||||
*,
|
||||
symbol: str = "BTC/USD_LEVERAGE",
|
||||
) -> Instrument:
|
||||
return Instrument(
|
||||
symbol=symbol,
|
||||
name=symbol,
|
||||
status="TRADING",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="LEVERAGE",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("LIMIT", "MARKET"),
|
||||
base_asset_precision=4,
|
||||
quote_asset_precision=4,
|
||||
tick_size=Decimal("0.05"),
|
||||
tick_value=Decimal("3878.86"),
|
||||
step_size=Decimal("0.0001"),
|
||||
min_qty=Decimal("0.0001"),
|
||||
max_qty=Decimal("1000"),
|
||||
min_notional=Decimal("1"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
|
||||
class StubInstrumentDocumentSource:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
document: object,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.document = document
|
||||
self.error = error
|
||||
self.call_count = 0
|
||||
|
||||
def fetch_instrument_document(self) -> object:
|
||||
self.call_count += 1
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.document
|
||||
|
||||
|
||||
class StubInstrumentDocumentHandler:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
instruments: tuple[Instrument, ...],
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.instruments = instruments
|
||||
self.error = error
|
||||
self.documents: list[object] = []
|
||||
|
||||
def handle_instrument_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> tuple[Instrument, ...]:
|
||||
self.documents.append(document)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.instruments
|
||||
|
||||
|
||||
def test_feed_satisfies_instrument_feed_protocol() -> None:
|
||||
source = StubInstrumentDocumentSource(
|
||||
document={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
assert isinstance(feed, InstrumentFeedProtocol)
|
||||
|
||||
|
||||
def test_feed_calls_source_once() -> None:
|
||||
source = StubInstrumentDocumentSource(
|
||||
document={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
feed.load_instruments()
|
||||
|
||||
assert source.call_count == 1
|
||||
|
||||
|
||||
def test_feed_calls_handler_once() -> None:
|
||||
source = StubInstrumentDocumentSource(
|
||||
document={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
feed.load_instruments()
|
||||
|
||||
assert len(handler.documents) == 1
|
||||
|
||||
|
||||
def test_feed_passes_document_to_handler_without_changes() -> None:
|
||||
document = {
|
||||
"status": "OK",
|
||||
"payload": {
|
||||
"symbols": [],
|
||||
},
|
||||
}
|
||||
|
||||
source = StubInstrumentDocumentSource(
|
||||
document=document,
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
feed.load_instruments()
|
||||
|
||||
assert handler.documents == [document]
|
||||
assert handler.documents[0] is document
|
||||
|
||||
|
||||
def test_feed_returns_handler_result_without_changes() -> None:
|
||||
instruments = (
|
||||
_instrument(),
|
||||
)
|
||||
|
||||
source = StubInstrumentDocumentSource(
|
||||
document={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
result = feed.load_instruments()
|
||||
|
||||
assert result is instruments
|
||||
|
||||
|
||||
def test_feed_preserves_instrument_order() -> None:
|
||||
instruments = (
|
||||
_instrument(symbol="BTC/USD_LEVERAGE"),
|
||||
_instrument(symbol="ETH/USD_LEVERAGE"),
|
||||
_instrument(symbol="XRP/USD_LEVERAGE"),
|
||||
)
|
||||
|
||||
source = StubInstrumentDocumentSource(
|
||||
document={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
result = feed.load_instruments()
|
||||
|
||||
assert tuple(item.symbol for item in result) == (
|
||||
"BTC/USD_LEVERAGE",
|
||||
"ETH/USD_LEVERAGE",
|
||||
"XRP/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
|
||||
def test_feed_returns_empty_tuple_without_error() -> None:
|
||||
source = StubInstrumentDocumentSource(
|
||||
document={
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
result = feed.load_instruments()
|
||||
|
||||
assert result == ()
|
||||
|
||||
|
||||
def test_feed_preserves_transport_error_without_wrapping() -> None:
|
||||
original_error = InstrumentReferenceTransportError(
|
||||
"Не удалось получить exchangeInfo."
|
||||
)
|
||||
|
||||
source = StubInstrumentDocumentSource(
|
||||
document=None,
|
||||
error=original_error,
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceTransportError,
|
||||
) as exc_info:
|
||||
feed.load_instruments()
|
||||
|
||||
assert exc_info.value is original_error
|
||||
assert source.call_count == 1
|
||||
assert handler.documents == []
|
||||
|
||||
|
||||
def test_feed_preserves_processing_error_without_wrapping() -> None:
|
||||
document = {
|
||||
"symbols": [],
|
||||
}
|
||||
original_error = InstrumentReferenceValueError(
|
||||
"Некорректное значение."
|
||||
)
|
||||
|
||||
source = StubInstrumentDocumentSource(
|
||||
document=document,
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
error=original_error,
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
) as exc_info:
|
||||
feed.load_instruments()
|
||||
|
||||
assert exc_info.value is original_error
|
||||
assert source.call_count == 1
|
||||
assert handler.documents == [document]
|
||||
|
||||
|
||||
def test_feed_does_not_retry_source_after_transport_error() -> None:
|
||||
original_error = InstrumentReferenceTransportError(
|
||||
"Network error."
|
||||
)
|
||||
|
||||
source = StubInstrumentDocumentSource(
|
||||
document=None,
|
||||
error=original_error,
|
||||
)
|
||||
handler = StubInstrumentDocumentHandler(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
feed = InstrumentFeed(
|
||||
source=source,
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
with pytest.raises(InstrumentReferenceTransportError):
|
||||
feed.load_instruments()
|
||||
|
||||
assert source.call_count == 1
|
||||
220
app/tests/unit/market_data/acquisition/feeds/test_quotes_feed.py
Normal file
220
app/tests/unit/market_data/acquisition/feeds/test_quotes_feed.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# app/tests/unit/market_data/acquisition/feeds/test_quotes_feed.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
QuoteTransportError,
|
||||
QuoteValueError,
|
||||
)
|
||||
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.protocol import QuoteFeedProtocol
|
||||
|
||||
|
||||
def _quote(
|
||||
*,
|
||||
symbol: str = "BTC/USD_LEVERAGE",
|
||||
) -> Quote:
|
||||
return Quote(
|
||||
symbol=symbol,
|
||||
last_price=Decimal("64159.45"),
|
||||
bid_price=Decimal("64159.45"),
|
||||
ask_price=Decimal("64159.55"),
|
||||
exchange_timestamp=datetime(
|
||||
2026,
|
||||
7,
|
||||
12,
|
||||
16,
|
||||
14,
|
||||
30,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
received_at=datetime(
|
||||
2026,
|
||||
7,
|
||||
12,
|
||||
16,
|
||||
14,
|
||||
31,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
class StubQuoteDocumentSource:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
document: object,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.document = document
|
||||
self.error = error
|
||||
self.symbols: list[str] = []
|
||||
|
||||
def fetch_quote_document(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> object:
|
||||
self.symbols.append(symbol)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.document
|
||||
|
||||
|
||||
class StubQuoteDocumentHandler:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
quote: Quote,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.quote = quote
|
||||
self.error = error
|
||||
self.documents: list[object] = []
|
||||
|
||||
def handle_quote_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> Quote:
|
||||
self.documents.append(document)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.quote
|
||||
|
||||
|
||||
def test_feed_satisfies_quote_feed_protocol() -> None:
|
||||
feed = QuotesFeed(
|
||||
source=StubQuoteDocumentSource(document={}),
|
||||
handler=StubQuoteDocumentHandler(quote=_quote()),
|
||||
)
|
||||
|
||||
assert isinstance(feed, QuoteFeedProtocol)
|
||||
|
||||
|
||||
def test_feed_calls_source_once() -> None:
|
||||
source = StubQuoteDocumentSource(document={})
|
||||
feed = QuotesFeed(
|
||||
source=source,
|
||||
handler=StubQuoteDocumentHandler(quote=_quote()),
|
||||
)
|
||||
|
||||
feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert source.symbols == ["BTC/USD_LEVERAGE"]
|
||||
|
||||
|
||||
def test_feed_passes_symbol_to_source_without_changes() -> None:
|
||||
source = StubQuoteDocumentSource(document={})
|
||||
feed = QuotesFeed(
|
||||
source=source,
|
||||
handler=StubQuoteDocumentHandler(quote=_quote()),
|
||||
)
|
||||
|
||||
feed.load_quote(" btc/usd_leverage ")
|
||||
|
||||
assert source.symbols == [" btc/usd_leverage "]
|
||||
|
||||
|
||||
def test_feed_calls_handler_once() -> None:
|
||||
handler = StubQuoteDocumentHandler(quote=_quote())
|
||||
feed = QuotesFeed(
|
||||
source=StubQuoteDocumentSource(document={}),
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert len(handler.documents) == 1
|
||||
|
||||
|
||||
def test_feed_passes_document_to_handler_without_changes() -> None:
|
||||
document = {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"lastPrice": "64159.45",
|
||||
}
|
||||
handler = StubQuoteDocumentHandler(quote=_quote())
|
||||
feed = QuotesFeed(
|
||||
source=StubQuoteDocumentSource(document=document),
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert handler.documents == [document]
|
||||
assert handler.documents[0] is document
|
||||
|
||||
|
||||
def test_feed_returns_handler_result_without_copying() -> None:
|
||||
quote = _quote()
|
||||
feed = QuotesFeed(
|
||||
source=StubQuoteDocumentSource(document={}),
|
||||
handler=StubQuoteDocumentHandler(quote=quote),
|
||||
)
|
||||
|
||||
result = feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert result is quote
|
||||
|
||||
|
||||
def test_feed_preserves_transport_error_without_wrapping() -> None:
|
||||
original_error = QuoteTransportError("Network error.")
|
||||
handler = StubQuoteDocumentHandler(quote=_quote())
|
||||
feed = QuotesFeed(
|
||||
source=StubQuoteDocumentSource(
|
||||
document=None,
|
||||
error=original_error,
|
||||
),
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
with pytest.raises(QuoteTransportError) as exc_info:
|
||||
feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert exc_info.value is original_error
|
||||
assert handler.documents == []
|
||||
|
||||
|
||||
def test_feed_preserves_handler_error_without_wrapping() -> None:
|
||||
document = {"symbol": "BTC/USD_LEVERAGE"}
|
||||
original_error = QuoteValueError("Invalid quote.")
|
||||
handler = StubQuoteDocumentHandler(
|
||||
quote=_quote(),
|
||||
error=original_error,
|
||||
)
|
||||
feed = QuotesFeed(
|
||||
source=StubQuoteDocumentSource(document=document),
|
||||
handler=handler,
|
||||
)
|
||||
|
||||
with pytest.raises(QuoteValueError) as exc_info:
|
||||
feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert exc_info.value is original_error
|
||||
assert handler.documents == [document]
|
||||
|
||||
|
||||
def test_feed_does_not_retry_source_after_error() -> None:
|
||||
source = StubQuoteDocumentSource(
|
||||
document=None,
|
||||
error=QuoteTransportError("Network error."),
|
||||
)
|
||||
feed = QuotesFeed(
|
||||
source=source,
|
||||
handler=StubQuoteDocumentHandler(quote=_quote()),
|
||||
)
|
||||
|
||||
with pytest.raises(QuoteTransportError):
|
||||
feed.load_quote("BTC/USD_LEVERAGE")
|
||||
|
||||
assert source.symbols == ["BTC/USD_LEVERAGE"]
|
||||
@@ -0,0 +1,220 @@
|
||||
# app/tests/unit/market_data/acquisition/handlers/test_instrument_handler.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceMappingError,
|
||||
InstrumentReferenceParseError,
|
||||
InstrumentReferenceSchemaError,
|
||||
InstrumentReferenceValueError,
|
||||
)
|
||||
from src.market_data.acquisition.handlers.instrument_handler import (
|
||||
DzengiInstrumentDocumentHandler,
|
||||
)
|
||||
from src.market_data.acquisition.protocol import (
|
||||
InstrumentDocumentHandler,
|
||||
)
|
||||
|
||||
|
||||
def _valid_symbol_document() -> dict[str, object]:
|
||||
return {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"name": "BTC/USD",
|
||||
"status": "TRADING",
|
||||
"assetType": "CRYPTOCURRENCY",
|
||||
"baseAsset": "BTC",
|
||||
"baseAssetPrecision": 4,
|
||||
"quoteAsset": "USD",
|
||||
"quoteAssetId": "USD_LEVERAGE",
|
||||
"quotePrecision": 4,
|
||||
"orderTypes": [
|
||||
"LIMIT",
|
||||
"MARKET",
|
||||
"STOP",
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"minQty": "0.0001",
|
||||
"maxQty": "1000",
|
||||
"stepSize": "0.0001",
|
||||
},
|
||||
{
|
||||
"filterType": "MIN_NOTIONAL",
|
||||
"minNotional": "1",
|
||||
},
|
||||
],
|
||||
"marketModes": [
|
||||
"REGULAR",
|
||||
],
|
||||
"marketType": "LEVERAGE",
|
||||
"country": "",
|
||||
"sector": "",
|
||||
"industry": "",
|
||||
"tradingHours": None,
|
||||
"tickSize": 0.05,
|
||||
"tickValue": 3878.86,
|
||||
"tradingFee": 0.06,
|
||||
"exchangeFee": None,
|
||||
"longRate": -0.01,
|
||||
"shortRate": 0.01,
|
||||
"swapChargeInterval": 480,
|
||||
"minSLGap": 0,
|
||||
"maxSLGap": 50.0,
|
||||
"minTPGap": 0,
|
||||
"maxTPGap": 50.0,
|
||||
}
|
||||
|
||||
|
||||
def _valid_unwrapped_document() -> dict[str, object]:
|
||||
return {
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1783537921471,
|
||||
"rateLimits": [],
|
||||
"exchangeFilters": [],
|
||||
"symbols": [
|
||||
_valid_symbol_document(),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _valid_wrapped_document() -> dict[str, object]:
|
||||
return {
|
||||
"status": "OK",
|
||||
"correlationId": "2",
|
||||
"payload": _valid_unwrapped_document(),
|
||||
}
|
||||
|
||||
|
||||
def test_handler_satisfies_instrument_document_handler_protocol() -> None:
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
assert isinstance(handler, InstrumentDocumentHandler)
|
||||
|
||||
|
||||
def test_handler_processes_valid_unwrapped_document() -> None:
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
instruments = handler.handle_instrument_document(
|
||||
_valid_unwrapped_document()
|
||||
)
|
||||
|
||||
assert isinstance(instruments, tuple)
|
||||
assert len(instruments) == 1
|
||||
|
||||
instrument = instruments[0]
|
||||
|
||||
assert instrument.symbol == "BTC/USD_LEVERAGE"
|
||||
assert instrument.name == "BTC/USD"
|
||||
assert instrument.status == "TRADING"
|
||||
assert instrument.base_asset == "BTC"
|
||||
assert instrument.quote_asset == "USD"
|
||||
assert instrument.asset_type == "CRYPTOCURRENCY"
|
||||
assert instrument.market_type == "LEVERAGE"
|
||||
assert instrument.market_modes == ("REGULAR",)
|
||||
assert instrument.order_types == (
|
||||
"LIMIT",
|
||||
"MARKET",
|
||||
"STOP",
|
||||
)
|
||||
|
||||
|
||||
def test_handler_processes_valid_wrapped_document() -> None:
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
instruments = handler.handle_instrument_document(
|
||||
_valid_wrapped_document()
|
||||
)
|
||||
|
||||
assert len(instruments) == 1
|
||||
assert instruments[0].symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_handler_returns_exact_decimal_values() -> None:
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
instruments = handler.handle_instrument_document(
|
||||
_valid_unwrapped_document()
|
||||
)
|
||||
|
||||
instrument = instruments[0]
|
||||
|
||||
assert instrument.tick_size == Decimal("0.05")
|
||||
assert instrument.tick_value == Decimal("3878.86")
|
||||
assert instrument.step_size == Decimal("0.0001")
|
||||
assert instrument.min_qty == Decimal("0.0001")
|
||||
assert instrument.max_qty == Decimal("1000")
|
||||
assert instrument.min_notional == Decimal("1")
|
||||
|
||||
|
||||
def test_handler_returns_empty_tuple_for_empty_symbols() -> None:
|
||||
document = _valid_unwrapped_document()
|
||||
document["symbols"] = []
|
||||
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
instruments = handler.handle_instrument_document(document)
|
||||
|
||||
assert instruments == ()
|
||||
|
||||
|
||||
def test_handler_preserves_schema_error() -> None:
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
with pytest.raises(InstrumentReferenceSchemaError):
|
||||
handler.handle_instrument_document([])
|
||||
|
||||
|
||||
def test_handler_preserves_parse_error() -> None:
|
||||
document = _valid_unwrapped_document()
|
||||
symbol = _valid_symbol_document()
|
||||
symbol["baseAssetPrecision"] = True
|
||||
document["symbols"] = [symbol]
|
||||
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
with pytest.raises(InstrumentReferenceParseError):
|
||||
handler.handle_instrument_document(document)
|
||||
|
||||
|
||||
def test_handler_preserves_value_error() -> None:
|
||||
document = _valid_unwrapped_document()
|
||||
symbol = _valid_symbol_document()
|
||||
symbol["tickSize"] = 0
|
||||
document["symbols"] = [symbol]
|
||||
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
with pytest.raises(InstrumentReferenceValueError):
|
||||
handler.handle_instrument_document(document)
|
||||
|
||||
|
||||
def test_handler_preserves_mapping_error() -> None:
|
||||
document = _valid_unwrapped_document()
|
||||
symbol = _valid_symbol_document()
|
||||
|
||||
lot_size = {
|
||||
"filterType": "LOT_SIZE",
|
||||
"minQty": "0.0001",
|
||||
"maxQty": "1000",
|
||||
"stepSize": "0.0001",
|
||||
}
|
||||
|
||||
symbol["filters"] = [
|
||||
lot_size,
|
||||
lot_size.copy(),
|
||||
]
|
||||
|
||||
document["symbols"] = [symbol]
|
||||
|
||||
handler = DzengiInstrumentDocumentHandler()
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceMappingError,
|
||||
match=r"несколько фильтров LOT_SIZE",
|
||||
):
|
||||
handler.handle_instrument_document(document)
|
||||
@@ -0,0 +1,156 @@
|
||||
# app/tests/unit/market_data/acquisition/handlers/test_quotes_handler.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timezone
|
||||
from decimal import Decimal
|
||||
from typing import TypeAlias
|
||||
|
||||
import pytest
|
||||
|
||||
import src.market_data.acquisition.handlers.quotes_handler as handler_module
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiTicker24hrResponse,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import QuoteSchemaError
|
||||
from src.market_data.acquisition.handlers.quotes_handler import (
|
||||
DzengiQuoteDocumentHandler,
|
||||
)
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.protocol import QuoteDocumentHandler
|
||||
|
||||
|
||||
PipelineCall: TypeAlias = (
|
||||
tuple[str, object]
|
||||
| tuple[str, object, object]
|
||||
)
|
||||
|
||||
|
||||
def _document() -> dict[str, object]:
|
||||
return {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"lastPrice": "64159.45",
|
||||
"bidPrice": "64159.45",
|
||||
"askPrice": "64159.55",
|
||||
"closeTime": 1783887270312,
|
||||
"volume": "9.6002",
|
||||
}
|
||||
|
||||
|
||||
def test_handler_implements_quote_document_handler_protocol() -> None:
|
||||
handler = DzengiQuoteDocumentHandler()
|
||||
|
||||
assert isinstance(handler, QuoteDocumentHandler)
|
||||
|
||||
|
||||
def test_handler_returns_canonical_quote() -> None:
|
||||
before = handler_module.datetime.now(timezone.utc)
|
||||
|
||||
result = DzengiQuoteDocumentHandler().handle_quote_document(
|
||||
_document()
|
||||
)
|
||||
|
||||
after = handler_module.datetime.now(timezone.utc)
|
||||
|
||||
assert isinstance(result, Quote)
|
||||
assert result.symbol == "BTC/USD_LEVERAGE"
|
||||
assert result.last_price == Decimal("64159.45")
|
||||
assert result.bid_price == Decimal("64159.45")
|
||||
assert result.ask_price == Decimal("64159.55")
|
||||
assert result.source == "dzengi"
|
||||
assert result.exchange_timestamp is not None
|
||||
assert result.exchange_timestamp.tzinfo is timezone.utc
|
||||
assert before <= result.received_at <= after
|
||||
|
||||
|
||||
def test_handler_executes_pipeline_in_order(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[PipelineCall] = []
|
||||
validated = object()
|
||||
response = DzengiTicker24hrResponse(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price="64159.45",
|
||||
bid_price="64159.45",
|
||||
ask_price="64159.55",
|
||||
close_time=1783887270312,
|
||||
)
|
||||
quote = Quote(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price=Decimal("64159.45"),
|
||||
bid_price=Decimal("64159.45"),
|
||||
ask_price=Decimal("64159.55"),
|
||||
exchange_timestamp=None,
|
||||
received_at=handler_module.datetime.now(timezone.utc),
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
def validate_schema(document: object) -> object:
|
||||
calls.append(("schema", document))
|
||||
return validated
|
||||
|
||||
def parse(document: object) -> DzengiTicker24hrResponse:
|
||||
calls.append(("parser", document))
|
||||
return response
|
||||
|
||||
def validate_values(
|
||||
value: DzengiTicker24hrResponse,
|
||||
) -> None:
|
||||
calls.append(("values", value))
|
||||
|
||||
def map_quote(
|
||||
value: DzengiTicker24hrResponse,
|
||||
*,
|
||||
received_at: object,
|
||||
) -> Quote:
|
||||
calls.append(
|
||||
(
|
||||
"mapper",
|
||||
value,
|
||||
received_at,
|
||||
)
|
||||
)
|
||||
return quote
|
||||
|
||||
monkeypatch.setattr(
|
||||
handler_module,
|
||||
"validate_quote_schema",
|
||||
validate_schema,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler_module,
|
||||
"parse_quote",
|
||||
parse,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler_module,
|
||||
"validate_quote_values",
|
||||
validate_values,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler_module,
|
||||
"map_dzengi_ticker_to_quote",
|
||||
map_quote,
|
||||
)
|
||||
|
||||
document = _document()
|
||||
result = (
|
||||
DzengiQuoteDocumentHandler()
|
||||
.handle_quote_document(document)
|
||||
)
|
||||
|
||||
assert result is quote
|
||||
assert calls[0] == ("schema", document)
|
||||
assert calls[1] == ("parser", validated)
|
||||
assert calls[2] == ("values", response)
|
||||
|
||||
mapper_call = calls[3]
|
||||
|
||||
assert len(mapper_call) == 3
|
||||
assert mapper_call[0] == "mapper"
|
||||
assert mapper_call[1] is response
|
||||
|
||||
|
||||
def test_handler_propagates_schema_error() -> None:
|
||||
with pytest.raises(QuoteSchemaError):
|
||||
DzengiQuoteDocumentHandler().handle_quote_document({})
|
||||
164
app/tests/unit/market_data/acquisition/models/test_instrument.py
Normal file
164
app/tests/unit/market_data/acquisition/models/test_instrument.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# app/tests/unit/market_data/acquisition/models/test_instrument.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
|
||||
|
||||
def test_instrument_stores_complete_reference_data() -> None:
|
||||
instrument = Instrument(
|
||||
symbol="ETH/EUR_LEVERAGE",
|
||||
name="ETH/EUR",
|
||||
status="TRADING",
|
||||
base_asset="ETH",
|
||||
quote_asset="EUR",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="LEVERAGE",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("LIMIT", "MARKET", "STOP"),
|
||||
base_asset_precision=3,
|
||||
quote_asset_precision=3,
|
||||
tick_size=Decimal("0.01"),
|
||||
tick_value=Decimal("18.3415"),
|
||||
step_size=Decimal("0.001"),
|
||||
min_qty=Decimal("0.001"),
|
||||
max_qty=Decimal("1000"),
|
||||
min_notional=Decimal("2"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=(
|
||||
"UTC; Mon - 21:00, 21:05 -; "
|
||||
"Tue - 21:00, 21:05 -"
|
||||
),
|
||||
)
|
||||
|
||||
assert instrument.symbol == "ETH/EUR_LEVERAGE"
|
||||
assert instrument.name == "ETH/EUR"
|
||||
assert instrument.status == "TRADING"
|
||||
|
||||
assert instrument.base_asset == "ETH"
|
||||
assert instrument.quote_asset == "EUR"
|
||||
assert instrument.asset_type == "CRYPTOCURRENCY"
|
||||
|
||||
assert instrument.market_type == "LEVERAGE"
|
||||
assert instrument.market_modes == ("REGULAR",)
|
||||
assert instrument.order_types == ("LIMIT", "MARKET", "STOP")
|
||||
|
||||
assert instrument.base_asset_precision == 3
|
||||
assert instrument.quote_asset_precision == 3
|
||||
|
||||
assert instrument.tick_size == Decimal("0.01")
|
||||
assert instrument.tick_value == Decimal("18.3415")
|
||||
|
||||
assert instrument.step_size == Decimal("0.001")
|
||||
assert instrument.min_qty == Decimal("0.001")
|
||||
assert instrument.max_qty == Decimal("1000")
|
||||
assert instrument.min_notional == Decimal("2")
|
||||
|
||||
assert instrument.country is None
|
||||
assert instrument.sector is None
|
||||
assert instrument.industry is None
|
||||
assert instrument.trading_hours is not None
|
||||
|
||||
|
||||
def test_instrument_accepts_missing_optional_reference_values() -> None:
|
||||
instrument = Instrument(
|
||||
symbol="TEST/USD",
|
||||
name="Test instrument",
|
||||
status="BREAK",
|
||||
base_asset="TEST",
|
||||
quote_asset="USD",
|
||||
asset_type=None,
|
||||
market_type="SPOT",
|
||||
market_modes=(),
|
||||
order_types=(),
|
||||
base_asset_precision=None,
|
||||
quote_asset_precision=None,
|
||||
tick_size=None,
|
||||
tick_value=None,
|
||||
step_size=None,
|
||||
min_qty=None,
|
||||
max_qty=None,
|
||||
min_notional=None,
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
assert instrument.asset_type is None
|
||||
assert instrument.market_modes == ()
|
||||
assert instrument.order_types == ()
|
||||
assert instrument.base_asset_precision is None
|
||||
assert instrument.quote_asset_precision is None
|
||||
assert instrument.tick_size is None
|
||||
assert instrument.tick_value is None
|
||||
assert instrument.step_size is None
|
||||
assert instrument.min_qty is None
|
||||
assert instrument.max_qty is None
|
||||
assert instrument.min_notional is None
|
||||
assert instrument.trading_hours is None
|
||||
|
||||
|
||||
def test_instrument_uses_immutable_sequences() -> None:
|
||||
instrument = Instrument(
|
||||
symbol="BTC/USD",
|
||||
name="BTC/USD",
|
||||
status="TRADING",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="SPOT",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("MARKET",),
|
||||
base_asset_precision=8,
|
||||
quote_asset_precision=2,
|
||||
tick_size=Decimal("0.01"),
|
||||
tick_value=None,
|
||||
step_size=Decimal("0.00000001"),
|
||||
min_qty=Decimal("0.00000001"),
|
||||
max_qty=Decimal("100"),
|
||||
min_notional=Decimal("1"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
assert isinstance(instrument.market_modes, tuple)
|
||||
assert isinstance(instrument.order_types, tuple)
|
||||
|
||||
|
||||
def test_instrument_is_immutable() -> None:
|
||||
instrument = Instrument(
|
||||
symbol="BTC/USD",
|
||||
name="BTC/USD",
|
||||
status="TRADING",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="SPOT",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("MARKET",),
|
||||
base_asset_precision=8,
|
||||
quote_asset_precision=2,
|
||||
tick_size=Decimal("0.01"),
|
||||
tick_value=None,
|
||||
step_size=Decimal("0.00000001"),
|
||||
min_qty=Decimal("0.00000001"),
|
||||
max_qty=Decimal("100"),
|
||||
min_notional=Decimal("1"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
instrument.status = "BREAK" # type: ignore[misc]
|
||||
@@ -0,0 +1,127 @@
|
||||
# app/tests/unit/market_data/acquisition/models/test_status.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.models.status import (
|
||||
InstrumentStatusClassification,
|
||||
InstrumentTradingState,
|
||||
classify_instrument_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
"TRADING",
|
||||
"OPEN",
|
||||
"ACTIVE",
|
||||
"ENABLED",
|
||||
"ONLINE",
|
||||
],
|
||||
)
|
||||
def test_classify_open_statuses(raw_status: str) -> None:
|
||||
result = classify_instrument_status(raw_status)
|
||||
|
||||
assert result == InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.OPEN,
|
||||
normalized_status=raw_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
"NOT_TRADABLE",
|
||||
"TRADING_DISABLED",
|
||||
"MARKET_DISABLED",
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
"CLOSE_ONLY",
|
||||
"REDUCE_ONLY",
|
||||
"VIEW_ONLY",
|
||||
],
|
||||
)
|
||||
def test_classify_not_tradable_statuses(raw_status: str) -> None:
|
||||
result = classify_instrument_status(raw_status)
|
||||
|
||||
assert result == InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.NOT_TRADABLE,
|
||||
normalized_status=raw_status,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
"BREAK",
|
||||
"CLOSED",
|
||||
"HALT",
|
||||
"HALTED",
|
||||
"PAUSED",
|
||||
"SUSPENDED",
|
||||
"DISABLED",
|
||||
"SETTLING",
|
||||
"POST_ONLY",
|
||||
],
|
||||
)
|
||||
def test_classify_break_statuses(raw_status: str) -> None:
|
||||
result = classify_instrument_status(raw_status)
|
||||
|
||||
assert result == InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.BREAK,
|
||||
normalized_status=raw_status,
|
||||
)
|
||||
|
||||
|
||||
def test_classification_normalizes_case_and_outer_spaces() -> None:
|
||||
result = classify_instrument_status(
|
||||
" trading "
|
||||
)
|
||||
|
||||
assert result == InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.OPEN,
|
||||
normalized_status="TRADING",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_status",
|
||||
[
|
||||
None,
|
||||
"",
|
||||
" ",
|
||||
" ",
|
||||
],
|
||||
)
|
||||
def test_empty_status_is_unknown(
|
||||
raw_status: str | None,
|
||||
) -> None:
|
||||
result = classify_instrument_status(raw_status)
|
||||
|
||||
assert result == InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.UNKNOWN,
|
||||
normalized_status=None,
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_status_preserves_normalized_value() -> None:
|
||||
result = classify_instrument_status(
|
||||
" maintenance "
|
||||
)
|
||||
|
||||
assert result == InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.UNKNOWN,
|
||||
normalized_status="MAINTENANCE",
|
||||
)
|
||||
|
||||
|
||||
def test_classification_result_is_frozen() -> None:
|
||||
result = classify_instrument_status(
|
||||
"TRADING"
|
||||
)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
result.normalized_status = "BREAK" # type: ignore[misc]
|
||||
159
app/tests/unit/market_data/acquisition/test_protocol.py
Normal file
159
app/tests/unit/market_data/acquisition/test_protocol.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# app/tests/unit/market_data/acquisition/test_protocol.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceMappingError,
|
||||
InstrumentReferenceParseError,
|
||||
InstrumentReferenceSchemaError,
|
||||
InstrumentReferenceTransportError,
|
||||
InstrumentReferenceValueError,
|
||||
MarketDataAcquisitionError,
|
||||
)
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.protocol import (
|
||||
InstrumentDocumentHandler,
|
||||
InstrumentDocumentSource,
|
||||
InstrumentFeedProtocol,
|
||||
)
|
||||
|
||||
|
||||
def _instrument() -> Instrument:
|
||||
return Instrument(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
name="BTC/USD",
|
||||
status="TRADING",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="LEVERAGE",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("LIMIT", "MARKET"),
|
||||
base_asset_precision=4,
|
||||
quote_asset_precision=4,
|
||||
tick_size=Decimal("0.05"),
|
||||
tick_value=Decimal("3878.86"),
|
||||
step_size=Decimal("0.0001"),
|
||||
min_qty=Decimal("0.0001"),
|
||||
max_qty=Decimal("1000"),
|
||||
min_notional=Decimal("1"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
|
||||
class StubInstrumentDocumentSource:
|
||||
def fetch_instrument_document(self) -> object:
|
||||
return {
|
||||
"symbols": [],
|
||||
}
|
||||
|
||||
|
||||
class StubInstrumentDocumentHandler:
|
||||
def handle_instrument_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> tuple[Instrument, ...]:
|
||||
del document
|
||||
return (_instrument(),)
|
||||
|
||||
|
||||
class StubInstrumentFeed:
|
||||
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||
return (_instrument(),)
|
||||
|
||||
|
||||
class InvalidSource:
|
||||
pass
|
||||
|
||||
|
||||
class InvalidHandler:
|
||||
pass
|
||||
|
||||
|
||||
class InvalidFeed:
|
||||
pass
|
||||
|
||||
|
||||
def test_document_source_satisfies_protocol() -> None:
|
||||
source = StubInstrumentDocumentSource()
|
||||
|
||||
assert isinstance(source, InstrumentDocumentSource)
|
||||
assert source.fetch_instrument_document() == {
|
||||
"symbols": [],
|
||||
}
|
||||
|
||||
|
||||
def test_document_handler_satisfies_protocol() -> None:
|
||||
handler = StubInstrumentDocumentHandler()
|
||||
|
||||
assert isinstance(handler, InstrumentDocumentHandler)
|
||||
|
||||
instruments = handler.handle_instrument_document(
|
||||
{
|
||||
"symbols": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert isinstance(instruments, tuple)
|
||||
assert len(instruments) == 1
|
||||
assert instruments[0].symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_instrument_feed_satisfies_protocol() -> None:
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
assert isinstance(feed, InstrumentFeedProtocol)
|
||||
|
||||
instruments = feed.load_instruments()
|
||||
|
||||
assert isinstance(instruments, tuple)
|
||||
assert len(instruments) == 1
|
||||
assert instruments[0].symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_objects_without_required_methods_do_not_satisfy_protocols() -> None:
|
||||
assert not isinstance(InvalidSource(), InstrumentDocumentSource)
|
||||
assert not isinstance(InvalidHandler(), InstrumentDocumentHandler)
|
||||
assert not isinstance(InvalidFeed(), InstrumentFeedProtocol)
|
||||
|
||||
|
||||
def test_protocols_support_structural_typing_without_inheritance() -> None:
|
||||
source: InstrumentDocumentSource = StubInstrumentDocumentSource()
|
||||
handler: InstrumentDocumentHandler = StubInstrumentDocumentHandler()
|
||||
feed: InstrumentFeedProtocol = StubInstrumentFeed()
|
||||
|
||||
document = source.fetch_instrument_document()
|
||||
handled_instruments = handler.handle_instrument_document(document)
|
||||
loaded_instruments = feed.load_instruments()
|
||||
|
||||
assert handled_instruments[0].symbol == "BTC/USD_LEVERAGE"
|
||||
assert loaded_instruments[0].symbol == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
def test_transport_error_inherits_acquisition_error() -> None:
|
||||
error = InstrumentReferenceTransportError(
|
||||
"Не удалось получить exchangeInfo."
|
||||
)
|
||||
|
||||
assert isinstance(error, MarketDataAcquisitionError)
|
||||
assert str(error) == "Не удалось получить exchangeInfo."
|
||||
|
||||
|
||||
def test_all_instrument_reference_errors_share_base_type() -> None:
|
||||
errors = (
|
||||
InstrumentReferenceTransportError(),
|
||||
InstrumentReferenceSchemaError(),
|
||||
InstrumentReferenceParseError(),
|
||||
InstrumentReferenceValueError(),
|
||||
InstrumentReferenceMappingError(),
|
||||
)
|
||||
|
||||
assert all(
|
||||
isinstance(error, MarketDataAcquisitionError)
|
||||
for error in errors
|
||||
)
|
||||
433
app/tests/unit/market_data/acquisition/test_registry.py
Normal file
433
app/tests/unit/market_data/acquisition/test_registry.py
Normal file
@@ -0,0 +1,433 @@
|
||||
# app/tests/unit/market_data/acquisition/test_registry.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentFeedRegistryError,
|
||||
MarketDataAcquisitionError,
|
||||
)
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
|
||||
from src.market_data.acquisition.registry import InstrumentFeedRegistry
|
||||
|
||||
|
||||
def _instrument(
|
||||
*,
|
||||
symbol: str = "BTC/USD_LEVERAGE",
|
||||
) -> Instrument:
|
||||
return Instrument(
|
||||
symbol=symbol,
|
||||
name=symbol,
|
||||
status="TRADING",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="LEVERAGE",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("LIMIT", "MARKET"),
|
||||
base_asset_precision=4,
|
||||
quote_asset_precision=4,
|
||||
tick_size=Decimal("0.05"),
|
||||
tick_value=Decimal("3878.86"),
|
||||
step_size=Decimal("0.0001"),
|
||||
min_qty=Decimal("0.0001"),
|
||||
max_qty=Decimal("1000"),
|
||||
min_notional=Decimal("1"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
|
||||
class StubInstrumentFeed:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
instruments: tuple[Instrument, ...] = (),
|
||||
) -> None:
|
||||
self.instruments = instruments
|
||||
self.load_call_count = 0
|
||||
|
||||
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||
self.load_call_count += 1
|
||||
return self.instruments
|
||||
|
||||
|
||||
class InvalidFeed:
|
||||
pass
|
||||
|
||||
|
||||
def test_register_and_get_feed() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
result = registry.get("dzengi")
|
||||
|
||||
assert result is feed
|
||||
|
||||
|
||||
def test_registry_preserves_feed_identity() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed(
|
||||
instruments=(
|
||||
_instrument(),
|
||||
)
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
registered_feed = registry.get("dzengi")
|
||||
|
||||
assert registered_feed is feed
|
||||
assert registered_feed.load_instruments() is feed.instruments
|
||||
|
||||
|
||||
def test_registry_accepts_instrument_feed_protocol() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
assert isinstance(feed, InstrumentFeedProtocol)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
assert registry.get("dzengi") is feed
|
||||
|
||||
|
||||
def test_registry_supports_multiple_source_names() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
dzengi_feed = StubInstrumentFeed(
|
||||
instruments=(
|
||||
_instrument(symbol="BTC/USD_LEVERAGE"),
|
||||
)
|
||||
)
|
||||
secondary_feed = StubInstrumentFeed(
|
||||
instruments=(
|
||||
_instrument(symbol="ETH/USD_LEVERAGE"),
|
||||
)
|
||||
)
|
||||
|
||||
registry.register("dzengi", dzengi_feed)
|
||||
registry.register("secondary", secondary_feed)
|
||||
|
||||
assert registry.get("dzengi") is dzengi_feed
|
||||
assert registry.get("secondary") is secondary_feed
|
||||
|
||||
|
||||
def test_registry_strips_outer_whitespace_from_source_name() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register(" dzengi ", feed)
|
||||
|
||||
assert registry.get("dzengi") is feed
|
||||
assert registry.get(" dzengi ") is feed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_name",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
],
|
||||
)
|
||||
def test_registry_rejects_empty_source_name(
|
||||
source_name: str,
|
||||
) -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
match=r"Имя источника Instrument Feed не должно быть пустым",
|
||||
):
|
||||
registry.register(source_name, feed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_name",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
],
|
||||
)
|
||||
def test_registry_rejects_empty_source_name_on_get(
|
||||
source_name: str,
|
||||
) -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
match=r"Имя источника Instrument Feed не должно быть пустым",
|
||||
):
|
||||
registry.get(source_name)
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_registration() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
first_feed = StubInstrumentFeed()
|
||||
second_feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", first_feed)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
match=r"уже зарегистрирован",
|
||||
):
|
||||
registry.register("dzengi", second_feed)
|
||||
|
||||
|
||||
def test_duplicate_registration_does_not_replace_original_feed() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
first_feed = StubInstrumentFeed()
|
||||
second_feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", first_feed)
|
||||
|
||||
with pytest.raises(InstrumentFeedRegistryError):
|
||||
registry.register("dzengi", second_feed)
|
||||
|
||||
assert registry.get("dzengi") is first_feed
|
||||
|
||||
|
||||
def test_duplicate_registration_uses_normalized_source_name() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
first_feed = StubInstrumentFeed()
|
||||
second_feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", first_feed)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
match=r"уже зарегистрирован",
|
||||
):
|
||||
registry.register(" dzengi ", second_feed)
|
||||
|
||||
|
||||
def test_registry_keeps_source_name_case_sensitive() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
lowercase_feed = StubInstrumentFeed()
|
||||
uppercase_feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", lowercase_feed)
|
||||
registry.register("DZENGI", uppercase_feed)
|
||||
|
||||
assert registry.get("dzengi") is lowercase_feed
|
||||
assert registry.get("DZENGI") is uppercase_feed
|
||||
|
||||
|
||||
def test_registry_rejects_unregistered_source() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
match=r"не зарегистрирован",
|
||||
):
|
||||
registry.get("dzengi")
|
||||
|
||||
|
||||
def test_registry_rejects_object_without_feed_protocol() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
match=r"не соответствует InstrumentFeedProtocol",
|
||||
):
|
||||
registry.register(
|
||||
"invalid",
|
||||
InvalidFeed(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_registry_does_not_load_feed_during_registration() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
assert feed.load_call_count == 0
|
||||
|
||||
|
||||
def test_registry_does_not_load_feed_during_get() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
result = registry.get("dzengi")
|
||||
|
||||
assert result is feed
|
||||
assert feed.load_call_count == 0
|
||||
|
||||
|
||||
def test_registry_stores_feed_not_instrument_result() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
instruments = (
|
||||
_instrument(),
|
||||
)
|
||||
feed = StubInstrumentFeed(
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
registered_feed = registry.get("dzengi")
|
||||
|
||||
assert registered_feed is feed
|
||||
assert registered_feed is not instruments
|
||||
|
||||
|
||||
def test_registry_error_inherits_acquisition_error() -> None:
|
||||
error = InstrumentFeedRegistryError(
|
||||
"Registry error."
|
||||
)
|
||||
|
||||
assert isinstance(error, MarketDataAcquisitionError)
|
||||
assert str(error) == "Registry error."
|
||||
|
||||
# Quotes Feed Registry tests.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.market_data.acquisition.exceptions import QuoteFeedRegistryError
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.protocol import QuoteFeedProtocol
|
||||
from src.market_data.acquisition.registry import QuoteFeedRegistry
|
||||
|
||||
|
||||
def _quote() -> Quote:
|
||||
return Quote(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price=Decimal("64159.45"),
|
||||
bid_price=Decimal("64159.45"),
|
||||
ask_price=Decimal("64159.55"),
|
||||
exchange_timestamp=datetime.now(timezone.utc),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
class StubQuoteFeed:
|
||||
def __init__(self) -> None:
|
||||
self.quote = _quote()
|
||||
self.symbols: list[str] = []
|
||||
|
||||
def load_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
self.symbols.append(symbol)
|
||||
return self.quote
|
||||
|
||||
|
||||
def test_quote_registry_registers_and_returns_feed() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
assert registry.get("dzengi") is feed
|
||||
|
||||
|
||||
def test_quote_registry_accepts_quote_feed_protocol() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
|
||||
assert isinstance(feed, QuoteFeedProtocol)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
assert registry.get("dzengi") is feed
|
||||
|
||||
|
||||
def test_quote_registry_strips_outer_whitespace() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
|
||||
registry.register(" dzengi ", feed)
|
||||
|
||||
assert registry.get("dzengi") is feed
|
||||
assert registry.get(" dzengi ") is feed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
|
||||
def test_quote_registry_rejects_empty_source_name(
|
||||
source_name: str,
|
||||
) -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
|
||||
with pytest.raises(
|
||||
QuoteFeedRegistryError,
|
||||
match=r"Имя источника Quotes Feed не должно быть пустым",
|
||||
):
|
||||
registry.register(source_name, StubQuoteFeed())
|
||||
|
||||
|
||||
def test_quote_registry_rejects_duplicate_registration() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
first_feed = StubQuoteFeed()
|
||||
|
||||
registry.register("dzengi", first_feed)
|
||||
|
||||
with pytest.raises(
|
||||
QuoteFeedRegistryError,
|
||||
match=r"уже зарегистрирован",
|
||||
):
|
||||
registry.register("dzengi", StubQuoteFeed())
|
||||
|
||||
assert registry.get("dzengi") is first_feed
|
||||
|
||||
|
||||
def test_quote_registry_rejects_unregistered_source() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
|
||||
with pytest.raises(
|
||||
QuoteFeedRegistryError,
|
||||
match=r"не зарегистрирован",
|
||||
):
|
||||
registry.get("dzengi")
|
||||
|
||||
|
||||
def test_quote_registry_rejects_invalid_feed() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
|
||||
with pytest.raises(
|
||||
QuoteFeedRegistryError,
|
||||
match=r"не соответствует QuoteFeedProtocol",
|
||||
):
|
||||
registry.register(
|
||||
"invalid",
|
||||
InvalidFeed(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_quote_registry_does_not_load_feed() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
result = registry.get("dzengi")
|
||||
|
||||
assert result is feed
|
||||
assert feed.symbols == []
|
||||
|
||||
|
||||
def test_quote_registry_error_inherits_acquisition_error() -> None:
|
||||
error = QuoteFeedRegistryError("Registry error.")
|
||||
|
||||
assert isinstance(error, MarketDataAcquisitionError)
|
||||
475
app/tests/unit/market_data/acquisition/test_service.py
Normal file
475
app/tests/unit/market_data/acquisition/test_service.py
Normal file
@@ -0,0 +1,475 @@
|
||||
# app/tests/unit/market_data/acquisition/test_service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentFeedRegistryError,
|
||||
InstrumentReferenceMappingError,
|
||||
InstrumentReferenceTransportError,
|
||||
InstrumentReferenceValueError,
|
||||
)
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.protocol import InstrumentFeedProtocol
|
||||
from src.market_data.acquisition.registry import InstrumentFeedRegistry
|
||||
from src.market_data.acquisition.service import InstrumentAcquisitionService
|
||||
|
||||
|
||||
def _instrument(
|
||||
*,
|
||||
symbol: str = "BTC/USD_LEVERAGE",
|
||||
) -> Instrument:
|
||||
return Instrument(
|
||||
symbol=symbol,
|
||||
name=symbol,
|
||||
status="TRADING",
|
||||
base_asset="BTC",
|
||||
quote_asset="USD",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
market_type="LEVERAGE",
|
||||
market_modes=("REGULAR",),
|
||||
order_types=("LIMIT", "MARKET"),
|
||||
base_asset_precision=4,
|
||||
quote_asset_precision=4,
|
||||
tick_size=Decimal("0.05"),
|
||||
tick_value=Decimal("3878.86"),
|
||||
step_size=Decimal("0.0001"),
|
||||
min_qty=Decimal("0.0001"),
|
||||
max_qty=Decimal("1000"),
|
||||
min_notional=Decimal("1"),
|
||||
country=None,
|
||||
sector=None,
|
||||
industry=None,
|
||||
trading_hours=None,
|
||||
)
|
||||
|
||||
|
||||
class StubInstrumentFeed:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
instruments: tuple[Instrument, ...] = (),
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.instruments = instruments
|
||||
self.error = error
|
||||
self.load_call_count = 0
|
||||
|
||||
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||
self.load_call_count += 1
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.instruments
|
||||
|
||||
|
||||
class RecordingInstrumentFeedRegistry(InstrumentFeedRegistry):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.requested_source_names: list[str] = []
|
||||
self.get_call_count = 0
|
||||
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> InstrumentFeedProtocol:
|
||||
self.get_call_count += 1
|
||||
self.requested_source_names.append(source_name)
|
||||
|
||||
return super().get(source_name)
|
||||
|
||||
|
||||
def test_service_loads_instruments_from_registered_feed() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
instruments = (
|
||||
_instrument(),
|
||||
)
|
||||
feed = StubInstrumentFeed(
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
result = service.load_instruments("dzengi")
|
||||
|
||||
assert result is instruments
|
||||
|
||||
|
||||
def test_service_passes_source_name_to_registry_without_changes() -> None:
|
||||
registry = RecordingInstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
service.load_instruments(" dzengi ")
|
||||
|
||||
assert registry.requested_source_names == [
|
||||
" dzengi ",
|
||||
]
|
||||
|
||||
|
||||
def test_service_calls_registry_once() -> None:
|
||||
registry = RecordingInstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
service.load_instruments("dzengi")
|
||||
|
||||
assert registry.get_call_count == 1
|
||||
|
||||
|
||||
def test_service_calls_feed_once() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
service.load_instruments("dzengi")
|
||||
|
||||
assert feed.load_call_count == 1
|
||||
|
||||
|
||||
def test_service_returns_feed_result_without_copying() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
instruments = (
|
||||
_instrument(),
|
||||
_instrument(symbol="ETH/USD_LEVERAGE"),
|
||||
)
|
||||
feed = StubInstrumentFeed(
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
result = service.load_instruments("dzengi")
|
||||
|
||||
assert result is instruments
|
||||
|
||||
|
||||
def test_service_preserves_instrument_order() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
instruments = (
|
||||
_instrument(symbol="BTC/USD_LEVERAGE"),
|
||||
_instrument(symbol="ETH/USD_LEVERAGE"),
|
||||
_instrument(symbol="XRP/USD_LEVERAGE"),
|
||||
)
|
||||
feed = StubInstrumentFeed(
|
||||
instruments=instruments,
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
result = service.load_instruments("dzengi")
|
||||
|
||||
assert tuple(item.symbol for item in result) == (
|
||||
"BTC/USD_LEVERAGE",
|
||||
"ETH/USD_LEVERAGE",
|
||||
"XRP/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
|
||||
def test_service_returns_empty_tuple_without_error() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed(
|
||||
instruments=(),
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
result = service.load_instruments("dzengi")
|
||||
|
||||
assert result == ()
|
||||
|
||||
|
||||
def test_service_preserves_registry_error_without_wrapping() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentFeedRegistryError,
|
||||
) as exc_info:
|
||||
service.load_instruments("dzengi")
|
||||
|
||||
assert "не зарегистрирован" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_service_does_not_call_feed_when_registry_fails() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
registered_feed = StubInstrumentFeed()
|
||||
|
||||
registry.register("registered", registered_feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
with pytest.raises(InstrumentFeedRegistryError):
|
||||
service.load_instruments("missing")
|
||||
|
||||
assert registered_feed.load_call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
InstrumentReferenceTransportError("Network error."),
|
||||
InstrumentReferenceValueError("Invalid value."),
|
||||
InstrumentReferenceMappingError("Mapping error."),
|
||||
],
|
||||
)
|
||||
def test_service_preserves_feed_error_without_wrapping(
|
||||
error: Exception,
|
||||
) -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
feed = StubInstrumentFeed(
|
||||
error=error,
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
with pytest.raises(type(error)) as exc_info:
|
||||
service.load_instruments("dzengi")
|
||||
|
||||
assert exc_info.value is error
|
||||
assert feed.load_call_count == 1
|
||||
|
||||
|
||||
def test_service_does_not_retry_feed_after_error() -> None:
|
||||
registry = InstrumentFeedRegistry()
|
||||
original_error = InstrumentReferenceTransportError(
|
||||
"Network error."
|
||||
)
|
||||
feed = StubInstrumentFeed(
|
||||
error=original_error,
|
||||
)
|
||||
|
||||
registry.register("dzengi", feed)
|
||||
|
||||
service = InstrumentAcquisitionService(
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
with pytest.raises(InstrumentReferenceTransportError):
|
||||
service.load_instruments("dzengi")
|
||||
|
||||
assert feed.load_call_count == 1
|
||||
|
||||
# Quote Acquisition Service tests.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
QuoteFeedRegistryError,
|
||||
QuoteTransportError,
|
||||
QuoteValueError,
|
||||
)
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.protocol import QuoteFeedProtocol
|
||||
from src.market_data.acquisition.registry import QuoteFeedRegistry
|
||||
from src.market_data.acquisition.service import QuoteAcquisitionService
|
||||
|
||||
|
||||
def _quote() -> Quote:
|
||||
return Quote(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
last_price=Decimal("64159.45"),
|
||||
bid_price=Decimal("64159.45"),
|
||||
ask_price=Decimal("64159.55"),
|
||||
exchange_timestamp=datetime.now(timezone.utc),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
class StubQuoteFeed:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
quote: Quote | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.quote = quote or _quote()
|
||||
self.error = error
|
||||
self.symbols: list[str] = []
|
||||
|
||||
def load_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
self.symbols.append(symbol)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
return self.quote
|
||||
|
||||
|
||||
class RecordingQuoteFeedRegistry(QuoteFeedRegistry):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.requested_source_names: list[str] = []
|
||||
self.get_call_count = 0
|
||||
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> QuoteFeedProtocol:
|
||||
self.get_call_count += 1
|
||||
self.requested_source_names.append(source_name)
|
||||
return super().get(source_name)
|
||||
|
||||
|
||||
def test_quote_service_loads_quote_from_registered_feed() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
quote = _quote()
|
||||
feed = StubQuoteFeed(quote=quote)
|
||||
registry.register("dzengi", feed)
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
result = service.load_quote(
|
||||
"dzengi",
|
||||
"BTC/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
assert result is quote
|
||||
|
||||
|
||||
def test_quote_service_passes_source_name_without_changes() -> None:
|
||||
registry = RecordingQuoteFeedRegistry()
|
||||
registry.register("dzengi", StubQuoteFeed())
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
service.load_quote(
|
||||
" dzengi ",
|
||||
"BTC/USD_LEVERAGE",
|
||||
)
|
||||
|
||||
assert registry.requested_source_names == [" dzengi "]
|
||||
|
||||
|
||||
def test_quote_service_calls_registry_once() -> None:
|
||||
registry = RecordingQuoteFeedRegistry()
|
||||
registry.register("dzengi", StubQuoteFeed())
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
|
||||
|
||||
assert registry.get_call_count == 1
|
||||
|
||||
|
||||
def test_quote_service_passes_symbol_without_changes() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
registry.register("dzengi", feed)
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
service.load_quote("dzengi", " btc/usd_leverage ")
|
||||
|
||||
assert feed.symbols == [" btc/usd_leverage "]
|
||||
|
||||
|
||||
def test_quote_service_calls_feed_once() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
registry.register("dzengi", feed)
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
|
||||
|
||||
assert feed.symbols == ["BTC/USD_LEVERAGE"]
|
||||
|
||||
|
||||
def test_quote_service_preserves_registry_error() -> None:
|
||||
service = QuoteAcquisitionService(
|
||||
registry=QuoteFeedRegistry(),
|
||||
)
|
||||
|
||||
with pytest.raises(QuoteFeedRegistryError):
|
||||
service.load_quote("missing", "BTC/USD_LEVERAGE")
|
||||
|
||||
|
||||
def test_quote_service_does_not_call_registered_feed_when_registry_fails() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed()
|
||||
registry.register("registered", feed)
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
with pytest.raises(QuoteFeedRegistryError):
|
||||
service.load_quote("missing", "BTC/USD_LEVERAGE")
|
||||
|
||||
assert feed.symbols == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
QuoteTransportError("Network error."),
|
||||
QuoteValueError("Invalid quote."),
|
||||
],
|
||||
)
|
||||
def test_quote_service_preserves_feed_error(
|
||||
error: Exception,
|
||||
) -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed(error=error)
|
||||
registry.register("dzengi", feed)
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
with pytest.raises(type(error)) as exc_info:
|
||||
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
|
||||
|
||||
assert exc_info.value is error
|
||||
assert feed.symbols == ["BTC/USD_LEVERAGE"]
|
||||
|
||||
|
||||
def test_quote_service_does_not_retry_after_error() -> None:
|
||||
registry = QuoteFeedRegistry()
|
||||
feed = StubQuoteFeed(
|
||||
error=QuoteTransportError("Network error."),
|
||||
)
|
||||
registry.register("dzengi", feed)
|
||||
service = QuoteAcquisitionService(registry=registry)
|
||||
|
||||
with pytest.raises(QuoteTransportError):
|
||||
service.load_quote("dzengi", "BTC/USD_LEVERAGE")
|
||||
|
||||
assert feed.symbols == ["BTC/USD_LEVERAGE"]
|
||||
366
app/tests/unit/market_data/acquisition/test_symbols.py
Normal file
366
app/tests/unit/market_data/acquisition/test_symbols.py
Normal file
@@ -0,0 +1,366 @@
|
||||
# app/tests/unit/market_data/acquisition/test_symbols.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.symbols import (
|
||||
normalize_symbol,
|
||||
resolve_symbol_index,
|
||||
symbol_candidates,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_symbol", "expected"),
|
||||
[
|
||||
(
|
||||
"BTC/USD",
|
||||
"BTC/USD",
|
||||
),
|
||||
(
|
||||
"btc/usd",
|
||||
"BTC/USD",
|
||||
),
|
||||
(
|
||||
" btc/usd ",
|
||||
"BTC/USD",
|
||||
),
|
||||
(
|
||||
"",
|
||||
"",
|
||||
),
|
||||
(
|
||||
" ",
|
||||
"",
|
||||
),
|
||||
(
|
||||
"btc / usd",
|
||||
"BTC / USD",
|
||||
),
|
||||
(
|
||||
"btc%2fusd",
|
||||
"BTC%2FUSD",
|
||||
),
|
||||
(
|
||||
"eth/usd_leverage",
|
||||
"ETH/USD_LEVERAGE",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_symbol_preserves_existing_contract(
|
||||
raw_symbol: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert normalize_symbol(raw_symbol) == expected
|
||||
|
||||
|
||||
def test_normalize_symbol_does_not_decode_encoded_separator() -> None:
|
||||
result = normalize_symbol(
|
||||
"btc%2fusd"
|
||||
)
|
||||
|
||||
assert result == "BTC%2FUSD"
|
||||
|
||||
|
||||
def test_normalize_symbol_does_not_remove_internal_spaces() -> None:
|
||||
result = normalize_symbol(
|
||||
" btc / usd "
|
||||
)
|
||||
|
||||
assert result == "BTC / USD"
|
||||
|
||||
|
||||
def test_normalize_symbol_does_not_add_leverage_suffix() -> None:
|
||||
result = normalize_symbol(
|
||||
"btc/usd"
|
||||
)
|
||||
|
||||
assert result == "BTC/USD"
|
||||
|
||||
|
||||
def test_normalize_symbol_preserves_existing_leverage_suffix() -> None:
|
||||
result = normalize_symbol(
|
||||
"btc/usd_leverage"
|
||||
)
|
||||
|
||||
assert result == "BTC/USD_LEVERAGE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_symbol",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
" ",
|
||||
"\t",
|
||||
"\n",
|
||||
],
|
||||
)
|
||||
def test_symbol_candidates_returns_empty_list_for_empty_value(
|
||||
raw_symbol: str,
|
||||
) -> None:
|
||||
assert symbol_candidates(raw_symbol) == []
|
||||
|
||||
|
||||
def test_symbol_candidates_returns_single_normalized_candidate() -> None:
|
||||
result = symbol_candidates(
|
||||
" btc/usd "
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_adds_decoded_separator_candidate() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc%2fusd"
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC%2FUSD",
|
||||
"BTC/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_adds_no_spaces_candidate() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc / usd"
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC / USD",
|
||||
"BTC/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_preserves_transformation_order() -> None:
|
||||
result = symbol_candidates(
|
||||
" btc%2f / usd "
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC%2F / USD",
|
||||
"BTC/ / USD",
|
||||
"BTC//USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_does_not_add_duplicate_after_separator_decode() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc/usd"
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_does_not_add_duplicate_after_space_removal() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc%2fusd"
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC%2FUSD",
|
||||
"BTC/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_returns_new_list_for_each_call() -> None:
|
||||
first = symbol_candidates(
|
||||
"btc/usd"
|
||||
)
|
||||
second = symbol_candidates(
|
||||
"btc/usd"
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert first is not second
|
||||
|
||||
|
||||
def test_symbol_candidates_does_not_modify_source_string() -> None:
|
||||
raw_symbol = " btc%2f / usd "
|
||||
|
||||
symbol_candidates(raw_symbol)
|
||||
|
||||
assert raw_symbol == " btc%2f / usd "
|
||||
|
||||
|
||||
def test_symbol_candidates_does_not_remove_internal_tab() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc\t/usd"
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC\t/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_does_not_remove_internal_newline() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc\n/usd"
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC\n/USD",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_preserves_leverage_suffix() -> None:
|
||||
result = symbol_candidates(
|
||||
" btc / usd_leverage "
|
||||
)
|
||||
|
||||
assert result == [
|
||||
"BTC / USD_LEVERAGE",
|
||||
"BTC/USD_LEVERAGE",
|
||||
]
|
||||
|
||||
|
||||
def test_symbol_candidates_returns_list() -> None:
|
||||
result = symbol_candidates(
|
||||
"btc/usd"
|
||||
)
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_resolve_symbol_index_finds_exact_match() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"BTC/USD_LEVERAGE",
|
||||
(
|
||||
"ETH/USD_LEVERAGE",
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
|
||||
|
||||
def test_resolve_symbol_index_is_case_insensitive() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"btc/usd_leverage",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_resolve_symbol_index_ignores_outer_spaces() -> None:
|
||||
result = resolve_symbol_index(
|
||||
" btc/usd_leverage ",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_resolve_symbol_index_supports_encoded_separator() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"btc%2fusd_leverage",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_resolve_symbol_index_supports_internal_spaces() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"btc / usd_leverage",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_resolve_symbol_index_returns_none_for_missing_symbol() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"XRP/USD_LEVERAGE",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
"ETH/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symbol_index_returns_none_for_empty_request() -> None:
|
||||
result = resolve_symbol_index(
|
||||
" ",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symbol_index_returns_none_for_empty_available_symbols() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"BTC/USD_LEVERAGE",
|
||||
(),
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symbol_index_preserves_candidate_priority() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"BTC%2FUSD_LEVERAGE",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
"BTC%2FUSD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
|
||||
|
||||
def test_resolve_symbol_index_preserves_available_symbol_order() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"BTC/USD_LEVERAGE",
|
||||
(
|
||||
"btc/usd_leverage",
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_resolve_symbol_index_returns_first_duplicate() -> None:
|
||||
result = resolve_symbol_index(
|
||||
"BTC/USD_LEVERAGE",
|
||||
(
|
||||
"BTC/USD_LEVERAGE",
|
||||
"BTC/USD_LEVERAGE",
|
||||
),
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_resolve_symbol_index_does_not_modify_available_symbols() -> None:
|
||||
available_symbols = [
|
||||
"BTC/USD_LEVERAGE",
|
||||
"ETH/USD_LEVERAGE",
|
||||
]
|
||||
original_symbols = list(available_symbols)
|
||||
|
||||
resolve_symbol_index(
|
||||
"BTC/USD_LEVERAGE",
|
||||
available_symbols,
|
||||
)
|
||||
|
||||
assert available_symbols == original_symbols
|
||||
@@ -0,0 +1,72 @@
|
||||
# app/tests/unit/market_data/acquisition/validation/test_quote_schema.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import QuoteSchemaError
|
||||
from src.market_data.acquisition.validation.schema import validate_quote_schema
|
||||
|
||||
|
||||
def _document() -> dict[str, object]:
|
||||
return {
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"lastPrice": "64159.45",
|
||||
"bidPrice": "64159.45",
|
||||
"askPrice": "64159.55",
|
||||
"closeTime": 1783887270312,
|
||||
"highPrice": "64261.45",
|
||||
}
|
||||
|
||||
|
||||
def test_validate_quote_schema_accepts_real_unwrapped_document() -> None:
|
||||
document = _document()
|
||||
|
||||
result = validate_quote_schema(document)
|
||||
|
||||
assert result.is_wrapped is False
|
||||
assert result.status is None
|
||||
assert result.correlation_id is None
|
||||
assert dict(result.payload) == document
|
||||
|
||||
|
||||
def test_validate_quote_schema_accepts_wrapped_document() -> None:
|
||||
payload = _document()
|
||||
|
||||
result = validate_quote_schema(
|
||||
{
|
||||
"status": "OK",
|
||||
"correlationId": "quote-1",
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
|
||||
assert result.is_wrapped is True
|
||||
assert result.status == "OK"
|
||||
assert result.correlation_id == "quote-1"
|
||||
assert dict(result.payload) == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing_key",
|
||||
[
|
||||
"symbol",
|
||||
"lastPrice",
|
||||
"bidPrice",
|
||||
"askPrice",
|
||||
"closeTime",
|
||||
],
|
||||
)
|
||||
def test_validate_quote_schema_rejects_missing_required_field(
|
||||
missing_key: str,
|
||||
) -> None:
|
||||
document = _document()
|
||||
document.pop(missing_key)
|
||||
|
||||
with pytest.raises(QuoteSchemaError, match=missing_key):
|
||||
validate_quote_schema(document)
|
||||
|
||||
|
||||
def test_validate_quote_schema_rejects_non_mapping_root() -> None:
|
||||
with pytest.raises(QuoteSchemaError, match="JSON-объектом"):
|
||||
validate_quote_schema([])
|
||||
@@ -0,0 +1,91 @@
|
||||
# app/tests/unit/market_data/acquisition/validation/test_quote_values.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiTicker24hrResponse,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import QuoteValueError
|
||||
from src.market_data.acquisition.validation.values import validate_quote_values
|
||||
|
||||
|
||||
def _response(
|
||||
*,
|
||||
symbol: str = "BTC/USD_LEVERAGE",
|
||||
last_price: str | int | float = "64159.45",
|
||||
bid_price: str | int | float = "64159.45",
|
||||
ask_price: str | int | float = "64159.55",
|
||||
close_time: int = 1783887270312,
|
||||
) -> DzengiTicker24hrResponse:
|
||||
return DzengiTicker24hrResponse(
|
||||
symbol=symbol,
|
||||
last_price=last_price,
|
||||
bid_price=bid_price,
|
||||
ask_price=ask_price,
|
||||
close_time=close_time,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_quote_values_accepts_real_response() -> None:
|
||||
validate_quote_values(_response())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "value"),
|
||||
[
|
||||
("last_price", "0"),
|
||||
("bid_price", "-1"),
|
||||
("ask_price", "NaN"),
|
||||
("last_price", "not-a-number"),
|
||||
],
|
||||
)
|
||||
def test_validate_quote_values_rejects_invalid_price(
|
||||
field_name: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
values = {
|
||||
"last_price": "64159.45",
|
||||
"bid_price": "64159.45",
|
||||
"ask_price": "64159.55",
|
||||
}
|
||||
values[field_name] = value
|
||||
|
||||
with pytest.raises(QuoteValueError):
|
||||
validate_quote_values(
|
||||
_response(
|
||||
last_price=values["last_price"],
|
||||
bid_price=values["bid_price"],
|
||||
ask_price=values["ask_price"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_validate_quote_values_rejects_empty_symbol() -> None:
|
||||
with pytest.raises(QuoteValueError, match="symbol"):
|
||||
validate_quote_values(_response(symbol=" "))
|
||||
|
||||
|
||||
def test_validate_quote_values_rejects_non_positive_close_time() -> None:
|
||||
with pytest.raises(QuoteValueError, match="closeTime"):
|
||||
validate_quote_values(_response(close_time=0))
|
||||
|
||||
|
||||
def test_validate_quote_values_rejects_crossed_market() -> None:
|
||||
with pytest.raises(QuoteValueError, match="bidPrice"):
|
||||
validate_quote_values(
|
||||
_response(
|
||||
bid_price="64160.00",
|
||||
ask_price="64159.55",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_validate_quote_values_accepts_equal_bid_and_ask() -> None:
|
||||
validate_quote_values(
|
||||
_response(
|
||||
bid_price="64159.45",
|
||||
ask_price="64159.45",
|
||||
)
|
||||
)
|
||||
268
app/tests/unit/market_data/acquisition/validation/test_schema.py
Normal file
268
app/tests/unit/market_data/acquisition/validation/test_schema.py
Normal file
@@ -0,0 +1,268 @@
|
||||
# app/tests/unit/market_data/acquisition/validation/test_schema.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceSchemaError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
validate_exchange_info_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_unwrapped_exchange_info_document() -> None:
|
||||
document = {
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1783537921471,
|
||||
"rateLimits": [],
|
||||
"exchangeFilters": [],
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"filters": [
|
||||
{
|
||||
"filterType": "LOT_SIZE",
|
||||
"minQty": "0.0001",
|
||||
"maxQty": "1000",
|
||||
"stepSize": "0.0001",
|
||||
}
|
||||
],
|
||||
"marketModes": ["REGULAR"],
|
||||
"orderTypes": ["LIMIT", "MARKET", "STOP"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
validated = validate_exchange_info_schema(document)
|
||||
|
||||
assert validated.is_wrapped is False
|
||||
assert validated.status is None
|
||||
assert validated.correlation_id is None
|
||||
assert validated.payload["symbols"] == document["symbols"]
|
||||
assert isinstance(validated.payload, MappingProxyType)
|
||||
|
||||
|
||||
def test_validate_wrapped_exchange_info_document() -> None:
|
||||
document = {
|
||||
"status": "OK",
|
||||
"correlationId": "2",
|
||||
"payload": {
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1628193845310,
|
||||
"rateLimits": [],
|
||||
"exchangeFilters": [],
|
||||
"symbols": [],
|
||||
},
|
||||
}
|
||||
|
||||
validated = validate_exchange_info_schema(document)
|
||||
|
||||
assert validated.is_wrapped is True
|
||||
assert validated.status == "OK"
|
||||
assert validated.correlation_id == "2"
|
||||
assert validated.payload["symbols"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"document",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
"invalid",
|
||||
123,
|
||||
],
|
||||
)
|
||||
def test_reject_non_object_root(document: object) -> None:
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\$ должен быть JSON-объектом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
def test_reject_non_object_wrapped_payload() -> None:
|
||||
document = {
|
||||
"status": "OK",
|
||||
"payload": [],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\$\.payload должен быть JSON-объектом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
def test_reject_missing_symbols() -> None:
|
||||
document = {
|
||||
"timezone": "UTC",
|
||||
"serverTime": 1783537921471,
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\$\.payload\.symbols должен быть JSON-массивом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
def test_reject_non_list_symbols() -> None:
|
||||
document = {
|
||||
"symbols": {},
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\$\.payload\.symbols должен быть JSON-массивом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
def test_reject_non_object_symbol_item() -> None:
|
||||
document = {
|
||||
"symbols": [
|
||||
"BTC/USD",
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\$\.payload\.symbols\[0\] должен быть JSON-объектом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
def test_reject_non_list_filters() -> None:
|
||||
document = {
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "BTC/USD",
|
||||
"filters": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\.filters должен быть JSON-массивом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
def test_reject_non_object_filter_item() -> None:
|
||||
document = {
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "BTC/USD",
|
||||
"filters": [
|
||||
"LOT_SIZE",
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=r"\.filters\[0\] должен быть JSON-объектом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "invalid_value"),
|
||||
[
|
||||
("marketModes", {}),
|
||||
("orderTypes", "MARKET"),
|
||||
],
|
||||
)
|
||||
def test_reject_non_list_string_collections(
|
||||
key: str,
|
||||
invalid_value: object,
|
||||
) -> None:
|
||||
document = {
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "BTC/USD",
|
||||
key: invalid_value,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=rf"\.{key} должен быть JSON-массивом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"marketModes",
|
||||
"orderTypes",
|
||||
],
|
||||
)
|
||||
def test_reject_non_string_collection_item(key: str) -> None:
|
||||
document = {
|
||||
"symbols": [
|
||||
{
|
||||
"symbol": "BTC/USD",
|
||||
key: [
|
||||
"REGULAR",
|
||||
123,
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=rf"\.{key}\[1\] должен быть строкой",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"rateLimits",
|
||||
"exchangeFilters",
|
||||
],
|
||||
)
|
||||
def test_reject_non_list_payload_collections(key: str) -> None:
|
||||
document = {
|
||||
"symbols": [],
|
||||
key: {},
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=rf"\.{key} должен быть JSON-массивом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"rateLimits",
|
||||
"exchangeFilters",
|
||||
],
|
||||
)
|
||||
def test_reject_non_object_payload_collection_item(key: str) -> None:
|
||||
document = {
|
||||
"symbols": [],
|
||||
key: [
|
||||
"invalid",
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceSchemaError,
|
||||
match=rf"\.{key}\[0\] должен быть JSON-объектом",
|
||||
):
|
||||
validate_exchange_info_schema(document)
|
||||
481
app/tests/unit/market_data/acquisition/validation/test_values.py
Normal file
481
app/tests/unit/market_data/acquisition/validation/test_values.py
Normal file
@@ -0,0 +1,481 @@
|
||||
# app/tests/unit/market_data/acquisition/validation/test_values.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiExchangeInfoPayload,
|
||||
DzengiExchangeInfoResponse,
|
||||
DzengiExchangeInfoSymbol,
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiUnknownFilter,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceValueError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.values import (
|
||||
validate_exchange_info_values,
|
||||
)
|
||||
|
||||
|
||||
def _valid_symbol() -> DzengiExchangeInfoSymbol:
|
||||
return DzengiExchangeInfoSymbol(
|
||||
symbol="ETH/EUR_LEVERAGE",
|
||||
name="ETH/EUR",
|
||||
status="TRADING",
|
||||
asset_type="CRYPTOCURRENCY",
|
||||
base_asset="ETH",
|
||||
base_asset_precision=3,
|
||||
quote_asset="EUR",
|
||||
quote_asset_id="EUR_LEVERAGE",
|
||||
quote_precision=3,
|
||||
order_types=("LIMIT", "MARKET", "STOP"),
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.001",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
),
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="2",
|
||||
),
|
||||
),
|
||||
market_modes=("REGULAR",),
|
||||
market_type="LEVERAGE",
|
||||
country="",
|
||||
sector="",
|
||||
industry="",
|
||||
trading_hours="UTC; Mon - 21:00, 21:05 -",
|
||||
tick_size=0.01,
|
||||
tick_value=18.3415,
|
||||
trading_fee=0.06,
|
||||
exchange_fee=None,
|
||||
long_rate=-0.01,
|
||||
short_rate=0.01,
|
||||
swap_charge_interval=480,
|
||||
min_sl_gap=0,
|
||||
max_sl_gap=50.0,
|
||||
min_tp_gap=0,
|
||||
max_tp_gap=50.0,
|
||||
)
|
||||
|
||||
|
||||
def _valid_response(
|
||||
*,
|
||||
symbol: DzengiExchangeInfoSymbol | None = None,
|
||||
rate_limits: tuple[DzengiRateLimit, ...] = (),
|
||||
exchange_filters: tuple[DzengiUnknownFilter, ...] = (),
|
||||
) -> DzengiExchangeInfoResponse:
|
||||
return DzengiExchangeInfoResponse(
|
||||
status="OK",
|
||||
correlation_id="2",
|
||||
payload=DzengiExchangeInfoPayload(
|
||||
timezone="UTC",
|
||||
server_time=1783537921471,
|
||||
rate_limits=rate_limits,
|
||||
exchange_filters=exchange_filters,
|
||||
symbols=(symbol or _valid_symbol(),),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_complete_exchange_info_values() -> None:
|
||||
response = _valid_response(
|
||||
rate_limits=(
|
||||
DzengiRateLimit(
|
||||
interval="MINUTE",
|
||||
interval_num=1,
|
||||
limit=1200,
|
||||
rate_limit_type="REQUEST_WEIGHT",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert validate_exchange_info_values(response) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
"symbol",
|
||||
"name",
|
||||
"status",
|
||||
"base_asset",
|
||||
"quote_asset",
|
||||
"market_type",
|
||||
],
|
||||
)
|
||||
def test_reject_empty_required_symbol_string(field: str) -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
**{field: " "},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match="не должен быть пустым",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_reject_empty_order_type() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
order_types=("LIMIT", " "),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"orderTypes\[1\] не должен быть пустым",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_reject_empty_market_mode() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
market_modes=("REGULAR", ""),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"marketModes\[1\] не должен быть пустым",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
"base_asset_precision",
|
||||
"quote_precision",
|
||||
"swap_charge_interval",
|
||||
],
|
||||
)
|
||||
def test_reject_negative_non_negative_integer_field(field: str) -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
**{field: -1},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match="должно быть больше или равно нулю",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tick_size",
|
||||
[
|
||||
0,
|
||||
-0.01,
|
||||
],
|
||||
)
|
||||
def test_reject_non_positive_tick_size(tick_size: float) -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
tick_size=tick_size,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"tickSize должно быть больше нуля",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tick_size",
|
||||
[
|
||||
float("nan"),
|
||||
float("inf"),
|
||||
float("-inf"),
|
||||
],
|
||||
)
|
||||
def test_reject_non_finite_tick_size(tick_size: float) -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
tick_size=tick_size,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"tickSize должно быть конечным числом",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_reject_non_numeric_lot_size_value() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="not-a-number",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"minQty должно быть корректным числом",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("min_qty", "0"),
|
||||
("max_qty", 0),
|
||||
("step_size", -1),
|
||||
],
|
||||
)
|
||||
def test_reject_non_positive_lot_size_values(
|
||||
field: str,
|
||||
value: str | int,
|
||||
) -> None:
|
||||
lot_size = DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="0.001",
|
||||
max_qty="1000",
|
||||
step_size="0.001",
|
||||
)
|
||||
|
||||
lot_size = replace(
|
||||
lot_size,
|
||||
**{field: value},
|
||||
)
|
||||
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
filters=(lot_size,),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match="должно быть больше нуля",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_reject_min_qty_greater_than_max_qty() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
filters=(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type="LOT_SIZE",
|
||||
min_qty="10",
|
||||
max_qty="1",
|
||||
step_size="0.1",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match="minQty не должно превышать",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_reject_negative_min_notional() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
filters=(
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="-1",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"minNotional должно быть больше или равно нулю",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_accept_zero_min_notional() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
filters=(
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type="MIN_NOTIONAL",
|
||||
min_notional="0",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert (
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_accept_negative_long_and_short_rates() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
long_rate=-0.15,
|
||||
short_rate=-0.25,
|
||||
)
|
||||
|
||||
assert (
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_accept_zero_optional_numeric_values() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
tick_value=0,
|
||||
trading_fee=0,
|
||||
exchange_fee=0,
|
||||
min_sl_gap=0,
|
||||
max_sl_gap=0,
|
||||
min_tp_gap=0,
|
||||
max_tp_gap=0,
|
||||
)
|
||||
|
||||
assert (
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("interval_num", "limit"),
|
||||
[
|
||||
(0, 1200),
|
||||
(1, 0),
|
||||
(-1, 1200),
|
||||
(1, -100),
|
||||
],
|
||||
)
|
||||
def test_reject_invalid_rate_limit_values(
|
||||
interval_num: int,
|
||||
limit: int,
|
||||
) -> None:
|
||||
response = _valid_response(
|
||||
rate_limits=(
|
||||
DzengiRateLimit(
|
||||
interval="MINUTE",
|
||||
interval_num=interval_num,
|
||||
limit=limit,
|
||||
rate_limit_type="REQUEST_WEIGHT",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match="должно быть больше нуля",
|
||||
):
|
||||
validate_exchange_info_values(response)
|
||||
|
||||
|
||||
def test_reject_empty_rate_limit_string() -> None:
|
||||
response = _valid_response(
|
||||
rate_limits=(
|
||||
DzengiRateLimit(
|
||||
interval=" ",
|
||||
interval_num=1,
|
||||
limit=1200,
|
||||
rate_limit_type="REQUEST_WEIGHT",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"interval не должен быть пустым",
|
||||
):
|
||||
validate_exchange_info_values(response)
|
||||
|
||||
|
||||
def test_reject_empty_unknown_instrument_filter_type() -> None:
|
||||
symbol = replace(
|
||||
_valid_symbol(),
|
||||
filters=(
|
||||
DzengiUnknownFilter(
|
||||
filter_type=" ",
|
||||
fields=(("enabled", True),),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"filterType не должен быть пустым",
|
||||
):
|
||||
validate_exchange_info_values(
|
||||
_valid_response(symbol=symbol)
|
||||
)
|
||||
|
||||
|
||||
def test_accept_empty_global_exchange_filter_type() -> None:
|
||||
response = _valid_response(
|
||||
exchange_filters=(
|
||||
DzengiUnknownFilter(
|
||||
filter_type="",
|
||||
fields=(("enabled", True),),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert validate_exchange_info_values(response) is None
|
||||
|
||||
|
||||
def test_reject_whitespace_global_exchange_filter_type() -> None:
|
||||
response = _valid_response(
|
||||
exchange_filters=(
|
||||
DzengiUnknownFilter(
|
||||
filter_type=" ",
|
||||
fields=(("enabled", True),),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InstrumentReferenceValueError,
|
||||
match=r"filterType не должен состоять только из пробелов",
|
||||
):
|
||||
validate_exchange_info_values(response)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.exceptions import QuoteSchemaError
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
validate_dzengi_websocket_quote_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_accepts_direct_unwrapped_message() -> None:
|
||||
result = validate_dzengi_websocket_quote_schema(
|
||||
{"symbol": "BTC/USD", "bid": "10", "ask": "11"}
|
||||
)
|
||||
assert result.payload["bid"] == "10"
|
||||
|
||||
|
||||
def test_accepts_double_payload_wrapper_and_root_symbol() -> None:
|
||||
result = validate_dzengi_websocket_quote_schema(
|
||||
{
|
||||
"symbol": "BTC/USD",
|
||||
"Payload": {"payload": {"bids": [["10", "1"]], "asks": [["11", "1"]]}},
|
||||
}
|
||||
)
|
||||
assert result.root_symbol == "BTC/USD"
|
||||
|
||||
|
||||
def test_accepts_ofr_alias() -> None:
|
||||
validate_dzengi_websocket_quote_schema(
|
||||
{"symbolName": "BTC/USD", "bid": "10", "ofr": "11"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"document",
|
||||
[
|
||||
[],
|
||||
{"bid": "10", "ask": "11"},
|
||||
{"symbol": "BTC/USD", "bid": "10"},
|
||||
{"symbol": "BTC/USD", "bids": [], "asks": [["11"]]},
|
||||
{"symbol": "BTC/USD", "bids": [["10"]], "asks": []},
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_structure(document: object) -> None:
|
||||
with pytest.raises(QuoteSchemaError):
|
||||
validate_dzengi_websocket_quote_schema(document)
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import DzengiWebSocketQuoteResponse
|
||||
from src.market_data.acquisition.exceptions import QuoteValueError
|
||||
from src.market_data.acquisition.validation.values import (
|
||||
validate_dzengi_websocket_quote_values,
|
||||
)
|
||||
|
||||
|
||||
def _response(**overrides: object) -> DzengiWebSocketQuoteResponse:
|
||||
values = {
|
||||
"symbol": "BTC/USD",
|
||||
"bid_price": "10",
|
||||
"ask_price": "11",
|
||||
"timestamp": 1000,
|
||||
}
|
||||
values.update(overrides)
|
||||
return DzengiWebSocketQuoteResponse(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_accepts_valid_values_and_missing_timestamp() -> None:
|
||||
validate_dzengi_websocket_quote_values(_response())
|
||||
validate_dzengi_websocket_quote_values(_response(timestamp=None))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"symbol": " "},
|
||||
{"bid_price": "0"},
|
||||
{"ask_price": "-1"},
|
||||
{"bid_price": "NaN"},
|
||||
{"bid_price": "12", "ask_price": "11"},
|
||||
{"timestamp": 0},
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_values(overrides: dict[str, object]) -> None:
|
||||
with pytest.raises(QuoteValueError):
|
||||
validate_dzengi_websocket_quote_values(_response(**overrides))
|
||||
Reference in New Issue
Block a user