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,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRateLimit,
|
DzengiRateLimit,
|
||||||
DzengiRawNumeric,
|
DzengiRawNumeric,
|
||||||
|
DzengiRestAggTrade,
|
||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
DzengiTicker24hrResponse,
|
DzengiTicker24hrResponse,
|
||||||
DzengiWebSocketOhlcEvent,
|
DzengiWebSocketOhlcEvent,
|
||||||
@@ -27,11 +28,13 @@ from src.market_data.acquisition.exceptions import (
|
|||||||
CandleWebSocketParseError,
|
CandleWebSocketParseError,
|
||||||
InstrumentReferenceParseError,
|
InstrumentReferenceParseError,
|
||||||
QuoteParseError,
|
QuoteParseError,
|
||||||
|
TradeParseError,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.validation.schema import (
|
from src.market_data.acquisition.validation.schema import (
|
||||||
ValidatedCandlesDocument,
|
ValidatedCandlesDocument,
|
||||||
ValidatedExchangeInfoDocument,
|
ValidatedExchangeInfoDocument,
|
||||||
ValidatedQuoteDocument,
|
ValidatedQuoteDocument,
|
||||||
|
ValidatedRestAggTradesDocument,
|
||||||
ValidatedWebSocketOhlcDocument,
|
ValidatedWebSocketOhlcDocument,
|
||||||
ValidatedWebSocketQuoteDocument,
|
ValidatedWebSocketQuoteDocument,
|
||||||
)
|
)
|
||||||
@@ -966,3 +969,97 @@ def _candle_required_raw_numeric(
|
|||||||
f"получен {type(value).__name__}."
|
f"получен {type(value).__name__}."
|
||||||
)
|
)
|
||||||
return value
|
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
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования проверенного документа в raw-модели сделок.
|
||||||
|
class TradeParseError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка проверки допустимости значений raw-моделей сделок.
|
||||||
|
class TradeValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Ошибка определения типа входящего WebSocket-сообщения
|
# Ошибка определения типа входящего WebSocket-сообщения
|
||||||
# и выбора специализированного адаптера.
|
# и выбора специализированного адаптера.
|
||||||
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
|||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRateLimit,
|
DzengiRateLimit,
|
||||||
DzengiRawNumeric,
|
DzengiRawNumeric,
|
||||||
|
DzengiRestAggTrade,
|
||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
DzengiKlinesResponse,
|
DzengiKlinesResponse,
|
||||||
DzengiTicker24hrResponse,
|
DzengiTicker24hrResponse,
|
||||||
@@ -23,6 +24,7 @@ from src.market_data.acquisition.exceptions import (
|
|||||||
CandleWebSocketValueError,
|
CandleWebSocketValueError,
|
||||||
InstrumentReferenceValueError,
|
InstrumentReferenceValueError,
|
||||||
QuoteValueError,
|
QuoteValueError,
|
||||||
|
TradeValueError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -815,4 +817,91 @@ def _candle_decimal(
|
|||||||
f"{path} должно быть конечным числом."
|
f"{path} должно быть конечным числом."
|
||||||
)
|
)
|
||||||
|
|
||||||
return decimal_value
|
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 (
|
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||||
DzengiLotSizeFilter,
|
DzengiLotSizeFilter,
|
||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
|
DzengiRestAggTrade,
|
||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
parse_exchange_info,
|
parse_exchange_info,
|
||||||
|
parse_rest_agg_trades,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
InstrumentReferenceParseError,
|
InstrumentReferenceParseError,
|
||||||
|
TradeParseError,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.validation.schema import (
|
from src.market_data.acquisition.validation.schema import (
|
||||||
ValidatedExchangeInfoDocument,
|
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]:
|
def _complete_symbol() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"symbol": "ETH/EUR_LEVERAGE",
|
"symbol": "ETH/EUR_LEVERAGE",
|
||||||
@@ -402,4 +417,229 @@ def test_parse_result_uses_immutable_sequences() -> None:
|
|||||||
assert isinstance(response.payload.symbols, tuple)
|
assert isinstance(response.payload.symbols, tuple)
|
||||||
assert isinstance(symbol.order_types, tuple)
|
assert isinstance(symbol.order_types, tuple)
|
||||||
assert isinstance(symbol.filters, tuple)
|
assert isinstance(symbol.filters, tuple)
|
||||||
assert isinstance(symbol.market_modes, 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,
|
DzengiLotSizeFilter,
|
||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRateLimit,
|
DzengiRateLimit,
|
||||||
|
DzengiRestAggTrade,
|
||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
InstrumentReferenceValueError,
|
InstrumentReferenceValueError,
|
||||||
|
TradeValueError,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.validation.values import (
|
from src.market_data.acquisition.validation.values import (
|
||||||
validate_exchange_info_values,
|
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:
|
def test_validate_complete_exchange_info_values() -> None:
|
||||||
response = _valid_response(
|
response = _valid_response(
|
||||||
rate_limits=(
|
rate_limits=(
|
||||||
@@ -478,4 +498,190 @@ def test_reject_whitespace_global_exchange_filter_type() -> None:
|
|||||||
InstrumentReferenceValueError,
|
InstrumentReferenceValueError,
|
||||||
match=r"filterType не должен состоять только из пробелов",
|
match=r"filterType не должен состоять только из пробелов",
|
||||||
):
|
):
|
||||||
validate_exchange_info_values(response)
|
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