feat: add market data architecture and complete migration through build 039

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit a996f2f797
443 changed files with 80452 additions and 1335 deletions

View File

@@ -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]

View File

@@ -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]

View File

@@ -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)

View File

@@ -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),
)

View File

@@ -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))

View File

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

View File

@@ -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"}
)

View File

@@ -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),
)

View File

@@ -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)

View File

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

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

View File

@@ -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)

View File

@@ -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({})

View 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]

View File

@@ -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]

View 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
)

View 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)

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

View 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

View File

@@ -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([])

View File

@@ -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",
)
)

View 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)

View 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)

View File

@@ -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)

View File

@@ -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))