build 044: establish canonical candles feed foundation

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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