Build 060.4-060.5: add REST trade validation pipeline
This commit is contained in:
@@ -17,6 +17,7 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiRawNumeric,
|
||||
DzengiRestAggTrade,
|
||||
DzengiUnknownFilter,
|
||||
DzengiTicker24hrResponse,
|
||||
DzengiWebSocketOhlcEvent,
|
||||
@@ -27,11 +28,13 @@ from src.market_data.acquisition.exceptions import (
|
||||
CandleWebSocketParseError,
|
||||
InstrumentReferenceParseError,
|
||||
QuoteParseError,
|
||||
TradeParseError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
ValidatedCandlesDocument,
|
||||
ValidatedExchangeInfoDocument,
|
||||
ValidatedQuoteDocument,
|
||||
ValidatedRestAggTradesDocument,
|
||||
ValidatedWebSocketOhlcDocument,
|
||||
ValidatedWebSocketQuoteDocument,
|
||||
)
|
||||
@@ -966,3 +969,97 @@ def _candle_required_raw_numeric(
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Преобразовать структурно проверенный ответ aggTrades
|
||||
# в транспортные модели адаптера Dzengi.
|
||||
def parse_rest_agg_trades(
|
||||
document: ValidatedRestAggTradesDocument,
|
||||
) -> tuple[DzengiRestAggTrade, ...]:
|
||||
"""
|
||||
Преобразовать элементы проверенного документа aggTrades
|
||||
в transport-модели Dzengi.
|
||||
|
||||
Функция не выполняет schema validation, предметную проверку значений,
|
||||
преобразование числовых значений в Decimal или mapping
|
||||
во внутреннюю модель Trade.
|
||||
"""
|
||||
|
||||
return tuple(
|
||||
_parse_rest_agg_trade_item(
|
||||
item,
|
||||
path=f"$[{index}]",
|
||||
)
|
||||
for index, item in enumerate(document.items)
|
||||
)
|
||||
|
||||
|
||||
def _parse_rest_agg_trade_item(
|
||||
item: Mapping[str, object],
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiRestAggTrade:
|
||||
return DzengiRestAggTrade(
|
||||
aggregate_trade_id=_trade_required_int(
|
||||
item.get("a"),
|
||||
path=f"{path}.a",
|
||||
),
|
||||
price=_trade_required_raw_numeric(
|
||||
item.get("p"),
|
||||
path=f"{path}.p",
|
||||
),
|
||||
quantity=_trade_required_raw_numeric(
|
||||
item.get("q"),
|
||||
path=f"{path}.q",
|
||||
),
|
||||
timestamp=_trade_required_int(
|
||||
item.get("T"),
|
||||
path=f"{path}.T",
|
||||
),
|
||||
buyer_is_maker=_trade_required_bool(
|
||||
item.get("m"),
|
||||
path=f"{path}.m",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _trade_required_int(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise TradeParseError(
|
||||
f"{path} должен быть целым числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _trade_required_raw_numeric(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiRawNumeric:
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
raise TradeParseError(
|
||||
f"{path} должен быть строкой или числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _trade_required_bool(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise TradeParseError(
|
||||
f"{path} должен быть логическим значением, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@@ -124,6 +124,16 @@ class TradeSchemaError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка преобразования проверенного документа в raw-модели сделок.
|
||||
class TradeParseError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка проверки допустимости значений raw-моделей сделок.
|
||||
class TradeValueError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка определения типа входящего WebSocket-сообщения
|
||||
# и выбора специализированного адаптера.
|
||||
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
||||
|
||||
@@ -12,6 +12,7 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiRawNumeric,
|
||||
DzengiRestAggTrade,
|
||||
DzengiUnknownFilter,
|
||||
DzengiKlinesResponse,
|
||||
DzengiTicker24hrResponse,
|
||||
@@ -23,6 +24,7 @@ from src.market_data.acquisition.exceptions import (
|
||||
CandleWebSocketValueError,
|
||||
InstrumentReferenceValueError,
|
||||
QuoteValueError,
|
||||
TradeValueError,
|
||||
)
|
||||
|
||||
|
||||
@@ -816,3 +818,90 @@ def _candle_decimal(
|
||||
)
|
||||
|
||||
return decimal_value
|
||||
|
||||
|
||||
def validate_rest_agg_trade_values(
|
||||
trades: tuple[DzengiRestAggTrade, ...],
|
||||
) -> None:
|
||||
"""
|
||||
Проверить допустимость значений transport-моделей Dzengi aggTrades.
|
||||
|
||||
Функция не изменяет transport-модели, не преобразует raw numeric
|
||||
значения в Decimal и не выполняет mapping во внутреннюю модель Trade.
|
||||
"""
|
||||
|
||||
for index, trade in enumerate(trades):
|
||||
_validate_rest_agg_trade(
|
||||
trade,
|
||||
path=f"$[{index}]",
|
||||
)
|
||||
|
||||
|
||||
def _validate_rest_agg_trade(
|
||||
trade: DzengiRestAggTrade,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
_trade_positive_int(
|
||||
trade.aggregate_trade_id,
|
||||
path=f"{path}.aggregateTradeId",
|
||||
)
|
||||
_trade_positive_decimal(
|
||||
trade.price,
|
||||
path=f"{path}.price",
|
||||
)
|
||||
_trade_positive_decimal(
|
||||
trade.quantity,
|
||||
path=f"{path}.quantity",
|
||||
)
|
||||
_trade_positive_int(
|
||||
trade.timestamp,
|
||||
path=f"{path}.timestamp",
|
||||
)
|
||||
|
||||
|
||||
def _trade_positive_int(
|
||||
value: int,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if isinstance(value, bool) or value <= 0:
|
||||
raise TradeValueError(
|
||||
f"{path} должно быть целым числом больше нуля."
|
||||
)
|
||||
|
||||
|
||||
def _trade_positive_decimal(
|
||||
value: DzengiRawNumeric,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
decimal_value = _trade_decimal(
|
||||
value,
|
||||
path=path,
|
||||
)
|
||||
|
||||
if decimal_value <= 0:
|
||||
raise TradeValueError(
|
||||
f"{path} должно быть больше нуля."
|
||||
)
|
||||
|
||||
|
||||
def _trade_decimal(
|
||||
value: DzengiRawNumeric,
|
||||
*,
|
||||
path: str,
|
||||
) -> Decimal:
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise TradeValueError(
|
||||
f"{path} должно быть корректным числом."
|
||||
) from exc
|
||||
|
||||
if not decimal_value.is_finite():
|
||||
raise TradeValueError(
|
||||
f"{path} должно быть конечным числом."
|
||||
)
|
||||
|
||||
return decimal_value
|
||||
|
||||
@@ -9,16 +9,20 @@ import pytest
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRestAggTrade,
|
||||
DzengiUnknownFilter,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||
parse_exchange_info,
|
||||
parse_rest_agg_trades,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceParseError,
|
||||
TradeParseError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
ValidatedExchangeInfoDocument,
|
||||
ValidatedRestAggTradesDocument,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +41,17 @@ def _validated_document(
|
||||
)
|
||||
|
||||
|
||||
def _validated_rest_agg_trades_document(
|
||||
items: list[dict[str, object]],
|
||||
) -> ValidatedRestAggTradesDocument:
|
||||
return ValidatedRestAggTradesDocument(
|
||||
items=tuple(
|
||||
MappingProxyType(dict(item))
|
||||
for item in items
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _complete_symbol() -> dict[str, object]:
|
||||
return {
|
||||
"symbol": "ETH/EUR_LEVERAGE",
|
||||
@@ -403,3 +418,228 @@ def test_parse_result_uses_immutable_sequences() -> None:
|
||||
assert isinstance(symbol.order_types, tuple)
|
||||
assert isinstance(symbol.filters, tuple)
|
||||
assert isinstance(symbol.market_modes, tuple)
|
||||
|
||||
|
||||
def test_parse_rest_agg_trades() -> None:
|
||||
document = _validated_rest_agg_trades_document(
|
||||
[
|
||||
{
|
||||
"a": 2134857062,
|
||||
"p": "64497.25",
|
||||
"q": "0.005",
|
||||
"T": 1784218066823,
|
||||
"m": False,
|
||||
},
|
||||
{
|
||||
"a": 2134857063,
|
||||
"p": 64498.10,
|
||||
"q": 2,
|
||||
"T": 1784218067000,
|
||||
"m": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
trades = parse_rest_agg_trades(document)
|
||||
|
||||
assert trades == (
|
||||
DzengiRestAggTrade(
|
||||
aggregate_trade_id=2134857062,
|
||||
price="64497.25",
|
||||
quantity="0.005",
|
||||
timestamp=1784218066823,
|
||||
buyer_is_maker=False,
|
||||
),
|
||||
DzengiRestAggTrade(
|
||||
aggregate_trade_id=2134857063,
|
||||
price=64498.10,
|
||||
quantity=2,
|
||||
timestamp=1784218067000,
|
||||
buyer_is_maker=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_empty_rest_agg_trades_document() -> None:
|
||||
document = _validated_rest_agg_trades_document([])
|
||||
|
||||
trades = parse_rest_agg_trades(document)
|
||||
|
||||
assert trades == ()
|
||||
|
||||
|
||||
def test_rest_agg_trade_parser_preserves_raw_numeric_values() -> None:
|
||||
document = _validated_rest_agg_trades_document(
|
||||
[
|
||||
{
|
||||
"a": 1,
|
||||
"p": "1.2300",
|
||||
"q": 5,
|
||||
"T": 1000,
|
||||
"m": False,
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"p": 1.25,
|
||||
"q": "0.0100",
|
||||
"T": 1001,
|
||||
"m": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
trades = parse_rest_agg_trades(document)
|
||||
|
||||
assert trades[0].price == "1.2300"
|
||||
assert trades[0].quantity == 5
|
||||
assert trades[1].price == 1.25
|
||||
assert trades[1].quantity == "0.0100"
|
||||
|
||||
|
||||
def test_rest_agg_trade_parser_returns_immutable_tuple() -> None:
|
||||
document = _validated_rest_agg_trades_document(
|
||||
[
|
||||
{
|
||||
"a": 1,
|
||||
"p": "10",
|
||||
"q": "2",
|
||||
"T": 1000,
|
||||
"m": False,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
trades = parse_rest_agg_trades(document)
|
||||
|
||||
assert isinstance(trades, tuple)
|
||||
assert isinstance(trades[0], DzengiRestAggTrade)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("a", None),
|
||||
("a", "1"),
|
||||
("a", 1.0),
|
||||
("a", False),
|
||||
("T", None),
|
||||
("T", "1000"),
|
||||
("T", 1000.0),
|
||||
("T", True),
|
||||
],
|
||||
)
|
||||
def test_reject_invalid_rest_agg_trade_integer_field(
|
||||
field: str,
|
||||
value: object,
|
||||
) -> None:
|
||||
item: dict[str, object] = {
|
||||
"a": 1,
|
||||
"p": "10",
|
||||
"q": "2",
|
||||
"T": 1000,
|
||||
"m": False,
|
||||
}
|
||||
item[field] = value
|
||||
|
||||
document = _validated_rest_agg_trades_document([item])
|
||||
|
||||
with pytest.raises(
|
||||
TradeParseError,
|
||||
match=rf"\$\[0\]\.{field} должен быть целым числом",
|
||||
):
|
||||
parse_rest_agg_trades(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("p", None),
|
||||
("p", []),
|
||||
("p", {}),
|
||||
("p", False),
|
||||
("q", None),
|
||||
("q", []),
|
||||
("q", {}),
|
||||
("q", True),
|
||||
],
|
||||
)
|
||||
def test_reject_invalid_rest_agg_trade_raw_numeric_field(
|
||||
field: str,
|
||||
value: object,
|
||||
) -> None:
|
||||
item: dict[str, object] = {
|
||||
"a": 1,
|
||||
"p": "10",
|
||||
"q": "2",
|
||||
"T": 1000,
|
||||
"m": False,
|
||||
}
|
||||
item[field] = value
|
||||
|
||||
document = _validated_rest_agg_trades_document([item])
|
||||
|
||||
with pytest.raises(
|
||||
TradeParseError,
|
||||
match=rf"\$\[0\]\.{field} должен быть строкой или числом",
|
||||
):
|
||||
parse_rest_agg_trades(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
None,
|
||||
0,
|
||||
1,
|
||||
"false",
|
||||
[],
|
||||
{},
|
||||
],
|
||||
)
|
||||
def test_reject_invalid_rest_agg_trade_buyer_is_maker(
|
||||
value: object,
|
||||
) -> None:
|
||||
document = _validated_rest_agg_trades_document(
|
||||
[
|
||||
{
|
||||
"a": 1,
|
||||
"p": "10",
|
||||
"q": "2",
|
||||
"T": 1000,
|
||||
"m": value,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeParseError,
|
||||
match=r"\$\[0\]\.m должен быть логическим значением",
|
||||
):
|
||||
parse_rest_agg_trades(document)
|
||||
|
||||
|
||||
def test_rest_agg_trade_parser_reports_item_index() -> None:
|
||||
document = _validated_rest_agg_trades_document(
|
||||
[
|
||||
{
|
||||
"a": 1,
|
||||
"p": "10",
|
||||
"q": "2",
|
||||
"T": 1000,
|
||||
"m": False,
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"p": [],
|
||||
"q": "3",
|
||||
"T": 1001,
|
||||
"m": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeParseError,
|
||||
match=r"\$\[1\]\.p должен быть строкой или числом",
|
||||
):
|
||||
parse_rest_agg_trades(document)
|
||||
|
||||
@@ -13,13 +13,16 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiRestAggTrade,
|
||||
DzengiUnknownFilter,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceValueError,
|
||||
TradeValueError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.values import (
|
||||
validate_exchange_info_values,
|
||||
validate_rest_agg_trade_values,
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +89,23 @@ def _valid_response(
|
||||
)
|
||||
|
||||
|
||||
def _valid_rest_agg_trade(
|
||||
*,
|
||||
aggregate_trade_id: int = 2134857062,
|
||||
price: str | int | float = "64497.25",
|
||||
quantity: str | int | float = "0.005",
|
||||
timestamp: int = 1784218066823,
|
||||
buyer_is_maker: bool = False,
|
||||
) -> DzengiRestAggTrade:
|
||||
return DzengiRestAggTrade(
|
||||
aggregate_trade_id=aggregate_trade_id,
|
||||
price=price,
|
||||
quantity=quantity,
|
||||
timestamp=timestamp,
|
||||
buyer_is_maker=buyer_is_maker,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_complete_exchange_info_values() -> None:
|
||||
response = _valid_response(
|
||||
rate_limits=(
|
||||
@@ -479,3 +499,189 @@ def test_reject_whitespace_global_exchange_filter_type() -> None:
|
||||
match=r"filterType не должен состоять только из пробелов",
|
||||
):
|
||||
validate_exchange_info_values(response)
|
||||
|
||||
|
||||
def test_validate_rest_agg_trade_values() -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(),
|
||||
)
|
||||
|
||||
assert validate_rest_agg_trade_values(trades) is None
|
||||
|
||||
|
||||
def test_validate_multiple_rest_agg_trade_values() -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=1,
|
||||
price="100.2500",
|
||||
quantity="0.0100",
|
||||
timestamp=1000,
|
||||
buyer_is_maker=False,
|
||||
),
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=2,
|
||||
price=101.5,
|
||||
quantity=2,
|
||||
timestamp=1001,
|
||||
buyer_is_maker=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert validate_rest_agg_trade_values(trades) is None
|
||||
|
||||
|
||||
def test_validate_empty_rest_agg_trade_values() -> None:
|
||||
assert validate_rest_agg_trade_values(()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("aggregate_trade_id", "timestamp"),
|
||||
[
|
||||
(0, 1000),
|
||||
(-1, 1000),
|
||||
(1, 0),
|
||||
(1, -1),
|
||||
],
|
||||
)
|
||||
def test_reject_non_positive_rest_agg_trade_integer_value(
|
||||
aggregate_trade_id: int,
|
||||
timestamp: int,
|
||||
) -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=aggregate_trade_id,
|
||||
timestamp=timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match="должно быть целым числом больше нуля",
|
||||
):
|
||||
validate_rest_agg_trade_values(trades)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("price", "0"),
|
||||
("price", 0),
|
||||
("price", 0.0),
|
||||
("price", "-0.01"),
|
||||
("price", -1),
|
||||
("quantity", "0"),
|
||||
("quantity", 0),
|
||||
("quantity", 0.0),
|
||||
("quantity", "-0.01"),
|
||||
("quantity", -1),
|
||||
],
|
||||
)
|
||||
def test_reject_non_positive_rest_agg_trade_numeric_value(
|
||||
field: str,
|
||||
value: str | int | float,
|
||||
) -> None:
|
||||
trade = replace(
|
||||
_valid_rest_agg_trade(),
|
||||
**{field: value},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=rf"\$\[0\]\.{field} должно быть больше нуля",
|
||||
):
|
||||
validate_rest_agg_trade_values((trade,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("price", "not-a-number"),
|
||||
("quantity", "not-a-number"),
|
||||
],
|
||||
)
|
||||
def test_reject_invalid_rest_agg_trade_numeric_string(
|
||||
field: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
trade = replace(
|
||||
_valid_rest_agg_trade(),
|
||||
**{field: value},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=rf"\$\[0\]\.{field} должно быть корректным числом",
|
||||
):
|
||||
validate_rest_agg_trade_values((trade,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("price", float("nan")),
|
||||
("price", float("inf")),
|
||||
("price", float("-inf")),
|
||||
("price", "NaN"),
|
||||
("price", "Infinity"),
|
||||
("price", "-Infinity"),
|
||||
("quantity", float("nan")),
|
||||
("quantity", float("inf")),
|
||||
("quantity", float("-inf")),
|
||||
("quantity", "NaN"),
|
||||
("quantity", "Infinity"),
|
||||
("quantity", "-Infinity"),
|
||||
],
|
||||
)
|
||||
def test_reject_non_finite_rest_agg_trade_numeric_value(
|
||||
field: str,
|
||||
value: str | float,
|
||||
) -> None:
|
||||
trade = replace(
|
||||
_valid_rest_agg_trade(),
|
||||
**{field: value},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=rf"\$\[0\]\.{field} должно быть конечным числом",
|
||||
):
|
||||
validate_rest_agg_trade_values((trade,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"buyer_is_maker",
|
||||
[
|
||||
False,
|
||||
True,
|
||||
],
|
||||
)
|
||||
def test_accept_rest_agg_trade_buyer_is_maker_values(
|
||||
buyer_is_maker: bool,
|
||||
) -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
buyer_is_maker=buyer_is_maker,
|
||||
),
|
||||
)
|
||||
|
||||
assert validate_rest_agg_trade_values(trades) is None
|
||||
|
||||
|
||||
def test_rest_agg_trade_value_error_reports_item_index() -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=1,
|
||||
timestamp=1000,
|
||||
),
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=2,
|
||||
quantity="0",
|
||||
timestamp=1001,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=r"\$\[1\]\.quantity должно быть больше нуля",
|
||||
):
|
||||
validate_rest_agg_trade_values(trades)
|
||||
394
docs/migrations/build_060_4.md
Normal file
394
docs/migrations/build_060_4.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Build 060.4 — REST Trade Parser
|
||||
|
||||
## Статус
|
||||
|
||||
✅ Завершён
|
||||
|
||||
---
|
||||
|
||||
# Цель Build
|
||||
|
||||
После завершения Build 060.3 система уже умела принимать REST-ответ endpoint `/api/v2/aggTrades`, проверять его структуру и преобразовывать в immutable-контракт:
|
||||
|
||||
```text
|
||||
REST JSON
|
||||
↓
|
||||
validate_rest_agg_trades_schema()
|
||||
↓
|
||||
ValidatedRestAggTradesDocument
|
||||
```
|
||||
|
||||
Однако структурно проверенный документ ещё не преобразовывался в transport-модели адаптера Dzengi.
|
||||
|
||||
Цель Build 060.4 — добавить отдельный REST Trade Parser, который:
|
||||
|
||||
- принимает только `ValidatedRestAggTradesDocument`;
|
||||
- проверяет типы полей каждой сделки;
|
||||
- создаёт `DzengiRestAggTrade`;
|
||||
- возвращает immutable-последовательность transport-моделей;
|
||||
- не выполняет value validation и canonical mapping.
|
||||
|
||||
Итоговый участок pipeline после Build 060.4:
|
||||
|
||||
```text
|
||||
REST JSON
|
||||
↓
|
||||
Schema Validation
|
||||
↓
|
||||
ValidatedRestAggTradesDocument
|
||||
↓
|
||||
REST Trade Parser
|
||||
↓
|
||||
tuple[DzengiRestAggTrade, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Причины появления Build
|
||||
|
||||
Schema Validation отвечает только за структуру внешнего документа. После Build 060.3 она гарантирует, что корень документа является JSON-массивом, каждый элемент является JSON-объектом, обязательные поля `a`, `p`, `q`, `T`, `m` присутствуют, ключи объектов являются строками, а документ переведён в immutable-представление.
|
||||
|
||||
Но Schema Validation намеренно не определяет, являются ли значения полей допустимыми транспортными типами. Именно Parser обязан отклонить такой документ и не допустить создание некорректной transport-модели.
|
||||
|
||||
---
|
||||
|
||||
# Архитектурное решение
|
||||
|
||||
В Build реализован отдельный parser-функционал для REST Trades:
|
||||
|
||||
```python
|
||||
parse_rest_agg_trades(
|
||||
document: ValidatedRestAggTradesDocument,
|
||||
) -> tuple[DzengiRestAggTrade, ...]
|
||||
```
|
||||
|
||||
Parser работает со всем REST-документом целиком и не принимает произвольный `dict` или `list`. Его входной контракт уже подтверждает, что документ прошёл Schema Validation.
|
||||
|
||||
```text
|
||||
ValidatedRestAggTradesDocument
|
||||
↓
|
||||
parse_rest_agg_trades()
|
||||
↓
|
||||
tuple[DzengiRestAggTrade, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Граница ответственности Parser
|
||||
|
||||
REST Trade Parser отвечает за:
|
||||
|
||||
- проверку типов полей;
|
||||
- исключение недопустимого использования `bool` как числа;
|
||||
- создание `DzengiRestAggTrade`;
|
||||
- сохранение исходных raw numeric значений;
|
||||
- формирование immutable `tuple`;
|
||||
- диагностический путь с индексом элемента.
|
||||
|
||||
Parser не отвечает за:
|
||||
|
||||
- проверку положительности цены и количества;
|
||||
- проверку диапазона timestamp;
|
||||
- проверку неотрицательности aggregate trade id;
|
||||
- преобразование `str | int | float` в `Decimal`;
|
||||
- построение canonical `Trade`;
|
||||
- определение стороны сделки;
|
||||
- REST transport, feed registry и runtime orchestration.
|
||||
|
||||
---
|
||||
|
||||
# Новое исключение
|
||||
|
||||
В Build добавлено исключение:
|
||||
|
||||
```python
|
||||
class TradeParseError(MarketDataAcquisitionError):
|
||||
pass
|
||||
```
|
||||
|
||||
Оно используется только для ошибок преобразования структурно проверенного REST Trade документа в transport-модели Dzengi.
|
||||
|
||||
Build сознательно не добавляет `TradeValueError`, `TradeMappingError`, `TradeTransportError` и `TradeFeedRegistryError`. Каждое исключение должно появляться только в Build, реализующем соответствующий слой pipeline.
|
||||
|
||||
---
|
||||
|
||||
# Входной контракт
|
||||
|
||||
Parser принимает:
|
||||
|
||||
```python
|
||||
ValidatedRestAggTradesDocument
|
||||
```
|
||||
|
||||
Контракт был добавлен в Build 060.3:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidatedRestAggTradesDocument:
|
||||
items: tuple[Mapping[str, object], ...]
|
||||
```
|
||||
|
||||
Каждый элемент `items` уже является mapping, содержит обязательные поля, имеет строковые ключи и защищён от изменения через `MappingProxyType`. Parser не повторяет Schema Validation.
|
||||
|
||||
---
|
||||
|
||||
# Выходной контракт
|
||||
|
||||
Parser возвращает:
|
||||
|
||||
```python
|
||||
tuple[DzengiRestAggTrade, ...]
|
||||
```
|
||||
|
||||
Каждый элемент является immutable transport-моделью:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiRestAggTrade:
|
||||
aggregate_trade_id: int
|
||||
price: DzengiRawNumeric
|
||||
quantity: DzengiRawNumeric
|
||||
timestamp: int
|
||||
buyer_is_maker: bool
|
||||
```
|
||||
|
||||
После Build 060.4 REST-ответ уже представлен типизированными transport-объектами, но ещё не преобразован во внутреннюю canonical-модель Dzentra.
|
||||
|
||||
---
|
||||
|
||||
# Реализация Parser
|
||||
|
||||
Основная функция:
|
||||
|
||||
```python
|
||||
def parse_rest_agg_trades(
|
||||
document: ValidatedRestAggTradesDocument,
|
||||
) -> tuple[DzengiRestAggTrade, ...]:
|
||||
return tuple(
|
||||
_parse_rest_agg_trade_item(
|
||||
item,
|
||||
path=f"$[{index}]",
|
||||
)
|
||||
for index, item in enumerate(document.items)
|
||||
)
|
||||
```
|
||||
|
||||
Функция проходит по всем элементам документа, сохраняет исходный порядок сделок, передаёт индекс элемента в diagnostic path и создаёт immutable `tuple`.
|
||||
|
||||
Пустой документ преобразуется в `()` и не считается ошибкой.
|
||||
|
||||
---
|
||||
|
||||
# Преобразование отдельной сделки
|
||||
|
||||
Каждый элемент преобразуется функцией `_parse_rest_agg_trade_item()`.
|
||||
|
||||
Соответствие внешних полей transport-модели:
|
||||
|
||||
| REST поле | Transport поле |
|
||||
|---|---|
|
||||
| `a` | `aggregate_trade_id` |
|
||||
| `p` | `price` |
|
||||
| `q` | `quantity` |
|
||||
| `T` | `timestamp` |
|
||||
| `m` | `buyer_is_maker` |
|
||||
|
||||
Parser не изменяет семантику значений.
|
||||
|
||||
---
|
||||
|
||||
# Контракт типов
|
||||
|
||||
Build фиксирует следующий transport type contract:
|
||||
|
||||
- `a` — строго `int`, но не `bool`;
|
||||
- `p` — `str | int | float`, но не `bool`;
|
||||
- `q` — `str | int | float`, но не `bool`;
|
||||
- `T` — строго `int`, но не `bool`;
|
||||
- `m` — строго `bool`.
|
||||
|
||||
Проверка диапазонов и смысловой допустимости значений в этот Build не входит.
|
||||
|
||||
---
|
||||
|
||||
# Почему `bool` исключается из числовых полей
|
||||
|
||||
В Python выражение `isinstance(True, int)` возвращает `True`. Без специальной проверки значения `True` и `False` могли бы быть ошибочно приняты как идентификатор сделки, timestamp, цена или количество.
|
||||
|
||||
Поэтому числовые helper-функции сначала проверяют `isinstance(value, bool)` и отклоняют такое значение до общей проверки числа. Это является частью transport contract, а не value validation.
|
||||
|
||||
---
|
||||
|
||||
# Raw numeric preservation
|
||||
|
||||
Поля `price` и `quantity` используют тип `DzengiRawNumeric`.
|
||||
|
||||
Parser не преобразует значения в `Decimal` и не нормализует их:
|
||||
|
||||
- строка `"1.2300"` остаётся строкой `"1.2300"`;
|
||||
- целое число `5` остаётся `5`;
|
||||
- число `1.25` остаётся `1.25`.
|
||||
|
||||
Это сохраняет исходное представление данных биржи до отдельного этапа Value Validation и Normalization.
|
||||
|
||||
---
|
||||
|
||||
# Diagnostic path
|
||||
|
||||
Parser формирует путь к каждому полю с учётом индекса элемента.
|
||||
|
||||
Например, ошибка цены во второй сделке будет представлена как:
|
||||
|
||||
```text
|
||||
$[1].p должен быть строкой или числом
|
||||
```
|
||||
|
||||
Это позволяет точно определить индекс некорректной сделки и имя поля.
|
||||
|
||||
---
|
||||
|
||||
# Отдельные Trade helper-функции
|
||||
|
||||
В Build добавлены специализированные helper-функции:
|
||||
|
||||
```python
|
||||
_trade_required_int()
|
||||
_trade_required_raw_numeric()
|
||||
_trade_required_bool()
|
||||
```
|
||||
|
||||
Они следуют общей parser-инфраструктуре проекта, но возбуждают именно `TradeParseError`. Переиспользование helper-функций другого feed было бы некорректным, поскольку они используют другие error contracts.
|
||||
|
||||
---
|
||||
|
||||
# Unit-тесты
|
||||
|
||||
В Build добавлен отдельный набор unit-тестов REST Trade Parser.
|
||||
|
||||
Проверяются:
|
||||
|
||||
- корректное преобразование нескольких сделок;
|
||||
- сохранение порядка элементов;
|
||||
- пустой документ;
|
||||
- immutable `tuple`;
|
||||
- создание `DzengiRestAggTrade`;
|
||||
- сохранение raw numeric значений;
|
||||
- отклонение неверных типов `a`, `T`, `p`, `q`, `m`;
|
||||
- отдельное отклонение `bool` в числовых полях;
|
||||
- корректный индекс элемента в сообщении об ошибке.
|
||||
|
||||
---
|
||||
|
||||
# Результаты проверки
|
||||
|
||||
Target tests:
|
||||
|
||||
```text
|
||||
46 passed
|
||||
```
|
||||
|
||||
Полная регрессия проекта:
|
||||
|
||||
```text
|
||||
1038 passed
|
||||
```
|
||||
|
||||
Дополнительно успешно выполнены:
|
||||
|
||||
```bash
|
||||
python -m compileall src
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Файл unit-тестов содержит корректный завершающий перевод строки.
|
||||
|
||||
---
|
||||
|
||||
# Изменённые файлы
|
||||
|
||||
В рамках Build изменены только три файла.
|
||||
|
||||
## `src/market_data/acquisition/exceptions.py`
|
||||
|
||||
Добавлено `TradeParseError`.
|
||||
|
||||
## `src/market_data/acquisition/adapters/dzengi/parser.py`
|
||||
|
||||
Добавлены:
|
||||
|
||||
- `parse_rest_agg_trades()`;
|
||||
- `_parse_rest_agg_trade_item()`;
|
||||
- `_trade_required_int()`;
|
||||
- `_trade_required_raw_numeric()`;
|
||||
- `_trade_required_bool()`;
|
||||
- необходимые импорты.
|
||||
|
||||
## `tests/unit/market_data/acquisition/adapters/dzengi/test_parser.py`
|
||||
|
||||
Добавлены helper создания validated-документа и полный набор позитивных и негативных parser-тестов.
|
||||
|
||||
Другие части проекта не изменялись.
|
||||
|
||||
---
|
||||
|
||||
# Что не изменялось
|
||||
|
||||
Build не затрагивает:
|
||||
|
||||
```text
|
||||
src/market_data/acquisition/adapters/dzengi/models.py
|
||||
src/market_data/acquisition/adapters/dzengi/mapper.py
|
||||
src/market_data/acquisition/validation/schema.py
|
||||
src/market_data/acquisition/validation/values.py
|
||||
src/market_data/acquisition/feeds/
|
||||
src/market_data/acquisition/runtime/
|
||||
```
|
||||
|
||||
Также не изменялись существующие parser-функции для ExchangeInfo, Quotes, Candles, WebSocket Quote и WebSocket OHLC.
|
||||
|
||||
---
|
||||
|
||||
# Архитектурный результат
|
||||
|
||||
После Build 060.4 REST Trades pipeline выглядит так:
|
||||
|
||||
```text
|
||||
REST JSON
|
||||
│
|
||||
▼
|
||||
validate_rest_agg_trades_schema()
|
||||
│
|
||||
▼
|
||||
ValidatedRestAggTradesDocument
|
||||
│
|
||||
▼
|
||||
parse_rest_agg_trades()
|
||||
│
|
||||
▼
|
||||
tuple[DzengiRestAggTrade, ...]
|
||||
```
|
||||
|
||||
Теперь внешний REST-документ структурно проверен, типизирован, преобразован в transport-модели, защищён от изменения и готов к Value Validation.
|
||||
|
||||
---
|
||||
|
||||
# Итог
|
||||
|
||||
Build 060.4 завершает parser-этап REST Trades Acquisition Pipeline.
|
||||
|
||||
Система получила строго типизированное преобразование:
|
||||
|
||||
```text
|
||||
ValidatedRestAggTradesDocument
|
||||
↓
|
||||
DzengiRestAggTrade
|
||||
```
|
||||
|
||||
При этом сохранены архитектурные границы:
|
||||
|
||||
- Schema Validation проверяет структуру;
|
||||
- Parser проверяет transport-типы;
|
||||
- Value Validation будет проверять допустимость значений;
|
||||
- Mapper будет строить canonical `Trade`.
|
||||
|
||||
Следующим этапом серии должен стать **Build 060.5 — REST Trade Value Validation**.
|
||||
Reference in New Issue
Block a user