feat: add market data architecture and complete migration through build 039

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

View File

@@ -0,0 +1,463 @@
# app/tests/unit/storage/test_instrument_store.py
from __future__ import annotations
from decimal import Decimal
from typing import cast
import pytest
from src.market_data.acquisition.models.instrument import Instrument
from src.storage.exceptions import (
InstrumentStoreError,
StorageError,
)
from src.storage.instrument_store import (
InMemoryInstrumentStore,
InstrumentStoreProtocol,
)
def _instrument(
*,
symbol: str = "BTC/USD_LEVERAGE",
name: str = "BTC/USD",
base_asset: str = "BTC",
) -> Instrument:
return Instrument(
symbol=symbol,
name=name,
status="TRADING",
base_asset=base_asset,
quote_asset="USD",
asset_type="CRYPTOCURRENCY",
market_type="LEVERAGE",
market_modes=("REGULAR",),
order_types=("LIMIT", "MARKET", "STOP"),
base_asset_precision=4,
quote_asset_precision=4,
tick_size=Decimal("0.05"),
tick_value=Decimal("3878.86"),
step_size=Decimal("0.0001"),
min_qty=Decimal("0.0001"),
max_qty=Decimal("1000"),
min_notional=Decimal("1"),
country=None,
sector=None,
industry=None,
trading_hours=None,
)
def test_in_memory_store_matches_protocol() -> None:
store = InMemoryInstrumentStore()
assert isinstance(
store,
InstrumentStoreProtocol,
)
def test_get_returns_none_for_unknown_source() -> None:
store = InMemoryInstrumentStore()
assert store.get("dzengi") is None
def test_set_and_get_preserve_same_tuple_object() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(),
)
store.set(
"dzengi",
instruments,
)
result = store.get("dzengi")
assert result is instruments
def test_empty_tuple_is_distinct_from_cache_miss() -> None:
store = InMemoryInstrumentStore()
assert store.get("dzengi") is None
store.set(
"dzengi",
(),
)
result = store.get("dzengi")
assert result == ()
assert result is not None
def test_different_sources_are_isolated() -> None:
store = InMemoryInstrumentStore()
dzengi_instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
secondary_instruments = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
dzengi_instruments,
)
store.set(
"secondary",
secondary_instruments,
)
assert store.get("dzengi") is dzengi_instruments
assert store.get("secondary") is secondary_instruments
def test_clear_removes_only_requested_source() -> None:
store = InMemoryInstrumentStore()
dzengi_instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
secondary_instruments = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
dzengi_instruments,
)
store.set(
"secondary",
secondary_instruments,
)
store.clear("dzengi")
assert store.get("dzengi") is None
assert store.get("secondary") is secondary_instruments
def test_clear_without_source_removes_all_sources() -> None:
store = InMemoryInstrumentStore()
store.set(
"dzengi",
(
_instrument(),
),
)
store.set(
"secondary",
(
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
),
)
store.clear()
assert store.get("dzengi") is None
assert store.get("secondary") is None
def test_set_replaces_existing_value() -> None:
store = InMemoryInstrumentStore()
first = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
second = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
first,
)
store.set(
"dzengi",
second,
)
assert store.get("dzengi") is second
def test_source_name_outer_spaces_are_normalized() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(),
)
store.set(
" dzengi ",
instruments,
)
assert store.get("dzengi") is instruments
assert store.get(" dzengi ") is instruments
def test_source_name_case_is_preserved() -> None:
store = InMemoryInstrumentStore()
lowercase = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
)
uppercase = (
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
store.set(
"dzengi",
lowercase,
)
store.set(
"DZENGI",
uppercase,
)
assert store.get("dzengi") is lowercase
assert store.get("DZENGI") is uppercase
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_get_rejects_empty_source_name(
source_name: str,
) -> None:
store = InMemoryInstrumentStore()
with pytest.raises(
InstrumentStoreError,
match=r"Имя источника Instrument Store не должно быть пустым",
):
store.get(source_name)
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_set_rejects_empty_source_name(
source_name: str,
) -> None:
store = InMemoryInstrumentStore()
with pytest.raises(
InstrumentStoreError,
match=r"Имя источника Instrument Store не должно быть пустым",
):
store.set(
source_name,
(),
)
@pytest.mark.parametrize(
"source_name",
[
"",
" ",
" ",
"\t",
"\n",
],
)
def test_targeted_clear_rejects_empty_source_name(
source_name: str,
) -> None:
store = InMemoryInstrumentStore()
with pytest.raises(
InstrumentStoreError,
match=r"Имя источника Instrument Store не должно быть пустым",
):
store.clear(source_name)
def test_set_rejects_list_instead_of_tuple() -> None:
store = InMemoryInstrumentStore()
invalid_instruments = cast(
tuple[Instrument, ...],
[
_instrument(),
],
)
with pytest.raises(
InstrumentStoreError,
match=r"должен быть передан как tuple",
):
store.set(
"dzengi",
invalid_instruments,
)
def test_set_rejects_non_instrument_item() -> None:
store = InMemoryInstrumentStore()
invalid_instruments = cast(
tuple[Instrument, ...],
(
"BTC/USD_LEVERAGE",
),
)
with pytest.raises(
InstrumentStoreError,
match=r"не являющийся Instrument",
):
store.set(
"dzengi",
invalid_instruments,
)
def test_store_preserves_instrument_order() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
_instrument(
symbol="XRP/USD_LEVERAGE",
name="XRP/USD",
base_asset="XRP",
),
)
store.set(
"dzengi",
instruments,
)
result = store.get("dzengi")
assert result is not None
assert [
instrument.symbol
for instrument in result
] == [
"BTC/USD_LEVERAGE",
"ETH/USD_LEVERAGE",
"XRP/USD_LEVERAGE",
]
def test_store_does_not_modify_input_tuple() -> None:
store = InMemoryInstrumentStore()
instruments = (
_instrument(
symbol="BTC/USD_LEVERAGE",
),
_instrument(
symbol="ETH/USD_LEVERAGE",
name="ETH/USD",
base_asset="ETH",
),
)
original = instruments
store.set(
"dzengi",
instruments,
)
assert instruments is original
assert store.get("dzengi") is original
def test_store_instances_are_isolated() -> None:
first_store = InMemoryInstrumentStore()
second_store = InMemoryInstrumentStore()
instruments = (
_instrument(),
)
first_store.set(
"dzengi",
instruments,
)
assert first_store.get("dzengi") is instruments
assert second_store.get("dzengi") is None
def test_clear_unknown_source_is_idempotent() -> None:
store = InMemoryInstrumentStore()
store.clear("dzengi")
store.clear("dzengi")
assert store.get("dzengi") is None
def test_instrument_store_error_inherits_storage_error() -> None:
error = InstrumentStoreError(
"Storage failure."
)
assert isinstance(
error,
StorageError,
)
assert str(error) == "Storage failure."

View File

@@ -0,0 +1,448 @@
# app/tests/unit/storage/test_quote_store.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import cast
import pytest
from src.market_data.acquisition.models.quote import Quote
from src.storage.exceptions import QuoteStoreError, StorageError
from src.storage.quote_store import (
InMemoryQuoteStore,
QuoteStoreProtocol,
)
def _quote(
*,
symbol: str = "BTC/USD_LEVERAGE",
last_price: Decimal = Decimal("64159.45"),
source: str = "dzengi",
) -> Quote:
return Quote(
symbol=symbol,
last_price=last_price,
bid_price=last_price - Decimal("0.05"),
ask_price=last_price + Decimal("0.05"),
exchange_timestamp=datetime(
2026,
7,
12,
18,
0,
tzinfo=timezone.utc,
),
received_at=datetime(
2026,
7,
12,
18,
0,
1,
tzinfo=timezone.utc,
),
source=source,
)
def test_in_memory_quote_store_matches_protocol() -> None:
assert isinstance(
InMemoryQuoteStore(),
QuoteStoreProtocol,
)
def test_get_returns_none_for_unknown_key() -> None:
store = InMemoryQuoteStore()
assert store.get("dzengi", "BTC/USD_LEVERAGE") is None
def test_set_and_get_preserve_quote_identity() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote)
assert store.get("dzengi", quote.symbol) is quote
def test_set_replaces_existing_quote() -> None:
store = InMemoryQuoteStore()
first = _quote(last_price=Decimal("100"))
second = _quote(last_price=Decimal("101"))
store.set("dzengi", first)
store.set("dzengi", second)
assert store.get("dzengi", second.symbol) is second
def test_different_sources_are_isolated() -> None:
store = InMemoryQuoteStore()
first = _quote(source="dzengi")
second = _quote(source="secondary")
store.set("dzengi", first)
store.set("secondary", second)
assert store.get("dzengi", first.symbol) is first
assert store.get("secondary", second.symbol) is second
def test_source_name_alias_does_not_have_to_match_quote_source() -> None:
store = InMemoryQuoteStore()
quote = _quote(source="dzengi")
store.set("dzengi-primary", quote)
assert store.get("dzengi-primary", quote.symbol) is quote
def test_source_name_outer_spaces_are_normalized() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set(" dzengi ", quote)
assert store.get("dzengi", quote.symbol) is quote
assert store.get(" dzengi ", quote.symbol) is quote
def test_source_name_case_is_preserved() -> None:
store = InMemoryQuoteStore()
lowercase = _quote(last_price=Decimal("100"))
uppercase = _quote(last_price=Decimal("200"))
store.set("dzengi", lowercase)
store.set("DZENGI", uppercase)
assert store.get("dzengi", lowercase.symbol) is lowercase
assert store.get("DZENGI", uppercase.symbol) is uppercase
def test_runtime_keys_are_isolated() -> None:
store = InMemoryQuoteStore()
auto_quote = _quote(last_price=Decimal("100"))
debug_quote = _quote(last_price=Decimal("200"))
store.set("dzengi", auto_quote, runtime_key="auto")
store.set("dzengi", debug_quote, runtime_key="debug_auto")
assert store.get(
"dzengi",
auto_quote.symbol,
runtime_key="auto",
) is auto_quote
assert store.get(
"dzengi",
debug_quote.symbol,
runtime_key="debug_auto",
) is debug_quote
def test_runtime_key_is_trimmed_and_lowercased() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote, runtime_key=" AUTO ")
assert store.get(
"dzengi",
quote.symbol,
runtime_key="auto",
) is quote
assert store.get(
"dzengi",
quote.symbol,
runtime_key=" AUTO ",
) is quote
def test_symbols_are_isolated() -> None:
store = InMemoryQuoteStore()
btc = _quote(symbol="BTC/USD_LEVERAGE")
eth = _quote(
symbol="ETH/USD_LEVERAGE",
last_price=Decimal("3500"),
)
store.set("dzengi", btc)
store.set("dzengi", eth)
assert store.get("dzengi", btc.symbol) is btc
assert store.get("dzengi", eth.symbol) is eth
def test_symbol_key_is_trimmed_and_uppercased() -> None:
store = InMemoryQuoteStore()
quote = _quote(symbol=" btc/usd_leverage ")
store.set("dzengi", quote)
assert store.get("dzengi", "BTC/USD_LEVERAGE") is quote
assert store.get("dzengi", " btc/usd_leverage ") is quote
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_get_rejects_empty_source_name(source_name: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"Имя источника Quote Store не должно быть пустым",
):
store.get(source_name, "BTC/USD_LEVERAGE")
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_set_rejects_empty_source_name(source_name: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.set(source_name, _quote())
@pytest.mark.parametrize("source_name", ["", " ", "\t", "\n"])
def test_clear_rejects_empty_source_name(source_name: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.clear(source_name=source_name)
@pytest.mark.parametrize("runtime_key", ["", " ", "\t", "\n"])
def test_get_rejects_empty_runtime_key(runtime_key: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"Runtime key Quote Store не должен быть пустым",
):
store.get(
"dzengi",
"BTC/USD_LEVERAGE",
runtime_key=runtime_key,
)
@pytest.mark.parametrize("runtime_key", ["", " ", "\t", "\n"])
def test_set_rejects_empty_runtime_key(runtime_key: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.set(
"dzengi",
_quote(),
runtime_key=runtime_key,
)
@pytest.mark.parametrize("runtime_key", ["", " ", "\t", "\n"])
def test_clear_rejects_empty_runtime_key(runtime_key: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.clear(runtime_key=runtime_key)
@pytest.mark.parametrize("symbol", ["", " ", "\t", "\n"])
def test_get_rejects_empty_symbol(symbol: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"Символ Quote Store не должен быть пустым",
):
store.get("dzengi", symbol)
@pytest.mark.parametrize("symbol", ["", " ", "\t", "\n"])
def test_set_rejects_quote_with_empty_symbol(symbol: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.set("dzengi", _quote(symbol=symbol))
@pytest.mark.parametrize("symbol", ["", " ", "\t", "\n"])
def test_clear_rejects_empty_symbol(symbol: str) -> None:
store = InMemoryQuoteStore()
with pytest.raises(QuoteStoreError):
store.clear(symbol=symbol)
def test_set_rejects_non_quote_object() -> None:
store = InMemoryQuoteStore()
with pytest.raises(
QuoteStoreError,
match=r"принимает только объект Quote",
):
store.set(
"dzengi",
cast(Quote, object()),
)
def test_clear_without_filters_removes_all_quotes() -> None:
store = InMemoryQuoteStore()
store.set("dzengi", _quote(symbol="BTC/USD_LEVERAGE"))
store.set("secondary", _quote(symbol="ETH/USD_LEVERAGE"))
store.clear()
assert store.get("dzengi", "BTC/USD_LEVERAGE") is None
assert store.get("secondary", "ETH/USD_LEVERAGE") is None
def test_clear_by_source_removes_all_source_quotes() -> None:
store = InMemoryQuoteStore()
dzengi_btc = _quote(symbol="BTC/USD_LEVERAGE")
dzengi_eth = _quote(symbol="ETH/USD_LEVERAGE")
secondary_btc = _quote(symbol="BTC/USD_LEVERAGE")
store.set("dzengi", dzengi_btc, runtime_key="auto")
store.set("dzengi", dzengi_eth, runtime_key="debug_auto")
store.set("secondary", secondary_btc, runtime_key="auto")
store.clear(source_name="dzengi")
assert store.get("dzengi", dzengi_btc.symbol, runtime_key="auto") is None
assert store.get("dzengi", dzengi_eth.symbol, runtime_key="debug_auto") is None
assert store.get("secondary", secondary_btc.symbol, runtime_key="auto") is secondary_btc
def test_clear_by_runtime_removes_all_runtime_quotes() -> None:
store = InMemoryQuoteStore()
auto_btc = _quote(symbol="BTC/USD_LEVERAGE")
auto_eth = _quote(symbol="ETH/USD_LEVERAGE")
debug_btc = _quote(symbol="BTC/USD_LEVERAGE")
store.set("dzengi", auto_btc, runtime_key="auto")
store.set("secondary", auto_eth, runtime_key="auto")
store.set("dzengi", debug_btc, runtime_key="debug_auto")
store.clear(runtime_key="AUTO")
assert store.get("dzengi", auto_btc.symbol, runtime_key="auto") is None
assert store.get("secondary", auto_eth.symbol, runtime_key="auto") is None
assert store.get("dzengi", debug_btc.symbol, runtime_key="debug_auto") is debug_btc
def test_clear_by_symbol_removes_symbol_from_all_namespaces() -> None:
store = InMemoryQuoteStore()
dzengi_btc = _quote(symbol="BTC/USD_LEVERAGE")
secondary_btc = _quote(symbol="BTC/USD_LEVERAGE")
dzengi_eth = _quote(symbol="ETH/USD_LEVERAGE")
store.set("dzengi", dzengi_btc, runtime_key="auto")
store.set("secondary", secondary_btc, runtime_key="debug_auto")
store.set("dzengi", dzengi_eth, runtime_key="auto")
store.clear(symbol=" btc/usd_leverage ")
assert store.get("dzengi", dzengi_btc.symbol, runtime_key="auto") is None
assert store.get("secondary", secondary_btc.symbol, runtime_key="debug_auto") is None
assert store.get("dzengi", dzengi_eth.symbol, runtime_key="auto") is dzengi_eth
def test_clear_exact_record_removes_only_that_record() -> None:
store = InMemoryQuoteStore()
auto_quote = _quote()
debug_quote = _quote(last_price=Decimal("65000"))
store.set("dzengi", auto_quote, runtime_key="auto")
store.set("dzengi", debug_quote, runtime_key="debug_auto")
store.clear(
source_name="dzengi",
symbol="BTC/USD_LEVERAGE",
runtime_key="auto",
)
assert store.get("dzengi", auto_quote.symbol, runtime_key="auto") is None
assert store.get("dzengi", debug_quote.symbol, runtime_key="debug_auto") is debug_quote
def test_clear_combined_source_and_runtime_filters() -> None:
store = InMemoryQuoteStore()
dzengi_auto_btc = _quote(symbol="BTC/USD_LEVERAGE")
dzengi_auto_eth = _quote(symbol="ETH/USD_LEVERAGE")
dzengi_debug_btc = _quote(symbol="BTC/USD_LEVERAGE")
secondary_auto_btc = _quote(symbol="BTC/USD_LEVERAGE")
store.set("dzengi", dzengi_auto_btc, runtime_key="auto")
store.set("dzengi", dzengi_auto_eth, runtime_key="auto")
store.set("dzengi", dzengi_debug_btc, runtime_key="debug_auto")
store.set("secondary", secondary_auto_btc, runtime_key="auto")
store.clear(source_name="dzengi", runtime_key="auto")
assert store.get("dzengi", dzengi_auto_btc.symbol, runtime_key="auto") is None
assert store.get("dzengi", dzengi_auto_eth.symbol, runtime_key="auto") is None
assert store.get("dzengi", dzengi_debug_btc.symbol, runtime_key="debug_auto") is dzengi_debug_btc
assert store.get("secondary", secondary_auto_btc.symbol, runtime_key="auto") is secondary_auto_btc
def test_clear_unknown_record_is_idempotent() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote)
store.clear(
source_name="unknown",
symbol="ETH/USD_LEVERAGE",
runtime_key="missing",
)
assert store.get("dzengi", quote.symbol) is quote
def test_store_instances_are_independent() -> None:
first = InMemoryQuoteStore()
second = InMemoryQuoteStore()
quote = _quote()
first.set("dzengi", quote)
assert first.get("dzengi", quote.symbol) is quote
assert second.get("dzengi", quote.symbol) is None
def test_store_preserves_decimal_values() -> None:
store = InMemoryQuoteStore()
quote = _quote(
last_price=Decimal("64159.45000001"),
)
store.set("dzengi", quote)
result = store.get("dzengi", quote.symbol)
assert result is not None
assert result is quote
assert isinstance(result.last_price, Decimal)
assert result.last_price == Decimal("64159.45000001")
def test_store_preserves_datetime_values() -> None:
store = InMemoryQuoteStore()
quote = _quote()
store.set("dzengi", quote)
result = store.get("dzengi", quote.symbol)
assert result is not None
assert result is quote
assert result.exchange_timestamp is quote.exchange_timestamp
assert result.received_at is quote.received_at
def test_quote_store_error_inherits_storage_error() -> None:
error = QuoteStoreError("test")
assert isinstance(error, StorageError)