build 039: complete Quotes Feed migration foundation

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit 7b62873832
443 changed files with 80452 additions and 1335 deletions

View File

@@ -0,0 +1,18 @@
# app/src/storage/exceptions.py
from __future__ import annotations
# Базовая ошибка storage-слоя.
class StorageError(Exception):
"""Base storage layer error."""
# Ошибка хранилища справочника инструментов.
class InstrumentStoreError(StorageError):
"""Instrument store contract or operation error."""
# Ошибка хранилища канонических котировок.
class QuoteStoreError(StorageError):
"""Quote store contract or operation error."""

View File

@@ -0,0 +1,110 @@
# app/src/storage/instrument_store.py
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.models.instrument import Instrument
from src.storage.exceptions import InstrumentStoreError
# Контракт runtime-хранилища канонического справочника инструментов.
@runtime_checkable
class InstrumentStoreProtocol(Protocol):
def get(
self,
source_name: str,
) -> tuple[Instrument, ...] | None:
"""
Вернуть сохранённый набор инструментов для источника.
None означает cache miss: данные для источника ещё не сохранялись.
Пустой tuple означает успешное сохранение пустого справочника.
"""
def set(
self,
source_name: str,
instruments: tuple[Instrument, ...],
) -> None:
"""Сохранить полный immutable-набор инструментов источника."""
def clear(
self,
source_name: str | None = None,
) -> None:
"""
Очистить данные одного источника или всё хранилище.
source_name=None очищает все сохранённые источники.
"""
# In-memory реализация runtime-хранилища справочника инструментов.
class InMemoryInstrumentStore:
def __init__(self) -> None:
self._items: dict[str, tuple[Instrument, ...]] = {}
def get(
self,
source_name: str,
) -> tuple[Instrument, ...] | None:
normalized_source_name = self._normalize_source_name(
source_name
)
return self._items.get(normalized_source_name)
def set(
self,
source_name: str,
instruments: tuple[Instrument, ...],
) -> None:
normalized_source_name = self._normalize_source_name(
source_name
)
if not isinstance(instruments, tuple):
raise InstrumentStoreError(
"Справочник инструментов должен быть передан как tuple."
)
if not all(
isinstance(instrument, Instrument)
for instrument in instruments
):
raise InstrumentStoreError(
"Справочник содержит объект, не являющийся Instrument."
)
self._items[normalized_source_name] = instruments
def clear(
self,
source_name: str | None = None,
) -> None:
if source_name is None:
self._items.clear()
return
normalized_source_name = self._normalize_source_name(
source_name
)
self._items.pop(
normalized_source_name,
None,
)
def _normalize_source_name(
self,
source_name: str,
) -> str:
normalized_source_name = str(source_name or "").strip()
if not normalized_source_name:
raise InstrumentStoreError(
"Имя источника Instrument Store не должно быть пустым."
)
return normalized_source_name

View File

@@ -0,0 +1,215 @@
# app/src/storage/quote_store.py
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.models.quote import Quote
from src.storage.exceptions import QuoteStoreError
# Контракт runtime-хранилища канонических котировок.
@runtime_checkable
class QuoteStoreProtocol(Protocol):
def get(
self,
source_name: str,
symbol: str,
*,
runtime_key: str = "default",
) -> Quote | None:
"""Вернуть котировку или None, если запись отсутствует."""
def set(
self,
source_name: str,
quote: Quote,
*,
runtime_key: str = "default",
) -> None:
"""Сохранить каноническую котировку без копирования модели."""
def clear(
self,
source_name: str | None = None,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> None:
"""Удалить записи, соответствующие переданным фильтрам."""
# In-memory реализация runtime-хранилища канонических котировок.
class InMemoryQuoteStore:
def __init__(self) -> None:
self._items: dict[tuple[str, str, str], Quote] = {}
def get(
self,
source_name: str,
symbol: str,
*,
runtime_key: str = "default",
) -> Quote | None:
return self._items.get(
self._key(
source_name=source_name,
symbol=symbol,
runtime_key=runtime_key,
)
)
def set(
self,
source_name: str,
quote: Quote,
*,
runtime_key: str = "default",
) -> None:
normalized_source_name = self._normalize_source_name(
source_name
)
normalized_runtime_key = self._normalize_runtime_key(
runtime_key
)
if not isinstance(quote, Quote):
raise QuoteStoreError(
"Quote Store принимает только объект Quote."
)
normalized_symbol = self._normalize_symbol(
quote.symbol
)
self._items[
(
normalized_source_name,
normalized_runtime_key,
normalized_symbol,
)
] = quote
def clear(
self,
source_name: str | None = None,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> None:
if (
source_name is None
and symbol is None
and runtime_key is None
):
self._items.clear()
return
normalized_source_name = (
self._normalize_source_name(source_name)
if source_name is not None
else None
)
normalized_symbol = (
self._normalize_symbol(symbol)
if symbol is not None
else None
)
normalized_runtime_key = (
self._normalize_runtime_key(runtime_key)
if runtime_key is not None
else None
)
keys_to_delete = [
key
for key in self._items
if self._matches_filters(
key,
source_name=normalized_source_name,
symbol=normalized_symbol,
runtime_key=normalized_runtime_key,
)
]
for key in keys_to_delete:
self._items.pop(key, None)
def _key(
self,
*,
source_name: str,
symbol: str,
runtime_key: str,
) -> tuple[str, str, str]:
return (
self._normalize_source_name(source_name),
self._normalize_runtime_key(runtime_key),
self._normalize_symbol(symbol),
)
def _matches_filters(
self,
key: tuple[str, str, str],
*,
source_name: str | None,
symbol: str | None,
runtime_key: str | None,
) -> bool:
key_source_name, key_runtime_key, key_symbol = key
if (
source_name is not None
and key_source_name != source_name
):
return False
if (
runtime_key is not None
and key_runtime_key != runtime_key
):
return False
if symbol is not None and key_symbol != symbol:
return False
return True
def _normalize_source_name(
self,
source_name: str,
) -> str:
normalized_source_name = str(source_name or "").strip()
if not normalized_source_name:
raise QuoteStoreError(
"Имя источника Quote Store не должно быть пустым."
)
return normalized_source_name
def _normalize_runtime_key(
self,
runtime_key: str,
) -> str:
normalized_runtime_key = str(runtime_key or "").strip().lower()
if not normalized_runtime_key:
raise QuoteStoreError(
"Runtime key Quote Store не должен быть пустым."
)
return normalized_runtime_key
def _normalize_symbol(
self,
symbol: str,
) -> str:
normalized_symbol = str(symbol or "").strip().upper()
if not normalized_symbol:
raise QuoteStoreError(
"Символ Quote Store не должен быть пустым."
)
return normalized_symbol

View File

@@ -1,3 +1,5 @@
# app/src/storage/repositories/balance_snapshots.py
from __future__ import annotations
import json
@@ -57,4 +59,4 @@ class BalanceSnapshotRepository:
}
)
return items
return items

View File

@@ -41,4 +41,4 @@ def check_database_health() -> tuple[bool, str]:
except Exception as exc:
return False, f"PostgreSQL error: {exc}"
return True, version
return True, version