build 044: establish canonical candles feed foundation
This commit is contained in:
@@ -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
|
||||
@@ -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, ...]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -84,3 +85,57 @@ 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.
|
||||
"""
|
||||
...
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
52
app/tests/unit/market_data/acquisition/models/test_candle.py
Normal file
52
app/tests/unit/market_data/acquisition/models/test_candle.py
Normal 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__")
|
||||
@@ -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"}])
|
||||
@@ -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
1299
docs/migrations/build_044.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user