build 045: register canonical candles feed in acquisition registry

This commit is contained in:
2026-07-15 13:25:04 +03:00
parent 47c75d68b1
commit 0e86cca19d
4 changed files with 1022 additions and 1 deletions

View File

@@ -90,4 +90,9 @@ class CandleValueError(MarketDataAcquisitionError):
# Ошибка преобразования raw-модели источника во внутреннюю модель Candle.
class CandleMappingError(MarketDataAcquisitionError):
pass
pass
# Ошибка регистрации или получения Candles Feed.
class CandleFeedRegistryError(MarketDataAcquisitionError):
pass

View File

@@ -3,10 +3,12 @@
from __future__ import annotations
from src.market_data.acquisition.exceptions import (
CandleFeedRegistryError,
InstrumentFeedRegistryError,
QuoteFeedRegistryError,
)
from src.market_data.acquisition.protocol import (
CandlesFeedProtocol,
InstrumentFeedProtocol,
QuoteFeedProtocol,
)
@@ -140,3 +142,68 @@ class QuoteFeedRegistry:
)
return normalized_source_name
# Реестр доступных потоков рыночных свечей.
class CandlesFeedRegistry:
def __init__(self) -> None:
self._feeds: dict[str, CandlesFeedProtocol] = {}
def register(
self,
source_name: str,
feed: CandlesFeedProtocol,
) -> None:
"""
Зарегистрировать Candles Feed для указанного источника.
Повторная регистрация того же имени запрещена, чтобы исключить
неявную замену production-зависимости.
"""
normalized_source_name = self._normalize_source_name(source_name)
if not isinstance(feed, CandlesFeedProtocol):
raise CandleFeedRegistryError(
f"Объект для источника '{normalized_source_name}' "
"не соответствует CandlesFeedProtocol."
)
if normalized_source_name in self._feeds:
raise CandleFeedRegistryError(
f"Candles Feed для источника "
f"'{normalized_source_name}' уже зарегистрирован."
)
self._feeds[normalized_source_name] = feed
def get(
self,
source_name: str,
) -> CandlesFeedProtocol:
"""Вернуть зарегистрированный Candles Feed по имени источника."""
normalized_source_name = self._normalize_source_name(source_name)
feed = self._feeds.get(normalized_source_name)
if feed is None:
raise CandleFeedRegistryError(
f"Candles Feed для источника "
f"'{normalized_source_name}' не зарегистрирован."
)
return feed
def _normalize_source_name(
self,
source_name: str,
) -> str:
normalized_source_name = source_name.strip()
if not normalized_source_name:
raise CandleFeedRegistryError(
"Имя источника Candles Feed не должно быть пустым."
)
return normalized_source_name

View File

@@ -431,3 +431,260 @@ def test_quote_registry_error_inherits_acquisition_error() -> None:
error = QuoteFeedRegistryError("Registry error.")
assert isinstance(error, MarketDataAcquisitionError)
# Candles Feed Registry tests.
from datetime import datetime
from src.market_data.acquisition.exceptions import CandleFeedRegistryError
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.protocol import CandlesFeedProtocol
from src.market_data.acquisition.registry import CandlesFeedRegistry
def _candle() -> Candle:
return Candle(
symbol="BTC/USD_LEVERAGE",
interval="1m",
open_time=datetime.now(timezone.utc),
open_price=Decimal("64100.00"),
high_price=Decimal("64200.00"),
low_price=Decimal("64000.00"),
close_price=Decimal("64150.00"),
volume=Decimal("12.5"),
source="rest_klines:bid",
)
class StubCandlesFeed:
def __init__(
self,
*,
candles: tuple[Candle, ...] = (),
) -> None:
self.candles = candles
self.calls: list[tuple[str, str, int, str]] = []
def load_candles(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
self.calls.append(
(
symbol,
interval,
limit,
price_type,
)
)
return self.candles
def test_candles_registry_registers_and_returns_feed() -> None:
registry = CandlesFeedRegistry()
feed = StubCandlesFeed()
registry.register("dzengi", feed)
assert registry.get("dzengi") is feed
def test_candles_registry_accepts_candles_feed_protocol() -> None:
registry = CandlesFeedRegistry()
feed = StubCandlesFeed()
assert isinstance(feed, CandlesFeedProtocol)
registry.register("dzengi", feed)
assert registry.get("dzengi") is feed
def test_candles_registry_preserves_feed_identity() -> None:
registry = CandlesFeedRegistry()
candles = (_candle(),)
feed = StubCandlesFeed(candles=candles)
registry.register("dzengi", feed)
registered_feed = registry.get("dzengi")
assert registered_feed is feed
assert registered_feed.load_candles(
"BTC/USD_LEVERAGE",
interval="1m",
limit=100,
price_type="bid",
) is candles
def test_candles_registry_supports_multiple_source_names() -> None:
registry = CandlesFeedRegistry()
dzengi_feed = StubCandlesFeed()
secondary_feed = StubCandlesFeed()
registry.register("dzengi", dzengi_feed)
registry.register("secondary", secondary_feed)
assert registry.get("dzengi") is dzengi_feed
assert registry.get("secondary") is secondary_feed
def test_candles_registry_strips_outer_whitespace() -> None:
registry = CandlesFeedRegistry()
feed = StubCandlesFeed()
registry.register(" dzengi ", feed)
assert registry.get("dzengi") is feed
assert registry.get(" dzengi ") is feed
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_candles_registry_rejects_empty_source_name(
source_name: str,
) -> None:
registry = CandlesFeedRegistry()
with pytest.raises(
CandleFeedRegistryError,
match=r"Имя источника Candles Feed не должно быть пустым",
):
registry.register(source_name, StubCandlesFeed())
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_candles_registry_rejects_empty_source_name_on_get(
source_name: str,
) -> None:
registry = CandlesFeedRegistry()
with pytest.raises(
CandleFeedRegistryError,
match=r"Имя источника Candles Feed не должно быть пустым",
):
registry.get(source_name)
def test_candles_registry_rejects_duplicate_registration() -> None:
registry = CandlesFeedRegistry()
first_feed = StubCandlesFeed()
second_feed = StubCandlesFeed()
registry.register("dzengi", first_feed)
with pytest.raises(
CandleFeedRegistryError,
match=r"уже зарегистрирован",
):
registry.register("dzengi", second_feed)
def test_candles_duplicate_does_not_replace_original_feed() -> None:
registry = CandlesFeedRegistry()
first_feed = StubCandlesFeed()
second_feed = StubCandlesFeed()
registry.register("dzengi", first_feed)
with pytest.raises(CandleFeedRegistryError):
registry.register("dzengi", second_feed)
assert registry.get("dzengi") is first_feed
def test_candles_duplicate_uses_normalized_source_name() -> None:
registry = CandlesFeedRegistry()
first_feed = StubCandlesFeed()
second_feed = StubCandlesFeed()
registry.register("dzengi", first_feed)
with pytest.raises(
CandleFeedRegistryError,
match=r"уже зарегистрирован",
):
registry.register(" dzengi ", second_feed)
def test_candles_registry_keeps_source_name_case_sensitive() -> None:
registry = CandlesFeedRegistry()
lowercase_feed = StubCandlesFeed()
uppercase_feed = StubCandlesFeed()
registry.register("dzengi", lowercase_feed)
registry.register("DZENGI", uppercase_feed)
assert registry.get("dzengi") is lowercase_feed
assert registry.get("DZENGI") is uppercase_feed
def test_candles_registry_rejects_unregistered_source() -> None:
registry = CandlesFeedRegistry()
with pytest.raises(
CandleFeedRegistryError,
match=r"не зарегистрирован",
):
registry.get("dzengi")
def test_candles_registry_rejects_invalid_feed() -> None:
registry = CandlesFeedRegistry()
with pytest.raises(
CandleFeedRegistryError,
match=r"не соответствует CandlesFeedProtocol",
):
registry.register(
"invalid",
InvalidFeed(), # type: ignore[arg-type]
)
def test_candles_registry_does_not_load_feed_during_registration() -> None:
registry = CandlesFeedRegistry()
feed = StubCandlesFeed()
registry.register("dzengi", feed)
assert feed.calls == []
def test_candles_registry_does_not_load_feed_during_get() -> None:
registry = CandlesFeedRegistry()
feed = StubCandlesFeed()
registry.register("dzengi", feed)
result = registry.get("dzengi")
assert result is feed
assert feed.calls == []
def test_candles_registry_error_inherits_acquisition_error() -> None:
error = CandleFeedRegistryError("Registry error.")
assert isinstance(error, MarketDataAcquisitionError)
assert str(error) == "Registry error."

View File

@@ -0,0 +1,692 @@
# Build 045 — регистрация канонического Candles Feed
## Статус
**Завершён**
---
## Цель Build
Продолжить поэтапную миграцию **OHLCV Feed** в утверждённую архитектуру `Market Data Acquisition` после завершения фундаментального слоя Candles Feed в Build 044.
Build 045 добавляет реестр канонических источников свечей и завершает следующий минимальный архитектурный шаг:
```text
Candles Feed
CandlesFeedRegistry
```
При этом:
- существующий runtime не переключается;
- `ExchangeService.get_klines()` не изменяется;
- legacy-путь получения свечей продолжает работать;
- новая архитектура остаётся изолированной от существующего торгового runtime;
- обратная совместимость полностью сохраняется.
---
## Исходное состояние
До Build 045 проект уже содержал фундамент канонического Candles Feed, созданный в Build 044:
```text
REST /api/v1/klines
DzengiCandlesDocumentSource
validate_candles_schema()
parse_candles()
validate_candles_values()
map_candles()
Candle
DzengiCandlesDocumentHandler
CandlesFeed
```
Также существовали:
- каноническая модель `Candle`;
- транспортная raw-модель Dzengi для свечей;
- REST document source;
- schema validation;
- parser;
- value validation;
- mapper;
- document handler;
- `CandlesFeed`;
- протоколы Candles Feed;
- специализированные исключения;
- unit-тесты фундаментального слоя.
Однако отсутствовал реестр, позволяющий регистрировать и выбирать конкретный `CandlesFeed` по имени источника.
---
## Объём изменений
Build 045 изменяет только следующие файлы:
```text
src/market_data/acquisition/exceptions.py
src/market_data/acquisition/registry.py
tests/unit/market_data/acquisition/test_registry.py
docs/migrations/build_045.md
```
Build не изменяет:
```text
src/market_data/acquisition/service.py
src/integrations/exchange/service.py
src/integrations/exchange/models.py
src/trading/
src/telegram/
```
---
## 1. Исключение CandleFeedRegistryError
В файл:
```text
src/market_data/acquisition/exceptions.py
```
добавлено специализированное исключение:
```python
class CandleFeedRegistryError(MarketDataAcquisitionError):
pass
```
Назначение исключения — изолировать ошибки регистрации и получения Candles Feed от других ошибок подсистемы `Market Data Acquisition`.
Иерархия:
```text
MarketDataAcquisitionError
CandleFeedRegistryError
```
---
## 2. Реестр CandlesFeedRegistry
В файл:
```text
src/market_data/acquisition/registry.py
```
добавлен класс:
```text
CandlesFeedRegistry
```
Реестр отвечает исключительно за регистрацию и получение реализаций:
```text
CandlesFeedProtocol
```
Реестр не выполняет:
- REST-запросы;
- schema validation;
- parsing;
- value validation;
- mapping;
- хранение свечей;
- управление runtime;
- переключение legacy-потребителей.
---
## 3. Контракт реестра
`CandlesFeedRegistry` предоставляет только операции регистрации и получения Candles Feed.
Публичный контракт:
```text
register()
get()
```
Внутренняя нормализация имени источника выполняется методом:
```text
_normalize_source_name()
```
Концептуально:
```text
source name
CandlesFeedRegistry
CandlesFeedProtocol
```
Пример:
```text
"dzengi"
CandlesFeedRegistry
CandlesFeed
```
Реестр принимает только объекты, соответствующие каноническому контракту:
```text
CandlesFeedProtocol
```
---
## 4. Нормализация имени источника
Имена источников нормализуются перед использованием.
Поддерживается удаление внешних пробелов:
```text
"dzengi"
" dzengi "
```
После нормализации оба значения соответствуют одному ключу:
```text
dzengi
```
Пустые имена источников не допускаются.
Регистр символов сохраняется. Имена источников являются case-sensitive:
```text
"dzengi"
```
и:
```text
"DZENGI"
```
считаются разными именами источников.
---
## 5. Защита от некорректной регистрации
`CandlesFeedRegistry` отклоняет:
- пустое имя источника;
- имя, состоящее только из пробелов;
- объект, не соответствующий `CandlesFeedProtocol`;
- повторную регистрацию уже существующего нормализованного имени источника.
Ошибки представлены через:
```text
CandleFeedRegistryError
```
---
## 6. Защита от повторной регистрации
Повторная регистрация одного и того же нормализованного имени источника запрещена.
Пример:
```text
register("dzengi", feed_a)
dzengi → feed_a
```
Повторная регистрация:
```text
register("dzengi", feed_b)
CandleFeedRegistryError
```
Исходный Feed при ошибке повторной регистрации не заменяется.
Для замены зарегистрированного Feed требуется отдельное явное изменение архитектурного контракта. В Build 045 такая возможность не добавлялась.
---
## 7. Получение зарегистрированного Feed
Зарегистрированный Candles Feed может быть получен по имени источника.
Концептуально:
```text
registry.get("dzengi")
CandlesFeedProtocol
```
При успешном получении реестр возвращает тот же объект Feed, который был ранее зарегистрирован:
```text
feed = CandlesFeed(...)
registry.register("dzengi", feed)
registry.get("dzengi") is feed
True
```
Попытка получить неизвестный источник приводит к:
```text
CandleFeedRegistryError
```
---
## 8. Ограниченный публичный контракт
Публичный контракт `CandlesFeedRegistry` в Build 045 ограничен двумя операциями:
```text
register()
get()
```
В Build 045 намеренно не добавлены:
```text
contains()
remove()
replace()
clear()
```
Реестр не предоставляет управление жизненным циклом зарегистрированных Feed и не выполняет их запуск или остановку.
Такое ограничение соответствует принципу минимального безопасного Build: добавляется только функциональность, необходимая для последующей интеграции Candles Feed в сервисный слой.
---
## 9. Изоляция от Acquisition Service
В рамках Build 045 намеренно не добавлялись:
```text
CandlesAcquisitionService
```
или метод:
```text
load_candles()
```
в существующий:
```text
src/market_data/acquisition/service.py
```
Архитектурная проверка:
```bash
grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
"CandlesAcquisitionService\|load_candles" \
src/market_data/acquisition/service.py
```
Результат:
```text
пусто
```
Это подтверждает, что Build 045 ограничен добавлением реестра и не выполняет преждевременную интеграцию сервисного слоя.
---
## 10. Изоляция от legacy runtime
Build 045 не изменяет существующий runtime-путь получения свечей:
```text
Trading / Market Analysis
ExchangeService.get_klines()
legacy parsing
KlineBatch
```
После Build 045 этот путь продолжает работать без изменений.
Новый канонический путь пока существует параллельно:
```text
DzengiCandlesDocumentSource
DzengiCandlesDocumentHandler
CandlesFeed
CandlesFeedRegistry
```
Таким образом, в системе временно существуют два пути:
```text
Legacy runtime path
+
Canonical Candles Feed path
```
Это ожидаемое переходное состояние безопасной поэтапной миграции.
---
## 11. Unit-тесты
Расширен файл:
```text
tests/unit/market_data/acquisition/test_registry.py
```
Добавлено покрытие для:
- создания `CandlesFeedRegistry`;
- регистрации Candles Feed;
- получения зарегистрированного Feed;
- нормализации внешних пробелов имени источника;
- case-sensitive поведения имён источников;
- отклонения пустого имени;
- отклонения имени, состоящего только из пробелов;
- отклонения объекта, не соответствующего `CandlesFeedProtocol`;
- защиты от повторной регистрации;
- проверки, что повторная регистрация не заменяет исходный Feed;
- получения неизвестного источника;
- сохранения identity зарегистрированного Feed;
- отсутствия вызова `load_candles()` при регистрации Feed;
- отсутствия вызова `load_candles()` при получении Feed;
- соответствия зарегистрированного объекта `CandlesFeedProtocol`;
- корректной работы специализированного `CandleFeedRegistryError`.
---
## 12. Результаты targeted-тестов
Выполнена команда:
```bash
python -m pytest -q \
tests/unit/market_data/acquisition/test_registry.py
```
Результат:
```text
61 passed in 0.04s
```
---
## 13. Результаты полного набора тестов
Выполнена команда:
```bash
python -m pytest -q
```
Результат:
```text
707 passed in 2.87s
```
Все unit-тесты проекта проходят успешно.
---
## 14. Архитектурная проверка реестра
Выполнена команда:
```bash
grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
"CandlesFeedRegistry\|CandleFeedRegistryError" \
src tests
```
Результат подтверждает, что новые сущности находятся только в ожидаемых областях:
```text
src/market_data/acquisition/registry.py
src/market_data/acquisition/exceptions.py
tests/unit/market_data/acquisition/test_registry.py
```
Неожиданных зависимостей не обнаружено.
---
## 15. Проверка отсутствия преждевременной сервисной интеграции
Выполнена команда:
```bash
grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
"CandlesAcquisitionService\|load_candles" \
src/market_data/acquisition/service.py
```
Результат:
```text
пусто
```
Это подтверждает, что Build 045 не расширяет `MarketDataAcquisitionService` и не переключает runtime.
---
## 16. Проверка синтаксиса
Выполнена команда:
```bash
python -m compileall \
src/market_data/acquisition/exceptions.py \
src/market_data/acquisition/registry.py \
tests/unit/market_data/acquisition/test_registry.py
```
Результат:
```text
успешно
```
Ошибок синтаксиса не обнаружено.
---
## 17. Проверка форматирования diff
Первоначальная проверка:
```bash
git diff --check
```
обнаружила одну лишнюю пустую строку в конце:
```text
app/tests/unit/market_data/acquisition/test_registry.py:691: new blank line at EOF.
```
После удаления лишней пустой строки необходимо повторно выполнить:
```bash
git diff --check
```
Финальный ожидаемый результат:
```text
пустой вывод
```
До создания commit эта проверка должна быть подтверждена.
---
## 18. Итоговая архитектура после Build 045
После завершения Build 045 канонический Candles Feed имеет следующую структуру:
```text
Dzengi REST /api/v1/klines
DzengiCandlesDocumentSource
validate_candles_schema()
parse_candles()
validate_candles_values()
map_candles()
Candle
DzengiCandlesDocumentHandler
CandlesFeed
CandlesFeedRegistry
```
При этом существующий production/runtime-путь остаётся неизменным:
```text
Trading / Market Analysis
ExchangeService.get_klines()
legacy parsing
KlineBatch
```
---
## 19. Что намеренно не входит в Build 045
Build 045 намеренно не выполняет:
- интеграцию Candles Feed в `MarketDataAcquisitionService`;
- изменение `ExchangeService.get_klines()`;
- изменение `Kline`;
- изменение `KlineBatch`;
- переключение `trading/market_analysis`;
- переключение HTF-анализа;
- удаление legacy parser свечей;
- добавление постоянного Candle Store;
- изменение runtime;
- изменение Telegram UI;
- изменение торговой логики.
Эти ограничения являются частью стратегии безопасной миграции.
---
## 20. Следующий Build
Следующий минимальный безопасный этап:
```text
Build 046 — интеграция канонического Candles Feed в MarketDataAcquisitionService
```
Предполагаемая цель:
```text
CandlesFeedRegistry
MarketDataAcquisitionService
load_candles(...)
tuple[Candle, ...]
```
Build 046 должен:
- добавить поддержку `CandlesFeedRegistry` в сервисный слой `Market Data Acquisition`;
- добавить публичный метод загрузки свечей через канонический сервис;
- сохранить существующие Instrument Reference Data и Quotes Feed без изменений;
- не переключать `ExchangeService.get_klines()`;
- не удалять legacy `Kline` и `KlineBatch`;
- не изменять торговую логику;
- сохранить полную обратную совместимость.
---
## Итог
Build 045 завершает регистрацию канонического Candles Feed.
Достигнуто состояние:
```text
OHLCV Feed foundation
✓ Candle model
✓ REST document source
✓ schema validation
✓ parser
✓ value validation
✓ mapper
✓ document handler
✓ Candles Feed
✓ CandlesFeedRegistry
☐ Acquisition Service integration
☐ ExchangeService compatibility bridge
☐ Consumer migration
☐ Legacy Kline removal
```
Build 045 является небольшим изолированным архитектурным шагом и не изменяет поведение существующего рабочего торгового бота.