Build 060.4-060.5: add REST trade validation pipeline

This commit is contained in:
2026-07-18 19:32:34 +03:00
parent c0f69ee94a
commit a0ae22cb2e
6 changed files with 1039 additions and 3 deletions

View File

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

View File

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

View File

@@ -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,
) )
@@ -816,3 +818,90 @@ def _candle_decimal(
) )
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

View File

@@ -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",
@@ -403,3 +418,228 @@ def test_parse_result_uses_immutable_sequences() -> None:
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)

View File

@@ -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=(
@@ -479,3 +499,189 @@ def test_reject_whitespace_global_exchange_filter_type() -> None:
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)

View 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**.