# Build 022 — Перевод валидации торгового символа на канонический `Instrument`

**Engineering Build Record**

---

## Контроль документа

| Свойство | Значение |
|---|---|
| Документ | Build 022 — Перевод валидации торгового символа на канонический `Instrument` |
| Тип документа | Engineering Build Record |
| Build | 022 |
| Статус | **Завершён** |
| Подсистема | Market Data Acquisition / Exchange Integration |
| Проект | Dzentra |
| Язык | Русский |
| Предыдущий Build | Build 021 — Перевод первой группы потребителей |
| Следующий этап | Build 023+ — Поэтапный перевод остальных потребителей |

---

## 1. Назначение Build

Build 022 продолжает поэтапную миграцию Dzentra с legacy-модели биржевого инструмента `ExchangeSymbol` на каноническую модель:

```text
Instrument
```

Основная задача Build 022:

> Перевести механизм валидации торгового символа `ExchangeService.validate_symbol()` и непосредственно связанный с ним runtime-status с legacy-модели `ExchangeSymbol` на канонический справочник `Instrument`, сохранив существующий внешний контракт и работоспособность старого бота.

После завершения Build 022 метод:

```python
ExchangeService.validate_symbol()
```

больше не использует:

```python
get_exchange_symbols()
```

для поиска и валидации торгового инструмента.

Каноническим источником данных теперь является:

```python
get_instruments()
```

который возвращает:

```python
tuple[Instrument, ...]
```

---

## 2. Архитектурный контекст

До начала миграции в системе существовала следующая цепочка:

```text
Exchange API
    ↓
exchangeInfo
    ↓
ExchangeService.get_exchange_symbols()
    ↓
list[ExchangeSymbol]
    ↓
ExchangeService.validate_symbol()
    ↓
SymbolValidationResult.symbol_info
    ↓
ExchangeSymbol | None
```

После внедрения нового Market Data Acquisition Layer в системе появился канонический pipeline:

```text
Exchange API
    ↓
Instrument Document Source
    ↓
Instrument Document Handler
    ↓
Instrument Feed
    ↓
Instrument Feed Registry
    ↓
Instrument Acquisition Service
    ↓
Instrument Store
    ↓
tuple[Instrument, ...]
```

На предыдущих этапах был создан новый канонический API:

```python
ExchangeService.get_instruments() -> tuple[Instrument, ...]
```

Build 021 перевёл первого внешнего потребителя:

```text
src/telegram/ui/currency_ui.py
```

с:

```python
get_exchange_symbols()
ExchangeSymbol
```

на:

```python
get_instruments()
Instrument
```

Build 022 продолжает миграцию на более глубоком уровне и переводит на `Instrument` механизм валидации торговых символов.

---

## 3. Состояние до Build 022

До Build 022 метод:

```python
ExchangeService.validate_symbol()
```

использовал:

```python
symbols = self.get_exchange_symbols()
```

и выполнял поиск среди:

```python
list[ExchangeSymbol]
```

Результат валидации имел контракт:

```python
@dataclass(slots=True)
class SymbolValidationResult:
    requested_symbol: str
    normalized_symbol: str

    is_valid: bool
    message: str

    symbol_info: ExchangeSymbol | None
```

Таким образом, несмотря на наличие канонического `Instrument Store`, валидация торгового символа всё ещё зависела от legacy-проекции:

```text
Instrument Store
    ↓
Instrument
    ↓
compatibility mapper
    ↓
ExchangeSymbol
    ↓
validate_symbol()
```

Это создавало лишний промежуточный слой:

```text
Instrument → ExchangeSymbol → validation
```

вместо прямой канонической цепочки:

```text
Instrument → validation
```

---

## 4. Целевое состояние Build 022

После Build 022 архитектура валидации выглядит следующим образом:

```text
Exchange API
    ↓
Market Data Acquisition
    ↓
Instrument Store
    ↓
ExchangeService.get_instruments()
    ↓
tuple[Instrument, ...]
    ↓
ExchangeService.validate_symbol()
    ↓
SymbolValidationResult
    ↓
Instrument | None
```

Главное архитектурное правило Build 022:

> `validate_symbol()` обязан работать непосредственно с каноническим справочником `Instrument` и не должен использовать legacy-метод `get_exchange_symbols()`.

---

## 5. Изменённые компоненты

В рамках Build 022 были изменены следующие файлы:

```text
app/src/integrations/exchange/models.py

app/src/integrations/exchange/service.py

app/tests/unit/integrations/exchange/test_service_validate_symbol.py

app/tests/unit/integrations/exchange/test_service_symbol_runtime_status.py
```

---

## 6. Изменение `SymbolValidationResult`

### До Build 022

Поле:

```python
symbol_info
```

содержало:

```python
ExchangeSymbol | None
```

Контракт:

```python
@dataclass(slots=True)
class SymbolValidationResult:
    requested_symbol: str
    normalized_symbol: str

    is_valid: bool
    message: str

    symbol_info: ExchangeSymbol | None
```

### После Build 022

Поле:

```python
symbol_info
```

содержит каноническую модель:

```python
Instrument | None
```

Итоговый контракт:

```python
@dataclass(slots=True)
class SymbolValidationResult:
    requested_symbol: str
    normalized_symbol: str

    is_valid: bool
    message: str

    symbol_info: Instrument | None
```

Для type checking используется импорт:

```python
if TYPE_CHECKING:
    from src.market_data.acquisition.models.instrument import Instrument
```

Это позволяет избежать ненужной runtime-зависимости модели exchange integration от реализации acquisition model при сохранении корректной типизации.

---

## 7. Изменение `validate_symbol()`

### До Build 022

Метод получал legacy-справочник:

```python
symbols = self.get_exchange_symbols()
```

и выполнял поиск среди объектов:

```python
ExchangeSymbol
```

Архитектурная цепочка имела вид:

```text
validate_symbol()
    ↓
get_exchange_symbols()
    ↓
compatibility projection
    ↓
ExchangeSymbol
```

### После Build 022

Метод получает канонический справочник:

```python
instruments = self.get_instruments()
```

и выполняет поиск непосредственно среди:

```python
Instrument
```

Новая цепочка:

```text
validate_symbol()
    ↓
get_instruments()
    ↓
Instrument Store
    ↓
Instrument
```

Legacy compatibility projection больше не участвует в валидации торгового символа.

---

## 8. Сохранённая семантика валидации

Build 022 не меняет пользовательскую и runtime-семантику `validate_symbol()`.

Сохранены следующие сценарии.

### 8.1. Пустой символ

Вход:

```text
"   "
```

Результат:

```python
SymbolValidationResult(
    requested_symbol="",
    normalized_symbol="",
    is_valid=False,
    message="Символ пустой.",
    symbol_info=None,
)
```

---

### 8.2. Mock mode

При:

```python
exchange_enabled=False
```

символ принимается без обращения к каноническому справочнику.

Пример результата:

```python
SymbolValidationResult(
    requested_symbol="BTC/USD_LEVERAGE",
    normalized_symbol="BTC/USD_LEVERAGE",
    is_valid=True,
    message="Mock mode active.",
    symbol_info=None,
)
```

---

### 8.3. Регистронезависимый поиск

Следующие значения считаются эквивалентными:

```text
BTC/USD_LEVERAGE
btc/usd_leverage
```

---

### 8.4. Игнорирование внешних пробелов

Следующие значения считаются эквивалентными:

```text
BTC/USD_LEVERAGE
  btc/usd_leverage
```

---

### 8.5. Поддержка encoded separator

Поддерживается представление:

```text
BTC%2FUSD_LEVERAGE
```

наряду с:

```text
BTC/USD_LEVERAGE
```

---

### 8.6. Поддержка внутренних пробелов

Поддерживается нормализация значения:

```text
btc / usd_leverage
```

до:

```text
BTC/USD_LEVERAGE
```

---

### 8.7. Отсутствующий инструмент

Если инструмент отсутствует в каноническом справочнике, возвращается неуспешный результат:

```python
SymbolValidationResult(
    requested_symbol="XRP/USD_LEVERAGE",
    normalized_symbol="XRP/USD_LEVERAGE",
    is_valid=False,
    message=(
        "Символ 'XRP/USD_LEVERAGE' "
        "не найден в exchangeInfo."
    ),
    symbol_info=None,
)
```

Сообщение сохранено для обратной совместимости.

---

### 8.8. Сохранение приоритета кандидатов

Если разные исходные значения после нормализации могут соответствовать одному символу, сохраняется существующий порядок выбора кандидатов.

В частности, точное исходное представление продолжает иметь приоритет в предусмотренных legacy-сценариях.

---

### 8.9. Сохранение первого дубликата

Если канонический справочник содержит несколько эквивалентных инструментов, возвращается первый подходящий объект согласно существующему порядку справочника.

---

## 9. Канонический объект в результате валидации

После Build 022 успешный результат:

```python
validation = service.validate_symbol(
    "BTC/USD_LEVERAGE"
)
```

содержит:

```python
validation.symbol_info
```

типа:

```python
Instrument
```

а не:

```python
ExchangeSymbol
```

Дополнительно сохранена identity-семантика.

Если канонический справочник содержит объект:

```python
instrument
```

то после успешной валидации выполняется:

```python
validation.symbol_info is instrument
```

То есть `validate_symbol()`:

- не создаёт копию `Instrument`;
- не создаёт `ExchangeSymbol`;
- не выполняет compatibility mapping;
- возвращает исходный канонический объект из справочника.

---

## 10. Перевод runtime-status

Метод:

```python
ExchangeService.get_symbol_runtime_status()
```

использует:

```python
validation = self.validate_symbol(symbol_to_use)
```

и получает статус торгового инструмента через:

```python
validation.symbol_info.status
```

После изменения контракта:

```python
SymbolValidationResult.symbol_info
```

runtime-status теперь фактически получает канонический:

```python
Instrument
```

вместо legacy:

```python
ExchangeSymbol
```

При этом внешний контракт runtime-status не изменён.

Сохраняются:

```text
ExchangeRuntimeStatus
ExchangeStatusCode
get_symbol_market_status()
```

и существующая классификация состояний рынка.

---

## 11. Сохранённая классификация runtime-status

Build 022 сохраняет существующую классификацию биржевых статусов.

### Открытый рынок

Следующие статусы классифицируются как открытый рынок:

```text
TRADING
OPEN
ACTIVE
ENABLED
ONLINE
```

Результат:

```text
ExchangeStatusCode.OPEN
reason = "market_open"
```

---

### Перерыв или остановка торгов

Следующие статусы классифицируются как перерыв:

```text
BREAK
CLOSED
HALT
HALTED
PAUSED
SUSPENDED
DISABLED
SETTLING
POST_ONLY
```

Результат:

```text
ExchangeStatusCode.BREAK
reason = "market_break"
```

---

### Инструмент недоступен для торговли

Следующие статусы классифицируются как неторгуемые:

```text
NOT_TRADABLE
TRADING_DISABLED
MARKET_DISABLED
UNAVAILABLE_FOR_TRADING
CLOSE_ONLY
REDUCE_ONLY
VIEW_ONLY
```

Результат:

```text
ExchangeStatusCode.BREAK
reason = "market_not_tradable"
```

---

### Неизвестный статус

Неизвестные значения классифицируются как:

```text
ExchangeStatusCode.UNKNOWN
reason = "market_status_unknown"
```

---

## 12. Сохранение проверки freshness

Build 022 не изменяет существующую проверку свежести рыночных данных.

Для открытого рынка runtime-status продолжает проверять:

```python
get_fresh_market_snapshot()
```

Если данные устарели, возвращается:

```text
ExchangeStatusCode.BREAK
reason = "market_data_stale"
raw_status = "STALE_MARKET_DATA"
```

Проверка freshness выполняется только для инструмента, который сначала классифицирован как открытый для торговли.

Для закрытого или приостановленного рынка получение snapshot не выполняется.

---

## 13. Сохранение legacy runtime-контракта

Метод:

```python
get_symbol_market_status()
```

продолжает возвращать legacy dictionary contract.

Пример:

```python
{
    "code": "BREAK",
    "status": "BREAK",
    "symbol": "BTC/USD",
    "is_open": False,
    "is_available": True,
    "is_auth_ok": True,
    "title": "Перерыв в торгах",
    "message": "Торги временно остановлены.",
    "ui_line": "⏸️ Перерыв в торгах",
    "reason": "market_break",
    "raw_status": "BREAK",
    "raw_error": None,
}
```

Таким образом, Build 022 меняет внутренний источник reference data, но не ломает существующий внешний runtime-контракт старого бота.

---

## 14. Архитектурные ограничения, закреплённые тестами

Build 022 вводит и закрепляет следующие архитектурные правила.

### Правило 1

`validate_symbol()` обязан использовать:

```python
get_instruments()
```

---

### Правило 2

`validate_symbol()` не должен использовать:

```python
get_exchange_symbols()
```

---

### Правило 3

`validate_symbol()` не должен напрямую вызывать:

```python
_load_instruments_via_acquisition()
```

Правильная цепочка:

```text
validate_symbol()
    ↓
get_instruments()
    ↓
Instrument Store
    ↓
Acquisition Pipeline при необходимости
```

Неправильная цепочка:

```text
validate_symbol()
    ↓
_load_instruments_via_acquisition()
```

---

### Правило 4

Успешная валидация должна возвращать исходный канонический:

```python
Instrument
```

---

### Правило 5

Legacy API:

```python
get_exchange_symbols()
```

может временно существовать только как compatibility layer для ещё не переведённых потребителей.

Он больше не является каноническим источником данных для `validate_symbol()`.

---

## 15. Тестовое покрытие `validate_symbol()`

После Build 022 тесты подтверждают следующие сценарии:

```text
пустой символ;
mock mode;
точное совпадение Instrument;
регистронезависимый поиск;
игнорирование внешних пробелов;
поддержка encoded separator;
поддержка внутренних пробелов;
отсутствующий инструмент;
возврат исходного объекта Instrument;
нормализация фактически найденного символа;
сохранение success message;
единственный вызов get_instruments();
запрет использования legacy get_exchange_symbols();
запрет прямого вызова acquisition;
сохранение приоритета кандидатов;
возврат первого дубликата;
сохранение типа SymbolValidationResult;
возврат канонического Instrument.
```

Результат:

```text
18 passed in 0.11s
```

---

## 16. Тестовое покрытие runtime-status

Тесты runtime-status были переведены с:

```python
ExchangeSymbol
```

на:

```python
Instrument
```

и подтверждают сохранение существующей runtime-семантики.

Проверяются:

```text
mock status;
explicit symbol;
default symbol;
invalid symbol;
exchange unavailable;
классификация открытого рынка;
классификация остановленного рынка;
классификация неторгуемого инструмента;
неизвестные статусы;
freshness только для открытого рынка;
stale market data;
граница freshness threshold;
отсутствующий age;
ошибка получения snapshot;
использование нормализованного символа;
сохранение legacy dictionary contract.
```

Результат:

```text
34 passed in 0.09s
```

---

## 17. Проверка компиляции

Выполнена команда:

```bash
python -m py_compile \
  src/integrations/exchange/models.py \
  src/integrations/exchange/service.py \
  tests/unit/integrations/exchange/test_service_validate_symbol.py \
  tests/unit/integrations/exchange/test_service_symbol_runtime_status.py
```

Результат:

```text
Ошибок компиляции нет.
```

---

## 18. Полный regression suite

После завершения Build 022 выполнен полный набор тестов:

```bash
python -m pytest -q
```

Результат:

```text
462 passed in 0.24s
```

Это подтверждает, что переход:

```text
validate_symbol()
    ↓
ExchangeSymbol → Instrument
```

не нарушил существующую функциональность проекта.

---

## 19. Состояние legacy-слоя после Build 022

После Build 022 legacy-компоненты всё ещё существуют:

```python
ExchangeSymbol
get_exchange_symbols()
map_instruments_to_exchange_symbols()
_exchange_symbols_projection_cache
```

Это ожидаемое состояние.

Они сохраняются для ещё не переведённых потребителей и не должны удаляться до завершения их поэтапной миграции.

Текущая архитектура:

```text
                         ┌─────────────────────┐
                         │  Instrument Store   │
                         └──────────┬──────────┘
                                    │
                                    ▼
                         ┌─────────────────────┐
                         │  get_instruments()  │
                         └──────────┬──────────┘
                                    │
                  ┌─────────────────┴─────────────────┐
                  │                                   │
                  ▼                                   ▼
       ┌─────────────────────┐             ┌─────────────────────┐
       │ Canonical consumers │             │ Compatibility layer │
       └─────────────────────┘             └──────────┬──────────┘
                  │                                   │
                  │                                   ▼
                  │                        ┌─────────────────────┐
                  │                        │   ExchangeSymbol    │
                  │                        └──────────┬──────────┘
                  │                                   │
                  ▼                                   ▼
       ┌─────────────────────┐             ┌─────────────────────┐
       │ currency_ui.py      │             │ Legacy consumers    │
       │ validate_symbol()   │             │ not migrated yet    │
       │ runtime-status      │             │                     │
       └─────────────────────┘             └─────────────────────┘
```

---

## 20. Что не входило в Build 022

Build 022 намеренно не выполняет полное удаление legacy-слоя.

В рамках этого Build не удаляются:

```python
ExchangeSymbol
get_exchange_symbols()
map_instrument_to_exchange_symbol()
map_instruments_to_exchange_symbols()
_exchange_symbols_projection_cache
```

Также Build 022 не выполняет массовый перевод всех оставшихся потребителей.

Не входят в scope Build 022 отдельные runtime-потребители:

```text
src/integrations/exchange/market_stream.py

src/integrations/exchange/market_data_runner.py

src/telegram/handlers/market.py
```

Их миграция должна выполняться поэтапно в следующих Build с отдельной проверкой контрактов и regression suite.

---

## 21. Итоговое состояние после Build 022

После завершения Build 022 достигнуто следующее состояние:

```text
Instrument Acquisition Pipeline
    ↓
Instrument Store
    ↓
get_instruments()
    ↓
    ├── currency_ui.py
    │
    ├── validate_symbol()
    │       ↓
    │   SymbolValidationResult
    │       ↓
    │   Instrument | None
    │
    └── runtime-status
```

Ключевой результат:

> Валидация торгового символа больше не зависит от legacy-модели `ExchangeSymbol` и работает непосредственно с каноническим справочником `Instrument`.

При этом:

```text
старый бот продолжает работать;
внешний контракт validate_symbol() сохранён;
runtime-status сохранён;
legacy dictionary contract сохранён;
полный regression suite проходит;
legacy compatibility layer остаётся доступен для ещё не переведённых потребителей.
```

---

## 22. Критерии завершения Build 022

Build 022 считается завершённым, поскольку выполнены все критерии:

- [x] `SymbolValidationResult.symbol_info` переведён на `Instrument | None`.
- [x] `validate_symbol()` использует `get_instruments()`.
- [x] `validate_symbol()` больше не использует `get_exchange_symbols()`.
- [x] `validate_symbol()` не вызывает acquisition pipeline напрямую.
- [x] Успешная валидация возвращает исходный канонический `Instrument`.
- [x] Сохранена существующая нормализация торговых символов.
- [x] Сохранена семантика mock mode.
- [x] Сохранена семантика invalid symbol.
- [x] Сохранён runtime-status.
- [x] Сохранена классификация биржевых статусов.
- [x] Сохранена freshness-проверка.
- [x] Сохранён legacy dictionary contract.
- [x] Целевые тесты `validate_symbol()` проходят: `18 passed`.
- [x] Целевые тесты runtime-status проходят: `34 passed`.
- [x] `py_compile` проходит без ошибок.
- [x] Полный regression suite проходит: `462 passed`.

---

## 23. Следующий этап

Следующий этап:

```text
Build 023+ — Поэтапный перевод остальных потребителей
```

Перед началом следующего Build необходимо определить очередную минимальную группу потребителей, которая всё ещё зависит от legacy reference data:

```python
ExchangeSymbol
get_exchange_symbols()
SymbolValidationResult.symbol_info
```

Рекомендуемый принцип дальнейшей миграции:

```text
один логически связанный набор потребителей
    ↓
перевод на Instrument
    ↓
целевые тесты
    ↓
полный regression suite
    ↓
grep-аудит
    ↓
документ Build
    ↓
следующий этап
```

Полное удаление:

```python
ExchangeSymbol
get_exchange_symbols()
map_instruments_to_exchange_symbols()
_exchange_symbols_projection_cache
```

должно выполняться только после того, как последний реальный потребитель будет переведён на канонический `Instrument`.

---

## 24. Финальный результат Build 022

```text
Build 022: ЗАВЕРШЁН

Канонический справочник:
Instrument Store
    ↓
get_instruments()
    ↓
Instrument

Переведённые потребители:
    ├── currency_ui.py
    ├── validate_symbol()
    └── runtime-status через validate_symbol()

Legacy compatibility:
    ├── ExchangeSymbol
    ├── get_exchange_symbols()
    ├── compatibility mapper
    └── projection cache

Статус legacy compatibility:
Временно сохранён для ещё не переведённых потребителей.

Regression:
462 passed.
```