32 KiB
Build 012 — Instrument Acquisition Service
Статус: Завершён
Подсистема: market_data/acquisition
Область: Instrument Reference Data
Тип изменения: Изолированное добавление application-level сервиса получения справочника инструментов без подключения к production runtime
Результат полного набора тестов: 180 passed
1. Цель Build 012
Цель Build 012 — реализовать application-level сервис, который получает Instrument Feed из Registry и запускает загрузку справочника инструментов.
Реализован класс:
InstrumentAcquisitionService
Его архитектурная граница:
source_name
↓
InstrumentAcquisitionService
↓
InstrumentFeedRegistry
↓
InstrumentFeedProtocol
↓
load_instruments()
↓
tuple[Instrument, ...]
Build 012 завершает orchestration-слой новой изолированной acquisition pipeline.
На этом этапе сервис:
получает Feed из Registry;
вызывает Feed;
возвращает результат Feed без изменения;
сохраняет специализированные ошибки без wrapping.
Build 012 не подключается к существующему ExchangeService, не меняет legacy runtime и не переключает production-потребителей на новую реализацию.
2. Почему Build 012 выполняется именно сейчас
До начала Build 012 были завершены:
Build 001 — внутренняя модель Instrument Reference Data
Build 002 — raw-модели ответа Dzengi
Build 003 — структурная валидация exchangeInfo
Build 004 — parser exchangeInfo
Build 005 — value validation
Build 006 — mapper Dzengi → Instrument
Build 007 — Protocol и Exceptions
Build 008 — Dzengi REST Adapter
Build 009 — Instrument Handler
Build 010 — Instrument Feed
Build 011 — Instrument Feed Registry
После Build 011 уже существовала цепочка:
source_name
↓
InstrumentFeedRegistry
↓
InstrumentFeedProtocol
И отдельно существовал полный Feed pipeline:
InstrumentFeed
↓
InstrumentDocumentSource
↓
DzengiInstrumentDocumentSource
↓
Dzengi REST API
↓
raw document
↓
InstrumentDocumentHandler
↓
DzengiInstrumentDocumentHandler
↓
schema validation
↓
parser
↓
value validation
↓
mapper
↓
tuple[Instrument, ...]
Однако отсутствовала application-level точка входа, соединяющая Registry с запуском Feed.
Build 012 создаёт эту точку:
InstrumentAcquisitionService
3. Архитектурная ответственность Acquisition Service
Единственная предметная ответственность сервиса:
получить source_name
↓
получить Feed через Registry
↓
вызвать load_instruments()
↓
вернуть tuple[Instrument, ...]
Минимальная логика сервиса:
feed = self._registry.get(source_name)
return feed.load_instruments()
Сервис не содержит собственной transport-, parsing-, validation-, mapping- или storage-логики.
4. Изменённые файлы
В рамках Build 012 реализован:
app/src/market_data/acquisition/service.py
Создан тестовый файл:
app/tests/unit/market_data/acquisition/test_service.py
Другие production-файлы не изменялись.
В частности, не изменялись:
app/src/market_data/acquisition/exceptions.py
app/src/market_data/acquisition/protocol.py
app/src/market_data/acquisition/registry.py
app/src/market_data/acquisition/feeds/instrument_feed.py
app/src/market_data/acquisition/adapters/dzengi/rest.py
app/src/market_data/acquisition/handlers/instrument_handler.py
app/src/integrations/exchange/*
app/src/telegram/*
app/src/trading/*
Re-export в __init__.py не добавлялся.
5. Реализованный InstrumentAcquisitionService
В файле:
app/src/market_data/acquisition/service.py
реализован класс:
class InstrumentAcquisitionService:
...
Его публичный контракт:
def load_instruments(
self,
source_name: str,
) -> tuple[Instrument, ...]:
...
Полная схема:
InstrumentAcquisitionService.load_instruments(source_name)
↓
InstrumentFeedRegistry.get(source_name)
↓
InstrumentFeedProtocol
↓
InstrumentFeedProtocol.load_instruments()
↓
tuple[Instrument, ...]
6. Явная dependency injection Registry
Registry передаётся сервису через конструктор:
def __init__(
self,
*,
registry: InstrumentFeedRegistry,
) -> None:
self._registry = registry
Правильная композиция:
registry = InstrumentFeedRegistry()
registry.register(
"dzengi",
feed,
)
service = InstrumentAcquisitionService(
registry=registry,
)
После этого:
instruments = service.load_instruments("dzengi")
Сервис не создаёт Registry самостоятельно.
7. Почему Service не создаёт Registry
Внутри InstrumentAcquisitionService отсутствует:
self._registry = InstrumentFeedRegistry()
Это принципиальное архитектурное решение.
Если бы Service самостоятельно создавал Registry:
Service
↓
создаёт Registry
↓
Registry изначально пуст
↓
требуется скрытая регистрация Feed
↓
Service начинает знать о конкретных источниках
Вместо этого используется:
готовый Registry
↓
передаётся Service
Это обеспечивает:
явные зависимости;
контролируемую composition;
независимость от конкретного источника;
простое тестирование;
отсутствие скрытой инициализации.
8. Независимость от Dzengi
InstrumentAcquisitionService не импортирует и не создаёт:
DzengiInstrumentDocumentSource
DzengiInstrumentDocumentHandler
DzengiExchangeInfoResponse
ExchangeRestClient
InstrumentFeed
Dzengi parser
Dzengi mapper
Сервис зависит только от:
Instrument
InstrumentFeedRegistry
Архитектурная схема:
InstrumentAcquisitionService
↓
InstrumentFeedRegistry
↓
InstrumentFeedProtocol
Конкретный источник определяется снаружи через регистрацию Feed.
9. Передача source_name без изменения
Service не выполняет над source_name:
strip()
lower()
casefold()
replace()
alias resolution
automatic source mapping
Он непосредственно передаёт полученное значение Registry:
feed = self._registry.get(source_name)
Например:
" dzengi "
передаётся в Registry именно как:
" dzengi "
Нормализация внешних пробелов является ответственностью:
InstrumentFeedRegistry
Это исключает дублирование правил между Service и Registry.
10. Получение Feed через Registry
Service не хранит Feed напрямую.
Отсутствует:
self._feed = feed
Вместо этого для каждого вызова:
service.load_instruments(source_name)
выполняется:
feed = self._registry.get(source_name)
Таким образом:
source_name
↓
Registry
↓
соответствующий Feed
Service не определяет самостоятельно, какой Feed использовать.
11. Однократный вызов Registry
Для одного вызова:
service.load_instruments("dzengi")
метод:
registry.get("dzengi")
вызывается ровно один раз.
Отсутствуют:
повторный lookup;
предварительная проверка наличия;
двойной get();
fallback lookup.
Это подтверждено unit-тестом.
12. Однократный вызов Feed
После успешного получения Feed выполняется:
feed.load_instruments()
ровно один раз.
Цепочка:
Service.load_instruments()
↓
Registry.get()
↓
Feed.load_instruments()
↓
return result
Отсутствуют:
retry;
повторный вызов после ошибки;
предварительный вызов;
дополнительная проверочная загрузка.
13. Возврат результата без копирования
Service непосредственно возвращает:
return feed.load_instruments()
Он не выполняет:
tuple(feed.load_instruments())
или:
result = feed.load_instruments()
return tuple(result)
Поэтому сохраняется identity результата:
result is instruments
равно:
True
Это подтверждено unit-тестами.
14. Сохранение порядка инструментов
Service не выполняет:
sorting;
filtering;
deduplication;
grouping;
reordering.
Если Feed возвращает:
BTC/USD_LEVERAGE
ETH/USD_LEVERAGE
XRP/USD_LEVERAGE
Service возвращает инструменты в том же порядке:
BTC/USD_LEVERAGE
ETH/USD_LEVERAGE
XRP/USD_LEVERAGE
Порядок результата Feed сохраняется.
15. Поведение при пустом результате
Если Feed возвращает:
()
Service также возвращает:
()
без ошибки.
Service не интерпретирует пустой результат как:
transport error;
schema error;
value error;
mapping error;
отсутствие источника.
На Build 012 отсутствует утверждённое правило, согласно которому пустой tuple должен считаться ошибкой.
16. Сохранение специализированных ошибок
В InstrumentAcquisitionService отсутствует общий try/except, который заменял бы исходные ошибки новой общей ошибкой.
Без изменения могут пройти:
InstrumentFeedRegistryError
InstrumentReferenceTransportError
InstrumentReferenceSchemaError
InstrumentReferenceParseError
InstrumentReferenceValueError
InstrumentReferenceMappingError
Схема:
Registry error
↓
Service
↓
та же Registry error
или:
Feed error
↓
Service
↓
та же Feed error
17. Сохранение identity ошибки
Тесты подтверждают не только тип ошибки, но и сохранение исходного объекта.
Если Feed выбрасывает:
original_error = InstrumentReferenceTransportError(
"Network error."
)
то Service передаёт именно этот объект:
exc_info.value is original_error
равно:
True
Ошибка не:
копируется;
оборачивается;
заменяется;
переводится в другой тип.
18. Ошибка отсутствующего Feed
Если Registry не содержит источник:
"dzengi"
вызов:
service.load_instruments("dzengi")
приводит к:
InstrumentFeedRegistryError
Service не выполняет:
fallback;
создание Feed;
регистрацию Feed;
использование default source;
возврат пустого tuple.
Ошибка Registry проходит наружу без wrapping.
19. Feed не вызывается при ошибке Registry
Последовательность:
Service.load_instruments()
↓
Registry.get()
↓
InstrumentFeedRegistryError
останавливается на ошибке Registry.
Метод:
feed.load_instruments()
не вызывается.
Это подтверждено unit-тестом.
20. Отсутствие retry после ошибки Feed
Если Feed выбрасывает:
InstrumentReferenceTransportError
Service не повторяет вызов.
Схема:
feed.load_instruments()
↓
InstrumentReferenceTransportError
↓
ошибка немедленно выходит из Service
Счётчик вызовов Feed остаётся:
1
Таким образом, Build 012 не вводит скрытую retry policy.
21. Почему retry отсутствует
Retry является отдельной operational policy.
Для его корректной реализации необходимо отдельно определить:
какие ошибки являются retryable;
максимальное число попыток;
интервалы между попытками;
backoff;
jitter;
timeout budget;
логирование повторных попыток;
поведение при исчерпании попыток.
Build 012 не должен неявно принимать эти архитектурные решения.
Поэтому:
один вызов Service
↓
один вызов Feed
22. Почему не создан новый Service exception
В Build 012 не добавлен:
InstrumentAcquisitionServiceError
Уже существуют специализированные ошибки:
InstrumentFeedRegistryError
InstrumentReferenceTransportError
InstrumentReferenceSchemaError
InstrumentReferenceParseError
InstrumentReferenceValueError
InstrumentReferenceMappingError
Создание общей ошибки:
InstrumentAcquisitionServiceError
и wrapping всех причин в неё ухудшило бы диагностируемость.
Поэтому сохраняется точная причина отказа.
23. Что Service не хранит
Внутри InstrumentAcquisitionService отсутствуют:
tuple[Instrument, ...];
последний успешный справочник;
предыдущий snapshot;
timestamp;
TTL;
cache age;
последняя ошибка;
индекс инструментов;
legacy ExchangeSymbol.
Service хранит только зависимость:
InstrumentFeedRegistry
24. Что Service не делает
Build 012 сознательно не выполняет:
REST-запросы напрямую;
получение exchangeInfo напрямую;
schema validation;
parsing;
value validation;
mapping;
создание Registry;
создание Feed;
создание Source;
создание Handler;
автоматическую регистрацию Feed;
retry;
backoff;
кэширование;
хранение Instrument;
сравнение snapshot;
фильтрацию инструментов;
сортировку инструментов;
дедупликацию инструментов;
нормализацию торговых символов;
преобразование Instrument в ExchangeSymbol;
изменение ExchangeService;
изменение AutoTrade;
изменение Telegram UI;
изменение trading runtime;
формирование пользовательских ошибок;
логирование событий.
Единственная orchestration-ответственность:
Registry lookup
↓
Feed invocation
25. Production composition не входит в Build 012
Build 012 не создаёт автоматически полную production-композицию:
DzengiInstrumentDocumentSource
+
DzengiInstrumentDocumentHandler
↓
InstrumentFeed
↓
InstrumentFeedRegistry
↓
InstrumentAcquisitionService
На текущем этапе новая pipeline остаётся изолированной.
Не создаётся:
global Registry;
global Service;
singleton Feed;
application startup wiring;
dependency container;
ExchangeService integration.
Это предотвращает преждевременное изменение production runtime.
26. Реализованные тестовые сценарии
Создан файл:
app/tests/unit/market_data/acquisition/test_service.py
Фактически выполнено:
13 tests
Проверены следующие сценарии:
- загрузка инструментов из зарегистрированного Feed;
- передача
source_nameRegistry без изменения; - однократный вызов Registry;
- однократный вызов Feed;
- возврат результата без копирования;
- сохранение порядка инструментов;
- возврат пустого tuple без ошибки;
- сохранение Registry error без wrapping;
- отсутствие вызова Feed при ошибке Registry;
- сохранение transport error без wrapping;
- сохранение value error без wrapping;
- сохранение mapping error без wrapping;
- отсутствие retry после ошибки Feed.
Часть сценариев реализована через параметризацию.
Фактическое количество выполненных тестовых случаев:
13
27. Проверка получения инструментов
Тест подтверждает:
registry = InstrumentFeedRegistry()
instruments = (
_instrument(),
)
feed = StubInstrumentFeed(
instruments=instruments,
)
registry.register(
"dzengi",
feed,
)
service = InstrumentAcquisitionService(
registry=registry,
)
result = service.load_instruments("dzengi")
assert result is instruments
Это доказывает:
Service получил Feed через Registry;
Feed был вызван;
результат Feed возвращён;
identity результата сохранена.
28. Проверка передачи source_name без изменения
Используется тестовый Registry, который записывает полученные имена.
Вызов:
service.load_instruments(" dzengi ")
приводит к передаче в Registry именно:
" dzengi "
Тест подтверждает:
assert registry.requested_source_names == [
" dzengi ",
]
Service не дублирует нормализацию Registry.
29. Проверка отсутствия retry
Тестовый Feed содержит счётчик:
self.load_call_count = 0
При вызове:
feed.load_instruments()
счётчик увеличивается.
Feed настроен на выбрасывание:
InstrumentReferenceTransportError
После вызова Service подтверждено:
assert feed.load_call_count == 1
Это доказывает отсутствие скрытой повторной попытки.
30. Выполненные проверки
Проверка 1 — unit-тесты Acquisition Service
Команда:
python -m pytest \
tests/unit/market_data/acquisition/test_service.py \
-q
Результат:
............. [100%]
13 passed in 0.01s
Статус:
PASSED
Проверка 2 — Python compilation
Команда:
python -m py_compile \
src/market_data/acquisition/service.py \
tests/unit/market_data/acquisition/test_service.py
Результат:
Команда завершилась без ошибок и без вывода.
Статус:
PASSED
Проверка 3 — полный набор тестов проекта
Команда:
python -m pytest -q
Результат:
.................................................................................................................................................................................... [100%]
180 passed in 0.09s
Статус:
PASSED
Проверка 4 — отсутствие преждевременной production-интеграции
Команда:
grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
-E "InstrumentAcquisitionService" \
src tests
Полученное production-определение находится только в:
src/market_data/acquisition/service.py
Остальные использования находятся исключительно в:
tests/unit/market_data/acquisition/test_service.py
Не обнаружено подключения к:
ExchangeService
Telegram UI
AutoTrade
Trading runtime
другим production-потребителям
Статус:
PASSED
31. Архитектура после Build 012
После завершения Build 012 новая часть подсистемы имеет следующую структуру:
market_data/
└── acquisition/
├── exceptions.py
│ ├── MarketDataAcquisitionError
│ ├── InstrumentReferenceTransportError
│ ├── InstrumentReferenceSchemaError
│ ├── InstrumentReferenceParseError
│ ├── InstrumentReferenceValueError
│ ├── InstrumentReferenceMappingError
│ └── InstrumentFeedRegistryError
│
├── protocol.py
│ ├── InstrumentDocumentSource
│ ├── InstrumentDocumentHandler
│ └── InstrumentFeedProtocol
│
├── registry.py
│ └── InstrumentFeedRegistry
│
├── service.py
│ └── InstrumentAcquisitionService
│
├── models/
│ └── instrument.py
│ └── Instrument
│
├── validation/
│ ├── schema.py
│ └── values.py
│
├── handlers/
│ └── instrument_handler.py
│ └── DzengiInstrumentDocumentHandler
│
├── feeds/
│ └── instrument_feed.py
│ └── InstrumentFeed
│
└── adapters/
└── dzengi/
├── models.py
├── parser.py
├── mapper.py
└── rest.py
├── _PayloadRestClient
└── DzengiInstrumentDocumentSource
32. Полная архитектурная цепочка после Build 012
После Build 012 реализована полная изолированная acquisition pipeline:
source_name
↓
InstrumentAcquisitionService
↓
InstrumentFeedRegistry
↓
InstrumentFeedProtocol
↓
InstrumentFeed
↓
InstrumentDocumentSource
↓
DzengiInstrumentDocumentSource
↓
ExchangeRestClient.get_payload()
↓
Dzengi REST API
После получения документа:
object
↓
InstrumentDocumentHandler
↓
DzengiInstrumentDocumentHandler
↓
validate_exchange_info_schema()
↓
ValidatedExchangeInfoDocument
↓
parse_exchange_info()
↓
DzengiExchangeInfoResponse
↓
validate_exchange_info_values()
↓
map_dzengi_exchange_info_to_instruments()
↓
tuple[Instrument, ...]
Таким образом, техническая цепочка новой acquisition-подсистемы завершена.
33. Что означает завершение изолированной acquisition pipeline
После Build 012 новая подсистема уже содержит все необходимые уровни:
Internal Model
↓
Raw Models
↓
Schema Validation
↓
Parser
↓
Value Validation
↓
Mapper
↓
Protocols
↓
REST Adapter
↓
Handler
↓
Feed
↓
Registry
↓
Acquisition Service
Но она ещё не заменяет legacy implementation.
Существующий бот продолжает использовать старый путь.
34. Что ещё не реализовано
После Build 012 отсутствуют:
проверка эквивалентности старой и новой реализации;
compatibility mapper Instrument → ExchangeSymbol;
переключение get_exchange_symbols();
перевод normalize_symbol()/symbol_candidates();
переключение validate_symbol();
переключение get_symbol_runtime_status();
подготовка переноса кэша;
перенос кэша;
перевод production-потребителей;
удаление legacy-кода.
Эти задачи относятся к следующим Build.
35. Влияние на legacy-систему
Build 012 не подключён к существующим компонентам:
ExchangeService
ExchangeSymbol
SymbolValidationResult
Telegram UI
AutoTrade
Market Stream
Market Data Runner
Execution Quality
Trading runtime
Не изменены:
ExchangeService.get_exchange_symbols()
ExchangeService.validate_symbol()
ExchangeService.get_symbol_runtime_status()
normalize_symbol()
symbol_candidates()
Старый production-путь продолжает работать без изменений.
36. Обратная совместимость
Подтверждено сохранение:
сигнатур существующих legacy-методов;
старых импортов;
существующего формата legacy-ошибок;
Telegram UI;
автоторговли;
runtime-поведения;
legacy ExchangeSymbol;
legacy-кэша.
Build 012 имеет полную обратную совместимость.
37. Классификация изменений
| Изменение | Классификация |
|---|---|
InstrumentAcquisitionService |
Обязательное архитектурное изменение |
| Registry lookup → Feed invocation | Обязательное архитектурное изменение |
| Явная dependency injection Registry | Обязательное разделение ответственности |
Передача source_name без изменения |
Исключение дублирования ответственности |
| Однократный вызов Registry | Предсказуемое orchestration-поведение |
| Однократный вызов Feed | Предсказуемое orchestration-поведение |
| Возврат результата без копирования | Сохранение контракта Feed |
| Сохранение специализированных ошибок | Улучшение диагностируемости |
| Retry | Не выполняется |
| Кэширование | Не выполняется |
| Production composition | Не выполняется |
| Изменение legacy-кода | Отсутствует |
| Изменение production-поведения | Отсутствует |
38. Условие завершения Build 012
Все условия выполнены:
InstrumentAcquisitionService
реализован;
Registry
передаётся как явная зависимость;
source_name
передаётся Registry без изменения;
Feed
получается через Registry;
Registry
вызывается ровно один раз;
Feed
вызывается ровно один раз;
результат Feed
возвращается без копирования;
порядок Instrument
сохраняется;
пустой tuple
возвращается без ошибки;
специализированные ошибки
сохраняются без wrapping;
identity ошибки
сохраняется;
Feed
не вызывается при ошибке Registry;
retry
отсутствует;
кэширование
отсутствует;
прямые Dzengi-зависимости
отсутствуют;
unit-тесты
проходят;
полный pytest
проходит;
production runtime
не затронут.
39. Итог Build 012
Build 012 завершён успешно.
Реализовано:
InstrumentAcquisitionService
source_name
↓
InstrumentFeedRegistry.get()
↓
InstrumentFeedProtocol
↓
load_instruments()
↓
tuple[Instrument, ...]
Подтверждено:
13 Acquisition Service tests passed
180 total project tests passed
Python compilation passed
No premature production integration detected
Legacy bot behavior unchanged
Итоговый статус:
BUILD 012 — COMPLETE
40. Следующий этап
Следующий этап утверждённого плана:
Build 013 — Проверка эквивалентности старой и новой реализации
Его задача — до любого переключения production-кода доказать, что legacy и новая implementation получают эквивалентные данные из одного и того же реального exchangeInfo.
Целевая схема:
один реальный exchangeInfo document
├──→ legacy implementation
│ ↓
│ list[ExchangeSymbol]
│
└──→ new acquisition pipeline
↓
tuple[Instrument, ...]
↓
equivalence comparison
На Build 013 необходимо определить точный набор полей для сравнения:
symbol;
name;
status;
base_asset;
quote_asset;
asset_type;
market_type;
market_modes;
order_types;
precisions;
tick_size;
tick_value;
step_size;
min_qty;
max_qty;
min_notional;
country;
sector;
industry;
trading_hours.
Build 013 не должен:
переключать ExchangeService.get_exchange_symbols();
изменять validate_symbol();
изменять get_symbol_runtime_status();
создавать compatibility mapper;
переносить кэш;
изменять Telegram UI;
изменять AutoTrade;
изменять trading runtime.
Только после доказанной эквивалентности можно переходить к:
Build 014 — Compatibility mapper Instrument → ExchangeSymbol