# Build 017 — Переключение `validate_symbol()` на новый механизм разрешения символов ## Статус **COMPLETE** --- ## Цель Build Перевести существующий метод `ExchangeService.validate_symbol()` с legacy-алгоритма поиска инструмента на новый канонический механизм разрешения символов, расположенный в подсистеме `src/market_data/acquisition/`. При этом необходимо сохранить без изменений существующий внешний контракт legacy-системы `SymbolValidationResult` и обеспечить полную обратную совместимость существующего бота. --- ## Архитектурный контекст До Build 017 метод `ExchangeService.validate_symbol()` самостоятельно выполнял разрешение символа через `symbol_candidates()` и вложенный цикл по результату `get_exchange_symbols()`. Фактически внутри `ExchangeService` находилась собственная логика поиска соответствующего торгового инструмента. После завершения Build 016 канонические функции `normalize_symbol()` и `symbol_candidates()` уже были перенесены в: src/market_data/acquisition/symbols.py Build 017 продолжает этот переход и выносит непосредственно алгоритм разрешения символа в новую подсистему Market Data Acquisition. --- ## Реализованная архитектура Каноническая логика работы с идентификаторами инструментов теперь находится в: src/market_data/acquisition/symbols.py Файл содержит: normalize_symbol() symbol_candidates() resolve_symbol_index() Распределение ответственности: normalize_symbol() │ ▼ Нормализация входного идентификатора │ ▼ symbol_candidates() │ ▼ Формирование упорядоченного набора кандидатов │ ▼ resolve_symbol_index() │ ▼ Поиск первого подходящего символа в доступной последовательности │ ▼ ExchangeService.validate_symbol() │ ▼ Формирование legacy SymbolValidationResult Таким образом: - `market_data/acquisition/symbols.py` отвечает за каноническую логику разрешения идентификатора; - `ExchangeService.validate_symbol()` отвечает за сохранение legacy API и формирование `SymbolValidationResult`; - `get_exchange_symbols()` остаётся compatibility boundary между новой моделью `Instrument` и legacy-моделью `ExchangeSymbol`. --- ## Новый канонический resolver В файле: src/market_data/acquisition/symbols.py добавлена функция: def resolve_symbol_index( raw_symbol: str, available_symbols: Sequence[str], ) -> int | None: Её ответственность: 1. Получить исходный идентификатор инструмента. 2. Сформировать кандидаты через `symbol_candidates()`. 3. Последовательно проверить кандидатов. 4. Последовательно проверить доступные символы. 5. Вернуть индекс первого совпавшего символа. 6. Вернуть `None`, если соответствие отсутствует. Возврат индекса, а не самого объекта, позволяет resolver оставаться независимым от: ExchangeSymbol Instrument SymbolValidationResult ExchangeService и работать только со строковыми идентификаторами. --- ## Сохранённый порядок разрешения Build 017 сохраняет существующую семантику legacy-реализации. Приоритет определяется в следующем порядке: 1. Порядок кандидатов из symbol_candidates() 2. Порядок available_symbols 3. Первое найденное совпадение Это означает, что resolver сохраняет: - приоритет исходного нормализованного значения; - приоритет декодированного варианта `%2F`; - приоритет варианта без внутренних пробелов; - порядок инструментов источника; - возврат первого совпадения при наличии дубликатов. --- ## Изменение `ExchangeService.validate_symbol()` Метод: ExchangeService.validate_symbol() сохранил существующий публичный контракт: def validate_symbol( self, raw_symbol: str, ) -> SymbolValidationResult: Сохраняются все существующие сценарии результата: ### Пустой символ Возвращается: SymbolValidationResult( requested_symbol=requested, normalized_symbol="", is_valid=False, message="Символ пустой.", symbol_info=None, ) ### Mock mode При отключённой реальной бирже возвращается успешный результат без `symbol_info`: SymbolValidationResult( requested_symbol=requested, normalized_symbol=requested, is_valid=True, message="Mock mode active.", symbol_info=None, ) ### Найденный символ При успешном разрешении возвращается исходный объект `ExchangeSymbol` из списка `get_exchange_symbols()`: SymbolValidationResult( requested_symbol=requested, normalized_symbol=normalize_symbol(symbol_info.symbol), is_valid=True, message="Символ найден в exchangeInfo.", symbol_info=symbol_info, ) ### Символ не найден Возвращается прежний отрицательный результат: SymbolValidationResult( requested_symbol=requested, normalized_symbol=requested, is_valid=False, message=f"Символ '{requested}' не найден в exchangeInfo.", symbol_info=None, ) Таким образом, потребители `validate_symbol()` не требуют изменений. --- ## Новый поток данных После Build 017 полный путь разрешения символа выглядит следующим образом: raw_symbol │ ▼ ExchangeService.validate_symbol() │ ▼ normalize_symbol() │ ▼ ExchangeService.get_exchange_symbols() │ ▼ tuple[Instrument, ...] │ ▼ Compatibility mapper │ ▼ list[ExchangeSymbol] │ ▼ resolve_symbol_index() │ ▼ matched index │ ▼ исходный ExchangeSymbol │ ▼ SymbolValidationResult При этом `validate_symbol()`: - не обращается напрямую к acquisition adapter; - не создаёт `InstrumentAcquisitionService`; - не выполняет REST-запрос; - не выполняет parsing; - не выполняет validation входного `exchangeInfo`; - не выполняет mapping `Instrument → ExchangeSymbol`; - не управляет cache; - использует только публичный legacy boundary `get_exchange_symbols()`. --- ## Сохранение compatibility boundary Build 017 намеренно не переводит `validate_symbol()` на прямую работу с `Instrument`. Текущий compatibility boundary остаётся следующим: Instrument Acquisition │ ▼ tuple[Instrument, ...] │ ▼ compatibility.py │ ▼ list[ExchangeSymbol] │ ▼ ExchangeService.get_exchange_symbols() │ ▼ ExchangeService.validate_symbol() │ ▼ SymbolValidationResult Это позволяет продолжать поэтапную миграцию без нарушения работы существующего бота. --- ## Сохранение `SymbolValidationResult` Legacy-модель: src/integrations/exchange/models.py остаётся без изменений. Контракт: class SymbolValidationResult: requested_symbol: str normalized_symbol: str is_valid: bool message: str symbol_info: ExchangeSymbol | None сохранён полностью. Это важно, поскольку `validate_symbol()` используется существующими runtime-компонентами, включая: - получение статуса торгового инструмента; - получение комиссии; - получение свечей; - получение цены; - market snapshot; - execution snapshot; - свежий REST snapshot; - market stream; - market data runner; - Telegram handlers. --- ## Сохранение object identity При успешном разрешении символа `validate_symbol()` возвращает тот же экземпляр `ExchangeSymbol`, который находится в результате `get_exchange_symbols()`. То есть сохраняется условие: result.symbol_info is symbol Это предотвращает: - создание лишних копий legacy-моделей; - изменение object identity; - расхождение между cache и результатом validation; - скрытые изменения поведения существующих потребителей. --- ## Изменённые файлы ### `src/market_data/acquisition/symbols.py` Добавлена каноническая функция: resolve_symbol_index() Функция выполняет независимое разрешение строкового идентификатора по последовательности доступных символов. ### `src/integrations/exchange/service.py` Метод: validate_symbol() переключён со встроенного двойного цикла на: resolve_symbol_index() При этом сохранены: - вызов `get_exchange_symbols()`; - `SymbolValidationResult`; - тексты сообщений; - mock mode; - порядок разрешения; - возврат исходного объекта `ExchangeSymbol`. ### `tests/unit/market_data/acquisition/test_symbols.py` Добавлены unit-тесты для `resolve_symbol_index()`. ### `tests/unit/integrations/exchange/test_service_validate_symbol.py` Добавлен отдельный набор unit-тестов для публичного legacy-контракта `ExchangeService.validate_symbol()`. --- ## Тестовое покрытие ### Канонический symbol resolver Выполнена команда: python -m pytest \ tests/unit/market_data/acquisition/test_symbols.py \ -q Результат: 41 passed in 0.02s Проверены: - точное совпадение; - регистронезависимое совпадение; - внешние пробелы; - `%2F`; - внутренние пробелы; - отсутствующий символ; - пустой запрос; - пустая последовательность доступных символов; - приоритет кандидатов; - порядок доступных символов; - первый дубликат; - отсутствие изменения входной последовательности. --- ## Тестирование `ExchangeService.validate_symbol()` Выполнена команда: python -m pytest \ tests/unit/integrations/exchange/test_service_validate_symbol.py \ -q Результат: 16 passed in 0.08s Проверены: - отклонение пустого символа; - mock mode; - точное совпадение; - регистронезависимость; - внешние пробелы; - encoded separator `%2F`; - внутренние пробелы; - отсутствующий символ; - возврат исходного `ExchangeSymbol`; - нормализация фактически найденного символа; - сохранение success message; - единственный вызов `get_exchange_symbols()`; - отсутствие прямого обращения к acquisition; - сохранение candidate priority; - возврат первого duplicate; - сохранение типа `SymbolValidationResult`. --- ## Проверка компиляции Выполнена команда: python -m py_compile \ src/market_data/acquisition/symbols.py \ src/integrations/exchange/service.py \ tests/unit/market_data/acquisition/test_symbols.py \ tests/unit/integrations/exchange/test_service_validate_symbol.py Результат: Успешно. Ошибок компиляции нет. --- ## Полный regression suite Выполнена команда: python -m pytest -q Результат: 299 passed in 0.17s Регрессий в существующем проекте не обнаружено. --- ## Финальная архитектурная проверка Выполнен поиск: grep -RIn \ --exclude-dir="__pycache__" \ --exclude="*.pyc" \ -E "validate_symbol|resolve_symbol_index|symbol_candidates|normalize_symbol|SymbolValidationResult|symbol_info" \ src tests Проверка подтвердила: - каноническая реализация `normalize_symbol()` находится в `src/market_data/acquisition/symbols.py`; - каноническая реализация `symbol_candidates()` находится там же; - `resolve_symbol_index()` находится там же; - `ExchangeService.validate_symbol()` использует `resolve_symbol_index()`; - старого двойного цикла внутри `validate_symbol()` больше нет; - `ExchangeService` использует канонические функции новой подсистемы; - legacy facade `src/integrations/exchange/symbol_utils.py` сохранён; - `SymbolValidationResult` сохранён; - `symbol_info: ExchangeSymbol | None` сохранён; - новый cache не добавлен; - прямого обращения `validate_symbol()` к acquisition adapter нет; - дублирования алгоритма разрешения символа не обнаружено. --- ## Архитектурные гарантии после Build 017 После завершения Build 017 выполняются следующие гарантии: 1. Каноническая логика разрешения символов принадлежит `market_data/acquisition`. 2. `ExchangeService.validate_symbol()` больше не содержит собственного алгоритма поиска совпадения. 3. `validate_symbol()` продолжает использовать `get_exchange_symbols()` как compatibility boundary. 4. Публичный контракт `SymbolValidationResult` не изменён. 5. `symbol_info` продолжает иметь тип `ExchangeSymbol | None`. 6. Object identity найденного `ExchangeSymbol` сохраняется. 7. Порядок кандидатов сохраняется. 8. Порядок доступных символов сохраняется. 9. При дубликатах возвращается первый найденный объект. 10. Новый cache не введён. 11. Существующий `_exchange_symbols_cache` продолжает работать без изменений. 12. Legacy facade `symbol_utils.py` сохранён. 13. Все существующие тесты проекта проходят. --- ## Что намеренно не входит в Build 017 Build 017 не выполняет: - удаление `SymbolValidationResult`; - перевод всех потребителей на `Instrument`; - удаление `ExchangeSymbol`; - удаление compatibility mapper; - удаление `_exchange_symbols_cache`; - прямую работу `validate_symbol()` с `tuple[Instrument, ...]`; - создание нового instrument cache; - изменение публичного API `ExchangeService`; - изменение runtime-логики бота; - изменение торговой логики. Эти изменения должны выполняться отдельными контролируемыми Build-шагами. --- ## Итог Build 017 завершён успешно. Реализовано переключение: ExchangeService.validate_symbol() │ ▼ legacy inline symbol matching на: ExchangeService.validate_symbol() │ ▼ market_data.acquisition.resolve_symbol_index() При этом полностью сохранены: - публичный legacy-контракт; - `SymbolValidationResult`; - `ExchangeSymbol`; - `symbol_info`; - object identity; - порядок разрешения; - mock mode; - compatibility boundary; - cache; - работа существующего бота. Финальный результат: BUILD 017 — COMPLETE Полный regression suite: 299 passed in 0.17s