From 0e86cca19d62d1b8eb81dd3078835c306944eebc Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 15 Jul 2026 13:25:04 +0300 Subject: [PATCH] build 045: register canonical candles feed in acquisition registry --- app/src/market_data/acquisition/exceptions.py | 7 +- app/src/market_data/acquisition/registry.py | 67 ++ .../market_data/acquisition/test_registry.py | 257 +++++++ docs/migrations/build_045.md | 692 ++++++++++++++++++ 4 files changed, 1022 insertions(+), 1 deletion(-) create mode 100644 docs/migrations/build_045.md diff --git a/app/src/market_data/acquisition/exceptions.py b/app/src/market_data/acquisition/exceptions.py index 89c4538..1a31ad4 100644 --- a/app/src/market_data/acquisition/exceptions.py +++ b/app/src/market_data/acquisition/exceptions.py @@ -90,4 +90,9 @@ class CandleValueError(MarketDataAcquisitionError): # Ошибка преобразования raw-модели источника во внутреннюю модель Candle. class CandleMappingError(MarketDataAcquisitionError): - pass \ No newline at end of file + pass + + +# Ошибка регистрации или получения Candles Feed. +class CandleFeedRegistryError(MarketDataAcquisitionError): + pass diff --git a/app/src/market_data/acquisition/registry.py b/app/src/market_data/acquisition/registry.py index abf13c8..ec6f650 100644 --- a/app/src/market_data/acquisition/registry.py +++ b/app/src/market_data/acquisition/registry.py @@ -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 diff --git a/app/tests/unit/market_data/acquisition/test_registry.py b/app/tests/unit/market_data/acquisition/test_registry.py index b3d8174..ae36b98 100644 --- a/app/tests/unit/market_data/acquisition/test_registry.py +++ b/app/tests/unit/market_data/acquisition/test_registry.py @@ -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." diff --git a/docs/migrations/build_045.md b/docs/migrations/build_045.md new file mode 100644 index 0000000..776e936 --- /dev/null +++ b/docs/migrations/build_045.md @@ -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 является небольшим изолированным архитектурным шагом и не изменяет поведение существующего рабочего торгового бота. \ No newline at end of file