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,
|
DzengiExchangeInfoResponse,
|
||||||
DzengiExchangeInfoSymbol,
|
DzengiExchangeInfoSymbol,
|
||||||
DzengiInstrumentFilter,
|
DzengiInstrumentFilter,
|
||||||
|
DzengiKlinesResponse,
|
||||||
DzengiLotSizeFilter,
|
DzengiLotSizeFilter,
|
||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRawNumeric,
|
DzengiRawNumeric,
|
||||||
@@ -16,9 +17,11 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
|||||||
DzengiWebSocketQuoteResponse,
|
DzengiWebSocketQuoteResponse,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleMappingError,
|
||||||
InstrumentReferenceMappingError,
|
InstrumentReferenceMappingError,
|
||||||
QuoteMappingError,
|
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.instrument import Instrument
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
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,
|
received_at=normalized_received_at,
|
||||||
source=_DZENGI_SOURCE_NAME,
|
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
|
bid_price: DzengiRawNumeric
|
||||||
ask_price: DzengiRawNumeric
|
ask_price: DzengiRawNumeric
|
||||||
timestamp: int | None
|
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,
|
DzengiInstrumentFilter,
|
||||||
DzengiJsonNumber,
|
DzengiJsonNumber,
|
||||||
DzengiJsonScalar,
|
DzengiJsonScalar,
|
||||||
|
DzengiKline,
|
||||||
|
DzengiKlinesResponse,
|
||||||
DzengiLotSizeFilter,
|
DzengiLotSizeFilter,
|
||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRateLimit,
|
DzengiRateLimit,
|
||||||
@@ -20,10 +22,12 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
|||||||
DzengiWebSocketQuoteResponse,
|
DzengiWebSocketQuoteResponse,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleParseError,
|
||||||
InstrumentReferenceParseError,
|
InstrumentReferenceParseError,
|
||||||
QuoteParseError,
|
QuoteParseError,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.validation.schema import (
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedCandlesDocument,
|
||||||
ValidatedExchangeInfoDocument,
|
ValidatedExchangeInfoDocument,
|
||||||
ValidatedQuoteDocument,
|
ValidatedQuoteDocument,
|
||||||
ValidatedWebSocketQuoteDocument,
|
ValidatedWebSocketQuoteDocument,
|
||||||
@@ -714,3 +718,153 @@ def _websocket_optional_timestamp(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return _quote_required_int(value, path=path)
|
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.integrations.exchange.rest_client import ExchangeRestClient
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleTransportError,
|
||||||
InstrumentReferenceTransportError,
|
InstrumentReferenceTransportError,
|
||||||
QuoteTransportError,
|
QuoteTransportError,
|
||||||
)
|
)
|
||||||
@@ -13,6 +14,7 @@ from src.market_data.acquisition.exceptions import (
|
|||||||
|
|
||||||
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
||||||
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
||||||
|
_KLINES_PATH = "/api/v1/klines"
|
||||||
|
|
||||||
|
|
||||||
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
||||||
@@ -104,3 +106,52 @@ class DzengiQuoteDocumentSource:
|
|||||||
"Не удалось получить текущую котировку "
|
"Не удалось получить текущую котировку "
|
||||||
f"от Dzengi для символа '{symbol}': {exc}"
|
f"от Dzengi для символа '{symbol}': {exc}"
|
||||||
) from 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):
|
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Ошибка получения Quotes Feed от внешнего источника.
|
# Ошибка получения Quotes Feed от внешнего источника.
|
||||||
class QuoteTransportError(MarketDataAcquisitionError):
|
class QuoteTransportError(MarketDataAcquisitionError):
|
||||||
pass
|
pass
|
||||||
@@ -65,3 +66,28 @@ class QuoteMappingError(MarketDataAcquisitionError):
|
|||||||
# Ошибка регистрации или получения Quotes Feed.
|
# Ошибка регистрации или получения Quotes Feed.
|
||||||
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
||||||
pass
|
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 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.instrument import Instrument
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
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 typing import Mapping
|
||||||
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleSchemaError,
|
||||||
InstrumentReferenceSchemaError,
|
InstrumentReferenceSchemaError,
|
||||||
QuoteSchemaError,
|
QuoteSchemaError,
|
||||||
)
|
)
|
||||||
@@ -199,6 +200,7 @@ def _require_list(
|
|||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
# Структурно проверенное представление ответа ticker/24hr.
|
# Структурно проверенное представление ответа ticker/24hr.
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ValidatedQuoteDocument:
|
class ValidatedQuoteDocument:
|
||||||
@@ -398,3 +400,195 @@ def _validate_websocket_quote_payload(
|
|||||||
raise QuoteSchemaError(
|
raise QuoteSchemaError(
|
||||||
"WebSocket quote не содержит bid/ask либо bids/asks."
|
"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,
|
DzengiRateLimit,
|
||||||
DzengiRawNumeric,
|
DzengiRawNumeric,
|
||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
|
DzengiKlinesResponse,
|
||||||
DzengiTicker24hrResponse,
|
DzengiTicker24hrResponse,
|
||||||
DzengiWebSocketQuoteResponse,
|
DzengiWebSocketQuoteResponse,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleValueError,
|
||||||
InstrumentReferenceValueError,
|
InstrumentReferenceValueError,
|
||||||
QuoteValueError,
|
QuoteValueError,
|
||||||
)
|
)
|
||||||
@@ -454,6 +456,7 @@ def _to_finite_decimal(
|
|||||||
|
|
||||||
return decimal_value
|
return decimal_value
|
||||||
|
|
||||||
|
|
||||||
def validate_quote_values(
|
def validate_quote_values(
|
||||||
response: DzengiTicker24hrResponse,
|
response: DzengiTicker24hrResponse,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -549,3 +552,128 @@ def validate_dzengi_websocket_quote_values(
|
|||||||
raise QuoteValueError(
|
raise QuoteValueError(
|
||||||
"$.payload.timestamp должно быть больше нуля."
|
"$.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