build 039: complete Quotes Feed migration foundation
This commit is contained in:
18
app/src/storage/exceptions.py
Normal file
18
app/src/storage/exceptions.py
Normal 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."""
|
||||
110
app/src/storage/instrument_store.py
Normal file
110
app/src/storage/instrument_store.py
Normal 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
|
||||
215
app/src/storage/quote_store.py
Normal file
215
app/src/storage/quote_store.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user