build 044: establish canonical candles feed foundation

This commit is contained in:
2026-07-15 10:26:55 +03:00
parent a6325cf4cf
commit 47c75d68b1
22 changed files with 2937 additions and 316 deletions

View File

@@ -9,6 +9,7 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiExchangeInfoResponse,
DzengiExchangeInfoSymbol,
DzengiInstrumentFilter,
DzengiKlinesResponse,
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiRawNumeric,
@@ -16,9 +17,11 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiWebSocketQuoteResponse,
)
from src.market_data.acquisition.exceptions import (
CandleMappingError,
InstrumentReferenceMappingError,
QuoteMappingError,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.models.quote import Quote
@@ -314,3 +317,91 @@ def map_dzengi_websocket_quote_to_quote(
received_at=normalized_received_at,
source=_DZENGI_SOURCE_NAME,
)
def map_dzengi_klines_to_candles(
response: DzengiKlinesResponse,
*,
symbol: str,
interval: str,
source: str,
) -> tuple[Candle, ...]:
"""
Преобразовать проверенные raw-модели Dzengi klines
в канонический immutable-набор Candle.
Функция предполагает, что до mapper уже были выполнены:
schema validation, parsing и value validation.
"""
candles = tuple(
Candle(
symbol=symbol.strip(),
interval=interval.strip(),
open_time=_candle_timestamp_ms_to_utc_datetime(
item.open_time,
),
open_price=_required_candle_decimal(
item.open_price,
field_name="open",
),
high_price=_required_candle_decimal(
item.high_price,
field_name="high",
),
low_price=_required_candle_decimal(
item.low_price,
field_name="low",
),
close_price=_required_candle_decimal(
item.close_price,
field_name="close",
),
volume=_required_candle_decimal(
item.volume,
field_name="volume",
),
source=source.strip(),
)
for item in response.items
)
return tuple(
sorted(
candles,
key=lambda candle: candle.open_time,
)
)
def _candle_timestamp_ms_to_utc_datetime(value: int) -> datetime:
try:
return datetime.fromtimestamp(
value / 1000,
tz=timezone.utc,
)
except (OverflowError, OSError, ValueError) as exc:
raise CandleMappingError(
"Поле openTime свечи невозможно преобразовать "
"в UTC datetime."
) from exc
def _required_candle_decimal(
value: DzengiRawNumeric,
*,
field_name: str,
) -> Decimal:
try:
result = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise CandleMappingError(
f"Поле {field_name} свечи невозможно "
"преобразовать в Decimal."
) from exc
if not result.is_finite():
raise CandleMappingError(
f"Поле {field_name} свечи должно быть конечным числом."
)
return result

View File

@@ -131,3 +131,20 @@ class DzengiWebSocketQuoteResponse:
bid_price: DzengiRawNumeric
ask_price: DzengiRawNumeric
timestamp: int | None
# Транспортное представление одной свечи Dzengi GET /api/v1/klines.
@dataclass(frozen=True, slots=True)
class DzengiKline:
open_time: int
open_price: str | int | float
high_price: str | int | float
low_price: str | int | float
close_price: str | int | float
volume: str | int | float
# Транспортное представление ответа Dzengi GET /api/v1/klines.
@dataclass(frozen=True, slots=True)
class DzengiKlinesResponse:
items: tuple[DzengiKline, ...]

View File

@@ -11,6 +11,8 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiInstrumentFilter,
DzengiJsonNumber,
DzengiJsonScalar,
DzengiKline,
DzengiKlinesResponse,
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiRateLimit,
@@ -20,10 +22,12 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiWebSocketQuoteResponse,
)
from src.market_data.acquisition.exceptions import (
CandleParseError,
InstrumentReferenceParseError,
QuoteParseError,
)
from src.market_data.acquisition.validation.schema import (
ValidatedCandlesDocument,
ValidatedExchangeInfoDocument,
ValidatedQuoteDocument,
ValidatedWebSocketQuoteDocument,
@@ -714,3 +718,153 @@ def _websocket_optional_timestamp(
return None
return _quote_required_int(value, path=path)
# Преобразовать структурно проверенный ответ klines в raw-модели Dzengi.
def parse_candles(
document: ValidatedCandlesDocument,
) -> DzengiKlinesResponse:
"""
Преобразовать элементы проверенного документа klines в transport-модели.
Функция не выполняет предметную проверку числовых значений,
OHLC-инвариантов или mapping во внутреннюю модель Candle.
"""
items = tuple(
_parse_candle_item(
item,
path=f"$.candles[{index}]",
)
for index, item in enumerate(document.items)
)
return DzengiKlinesResponse(items=items)
def _parse_candle_item(
item: object,
*,
path: str,
) -> DzengiKline:
if isinstance(item, Mapping):
return _parse_candle_mapping(item, path=path)
if isinstance(item, tuple):
return _parse_candle_sequence(item, path=path)
raise CandleParseError(
f"{path} должен быть проверенным JSON-объектом или массивом."
)
def _parse_candle_mapping(
item: Mapping[str, object],
*,
path: str,
) -> DzengiKline:
open_time_key = _first_present_key(
item,
keys=("openTime", "open_time", "time", "timestamp"),
)
if open_time_key is None:
raise CandleParseError(
f"{path} не содержит openTime, open_time, time или timestamp."
)
return DzengiKline(
open_time=_candle_required_int(
item.get(open_time_key),
path=f"{path}.{open_time_key}",
),
open_price=_candle_required_raw_numeric(
_required_candle_field(item, key="open", path=path),
path=f"{path}.open",
),
high_price=_candle_required_raw_numeric(
_required_candle_field(item, key="high", path=path),
path=f"{path}.high",
),
low_price=_candle_required_raw_numeric(
_required_candle_field(item, key="low", path=path),
path=f"{path}.low",
),
close_price=_candle_required_raw_numeric(
_required_candle_field(item, key="close", path=path),
path=f"{path}.close",
),
volume=_candle_required_raw_numeric(
_required_candle_field(item, key="volume", path=path),
path=f"{path}.volume",
),
)
def _parse_candle_sequence(
item: tuple[object, ...],
*,
path: str,
) -> DzengiKline:
if len(item) < 6:
raise CandleParseError(
f"{path} должен содержать минимум 6 элементов."
)
return DzengiKline(
open_time=_candle_required_int(item[0], path=f"{path}[0]"),
open_price=_candle_required_raw_numeric(item[1], path=f"{path}[1]"),
high_price=_candle_required_raw_numeric(item[2], path=f"{path}[2]"),
low_price=_candle_required_raw_numeric(item[3], path=f"{path}[3]"),
close_price=_candle_required_raw_numeric(item[4], path=f"{path}[4]"),
volume=_candle_required_raw_numeric(item[5], path=f"{path}[5]"),
)
def _first_present_key(
mapping: Mapping[str, object],
*,
keys: tuple[str, ...],
) -> str | None:
for key in keys:
if key in mapping:
return key
return None
def _required_candle_field(
mapping: Mapping[str, object],
*,
key: str,
path: str,
) -> object:
if key not in mapping:
raise CandleParseError(
f"{path}.{key} отсутствует в элементе klines."
)
return mapping.get(key)
def _candle_required_int(
value: object,
*,
path: str,
) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise CandleParseError(
f"{path} должен быть целым числом, "
f"получен {type(value).__name__}."
)
return value
def _candle_required_raw_numeric(
value: object,
*,
path: str,
) -> DzengiRawNumeric:
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
raise CandleParseError(
f"{path} должен быть строкой или числом, "
f"получен {type(value).__name__}."
)
return value

View File

@@ -6,6 +6,7 @@ from typing import Protocol
from src.integrations.exchange.rest_client import ExchangeRestClient
from src.market_data.acquisition.exceptions import (
CandleTransportError,
InstrumentReferenceTransportError,
QuoteTransportError,
)
@@ -13,6 +14,7 @@ from src.market_data.acquisition.exceptions import (
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
_KLINES_PATH = "/api/v1/klines"
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
@@ -104,3 +106,52 @@ class DzengiQuoteDocumentSource:
"Не удалось получить текущую котировку "
f"от Dzengi для символа '{symbol}': {exc}"
) from exc
class DzengiCandlesDocumentSource:
"""Источник сырого документа свечей через Dzengi REST API."""
def __init__(
self,
client: _PayloadRestClient | None = None,
) -> None:
self._client = client
def fetch_candles_document(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> object:
"""
Получить декодированный ответ Dzengi klines без его обработки.
Метод не выполняет нормализацию параметров запроса, schema validation,
parsing, value validation, mapping, retry, сортировку или кэширование.
"""
try:
client: _PayloadRestClient = (
self._client
if self._client is not None
else ExchangeRestClient()
)
return client.get_payload(
_KLINES_PATH,
params={
"symbol": symbol,
"interval": interval,
"limit": str(limit),
"priceType": price_type,
},
)
except Exception as exc:
raise CandleTransportError(
"Не удалось получить свечи "
f"от Dzengi для символа '{symbol}', "
f"интервала '{interval}' и типа цены '{price_type}': {exc}"
) from exc

View File

@@ -37,6 +37,7 @@ class InstrumentReferenceMappingError(MarketDataAcquisitionError):
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
pass
# Ошибка получения Quotes Feed от внешнего источника.
class QuoteTransportError(MarketDataAcquisitionError):
pass
@@ -65,3 +66,28 @@ class QuoteMappingError(MarketDataAcquisitionError):
# Ошибка регистрации или получения Quotes Feed.
class QuoteFeedRegistryError(MarketDataAcquisitionError):
pass
# Ошибка получения Candles Feed от внешнего источника.
class CandleTransportError(MarketDataAcquisitionError):
pass
# Ошибка структуры документа Candles Feed.
class CandleSchemaError(MarketDataAcquisitionError):
pass
# Ошибка преобразования проверенного документа в raw-модели свечей.
class CandleParseError(MarketDataAcquisitionError):
pass
# Ошибка допустимости значений Candles Feed.
class CandleValueError(MarketDataAcquisitionError):
pass
# Ошибка преобразования raw-модели источника во внутреннюю модель Candle.
class CandleMappingError(MarketDataAcquisitionError):
pass

View File

@@ -0,0 +1,53 @@
# app/src/market_data/acquisition/feeds/candles_feed.py
from __future__ import annotations
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.protocol import (
CandlesDocumentHandler,
CandlesDocumentSource,
)
class CandlesFeed:
"""
Read-only Feed канонических рыночных свечей.
Feed координирует получение и обработку документа, но не выполняет
transport parsing, value validation, mapping или кэширование.
"""
def __init__(
self,
*,
source: CandlesDocumentSource,
handler: CandlesDocumentHandler,
) -> None:
self._source = source
self._handler = handler
def load_candles(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
"""
Получить канонический immutable-набор свечей инструмента.
"""
document = self._source.fetch_candles_document(
symbol,
interval=interval,
limit=limit,
price_type=price_type,
)
return self._handler.handle_candles_document(
document,
symbol=symbol,
interval=interval,
source=f"rest_klines:{price_type}",
)

View File

@@ -0,0 +1,54 @@
# app/src/market_data/acquisition/handlers/candles_handler.py
from __future__ import annotations
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_klines_to_candles,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_candles,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.validation.schema import (
validate_candles_schema,
)
from src.market_data.acquisition.validation.values import (
validate_candles_values,
)
# Обработчик документа Candles Feed формата Dzengi /api/v1/klines.
class DzengiCandlesDocumentHandler:
def handle_candles_document(
self,
document: object,
*,
symbol: str,
interval: str,
source: str,
) -> tuple[Candle, ...]:
"""
Преобразовать сырой документ Dzengi в канонические модели Candle.
Порядок обработки:
schema validation
parsing
value validation
mapping
"""
validated_document = validate_candles_schema(document)
response = parse_candles(validated_document)
validate_candles_values(response)
return map_dzengi_klines_to_candles(
response,
symbol=symbol,
interval=interval,
source=source,
)

View File

@@ -0,0 +1,24 @@
# app/src/market_data/acquisition/models/candle.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
# Каноническая неизменяемая модель одной рыночной свечи OHLCV.
@dataclass(frozen=True, slots=True)
class Candle:
symbol: str
interval: str
open_time: datetime
open_price: Decimal
high_price: Decimal
low_price: Decimal
close_price: Decimal
volume: Decimal
source: str

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.models.quote import Quote
@@ -83,4 +84,58 @@ class QuoteFeedProtocol(Protocol):
"""
Получить внутреннюю модель текущей котировки инструмента.
"""
...
# Источник сырого документа Candles Feed.
@runtime_checkable
class CandlesDocumentSource(Protocol):
def fetch_candles_document(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> object:
"""
Получить декодированный транспортный документ рыночных свечей.
Источник не выполняет schema validation, parsing, value validation
или mapping во внутреннюю модель Candle.
"""
...
# Обработчик сырого документа Candles Feed.
@runtime_checkable
class CandlesDocumentHandler(Protocol):
def handle_candles_document(
self,
document: object,
*,
symbol: str,
interval: str,
source: str,
) -> tuple[Candle, ...]:
"""
Преобразовать сырой документ в проверенный immutable-набор Candle.
"""
...
# Источник готового набора свечей.
@runtime_checkable
class CandlesFeedProtocol(Protocol):
def load_candles(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
"""
Получить immutable-набор внутренних моделей Candle.
"""
...

View File

@@ -7,6 +7,7 @@ from types import MappingProxyType
from typing import Mapping
from src.market_data.acquisition.exceptions import (
CandleSchemaError,
InstrumentReferenceSchemaError,
QuoteSchemaError,
)
@@ -199,6 +200,7 @@ def _require_list(
return value
# Структурно проверенное представление ответа ticker/24hr.
@dataclass(frozen=True, slots=True)
class ValidatedQuoteDocument:
@@ -398,3 +400,195 @@ def _validate_websocket_quote_payload(
raise QuoteSchemaError(
"WebSocket quote не содержит bid/ask либо bids/asks."
)
# Структурно проверенное представление ответа Dzengi /api/v1/klines.
@dataclass(frozen=True, slots=True)
class ValidatedCandlesDocument:
items: tuple[object, ...]
def validate_candles_schema(
document: object,
) -> ValidatedCandlesDocument:
"""
Проверить структуру ответа Dzengi klines без проверки значений.
Поддерживаются:
1. Корневой JSON-массив:
[
[...],
{...}
]
2. Корневой JSON-объект с массивом в одном из полей:
{
"klines": [...]
}
{
"candles": [...]
}
{
"data": [...]
}
{
"result": [...]
}
3. Wrapped-формат:
{
"payload": [...]
}
{
"payload": {
"klines": [...]
}
}
Внутри payload также поддерживаются поля candles и data.
"""
raw_items = _extract_candle_items(document)
validated_items: list[object] = []
for index, item in enumerate(raw_items):
path = f"$.candles[{index}]"
validated_items.append(
_validate_and_freeze_candle_item(
item,
path=path,
)
)
return ValidatedCandlesDocument(
items=tuple(validated_items),
)
def _extract_candle_items(
document: object,
) -> list[object]:
if isinstance(document, list):
return document
root = _require_candle_mapping(
document,
path="$",
)
direct_items = _find_candle_list(
root,
keys=("klines", "candles", "data", "result"),
path="$",
)
if direct_items is not None:
return direct_items
if "payload" not in root:
raise CandleSchemaError(
"$ не содержит поддерживаемый массив klines."
)
payload = root.get("payload")
if isinstance(payload, list):
return payload
payload_mapping = _require_candle_mapping(
payload,
path="$.payload",
)
wrapped_items = _find_candle_list(
payload_mapping,
keys=("klines", "candles", "data"),
path="$.payload",
)
if wrapped_items is not None:
return wrapped_items
raise CandleSchemaError(
"$.payload не содержит поддерживаемый массив klines."
)
def _find_candle_list(
mapping: Mapping[str, object],
*,
keys: tuple[str, ...],
path: str,
) -> list[object] | None:
for key in keys:
if key not in mapping:
continue
value = mapping.get(key)
if not isinstance(value, list):
raise CandleSchemaError(
f"{path}.{key} должен быть JSON-массивом, "
f"получен {type(value).__name__}."
)
return value
return None
def _validate_and_freeze_candle_item(
item: object,
*,
path: str,
) -> object:
if isinstance(item, dict):
mapping = _require_candle_mapping(
item,
path=path,
)
return MappingProxyType(dict(mapping))
if isinstance(item, list):
if len(item) < 6:
raise CandleSchemaError(
f"{path} должен содержать минимум 6 элементов, "
f"получено {len(item)}."
)
return tuple(item)
raise CandleSchemaError(
f"{path} должен быть JSON-объектом или JSON-массивом, "
f"получен {type(item).__name__}."
)
def _require_candle_mapping(
value: object,
*,
path: str,
) -> Mapping[str, object]:
if not isinstance(value, dict):
raise CandleSchemaError(
f"{path} должен быть JSON-объектом, "
f"получен {type(value).__name__}."
)
for key in value:
if not isinstance(key, str):
raise CandleSchemaError(
f"{path} содержит нестроковый ключ "
f"типа {type(key).__name__}."
)
return value

View File

@@ -13,10 +13,12 @@ from src.market_data.acquisition.adapters.dzengi.models import (
DzengiRateLimit,
DzengiRawNumeric,
DzengiUnknownFilter,
DzengiKlinesResponse,
DzengiTicker24hrResponse,
DzengiWebSocketQuoteResponse,
)
from src.market_data.acquisition.exceptions import (
CandleValueError,
InstrumentReferenceValueError,
QuoteValueError,
)
@@ -454,6 +456,7 @@ def _to_finite_decimal(
return decimal_value
def validate_quote_values(
response: DzengiTicker24hrResponse,
) -> None:
@@ -549,3 +552,128 @@ def validate_dzengi_websocket_quote_values(
raise QuoteValueError(
"$.payload.timestamp должно быть больше нуля."
)
def validate_candles_values(
response: DzengiKlinesResponse,
) -> None:
"""
Проверить допустимость значений raw-моделей Dzengi klines.
Функция не изменяет transport-модели, не выполняет mapping в Candle
и не проверяет последовательность временных меток между свечами.
"""
for index, candle in enumerate(response.items):
path = f"$.candles[{index}]"
if isinstance(candle.open_time, bool) or candle.open_time <= 0:
raise CandleValueError(
f"{path}.openTime должно быть целым числом больше нуля."
)
open_price = _candle_positive_decimal(
candle.open_price,
path=f"{path}.open",
)
high_price = _candle_positive_decimal(
candle.high_price,
path=f"{path}.high",
)
low_price = _candle_positive_decimal(
candle.low_price,
path=f"{path}.low",
)
close_price = _candle_positive_decimal(
candle.close_price,
path=f"{path}.close",
)
_candle_non_negative_decimal(
candle.volume,
path=f"{path}.volume",
)
if high_price < low_price:
raise CandleValueError(
f"{path}.high не должно быть меньше {path}.low."
)
if high_price < open_price:
raise CandleValueError(
f"{path}.high не должно быть меньше {path}.open."
)
if high_price < close_price:
raise CandleValueError(
f"{path}.high не должно быть меньше {path}.close."
)
if low_price > open_price:
raise CandleValueError(
f"{path}.low не должно превышать {path}.open."
)
if low_price > close_price:
raise CandleValueError(
f"{path}.low не должно превышать {path}.close."
)
def _candle_positive_decimal(
value: object,
*,
path: str,
) -> Decimal:
decimal_value = _candle_decimal(
value,
path=path,
)
if decimal_value <= 0:
raise CandleValueError(
f"{path} должно быть больше нуля."
)
return decimal_value
def _candle_non_negative_decimal(
value: object,
*,
path: str,
) -> Decimal:
decimal_value = _candle_decimal(
value,
path=path,
)
if decimal_value < 0:
raise CandleValueError(
f"{path} должно быть больше или равно нулю."
)
return decimal_value
def _candle_decimal(
value: object,
*,
path: str,
) -> Decimal:
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
raise CandleValueError(
f"{path} должно быть числом или числовой строкой."
)
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise CandleValueError(
f"{path} должно быть корректным числом."
) from exc
if not decimal_value.is_finite():
raise CandleValueError(
f"{path} должно быть конечным числом."
)
return decimal_value

View File

@@ -0,0 +1,80 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_candle_mapper.py
from __future__ import annotations
from datetime import timezone
from decimal import Decimal
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_klines_to_candles,
)
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiKline,
DzengiKlinesResponse,
)
def _item(open_time: int) -> DzengiKline:
return DzengiKline(
open_time=open_time,
open_price="100.10",
high_price="110.20",
low_price="90.30",
close_price="105.40",
volume="12.50",
)
def test_map_dzengi_klines_to_candles_maps_canonical_values() -> None:
response = DzengiKlinesResponse(items=(_item(1000),))
result = map_dzengi_klines_to_candles(
response,
symbol="BTC/USD",
interval="1m",
source="rest_klines:bid",
)
assert len(result) == 1
candle = result[0]
assert candle.symbol == "BTC/USD"
assert candle.interval == "1m"
assert candle.open_time.tzinfo is timezone.utc
assert candle.open_time.timestamp() == 1
assert candle.open_price == Decimal("100.10")
assert candle.high_price == Decimal("110.20")
assert candle.low_price == Decimal("90.30")
assert candle.close_price == Decimal("105.40")
assert candle.volume == Decimal("12.50")
assert candle.source == "rest_klines:bid"
def test_map_dzengi_klines_to_candles_sorts_by_open_time() -> None:
response = DzengiKlinesResponse(
items=(
_item(3000),
_item(1000),
_item(2000),
)
)
result = map_dzengi_klines_to_candles(
response,
symbol="BTC/USD",
interval="1m",
source="rest_klines:bid",
)
assert [item.open_time.timestamp() for item in result] == [1, 2, 3]
def test_map_dzengi_klines_to_candles_returns_tuple() -> None:
result = map_dzengi_klines_to_candles(
DzengiKlinesResponse(items=()),
symbol="BTC/USD",
interval="1m",
source="rest_klines:bid",
)
assert result == ()
assert isinstance(result, tuple)

View File

@@ -0,0 +1,135 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_candle_parser.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.parser import parse_candles
from src.market_data.acquisition.exceptions import CandleParseError
from src.market_data.acquisition.validation.schema import (
validate_candles_schema,
)
def test_parse_candles_parses_object_item() -> None:
document = validate_candles_schema(
[
{
"openTime": 1000,
"open": "100",
"high": "110",
"low": "90",
"close": "105",
"volume": "12.5",
}
]
)
result = parse_candles(document)
assert len(result.items) == 1
item = result.items[0]
assert item.open_time == 1000
assert item.open_price == "100"
assert item.high_price == "110"
assert item.low_price == "90"
assert item.close_price == "105"
assert item.volume == "12.5"
@pytest.mark.parametrize(
"time_key",
["openTime", "open_time", "time", "timestamp"],
)
def test_parse_candles_supports_time_aliases(time_key: str) -> None:
document = validate_candles_schema(
[
{
time_key: 1000,
"open": "100",
"high": "110",
"low": "90",
"close": "105",
"volume": "12.5",
}
]
)
result = parse_candles(document)
assert result.items[0].open_time == 1000
def test_parse_candles_parses_array_item() -> None:
document = validate_candles_schema(
[[1000, "100", "110", "90", "105", "12.5", "ignored"]]
)
result = parse_candles(document)
item = result.items[0]
assert item.open_time == 1000
assert item.open_price == "100"
assert item.high_price == "110"
assert item.low_price == "90"
assert item.close_price == "105"
assert item.volume == "12.5"
def test_parse_candles_accepts_empty_document() -> None:
result = parse_candles(validate_candles_schema([]))
assert result.items == ()
def test_parse_candles_rejects_missing_required_field() -> None:
document = validate_candles_schema(
[
{
"openTime": 1000,
"open": "100",
"high": "110",
"low": "90",
"volume": "12.5",
}
]
)
with pytest.raises(CandleParseError, match="close"):
parse_candles(document)
@pytest.mark.parametrize(
"invalid_time",
[True, 1.5, "1000", None],
)
def test_parse_candles_rejects_invalid_open_time(
invalid_time: object,
) -> None:
document = validate_candles_schema(
[[invalid_time, "100", "110", "90", "105", "12.5"]]
)
with pytest.raises(
CandleParseError,
match=r"\$\.candles\[0\]\[0\]",
):
parse_candles(document)
@pytest.mark.parametrize(
"invalid_value",
[True, None, {}, []],
)
def test_parse_candles_rejects_invalid_numeric_transport_value(
invalid_value: object,
) -> None:
document = validate_candles_schema(
[[1000, invalid_value, "110", "90", "105", "12.5"]]
)
with pytest.raises(
CandleParseError,
match=r"\$\.candles\[0\]\[1\]",
):
parse_candles(document)

View File

@@ -0,0 +1,100 @@
# app/tests/unit/market_data/acquisition/adapters/dzengi/test_candle_rest.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiCandlesDocumentSource,
)
from src.market_data.acquisition.exceptions import CandleTransportError
class StubPayloadRestClient:
def __init__(
self,
*,
result: object = None,
error: Exception | None = None,
) -> None:
self.result = result
self.error = error
self.calls: list[
tuple[str, dict[str, str] | None, dict[str, str] | None]
] = []
def get_payload(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> object:
self.calls.append((path, params, headers))
if self.error is not None:
raise self.error
return self.result
def test_fetch_candles_document_calls_klines_endpoint() -> None:
document = {"klines": []}
client = StubPayloadRestClient(result=document)
source = DzengiCandlesDocumentSource(client=client)
result = source.fetch_candles_document(
"BTC/USD",
interval="5m",
limit=200,
price_type="ask",
)
assert result is document
assert client.calls == [
(
"/api/v1/klines",
{
"symbol": "BTC/USD",
"interval": "5m",
"limit": "200",
"priceType": "ask",
},
None,
)
]
def test_fetch_candles_document_does_not_transform_document() -> None:
document = [
[1000, "100", "110", "90", "105", "12.5"],
]
client = StubPayloadRestClient(result=document)
source = DzengiCandlesDocumentSource(client=client)
result = source.fetch_candles_document(
"BTC/USD",
interval="1m",
limit=1,
price_type="bid",
)
assert result is document
def test_fetch_candles_document_wraps_transport_error() -> None:
original_error = RuntimeError("transport unavailable")
client = StubPayloadRestClient(error=original_error)
source = DzengiCandlesDocumentSource(client=client)
with pytest.raises(
CandleTransportError,
match="Не удалось получить свечи",
) as error_info:
source.fetch_candles_document(
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert error_info.value.__cause__ is original_error

View File

@@ -0,0 +1,164 @@
# app/tests/unit/market_data/acquisition/feeds/test_candles_feed.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.feeds.candles_feed import CandlesFeed
from src.market_data.acquisition.models.candle import Candle
def _candle() -> Candle:
return Candle(
symbol="BTC/USD",
interval="1m",
open_time=datetime(2026, 7, 15, 6, 0, tzinfo=timezone.utc),
open_price=Decimal("100"),
high_price=Decimal("110"),
low_price=Decimal("90"),
close_price=Decimal("105"),
volume=Decimal("12"),
source="rest_klines:bid",
)
class StubCandlesDocumentSource:
def __init__(
self,
*,
document: object,
error: Exception | None = None,
) -> None:
self.document = document
self.error = error
self.calls: list[tuple[str, str, int, str]] = []
def fetch_candles_document(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> object:
self.calls.append((symbol, interval, limit, price_type))
if self.error is not None:
raise self.error
return self.document
class StubCandlesDocumentHandler:
def __init__(
self,
*,
candles: tuple[Candle, ...],
error: Exception | None = None,
) -> None:
self.candles = candles
self.error = error
self.calls: list[tuple[object, str, str, str]] = []
def handle_candles_document(
self,
document: object,
*,
symbol: str,
interval: str,
source: str,
) -> tuple[Candle, ...]:
self.calls.append((document, symbol, interval, source))
if self.error is not None:
raise self.error
return self.candles
def test_load_candles_coordinates_source_and_handler() -> None:
document = {"klines": []}
candles = (_candle(),)
source = StubCandlesDocumentSource(document=document)
handler = StubCandlesDocumentHandler(candles=candles)
feed = CandlesFeed(source=source, handler=handler)
result = feed.load_candles(
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert result is candles
assert source.calls == [("BTC/USD", "1m", 100, "bid")]
assert handler.calls == [
(
document,
"BTC/USD",
"1m",
"rest_klines:bid",
)
]
def test_load_candles_does_not_trim_handler_result() -> None:
candles = (_candle(), _candle())
source = StubCandlesDocumentSource(document=[])
handler = StubCandlesDocumentHandler(candles=candles)
feed = CandlesFeed(source=source, handler=handler)
result = feed.load_candles(
"BTC/USD",
interval="1m",
limit=1,
price_type="ask",
)
assert result is candles
assert len(result) == 2
def test_load_candles_propagates_source_error() -> None:
original_error = RuntimeError("source failed")
source = StubCandlesDocumentSource(
document=None,
error=original_error,
)
handler = StubCandlesDocumentHandler(candles=())
feed = CandlesFeed(source=source, handler=handler)
with pytest.raises(RuntimeError, match="source failed") as error_info:
feed.load_candles(
"BTC/USD",
interval="1m",
limit=100,
price_type="bid",
)
assert error_info.value is original_error
assert handler.calls == []
def test_load_candles_propagates_handler_error() -> None:
original_error = ValueError("handler failed")
document = {"klines": []}
source = StubCandlesDocumentSource(document=document)
handler = StubCandlesDocumentHandler(
candles=(),
error=original_error,
)
feed = CandlesFeed(source=source, handler=handler)
with pytest.raises(ValueError, match="handler failed") as error_info:
feed.load_candles(
"BTC/USD",
interval="5m",
limit=50,
price_type="ask",
)
assert error_info.value is original_error

View File

@@ -0,0 +1,31 @@
# app/tests/unit/market_data/acquisition/handlers/test_candles_handler.py
from __future__ import annotations
from src.market_data.acquisition.handlers.candles_handler import (
DzengiCandlesDocumentHandler,
)
def test_handle_candles_document_runs_complete_pipeline() -> None:
handler = DzengiCandlesDocumentHandler()
result = handler.handle_candles_document(
{
"payload": {
"klines": [
[2000, "101", "111", "91", "106", "13"],
[1000, "100", "110", "90", "105", "12"],
]
}
},
symbol="BTC/USD",
interval="1m",
source="rest_klines:bid",
)
assert len(result) == 2
assert [item.open_time.timestamp() for item in result] == [1, 2]
assert all(item.symbol == "BTC/USD" for item in result)
assert all(item.interval == "1m" for item in result)
assert all(item.source == "rest_klines:bid" for item in result)

View File

@@ -0,0 +1,52 @@
# app/tests/unit/market_data/acquisition/models/test_candle.py
from __future__ import annotations
from dataclasses import FrozenInstanceError
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.models.candle import Candle
def _candle() -> Candle:
return Candle(
symbol="BTC/USD",
interval="1m",
open_time=datetime(2026, 7, 15, 6, 0, tzinfo=timezone.utc),
open_price=Decimal("100"),
high_price=Decimal("110"),
low_price=Decimal("90"),
close_price=Decimal("105"),
volume=Decimal("12.5"),
source="rest_klines:bid",
)
def test_candle_stores_canonical_values() -> None:
candle = _candle()
assert candle.symbol == "BTC/USD"
assert candle.interval == "1m"
assert candle.open_time.tzinfo is timezone.utc
assert candle.open_price == Decimal("100")
assert candle.high_price == Decimal("110")
assert candle.low_price == Decimal("90")
assert candle.close_price == Decimal("105")
assert candle.volume == Decimal("12.5")
assert candle.source == "rest_klines:bid"
def test_candle_is_frozen() -> None:
candle = _candle()
with pytest.raises(FrozenInstanceError):
candle.close_price = Decimal("106") # type: ignore[misc]
def test_candle_uses_slots() -> None:
candle = _candle()
assert not hasattr(candle, "__dict__")

View File

@@ -0,0 +1,120 @@
# app/tests/unit/market_data/acquisition/validation/test_candle_schema.py
from __future__ import annotations
from types import MappingProxyType
import pytest
from src.market_data.acquisition.exceptions import CandleSchemaError
from src.market_data.acquisition.validation.schema import (
validate_candles_schema,
)
_VALID_ARRAY_ITEM = [1000, "100", "110", "90", "105", "12.5"]
_VALID_OBJECT_ITEM = {
"openTime": 1000,
"open": "100",
"high": "110",
"low": "90",
"close": "105",
"volume": "12.5",
}
@pytest.mark.parametrize(
"document",
[
[_VALID_ARRAY_ITEM],
{"klines": [_VALID_ARRAY_ITEM]},
{"candles": [_VALID_ARRAY_ITEM]},
{"data": [_VALID_ARRAY_ITEM]},
{"result": [_VALID_ARRAY_ITEM]},
{"payload": [_VALID_ARRAY_ITEM]},
{"payload": {"klines": [_VALID_ARRAY_ITEM]}},
{"payload": {"candles": [_VALID_ARRAY_ITEM]}},
{"payload": {"data": [_VALID_ARRAY_ITEM]}},
],
)
def test_validate_candles_schema_accepts_supported_envelopes(
document: object,
) -> None:
result = validate_candles_schema(document)
assert len(result.items) == 1
assert result.items[0] == tuple(_VALID_ARRAY_ITEM)
def test_validate_candles_schema_accepts_object_item() -> None:
result = validate_candles_schema([_VALID_OBJECT_ITEM])
assert len(result.items) == 1
assert isinstance(result.items[0], MappingProxyType)
assert dict(result.items[0]) == _VALID_OBJECT_ITEM
def test_validate_candles_schema_accepts_empty_list() -> None:
result = validate_candles_schema([])
assert result.items == ()
@pytest.mark.parametrize(
"document",
[
None,
"invalid",
123,
{"unknown": []},
{"payload": {"unknown": []}},
],
)
def test_validate_candles_schema_rejects_unknown_document(
document: object,
) -> None:
with pytest.raises(CandleSchemaError):
validate_candles_schema(document)
@pytest.mark.parametrize(
"document",
[
{"klines": {}},
{"payload": {"klines": {}}},
],
)
def test_validate_candles_schema_rejects_non_list_container(
document: object,
) -> None:
with pytest.raises(CandleSchemaError, match="JSON-массивом"):
validate_candles_schema(document)
@pytest.mark.parametrize(
"item",
[
"invalid",
123,
None,
True,
],
)
def test_validate_candles_schema_rejects_invalid_item(
item: object,
) -> None:
with pytest.raises(
CandleSchemaError,
match="JSON-объектом или JSON-массивом",
):
validate_candles_schema([item])
def test_validate_candles_schema_rejects_short_array() -> None:
with pytest.raises(CandleSchemaError, match="минимум 6"):
validate_candles_schema([[1000, "100", "110"]])
def test_validate_candles_schema_rejects_non_string_object_key() -> None:
with pytest.raises(CandleSchemaError, match="нестроковый ключ"):
validate_candles_schema([{1: "invalid"}])

View File

@@ -0,0 +1,109 @@
# app/tests/unit/market_data/acquisition/validation/test_candle_values.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiKline,
DzengiKlinesResponse,
)
from src.market_data.acquisition.exceptions import CandleValueError
from src.market_data.acquisition.validation.values import (
validate_candles_values,
)
def _response(
*,
open_time: int = 1000,
open_price: str | int | float = "100",
high_price: str | int | float = "110",
low_price: str | int | float = "90",
close_price: str | int | float = "105",
volume: str | int | float = "12.5",
) -> DzengiKlinesResponse:
return DzengiKlinesResponse(
items=(
DzengiKline(
open_time=open_time,
open_price=open_price,
high_price=high_price,
low_price=low_price,
close_price=close_price,
volume=volume,
),
)
)
def test_validate_candles_values_accepts_valid_response() -> None:
validate_candles_values(_response())
def test_validate_candles_values_accepts_zero_volume() -> None:
validate_candles_values(_response(volume=0))
@pytest.mark.parametrize(
("field_name", "value"),
[
("open_price", 0),
("high_price", 0),
("low_price", 0),
("close_price", 0),
("volume", -1),
],
)
def test_validate_candles_values_rejects_invalid_bounds(
field_name: str,
value: int,
) -> None:
kwargs = {field_name: value}
with pytest.raises(CandleValueError):
validate_candles_values(_response(**kwargs))
@pytest.mark.parametrize(
"value",
["NaN", "Infinity", "-Infinity"],
)
def test_validate_candles_values_rejects_non_finite_values(
value: str,
) -> None:
with pytest.raises(CandleValueError):
validate_candles_values(_response(open_price=value))
@pytest.mark.parametrize(
"open_time",
[0, -1],
)
def test_validate_candles_values_rejects_invalid_open_time(
open_time: int,
) -> None:
with pytest.raises(
CandleValueError,
match=r"\$\.candles\[0\]\.openTime",
):
validate_candles_values(_response(open_time=open_time))
@pytest.mark.parametrize(
"response",
[
_response(high_price="99"),
_response(low_price="101"),
_response(high_price="80", low_price="90"),
],
)
def test_validate_candles_values_rejects_invalid_ohlc(
response: DzengiKlinesResponse,
) -> None:
with pytest.raises(CandleValueError):
validate_candles_values(response)
def test_validate_candles_values_accepts_empty_response() -> None:
validate_candles_values(DzengiKlinesResponse(items=()))

1299
docs/migrations/build_044.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,246 +0,0 @@
((.venv) ) segeba@mbpbsg dzentra_bot % >....
elif isinstance(data.get("payload"), dict):
payload = data["payload"]
if isinstance(payload.get("symbols"), list):
symbols = payload["symbols"]
print("Количество symbols:", len(symbols) if symbols is not None else None)
if symbols:
dict_items = [item for item in symbols if isinstance(item, dict)]
all_keys = sorted(
{
key
for item in dict_items
for key in item
}
)
print("Все ключи symbol items:")
for key in all_keys:
print(f" {key}")
print("\nПервый symbol item:")
print(json.dumps(dict_items[0], ensure_ascii=False, indent=2))
PY
Корневой тип: dict
Корневые ключи: ['exchangeFilters', 'rateLimits', 'serverTime', 'symbols', 'timezone']
Количество symbols: 51
Все ключи symbol items:
assetType
baseAsset
baseAssetPrecision
country
filters
industry
longRate
marketModes
marketType
maxSLGap
maxTPGap
minSLGap
minTPGap
name
orderTypes
quoteAsset
quoteAssetId
quotePrecision
sector
shortRate
status
swapChargeInterval
symbol
tickSize
tickValue
tradingFee
tradingHours
Первый symbol item:
{
"assetType": "CRYPTOCURRENCY",
"baseAsset": "ETH",
"baseAssetPrecision": 3,
"country": "",
"filters": [
{
"filterType": "LOT_SIZE",
"maxQty": "1000",
"minQty": "0.001",
"stepSize": "0.001"
},
{
"filterType": "MIN_NOTIONAL",
"minNotional": "2"
}
],
"industry": "",
"longRate": -0.01,
"marketModes": [
"REGULAR"
],
"marketType": "LEVERAGE",
"maxSLGap": 50.0,
"maxTPGap": 50.0,
"minSLGap": 0,
"minTPGap": 0,
"name": "ETH/EUR",
"orderTypes": [
"LIMIT",
"MARKET",
"STOP"
],
"quoteAsset": "EUR",
"quoteAssetId": "EUR_LEVERAGE",
"quotePrecision": 3,
"sector": "",
"shortRate": 0.01,
"status": "TRADING",
"swapChargeInterval": 480,
"symbol": "ETH/EUR_LEVERAGE",
"tickSize": 0.01,
"tickValue": 18.3415,
"tradingFee": 0.06,
"tradingHours": "UTC; Mon - 21:00, 21:05 -; Tue - 21:00, 21:05 -; Wed - 21:00, 21:05 -; Thu - 21:00, 21:05 -; Fri - 21:00, 22:01 -; Sat - 05:00, 07:00 - 21:00, 21:05 -; Sun - 21:00, 21:05 -"
}
((.venv) ) segeba@mbpbsg dzentra_bot % >....
filter_keys: dict[str, set[str]] = {}
for item in symbols:
if not isinstance(item, dict):
continue
filters = item.get("filters")
if not isinstance(filters, list):
continue
for entry in filters:
if not isinstance(entry, dict):
continue
filter_type = str(entry.get("filterType") or "<missing>")
filter_types[filter_type] += 1
filter_keys.setdefault(filter_type, set()).update(
str(key) for key in entry
)
print("Типы filters:")
for filter_type, count in sorted(filter_types.items()):
print(f"{filter_type}: {count}")
print(" keys:", sorted(filter_keys[filter_type]))
PY
Типы filters:
LOT_SIZE: 51
keys: ['filterType', 'maxQty', 'minQty', 'stepSize']
MIN_NOTIONAL: 39
keys: ['filterType', 'minNotional']
python - <<'PY'
import json
from collections import Counter
from pathlib import Path
path = Path(
"app/tools/dzengi_probe/runtime_samples/rest/exchangeInfo/all.json"
)
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("symbols"), list):
symbols = data["symbols"]
elif (
isinstance(data, dict)
and isinstance(data.get("payload"), dict)
and isinstance(data["payload"].get("symbols"), list)
):
symbols = data["payload"]["symbols"]
else:
raise SystemExit("symbols не найдены")
fields = [
"symbol",
"name",
"status",
"baseAsset",
"quoteAsset",
"marketModes",
"marketType",
"tickSize",
"stepSize",
"minQty",
"minNotional",
"filters",
]
for field in fields:
present = 0
non_empty = 0
types = Counter()
for item in symbols:
if not isinstance(item, dict):
continue
if field in item:
present += 1
value = item[field]
types[type(value).__name__] += 1
if value not in (None, "", [], {}):
non_empty += 1
print(
f"{field}: present={present}, "
f"non_empty={non_empty}, "
f"types={dict(types)}"
)
PY
symbol: present=51, non_empty=51, types={'str': 51}
name: present=51, non_empty=51, types={'str': 51}
status: present=51, non_empty=51, types={'str': 51}
baseAsset: present=51, non_empty=51, types={'str': 51}
quoteAsset: present=51, non_empty=51, types={'str': 51}
marketModes: present=51, non_empty=51, types={'list': 51}
marketType: present=51, non_empty=51, types={'str': 51}
tickSize: present=51, non_empty=51, types={'float': 48, 'int': 3}
stepSize: present=0, non_empty=0, types={}
minQty: present=0, non_empty=0, types={}
minNotional: present=0, non_empty=0, types={}
filters: present=51, non_empty=51, types={'list': 51}
((.venv) ) segeba@mbpbsg dzentra_bot % ;2B
((.venv) ) segeba@mbpbsg dzentra_bot % ;2Bgrep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
-E "from src\.telegram\.handlers\.market import|import src\.telegram\.handlers\.market|include_router\(.*market|market\.router|handlers\.market" \
app/src app/tests tests 2>/dev/null
((.venv) ) segeba@mbpbsg dzentra_bot % grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
-E "include_router|include_routers" \
app/src \
| grep -Ei "market|router"
app/src/telegram/routers.py:16: dispatcher.include_router(start_router)
app/src/telegram/routers.py:17: dispatcher.include_router(home_router)
app/src/telegram/routers.py:18: dispatcher.include_router(portfolio_router)
app/src/telegram/routers.py:19: dispatcher.include_router(auto_router)
app/src/telegram/routers.py:20: dispatcher.include_router(journal_router)
app/src/telegram/routers.py:21: dispatcher.include_router(debug_auto_router)
app/src/telegram/routers.py:22: dispatcher.include_router(debug_router)
app/src/telegram/routers.py:23: dispatcher.include_router(system_router)
app/src/telegram/handlers/auto/__init__.py:8:router.include_router(main_router)
app/src/telegram/handlers/auto/__init__.py:9:router.include_router(risk_router)
((.venv) ) segeba@mbpbsg dzentra_bot %
((.venv) ) segeba@mbpbsg dzentra_bot % grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
-E "from src\.telegram\.ui\.currency_ui import|import src\.telegram\.ui\.currency_ui" \
app/src app/tests tests 2>/dev/null
app/src/telegram/handlers/market.py:28:from src.telegram.ui.currency_ui import format_usd_amount
app/src/telegram/handlers/portfolio.py:26:from src.telegram.ui.currency_ui import (

File diff suppressed because one or more lines are too long