Build 060.29: implement Market Data Access and Replay

This commit is contained in:
2026-08-02 19:20:14 +03:00
parent 8c485e32b1
commit 8c98de9acc
48 changed files with 15614 additions and 58 deletions

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from src.market_data.access import (
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
CandleRevisionHistoryReaderProtocol,
MarketDataAccessError,
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
MarketDataCursorError,
MarketDataHistoricalAccessProtocol,
QuoteHistoryPage,
QuoteHistoryQuery,
QuoteHistoryReaderProtocol,
TradeHistoryPage,
TradeHistoryQuery,
TradeHistoryReaderProtocol,
)
class RecordingHistoricalAccess:
def query_trades(
self,
query: TradeHistoryQuery,
) -> TradeHistoryPage:
return TradeHistoryPage(query=query, items=())
def query_quotes(
self,
query: QuoteHistoryQuery,
) -> QuoteHistoryPage:
return QuoteHistoryPage(query=query, items=())
def query_candle_revisions(
self,
query: CandleRevisionHistoryQuery,
) -> CandleRevisionHistoryPage:
return CandleRevisionHistoryPage(query=query, items=())
def test_history_reader_protocols_are_runtime_checkable() -> None:
access = RecordingHistoricalAccess()
assert isinstance(access, TradeHistoryReaderProtocol)
assert isinstance(access, QuoteHistoryReaderProtocol)
assert isinstance(access, CandleRevisionHistoryReaderProtocol)
assert isinstance(access, MarketDataHistoricalAccessProtocol)
def test_access_error_hierarchy_is_specialized() -> None:
assert issubclass(MarketDataAccessValidationError, MarketDataAccessError)
assert issubclass(MarketDataCursorError, MarketDataAccessValidationError)
assert issubclass(MarketDataAccessIntegrityError, MarketDataAccessError)
assert issubclass(MarketDataAccessOperationError, MarketDataAccessError)

View File

@@ -0,0 +1,882 @@
from __future__ import annotations
from dataclasses import FrozenInstanceError, replace
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
import pytest
from src.market_data.access import (
HISTORY_PAGE_LIMIT_MAX,
CandleRevisionHistoryCursor,
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
CandleRevisionHistoryRecord,
HistoricalTimeRange,
QuoteHistoryCursor,
QuoteHistoryPage,
QuoteHistoryQuery,
QuoteHistoryRecord,
TradeHistoryCursor,
TradeHistoryPage,
TradeHistoryQuery,
TradeHistoryRecord,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
VENUE = "dzengi"
SYMBOL = "BTC/USD_LEVERAGE"
START = datetime(2026, 8, 2, 10, 0, tzinfo=timezone.utc)
END = START + timedelta(hours=1)
SOURCE = "dzengi_websocket_trade"
def make_trade(
*,
trade_id: int = 100,
executed_at: datetime = START + timedelta(minutes=1),
symbol: str = SYMBOL,
source: str = SOURCE,
) -> Trade:
return Trade(
symbol=symbol,
trade_id=trade_id,
price=Decimal("65000.25"),
quantity=Decimal("0.001"),
executed_at=executed_at,
aggressor_side=TradeAggressorSide.BUY,
source=source,
)
def make_quote(
*,
received_at: datetime = START + timedelta(minutes=2),
) -> Quote:
return Quote(
symbol=SYMBOL,
last_price=Decimal("65000"),
bid_price=Decimal("64999"),
ask_price=Decimal("65001"),
exchange_timestamp=received_at - timedelta(milliseconds=1),
received_at=received_at,
source="dzengi_rest_quote",
)
def make_candle(
*,
open_time: datetime = START,
interval: str = "1m",
) -> Candle:
return Candle(
symbol=SYMBOL,
interval=interval,
open_time=open_time,
open_price=Decimal("64900"),
high_price=Decimal("65100"),
low_price=Decimal("64800"),
close_price=Decimal("65000"),
volume=Decimal("12.5"),
source="dzengi_rest_candle",
)
def make_range() -> HistoricalTimeRange:
return HistoricalTimeRange(start_time=START, end_time=END)
def make_trade_query(
*,
cursor: TradeHistoryCursor | None = None,
limit: int = 500,
) -> TradeHistoryQuery:
return TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
limit=limit,
cursor=cursor,
)
def make_trade_record(
*,
trade_id: int = 100,
executed_at: datetime = START + timedelta(minutes=1),
replay_sequence: int = 10,
) -> TradeHistoryRecord:
return TradeHistoryRecord(
venue=VENUE,
trade=make_trade(
trade_id=trade_id,
executed_at=executed_at,
),
first_observed_at=executed_at + timedelta(seconds=1),
last_observed_at=executed_at + timedelta(seconds=2),
observation_sources=(SOURCE,),
replay_sequence=replay_sequence,
)
def make_quote_record(
*,
received_at: datetime = START + timedelta(minutes=2),
replay_sequence: int = 20,
) -> QuoteHistoryRecord:
quote = make_quote(received_at=received_at)
return QuoteHistoryRecord(
venue=VENUE,
quote=quote,
observation_sources=(quote.source,),
replay_sequence=replay_sequence,
)
def make_candle_record(
*,
open_time: datetime = START,
observed_at: datetime = START + timedelta(seconds=30),
replay_sequence: int = 30,
interval: str = "1m",
) -> CandleRevisionHistoryRecord:
candle = make_candle(open_time=open_time, interval=interval)
return CandleRevisionHistoryRecord(
venue=VENUE,
candle=candle,
observed_at=observed_at,
is_final=False,
observation_sources=(candle.source,),
replay_sequence=replay_sequence,
)
def test_time_range_is_half_open_and_normalized_to_utc() -> None:
offset = timezone(timedelta(hours=3))
time_range = HistoricalTimeRange(
start_time=START.astimezone(offset),
end_time=END.astimezone(offset),
)
assert time_range.start_time == START
assert time_range.start_time.tzinfo is timezone.utc
assert time_range.end_time == END
assert time_range.contains(START)
assert time_range.contains(END - timedelta(microseconds=1))
assert not time_range.contains(END)
@pytest.mark.parametrize(
("start_time", "end_time", "error_type"),
(
(datetime(2026, 8, 2, 10, 0), END, ValueError),
(START, datetime(2026, 8, 2, 11, 0), ValueError),
(START, START, ValueError),
(END, START, ValueError),
("2026-08-02", END, TypeError),
),
)
def test_time_range_rejects_invalid_boundaries(
start_time: Any,
end_time: Any,
error_type: type[Exception],
) -> None:
with pytest.raises(error_type):
HistoricalTimeRange(
start_time=start_time,
end_time=end_time,
)
def test_history_records_preserve_exact_canonical_payloads() -> None:
trade = make_trade()
quote = make_quote()
candle = make_candle()
trade_record = TradeHistoryRecord(
venue=" dzengi ",
trade=trade,
first_observed_at=trade.executed_at,
last_observed_at=trade.executed_at,
observation_sources=(trade.source,),
replay_sequence=1,
)
quote_record = QuoteHistoryRecord(
venue=VENUE,
quote=quote,
observation_sources=(quote.source,),
replay_sequence=2,
)
candle_record = CandleRevisionHistoryRecord(
venue=VENUE,
candle=candle,
observed_at=candle.open_time,
is_final=True,
observation_sources=(candle.source,),
replay_sequence=3,
)
assert trade_record.venue == VENUE
assert trade_record.trade is trade
assert quote_record.quote is quote
assert candle_record.candle is candle
assert trade_record.event_time == trade.executed_at
assert quote_record.event_time == quote.received_at
assert candle_record.event_time == candle.open_time
assert candle_record.replay_at == candle.open_time
@pytest.mark.parametrize("invalid_sequence", (True, 0, -1, 1.5, "1"))
def test_records_reject_invalid_replay_sequence(
invalid_sequence: Any,
) -> None:
with pytest.raises((TypeError, ValueError), match="replay_sequence"):
make_trade_record(replay_sequence=invalid_sequence)
@pytest.mark.parametrize(
"observation_sources",
(
[],
(),
("",),
(SOURCE, SOURCE),
("another_source",),
),
)
def test_trade_record_rejects_invalid_provenance(
observation_sources: Any,
) -> None:
trade = make_trade()
with pytest.raises((TypeError, ValueError)):
TradeHistoryRecord(
venue=VENUE,
trade=trade,
first_observed_at=trade.executed_at,
last_observed_at=trade.executed_at,
observation_sources=observation_sources,
replay_sequence=1,
)
def test_trade_record_rejects_reversed_observation_times() -> None:
trade = make_trade()
with pytest.raises(ValueError, match="last_observed_at"):
TradeHistoryRecord(
venue=VENUE,
trade=trade,
first_observed_at=trade.executed_at + timedelta(seconds=1),
last_observed_at=trade.executed_at,
observation_sources=(trade.source,),
replay_sequence=1,
)
def test_candle_record_uses_open_time_for_history_and_observed_for_replay() -> None:
record = make_candle_record()
assert record.event_time == START
assert record.replay_at == START + timedelta(seconds=30)
assert record.interval == "1m"
def test_candle_record_rejects_observation_before_open_time() -> None:
with pytest.raises(ValueError, match="observed_at"):
make_candle_record(
observed_at=START - timedelta(microseconds=1),
)
@pytest.mark.parametrize("is_final", (0, 1, None, "true"))
def test_candle_record_requires_exact_boolean(is_final: Any) -> None:
candle = make_candle()
with pytest.raises(TypeError, match="is_final"):
CandleRevisionHistoryRecord(
venue=VENUE,
candle=candle,
observed_at=candle.open_time,
is_final=is_final,
observation_sources=(candle.source,),
replay_sequence=1,
)
def test_trade_cursor_normalizes_scope_and_time() -> None:
offset = timezone(timedelta(hours=3))
cursor = TradeHistoryCursor(
venue=" dzengi ",
symbol=" btc/usd_leverage ",
time_range=make_range(),
executed_at=(START + timedelta(minutes=1)).astimezone(offset),
replay_sequence=5,
)
assert cursor.venue == VENUE
assert cursor.symbol == SYMBOL
assert cursor.executed_at.tzinfo is timezone.utc
@pytest.mark.parametrize(
"cursor_time",
(
START - timedelta(microseconds=1),
END,
END + timedelta(microseconds=1),
),
)
def test_cursor_position_must_belong_to_query_range(
cursor_time: datetime,
) -> None:
with pytest.raises(ValueError, match="query range"):
TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=cursor_time,
replay_sequence=1,
)
def test_cursor_rejects_unknown_version() -> None:
with pytest.raises(ValueError, match="version"):
TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=START,
replay_sequence=1,
version=2,
)
@pytest.mark.parametrize("limit", (1, HISTORY_PAGE_LIMIT_MAX))
def test_trade_query_accepts_limit_boundaries(limit: int) -> None:
query = TradeHistoryQuery(
venue=" dzengi ",
symbol="btc/usd_leverage",
time_range=make_range(),
limit=limit,
)
assert query.venue == VENUE
assert query.symbol == SYMBOL
assert query.limit == limit
@pytest.mark.parametrize("limit", (True, 0, -1, 1.5, 1001))
def test_query_rejects_invalid_limit(limit: Any) -> None:
with pytest.raises((TypeError, ValueError), match="limit"):
TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
limit=limit,
)
def test_query_accepts_cursor_when_only_page_limit_changes() -> None:
time_range = make_range()
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=time_range,
executed_at=START,
replay_sequence=1,
)
query = TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=time_range,
limit=17,
cursor=cursor,
)
assert query.cursor is cursor
def test_query_rejects_cursor_from_another_scope() -> None:
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=START,
replay_sequence=1,
)
with pytest.raises(ValueError, match="scope"):
TradeHistoryQuery(
venue=VENUE,
symbol="ETH/USD_LEVERAGE",
time_range=make_range(),
cursor=cursor,
)
def test_query_rejects_cursor_of_another_data_type() -> None:
quote_cursor = QuoteHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
received_at=START,
replay_sequence=1,
)
with pytest.raises(TypeError, match="TradeHistoryCursor"):
TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
cursor=quote_cursor, # type: ignore[arg-type]
)
def test_candle_query_preserves_interval_case() -> None:
query = CandleRevisionHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
interval=" 1M ",
time_range=make_range(),
)
assert query.interval == "1M"
def test_empty_page_is_valid_without_cursor() -> None:
page = TradeHistoryPage(query=make_trade_query(), items=())
assert page.items == ()
assert page.has_more is False
def test_empty_page_rejects_cursor() -> None:
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=START,
replay_sequence=1,
)
with pytest.raises(ValueError, match="empty page"):
TradeHistoryPage(
query=make_trade_query(),
items=(),
next_cursor=cursor,
)
def test_trade_page_accepts_signed_rollover_at_equal_timestamp() -> None:
rollover_time = START + timedelta(minutes=1)
first = make_trade_record(
trade_id=2_147_483_647,
executed_at=rollover_time,
replay_sequence=10,
)
second = make_trade_record(
trade_id=-2_147_483_648,
executed_at=rollover_time,
replay_sequence=11,
)
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=rollover_time,
replay_sequence=11,
)
page = TradeHistoryPage(
query=make_trade_query(),
items=(first, second),
next_cursor=cursor,
)
assert page.items == (first, second)
assert page.has_more is True
def test_trade_page_accepts_negative_one_to_zero_boundary() -> None:
event_time = START + timedelta(minutes=1)
page = TradeHistoryPage(
query=make_trade_query(),
items=(
make_trade_record(
trade_id=-1,
executed_at=event_time,
replay_sequence=20,
),
make_trade_record(
trade_id=0,
executed_at=event_time,
replay_sequence=21,
),
),
)
assert [item.trade.trade_id for item in page.items] == [-1, 0]
def test_page_rejects_reverse_or_duplicate_order_key() -> None:
first = make_trade_record(replay_sequence=2)
second = make_trade_record(replay_sequence=1)
with pytest.raises(ValueError, match="strictly ordered"):
TradeHistoryPage(
query=make_trade_query(),
items=(first, second),
)
def test_page_rejects_cursor_not_pointing_to_last_item() -> None:
item = make_trade_record(replay_sequence=10)
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=item.event_time,
replay_sequence=9,
)
with pytest.raises(ValueError, match="last page item"):
TradeHistoryPage(
query=make_trade_query(),
items=(item,),
next_cursor=cursor,
)
@pytest.mark.parametrize(
"page",
(
QuoteHistoryPage(
query=QuoteHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
),
items=(make_quote_record(),),
),
CandleRevisionHistoryPage(
query=CandleRevisionHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
interval="1m",
time_range=make_range(),
),
items=(make_candle_record(),),
),
),
)
def test_quote_and_candle_pages_are_typed_and_immutable(page: Any) -> None:
assert not hasattr(page, "__dict__")
with pytest.raises(FrozenInstanceError):
setattr(page, "items", ())
def test_cursor_and_query_classes_use_slots_and_are_frozen() -> None:
query = QuoteHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
)
assert not hasattr(query, "__dict__")
with pytest.raises(FrozenInstanceError):
setattr(query, "venue", "other")
def test_cursor_window_mismatch_is_rejected() -> None:
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=START,
replay_sequence=1,
)
shifted = HistoricalTimeRange(
start_time=START - timedelta(minutes=1),
end_time=END,
)
with pytest.raises(ValueError, match="scope"):
TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=shifted,
cursor=cursor,
)
def test_candle_cursor_interval_mismatch_is_rejected() -> None:
cursor = CandleRevisionHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
interval="1m",
time_range=make_range(),
open_time=START,
replay_sequence=1,
)
with pytest.raises(ValueError, match="interval"):
CandleRevisionHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
interval="5m",
time_range=make_range(),
cursor=cursor,
)
def test_page_rejects_mixed_query_scope() -> None:
first = make_trade_record(replay_sequence=1)
second = replace(
make_trade_record(
executed_at=START + timedelta(minutes=2),
replay_sequence=2,
),
venue="other",
)
with pytest.raises(ValueError, match="one query scope"):
TradeHistoryPage(
query=make_trade_query(),
items=(first, second),
)
def test_terminal_page_rejects_item_outside_query_range() -> None:
item = make_trade_record(
executed_at=START - timedelta(microseconds=1),
)
with pytest.raises(ValueError, match="query range"):
TradeHistoryPage(
query=make_trade_query(),
items=(item,),
)
def test_page_rejects_more_items_than_query_limit() -> None:
first = make_trade_record(replay_sequence=1)
second = make_trade_record(
executed_at=START + timedelta(minutes=2),
replay_sequence=2,
)
with pytest.raises(ValueError, match="query limit"):
TradeHistoryPage(
query=make_trade_query(limit=1),
items=(first, second),
)
def test_page_items_must_follow_incoming_cursor() -> None:
cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=START + timedelta(minutes=1),
replay_sequence=10,
)
with pytest.raises(ValueError, match="follow query cursor"):
TradeHistoryPage(
query=make_trade_query(cursor=cursor),
items=(make_trade_record(replay_sequence=10),),
)
def test_records_reject_canonical_payload_subclasses() -> None:
class TradeSubclass(Trade):
pass
class QuoteSubclass(Quote):
pass
class CandleSubclass(Candle):
pass
trade = make_trade()
quote = make_quote()
candle = make_candle()
trade_subclass = TradeSubclass(
symbol=trade.symbol,
trade_id=trade.trade_id,
price=trade.price,
quantity=trade.quantity,
executed_at=trade.executed_at,
aggressor_side=trade.aggressor_side,
source=trade.source,
)
quote_subclass = QuoteSubclass(
symbol=quote.symbol,
last_price=quote.last_price,
bid_price=quote.bid_price,
ask_price=quote.ask_price,
exchange_timestamp=quote.exchange_timestamp,
received_at=quote.received_at,
source=quote.source,
)
candle_subclass = CandleSubclass(
symbol=candle.symbol,
interval=candle.interval,
open_time=candle.open_time,
open_price=candle.open_price,
high_price=candle.high_price,
low_price=candle.low_price,
close_price=candle.close_price,
volume=candle.volume,
source=candle.source,
)
with pytest.raises(TypeError, match="Canonical Trade"):
TradeHistoryRecord(
venue=VENUE,
trade=trade_subclass,
first_observed_at=trade.executed_at,
last_observed_at=trade.executed_at,
observation_sources=(trade.source,),
replay_sequence=1,
)
with pytest.raises(TypeError, match="Canonical Quote"):
QuoteHistoryRecord(
venue=VENUE,
quote=quote_subclass,
observation_sources=(quote.source,),
replay_sequence=1,
)
with pytest.raises(TypeError, match="Canonical Candle"):
CandleRevisionHistoryRecord(
venue=VENUE,
candle=candle_subclass,
observed_at=candle.open_time,
is_final=False,
observation_sources=(candle.source,),
replay_sequence=1,
)
def test_cursor_and_query_reject_time_range_subclass() -> None:
class HistoricalTimeRangeSubclass(HistoricalTimeRange):
pass
time_range = HistoricalTimeRangeSubclass(START, END)
with pytest.raises(TypeError, match="HistoricalTimeRange"):
TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=time_range,
executed_at=START,
replay_sequence=1,
)
with pytest.raises(TypeError, match="HistoricalTimeRange"):
TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=time_range,
)
def test_query_rejects_cursor_subclass() -> None:
class TradeHistoryCursorSubclass(TradeHistoryCursor):
pass
cursor = TradeHistoryCursorSubclass(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=START,
replay_sequence=1,
)
with pytest.raises(TypeError, match="TradeHistoryCursor"):
TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
cursor=cursor,
)
def test_page_rejects_query_record_and_cursor_subclasses() -> None:
class TradeHistoryQuerySubclass(TradeHistoryQuery):
pass
class TradeHistoryRecordSubclass(TradeHistoryRecord):
@property
def order_key(self) -> tuple[datetime, int]:
return (END, 1)
class TradeHistoryCursorSubclass(TradeHistoryCursor):
pass
item = make_trade_record(replay_sequence=10)
with pytest.raises(TypeError, match="TradeHistoryQuery"):
TradeHistoryPage(
query=TradeHistoryQuerySubclass(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
),
items=(item,),
)
item_subclass = TradeHistoryRecordSubclass(
venue=item.venue,
trade=item.trade,
first_observed_at=item.first_observed_at,
last_observed_at=item.last_observed_at,
observation_sources=item.observation_sources,
replay_sequence=item.replay_sequence,
)
with pytest.raises(TypeError, match="TradeHistoryRecord"):
TradeHistoryPage(
query=make_trade_query(),
items=(item_subclass,),
)
cursor_subclass = TradeHistoryCursorSubclass(
venue=VENUE,
symbol=SYMBOL,
time_range=make_range(),
executed_at=item.event_time,
replay_sequence=item.replay_sequence,
)
with pytest.raises(TypeError, match="TradeHistoryCursor"):
TradeHistoryPage(
query=make_trade_query(),
items=(item,),
next_cursor=cursor_subclass,
)
def test_page_rejects_items_tuple_subclass() -> None:
class ItemsTupleSubclass(tuple):
pass
with pytest.raises(TypeError, match="items must be a tuple"):
TradeHistoryPage(
query=make_trade_query(),
items=ItemsTupleSubclass((make_trade_record(),)),
)

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from src.market_data.access.contracts import (
MarketDataHistoricalAccessProtocol,
)
from src.market_data.access.market_data_historical_access import (
MarketDataHistoricalAccess,
)
from src.market_data.access.models import (
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
HistoricalTimeRange,
QuoteHistoryPage,
QuoteHistoryQuery,
TradeHistoryPage,
TradeHistoryQuery,
)
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
TIME_RANGE = HistoricalTimeRange(
start_time=NOW,
end_time=NOW + timedelta(hours=1),
)
class RecordingTradeReader:
def __init__(self, page: TradeHistoryPage) -> None:
self.page = page
self.queries: list[TradeHistoryQuery] = []
def query_trades(self, query: TradeHistoryQuery) -> TradeHistoryPage:
self.queries.append(query)
return self.page
class RecordingQuoteReader:
def __init__(self, page: QuoteHistoryPage) -> None:
self.page = page
self.queries: list[QuoteHistoryQuery] = []
def query_quotes(self, query: QuoteHistoryQuery) -> QuoteHistoryPage:
self.queries.append(query)
return self.page
class RecordingCandleReader:
def __init__(self, page: CandleRevisionHistoryPage) -> None:
self.page = page
self.queries: list[CandleRevisionHistoryQuery] = []
def query_candle_revisions(
self,
query: CandleRevisionHistoryQuery,
) -> CandleRevisionHistoryPage:
self.queries.append(query)
return self.page
class BrokenTradeReader:
def __init__(self, error: RuntimeError) -> None:
self.error = error
def query_trades(self, query: TradeHistoryQuery) -> TradeHistoryPage:
del query
raise self.error
def make_dependencies() -> tuple[
MarketDataHistoricalAccess,
RecordingTradeReader,
RecordingQuoteReader,
RecordingCandleReader,
]:
trade_query = TradeHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
time_range=TIME_RANGE,
)
quote_query = QuoteHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
time_range=TIME_RANGE,
)
candle_query = CandleRevisionHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
interval="1m",
time_range=TIME_RANGE,
)
trade_reader = RecordingTradeReader(
TradeHistoryPage(query=trade_query, items=()),
)
quote_reader = RecordingQuoteReader(
QuoteHistoryPage(query=quote_query, items=()),
)
candle_reader = RecordingCandleReader(
CandleRevisionHistoryPage(query=candle_query, items=()),
)
access = MarketDataHistoricalAccess(
trade_reader=trade_reader,
quote_reader=quote_reader,
candle_revision_reader=candle_reader,
)
return access, trade_reader, quote_reader, candle_reader
def test_implements_combined_protocol_and_uses_slots() -> None:
access, *_ = make_dependencies()
assert isinstance(access, MarketDataHistoricalAccessProtocol)
assert not hasattr(access, "__dict__")
@pytest.mark.parametrize(
"dependency_name",
(
"trade_reader",
"quote_reader",
"candle_revision_reader",
),
)
def test_rejects_dependency_without_required_protocol(
dependency_name: str,
) -> None:
_, trade_reader, quote_reader, candle_reader = make_dependencies()
dependencies: dict[str, Any] = {
"trade_reader": trade_reader,
"quote_reader": quote_reader,
"candle_revision_reader": candle_reader,
}
dependencies[dependency_name] = object()
with pytest.raises(TypeError, match=dependency_name):
MarketDataHistoricalAccess(**dependencies)
def test_delegates_each_query_without_rebuilding_page() -> None:
access, trade_reader, quote_reader, candle_reader = make_dependencies()
trade_query = TradeHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
time_range=TIME_RANGE,
limit=10,
)
quote_query = QuoteHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
time_range=TIME_RANGE,
limit=20,
)
candle_query = CandleRevisionHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
interval="1m",
time_range=TIME_RANGE,
limit=30,
)
trade_page = access.query_trades(trade_query)
quote_page = access.query_quotes(quote_query)
candle_page = access.query_candle_revisions(candle_query)
assert trade_page is trade_reader.page
assert quote_page is quote_reader.page
assert candle_page is candle_reader.page
assert trade_reader.queries == [trade_query]
assert quote_reader.queries == [quote_query]
assert candle_reader.queries == [candle_query]
def test_does_not_swallow_reader_error() -> None:
_, _, quote_reader, candle_reader = make_dependencies()
expected = RuntimeError("reader failed")
broken_reader = BrokenTradeReader(expected)
access = MarketDataHistoricalAccess(
trade_reader=broken_reader,
quote_reader=quote_reader,
candle_revision_reader=candle_reader,
)
query = TradeHistoryQuery(
venue="dzengi",
symbol="BTC/USD_LEVERAGE",
time_range=TIME_RANGE,
)
with pytest.raises(RuntimeError) as captured:
access.query_trades(query)
assert captured.value is expected

View File

@@ -0,0 +1,620 @@
from __future__ import annotations
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
import pytest
from src.market_data.access.contracts import (
CandleRevisionHistoryReaderProtocol,
)
from src.market_data.access.exceptions import (
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
)
from src.market_data.access.models import (
CandleRevisionHistoryCursor,
CandleRevisionHistoryQuery,
HistoricalTimeRange,
)
from src.market_data.access.postgres_candle_revision_history_repository import (
PostgresCandleRevisionHistoryRepository,
)
from src.market_data.acquisition.models.candle import Candle
VENUE = "dzengi"
SYMBOL = "BTC/USD_LEVERAGE"
INTERVAL = "1m"
START = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
END = START + timedelta(hours=1)
SOURCE = "dzengi_websocket_candle"
_DEFAULT = object()
class RecordingCursor:
def __init__(self, rows: object = ()) -> None:
self.rows = rows
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.enter_calls = 0
self.exit_exception_types: list[type[BaseException] | None] = []
self.execute_error: BaseException | None = None
self.fetchall_error: BaseException | None = None
self.exit_error: BaseException | None = None
def __enter__(self) -> RecordingCursor:
self.enter_calls += 1
return self
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object,
) -> None:
self.exit_exception_types.append(exception_type)
if self.exit_error is not None:
raise self.exit_error
return None
def execute(self, sql: str, parameters: tuple[object, ...]) -> None:
self.calls.append((sql, parameters))
if self.execute_error is not None:
raise self.execute_error
def fetchall(self) -> object:
if self.fetchall_error is not None:
raise self.fetchall_error
return self.rows
class RecordingConnection:
def __init__(self, cursor: RecordingCursor) -> None:
self._cursor = cursor
self.enter_calls = 0
self.cursor_calls = 0
self.exit_exception_types: list[type[BaseException] | None] = []
self.cursor_error: BaseException | None = None
self.exit_error: BaseException | None = None
def __enter__(self) -> RecordingConnection:
self.enter_calls += 1
return self
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object,
) -> None:
self.exit_exception_types.append(exception_type)
if self.exit_error is not None:
raise self.exit_error
return None
def cursor(self) -> RecordingCursor:
self.cursor_calls += 1
if self.cursor_error is not None:
raise self.cursor_error
return self._cursor
@dataclass
class RecordingProvider:
connection: RecordingConnection
calls: int = 0
error: BaseException | None = None
def __call__(self) -> RecordingConnection:
self.calls += 1
if self.error is not None:
raise self.error
return self.connection
def make_query(
*,
interval: str = INTERVAL,
limit: int = 3,
cursor: CandleRevisionHistoryCursor | None = None,
) -> CandleRevisionHistoryQuery:
return CandleRevisionHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
interval=interval,
time_range=HistoricalTimeRange(START, END),
limit=limit,
cursor=cursor,
)
def make_row(
*,
venue: object = VENUE,
symbol: object = SYMBOL,
interval: object = INTERVAL,
open_time: object = START + timedelta(minutes=1),
observed_at: object = _DEFAULT,
open_price: object = Decimal("64000"),
high_price: object = Decimal("64200"),
low_price: object = Decimal("63900"),
close_price: object = Decimal("64150"),
volume: object = Decimal("1.25"),
is_final: object = False,
source: object = SOURCE,
observation_sources: object = _DEFAULT,
replay_sequence: object = 10,
canonical_schema_version: object = 1,
) -> tuple[object, ...]:
resolved_observed_at = (
START + timedelta(minutes=1, seconds=10)
if observed_at is _DEFAULT
else observed_at
)
resolved_sources = (
[SOURCE]
if observation_sources is _DEFAULT
else observation_sources
)
return (
venue,
symbol,
interval,
open_time,
resolved_observed_at,
open_price,
high_price,
low_price,
close_price,
volume,
is_final,
source,
resolved_sources,
replay_sequence,
canonical_schema_version,
)
def dependencies(
rows: object = (),
) -> tuple[
PostgresCandleRevisionHistoryRepository,
RecordingCursor,
RecordingConnection,
RecordingProvider,
]:
cursor = RecordingCursor(rows)
connection = RecordingConnection(cursor)
provider = RecordingProvider(connection)
repository = PostgresCandleRevisionHistoryRepository(
connection_provider=provider,
)
return repository, cursor, connection, provider
def normalized_sql(sql: str) -> str:
return " ".join(sql.split())
def test_constructor_is_no_io_slotted_and_matches_protocol() -> None:
repository, _, _, provider = dependencies()
assert provider.calls == 0
assert not hasattr(repository, "__dict__")
assert isinstance(repository, CandleRevisionHistoryReaderProtocol)
def test_constructor_rejects_non_callable_provider() -> None:
with pytest.raises(TypeError, match="connection_provider"):
PostgresCandleRevisionHistoryRepository(
connection_provider=None, # type: ignore[arg-type]
)
def test_exact_query_is_validated_before_connection_borrow() -> None:
class QuerySubclass(CandleRevisionHistoryQuery):
pass
repository, _, _, provider = dependencies()
query = QuerySubclass(
venue=VENUE,
symbol=SYMBOL,
interval=INTERVAL,
time_range=HistoricalTimeRange(START, END),
)
with pytest.raises(MarketDataAccessValidationError, match="query"):
repository.query_candle_revisions(query)
assert provider.calls == 0
def test_first_page_uses_open_time_half_open_order_and_limit_plus_one() -> None:
first_open = START + timedelta(minutes=1)
second_open = START + timedelta(minutes=2)
repository, cursor, connection, provider = dependencies(
[
make_row(open_time=first_open, replay_sequence=10),
make_row(
open_time=second_open,
observed_at=second_open + timedelta(seconds=10),
is_final=True,
replay_sequence=11,
),
]
)
query = make_query(limit=3)
page = repository.query_candle_revisions(query)
sql, parameters = cursor.calls[0]
compact_sql = normalized_sql(sql)
assert "open_time >= %s" in compact_sql
assert "open_time < %s" in compact_sql
assert "observed_at >= %s" not in compact_sql
assert "(open_time, replay_sequence) >" not in compact_sql
assert "ORDER BY open_time ASC, replay_sequence ASC" in compact_sql
assert compact_sql.endswith("LIMIT %s")
assert parameters == (VENUE, SYMBOL, INTERVAL, START, END, 4)
assert [item.replay_sequence for item in page.items] == [10, 11]
assert type(page.items[0].candle) is Candle
assert page.items[0].event_time is first_open
assert page.items[1].is_final is True
assert page.next_cursor is None
assert provider.calls == 1
assert connection.enter_calls == 1
assert connection.cursor_calls == 1
assert cursor.enter_calls == 1
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [None]
def test_interval_is_case_sensitive_and_not_normalized() -> None:
repository, cursor, _, _ = dependencies([])
query = make_query(interval="1M")
repository.query_candle_revisions(query)
assert cursor.calls[0][1][2] == "1M"
def test_keyset_query_uses_exact_open_time_cursor_tuple() -> None:
cursor_time = START + timedelta(minutes=5)
incoming = CandleRevisionHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
interval=INTERVAL,
time_range=HistoricalTimeRange(START, END),
open_time=cursor_time,
replay_sequence=25,
)
repository, cursor, _, _ = dependencies(
[
make_row(
open_time=cursor_time,
observed_at=cursor_time + timedelta(seconds=10),
replay_sequence=26,
)
]
)
repository.query_candle_revisions(make_query(limit=2, cursor=incoming))
sql, parameters = cursor.calls[0]
assert (
"(open_time, replay_sequence) > (%s, %s)"
in normalized_sql(sql)
)
assert parameters == (
VENUE,
SYMBOL,
INTERVAL,
START,
END,
cursor_time,
25,
3,
)
def test_limit_plus_one_creates_cursor_from_last_returned_item() -> None:
open_times = tuple(
START + timedelta(minutes=index) for index in (1, 2, 3)
)
repository, _, _, _ = dependencies(
[
make_row(
open_time=open_time,
observed_at=open_time + timedelta(seconds=10),
replay_sequence=10 + index,
)
for index, open_time in enumerate(open_times)
]
)
query = make_query(limit=2)
page = repository.query_candle_revisions(query)
assert [item.replay_sequence for item in page.items] == [10, 11]
assert page.next_cursor is not None
assert page.next_cursor.venue == VENUE
assert page.next_cursor.symbol == SYMBOL
assert page.next_cursor.interval == INTERVAL
assert page.next_cursor.time_range is query.time_range
assert page.next_cursor.open_time == open_times[1]
assert page.next_cursor.replay_sequence == 11
def test_empty_result_is_valid_without_cursor() -> None:
repository, _, _, _ = dependencies([])
page = repository.query_candle_revisions(make_query())
assert page.items == ()
assert page.next_cursor is None
assert page.has_more is False
def test_history_range_uses_open_time_not_observed_at() -> None:
open_time = START + timedelta(minutes=1)
observed_at = END + timedelta(minutes=10)
repository, _, _, _ = dependencies(
[make_row(open_time=open_time, observed_at=observed_at)]
)
page = repository.query_candle_revisions(make_query())
assert page.items[0].event_time == open_time
assert page.items[0].replay_at == observed_at
@pytest.mark.parametrize(
("overrides", "error_match"),
(
({"venue": "other"}, "scope"),
({"symbol": "ETH/USD_LEVERAGE"}, "scope"),
({"interval": "1M"}, "scope"),
({"open_time": END, "observed_at": END}, "range"),
),
)
def test_rows_outside_exact_scope_or_range_are_rejected(
overrides: dict[str, object],
error_match: str,
) -> None:
repository, _, _, _ = dependencies([make_row(**overrides)])
with pytest.raises(MarketDataAccessIntegrityError, match=error_match):
repository.query_candle_revisions(make_query())
@pytest.mark.parametrize(
"overrides",
(
{"venue": " dzengi"},
{"symbol": "btc/usd_leverage"},
{"interval": " 1m"},
{"open_time": START.replace(tzinfo=None)},
{"observed_at": START.replace(tzinfo=None)},
{"observed_at": START},
{"open_price": Decimal("0")},
{"high_price": Decimal("NaN")},
{"low_price": Decimal("65000")},
{"close_price": Decimal("65000")},
{"volume": Decimal("-0.01")},
{"is_final": 1},
{"source": " source"},
{"observation_sources": ()},
{"observation_sources": []},
{"observation_sources": [SOURCE, SOURCE]},
{"observation_sources": ["recovery", SOURCE]},
{"replay_sequence": True},
{"replay_sequence": 0},
{"canonical_schema_version": 2},
),
)
def test_corrupt_stored_values_raise_integrity_error(
overrides: dict[str, object],
) -> None:
repository, cursor, connection, _ = dependencies(
[make_row(**overrides)]
)
with pytest.raises(
MarketDataAccessIntegrityError,
match="invalid Canonical Candle",
):
repository.query_candle_revisions(make_query())
assert cursor.exit_exception_types == [MarketDataAccessIntegrityError]
assert connection.exit_exception_types == [
MarketDataAccessIntegrityError
]
@pytest.mark.parametrize("row", ((), [object()] * 15, (object(),) * 14))
def test_invalid_row_shape_raises_integrity_error(row: object) -> None:
repository, _, _, _ = dependencies([row])
with pytest.raises(MarketDataAccessIntegrityError, match="row"):
repository.query_candle_revisions(make_query())
def test_invalid_rows_collection_and_excess_rows_are_rejected() -> None:
repository, _, _, _ = dependencies(iter(()))
with pytest.raises(MarketDataAccessIntegrityError, match="rows"):
repository.query_candle_revisions(make_query())
repository, _, _, _ = dependencies(
[
make_row(
open_time=START + timedelta(minutes=index + 1),
observed_at=START + timedelta(minutes=index + 1, seconds=1),
replay_sequence=index + 1,
)
for index in range(3)
]
)
with pytest.raises(MarketDataAccessIntegrityError, match="limit"):
repository.query_candle_revisions(make_query(limit=1))
def test_unordered_rows_and_duplicate_global_sequence_are_rejected() -> None:
earlier = START + timedelta(minutes=1)
later = START + timedelta(minutes=2)
unordered, _, _, _ = dependencies(
[
make_row(
open_time=later,
observed_at=later,
replay_sequence=10,
),
make_row(
open_time=earlier,
observed_at=earlier,
replay_sequence=11,
),
]
)
with pytest.raises(MarketDataAccessIntegrityError, match="ordered"):
unordered.query_candle_revisions(make_query())
duplicate, _, _, _ = dependencies(
[
make_row(
open_time=earlier,
observed_at=earlier,
replay_sequence=10,
),
make_row(
open_time=later,
observed_at=later,
replay_sequence=10,
),
]
)
with pytest.raises(
MarketDataAccessIntegrityError,
match="duplicate replay_sequence",
):
duplicate.query_candle_revisions(make_query())
def test_rows_must_strictly_follow_incoming_cursor() -> None:
cursor_time = START + timedelta(minutes=1)
incoming = CandleRevisionHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
interval=INTERVAL,
time_range=HistoricalTimeRange(START, END),
open_time=cursor_time,
replay_sequence=10,
)
repository, _, _, _ = dependencies(
[
make_row(
open_time=cursor_time,
observed_at=cursor_time,
replay_sequence=10,
)
]
)
with pytest.raises(MarketDataAccessIntegrityError, match="cursor"):
repository.query_candle_revisions(make_query(cursor=incoming))
def test_database_error_is_wrapped_after_context_cleanup() -> None:
repository, cursor, connection, _ = dependencies()
cursor.execute_error = RuntimeError("database failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_candle_revisions(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert cursor.exit_exception_types == [RuntimeError]
assert connection.exit_exception_types == [RuntimeError]
def test_provider_error_is_wrapped_without_entering_connection() -> None:
repository, _, connection, provider = dependencies()
provider.error = RuntimeError("provider failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_candle_revisions(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert provider.calls == 1
assert connection.enter_calls == 0
def test_base_exception_is_not_wrapped_and_reaches_cleanup() -> None:
repository, cursor, connection, _ = dependencies()
cursor.execute_error = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
repository.query_candle_revisions(make_query())
assert cursor.exit_exception_types == [KeyboardInterrupt]
assert connection.exit_exception_types == [KeyboardInterrupt]
def test_cleanup_error_obeys_exception_and_base_exception_contract() -> None:
repository, cursor, connection, _ = dependencies([])
cursor.exit_error = RuntimeError("cursor cleanup failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_candle_revisions(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [RuntimeError]
repository, cursor, connection, _ = dependencies([])
cursor.exit_error = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
repository.query_candle_revisions(make_query())
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [KeyboardInterrupt]
def test_bound_provider_can_reuse_caller_owned_connection() -> None:
cursor = RecordingCursor([make_row()])
connection = RecordingConnection(cursor)
provider_calls = 0
def bound_provider() -> Any:
nonlocal provider_calls
provider_calls += 1
return nullcontext(connection)
repository = PostgresCandleRevisionHistoryRepository(
connection_provider=bound_provider,
)
page = repository.query_candle_revisions(make_query())
assert len(page.items) == 1
assert provider_calls == 1
assert connection.enter_calls == 0
assert connection.exit_exception_types == []
assert cursor.exit_exception_types == [None]

View File

@@ -0,0 +1,529 @@
from __future__ import annotations
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
import pytest
from src.market_data.access.contracts import QuoteHistoryReaderProtocol
from src.market_data.access.exceptions import (
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
)
from src.market_data.access.models import (
HistoricalTimeRange,
QuoteHistoryCursor,
QuoteHistoryQuery,
)
from src.market_data.access.postgres_quote_history_repository import (
PostgresQuoteHistoryRepository,
)
from src.market_data.acquisition.models.quote import Quote
VENUE = "dzengi"
SYMBOL = "BTC/USD_LEVERAGE"
START = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
END = START + timedelta(hours=1)
SOURCE = "dzengi_websocket_quote"
class RecordingCursor:
def __init__(self, rows: object = ()) -> None:
self.rows = rows
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.enter_calls = 0
self.exit_exception_types: list[type[BaseException] | None] = []
self.execute_error: BaseException | None = None
self.fetchall_error: BaseException | None = None
self.exit_error: BaseException | None = None
def __enter__(self) -> RecordingCursor:
self.enter_calls += 1
return self
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object,
) -> None:
self.exit_exception_types.append(exception_type)
if self.exit_error is not None:
raise self.exit_error
return None
def execute(self, sql: str, parameters: tuple[object, ...]) -> None:
self.calls.append((sql, parameters))
if self.execute_error is not None:
raise self.execute_error
def fetchall(self) -> object:
if self.fetchall_error is not None:
raise self.fetchall_error
return self.rows
class RecordingConnection:
def __init__(self, cursor: RecordingCursor) -> None:
self._cursor = cursor
self.enter_calls = 0
self.cursor_calls = 0
self.exit_exception_types: list[type[BaseException] | None] = []
self.cursor_error: BaseException | None = None
self.exit_error: BaseException | None = None
def __enter__(self) -> RecordingConnection:
self.enter_calls += 1
return self
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object,
) -> None:
self.exit_exception_types.append(exception_type)
if self.exit_error is not None:
raise self.exit_error
return None
def cursor(self) -> RecordingCursor:
self.cursor_calls += 1
if self.cursor_error is not None:
raise self.cursor_error
return self._cursor
@dataclass
class RecordingProvider:
connection: RecordingConnection
calls: int = 0
error: BaseException | None = None
def __call__(self) -> RecordingConnection:
self.calls += 1
if self.error is not None:
raise self.error
return self.connection
def make_query(
*,
limit: int = 3,
cursor: QuoteHistoryCursor | None = None,
) -> QuoteHistoryQuery:
return QuoteHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
limit=limit,
cursor=cursor,
)
def make_row(
*,
venue: object = VENUE,
symbol: object = SYMBOL,
received_at: object = START + timedelta(minutes=1),
exchange_timestamp: object = START + timedelta(seconds=59),
last_price: object = Decimal("64159.45"),
bid_price: object = Decimal("64159.40"),
ask_price: object = Decimal("64159.50"),
source: object = SOURCE,
observation_sources: object = None,
replay_sequence: object = 10,
canonical_schema_version: object = 1,
) -> tuple[object, ...]:
sources = [SOURCE] if observation_sources is None else observation_sources
return (
venue,
symbol,
received_at,
exchange_timestamp,
last_price,
bid_price,
ask_price,
source,
sources,
replay_sequence,
canonical_schema_version,
)
def dependencies(
rows: object = (),
) -> tuple[
PostgresQuoteHistoryRepository,
RecordingCursor,
RecordingConnection,
RecordingProvider,
]:
cursor = RecordingCursor(rows)
connection = RecordingConnection(cursor)
provider = RecordingProvider(connection)
repository = PostgresQuoteHistoryRepository(
connection_provider=provider,
)
return repository, cursor, connection, provider
def normalized_sql(sql: str) -> str:
return " ".join(sql.split())
def test_constructor_is_no_io_slotted_and_matches_protocol() -> None:
repository, _, _, provider = dependencies()
assert provider.calls == 0
assert not hasattr(repository, "__dict__")
assert isinstance(repository, QuoteHistoryReaderProtocol)
def test_constructor_rejects_non_callable_provider() -> None:
with pytest.raises(TypeError, match="connection_provider"):
PostgresQuoteHistoryRepository(
connection_provider=None, # type: ignore[arg-type]
)
def test_exact_query_is_validated_before_connection_borrow() -> None:
class QuoteHistoryQuerySubclass(QuoteHistoryQuery):
pass
repository, _, _, provider = dependencies()
query = QuoteHistoryQuerySubclass(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
)
with pytest.raises(MarketDataAccessValidationError, match="query"):
repository.query_quotes(query)
assert provider.calls == 0
def test_first_page_uses_received_time_half_open_order_and_limit() -> None:
first_time = START + timedelta(minutes=1)
second_time = START + timedelta(minutes=2)
repository, cursor, connection, provider = dependencies(
[
make_row(received_at=first_time, replay_sequence=10),
make_row(received_at=second_time, replay_sequence=11),
]
)
query = make_query(limit=3)
page = repository.query_quotes(query)
sql, parameters = cursor.calls[0]
compact_sql = normalized_sql(sql)
assert "received_at >= %s" in compact_sql
assert "received_at < %s" in compact_sql
assert "(received_at, replay_sequence) >" not in compact_sql
assert "ORDER BY received_at ASC, replay_sequence ASC" in compact_sql
assert compact_sql.endswith("LIMIT %s")
assert parameters == (VENUE, SYMBOL, START, END, 4)
assert [item.replay_sequence for item in page.items] == [10, 11]
assert type(page.items[0].quote) is Quote
assert page.items[0].quote.received_at is first_time
assert page.items[0].observation_sources == (SOURCE,)
assert page.next_cursor is None
assert provider.calls == 1
assert connection.enter_calls == 1
assert connection.cursor_calls == 1
assert cursor.enter_calls == 1
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [None]
def test_keyset_query_uses_exact_received_at_and_sequence_cursor() -> None:
cursor_position = START + timedelta(minutes=5)
incoming_cursor = QuoteHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
received_at=cursor_position,
replay_sequence=25,
)
repository, recording_cursor, _, _ = dependencies(
[make_row(received_at=cursor_position, replay_sequence=26)]
)
repository.query_quotes(make_query(limit=2, cursor=incoming_cursor))
sql, parameters = recording_cursor.calls[0]
assert (
"(received_at, replay_sequence) > (%s, %s)"
in normalized_sql(sql)
)
assert parameters == (
VENUE,
SYMBOL,
START,
END,
cursor_position,
25,
3,
)
def test_limit_plus_one_creates_cursor_from_last_returned_quote() -> None:
times = tuple(START + timedelta(minutes=index) for index in (1, 2, 3))
repository, _, _, _ = dependencies(
[
make_row(received_at=event_time, replay_sequence=10 + index)
for index, event_time in enumerate(times)
]
)
query = make_query(limit=2)
page = repository.query_quotes(query)
assert len(page.items) == 2
assert [item.replay_sequence for item in page.items] == [10, 11]
assert page.next_cursor is not None
assert page.next_cursor.venue == query.venue
assert page.next_cursor.symbol == query.symbol
assert page.next_cursor.time_range is query.time_range
assert page.next_cursor.received_at == times[1]
assert page.next_cursor.replay_sequence == 11
def test_empty_result_and_exact_start_are_valid() -> None:
empty_repository, _, _, _ = dependencies([])
empty_page = empty_repository.query_quotes(make_query())
assert empty_page.items == ()
assert empty_page.next_cursor is None
assert empty_page.has_more is False
start_repository, _, _, _ = dependencies(
[make_row(received_at=START)]
)
start_page = start_repository.query_quotes(make_query())
assert start_page.items[0].event_time == START
def test_none_exchange_timestamp_is_preserved() -> None:
repository, _, _, _ = dependencies(
[make_row(exchange_timestamp=None)]
)
page = repository.query_quotes(make_query())
assert page.items[0].quote.exchange_timestamp is None
def test_backend_cannot_return_more_than_limit_plus_one() -> None:
repository, _, _, _ = dependencies(
[
make_row(
received_at=START + timedelta(minutes=index + 1),
replay_sequence=10 + index,
)
for index in range(3)
]
)
with pytest.raises(MarketDataAccessIntegrityError, match="limit"):
repository.query_quotes(make_query(limit=1))
@pytest.mark.parametrize(
"overrides",
(
{"venue": "other"},
{"venue": " dzengi "},
{"symbol": "btc/usd_leverage"},
{"received_at": END},
{"received_at": START.replace(tzinfo=None)},
{"exchange_timestamp": START.replace(tzinfo=None)},
{"last_price": Decimal("0")},
{"last_price": Decimal("NaN")},
{"last_price": 1},
{"bid_price": Decimal("64160")},
{"ask_price": Decimal("0")},
{"source": " source "},
{"observation_sources": ()},
{"observation_sources": []},
{"observation_sources": [SOURCE, SOURCE]},
{"observation_sources": ["recovery", SOURCE]},
{"replay_sequence": True},
{"replay_sequence": 0},
{"canonical_schema_version": 2},
),
)
def test_corrupt_or_out_of_scope_values_raise_integrity_error(
overrides: dict[str, object],
) -> None:
repository, cursor, connection, _ = dependencies(
[make_row(**overrides)]
)
with pytest.raises(MarketDataAccessIntegrityError):
repository.query_quotes(make_query())
assert cursor.exit_exception_types == [MarketDataAccessIntegrityError]
assert connection.exit_exception_types == [MarketDataAccessIntegrityError]
@pytest.mark.parametrize(
"row",
((), [object()] * 11, (object(),) * 10),
)
def test_invalid_row_shape_raises_integrity_error(row: object) -> None:
repository, _, _, _ = dependencies([row])
with pytest.raises(MarketDataAccessIntegrityError, match="row"):
repository.query_quotes(make_query())
@pytest.mark.parametrize("rows", (None, "rows", object()))
def test_invalid_rows_collection_raises_integrity_error(rows: object) -> None:
repository, _, _, _ = dependencies(rows)
with pytest.raises(MarketDataAccessIntegrityError, match="rows"):
repository.query_quotes(make_query())
def test_unordered_rows_and_duplicate_global_sequence_are_rejected() -> None:
later = START + timedelta(minutes=2)
earlier = START + timedelta(minutes=1)
unordered_repository, _, _, _ = dependencies(
[
make_row(received_at=later, replay_sequence=10),
make_row(received_at=earlier, replay_sequence=11),
]
)
with pytest.raises(MarketDataAccessIntegrityError, match="ordered"):
unordered_repository.query_quotes(make_query())
duplicate_repository, _, _, _ = dependencies(
[
make_row(received_at=earlier, replay_sequence=10),
make_row(received_at=later, replay_sequence=10),
]
)
with pytest.raises(
MarketDataAccessIntegrityError,
match="duplicate replay_sequence",
):
duplicate_repository.query_quotes(make_query())
def test_rows_must_strictly_follow_incoming_cursor() -> None:
cursor_time = START + timedelta(minutes=1)
incoming = QuoteHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
received_at=cursor_time,
replay_sequence=10,
)
repository, _, _, _ = dependencies(
[make_row(received_at=cursor_time, replay_sequence=10)]
)
with pytest.raises(MarketDataAccessIntegrityError, match="cursor"):
repository.query_quotes(make_query(cursor=incoming))
def test_database_error_is_wrapped_after_context_cleanup() -> None:
repository, cursor, connection, _ = dependencies()
cursor.execute_error = RuntimeError("database failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_quotes(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert cursor.exit_exception_types == [RuntimeError]
assert connection.exit_exception_types == [RuntimeError]
def test_provider_error_is_wrapped_without_entering_connection() -> None:
repository, _, connection, provider = dependencies()
provider.error = RuntimeError("provider failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_quotes(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert provider.calls == 1
assert connection.enter_calls == 0
def test_keyboard_interrupt_is_not_wrapped_and_reaches_cleanup() -> None:
repository, cursor, connection, _ = dependencies()
cursor.execute_error = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
repository.query_quotes(make_query())
assert cursor.exit_exception_types == [KeyboardInterrupt]
assert connection.exit_exception_types == [KeyboardInterrupt]
def test_cleanup_error_obeys_exception_and_base_exception_contract() -> None:
repository, cursor, connection, _ = dependencies([])
cursor.exit_error = RuntimeError("cursor cleanup failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_quotes(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [RuntimeError]
repository, cursor, connection, _ = dependencies([])
cursor.exit_error = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
repository.query_quotes(make_query())
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [KeyboardInterrupt]
def test_bound_provider_can_reuse_caller_owned_connection() -> None:
cursor = RecordingCursor([make_row()])
connection = RecordingConnection(cursor)
provider_calls = 0
def bound_provider() -> Any:
nonlocal provider_calls
provider_calls += 1
return nullcontext(connection)
repository = PostgresQuoteHistoryRepository(
connection_provider=bound_provider,
)
page = repository.query_quotes(make_query())
assert len(page.items) == 1
assert provider_calls == 1
assert connection.enter_calls == 0
assert connection.exit_exception_types == []
assert cursor.exit_exception_types == [None]

View File

@@ -0,0 +1,622 @@
from __future__ import annotations
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
import pytest
from src.market_data.access import (
HistoricalTimeRange,
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
PostgresTradeHistoryRepository,
TradeHistoryCursor,
TradeHistoryQuery,
TradeHistoryReaderProtocol,
)
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
from src.market_data.acquisition.trade_id_sequence import (
SIGNED_TRADE_ID_MAX,
SIGNED_TRADE_ID_MIN,
)
VENUE = "dzengi"
SYMBOL = "BTC/USD_LEVERAGE"
START = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
END = START + timedelta(hours=1)
SOURCE = "dzengi_websocket_trade"
class RecordingCursor:
def __init__(self, rows: object = ()) -> None:
self.rows = rows
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.enter_calls = 0
self.exit_exception_types: list[type[BaseException] | None] = []
self.execute_error: BaseException | None = None
self.fetchall_error: BaseException | None = None
self.exit_error: BaseException | None = None
def __enter__(self) -> RecordingCursor:
self.enter_calls += 1
return self
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object,
) -> None:
self.exit_exception_types.append(exception_type)
if self.exit_error is not None:
raise self.exit_error
return None
def execute(self, sql: str, parameters: tuple[object, ...]) -> None:
self.calls.append((sql, parameters))
if self.execute_error is not None:
raise self.execute_error
def fetchall(self) -> object:
if self.fetchall_error is not None:
raise self.fetchall_error
return self.rows
class RecordingConnection:
def __init__(self, cursor: RecordingCursor) -> None:
self._cursor = cursor
self.enter_calls = 0
self.cursor_calls = 0
self.exit_exception_types: list[type[BaseException] | None] = []
self.cursor_error: BaseException | None = None
self.exit_error: BaseException | None = None
def __enter__(self) -> RecordingConnection:
self.enter_calls += 1
return self
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object,
) -> None:
self.exit_exception_types.append(exception_type)
if self.exit_error is not None:
raise self.exit_error
return None
def cursor(self) -> RecordingCursor:
self.cursor_calls += 1
if self.cursor_error is not None:
raise self.cursor_error
return self._cursor
@dataclass
class RecordingProvider:
connection: RecordingConnection
calls: int = 0
error: BaseException | None = None
def __call__(self) -> RecordingConnection:
self.calls += 1
if self.error is not None:
raise self.error
return self.connection
def make_query(
*,
limit: int = 3,
cursor: TradeHistoryCursor | None = None,
) -> TradeHistoryQuery:
return TradeHistoryQuery(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
limit=limit,
cursor=cursor,
)
def make_row(
*,
venue: object = VENUE,
symbol: object = SYMBOL,
trade_id: object = 100,
executed_at: object = START + timedelta(minutes=1),
price: object = Decimal("64159.45"),
quantity: object = Decimal("0.125"),
aggressor_side: object = "buy",
source: object = SOURCE,
first_observed_at: object = START + timedelta(minutes=1, seconds=1),
last_observed_at: object = START + timedelta(minutes=1, seconds=2),
observation_sources: object = None,
replay_sequence: object = 10,
canonical_schema_version: object = 1,
) -> tuple[object, ...]:
sources = [SOURCE] if observation_sources is None else observation_sources
return (
venue,
symbol,
trade_id,
executed_at,
price,
quantity,
aggressor_side,
source,
first_observed_at,
last_observed_at,
sources,
replay_sequence,
canonical_schema_version,
)
def dependencies(
rows: object = (),
) -> tuple[
PostgresTradeHistoryRepository,
RecordingCursor,
RecordingConnection,
RecordingProvider,
]:
cursor = RecordingCursor(rows)
connection = RecordingConnection(cursor)
provider = RecordingProvider(connection)
repository = PostgresTradeHistoryRepository(
connection_provider=provider,
)
return repository, cursor, connection, provider
def normalized_sql(sql: str) -> str:
return " ".join(sql.split())
def test_constructor_is_no_io_slotted_and_matches_protocol() -> None:
repository, _, _, provider = dependencies()
assert provider.calls == 0
assert not hasattr(repository, "__dict__")
assert isinstance(repository, TradeHistoryReaderProtocol)
def test_constructor_rejects_non_callable_provider() -> None:
with pytest.raises(TypeError, match="connection_provider"):
PostgresTradeHistoryRepository(
connection_provider=None, # type: ignore[arg-type]
)
def test_exact_query_is_validated_before_connection_borrow() -> None:
class TradeHistoryQuerySubclass(TradeHistoryQuery):
pass
repository, _, _, provider = dependencies()
query = TradeHistoryQuerySubclass(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
)
with pytest.raises(MarketDataAccessValidationError, match="query"):
repository.query_trades(query)
assert provider.calls == 0
def test_first_page_uses_half_open_ordered_limit_plus_one_query() -> None:
first_time = START + timedelta(minutes=1)
second_time = START + timedelta(minutes=2)
repository, cursor, connection, provider = dependencies(
[
make_row(executed_at=first_time, replay_sequence=10),
make_row(
trade_id=101,
executed_at=second_time,
first_observed_at=second_time + timedelta(seconds=1),
last_observed_at=second_time + timedelta(seconds=2),
replay_sequence=11,
),
]
)
query = make_query(limit=3)
page = repository.query_trades(query)
sql, parameters = cursor.calls[0]
compact_sql = normalized_sql(sql)
assert "executed_at >= %s" in compact_sql
assert "executed_at < %s" in compact_sql
assert "(executed_at, replay_sequence) >" not in compact_sql
assert "ORDER BY executed_at ASC, replay_sequence ASC" in compact_sql
assert compact_sql.endswith("LIMIT %s")
assert parameters == (VENUE, SYMBOL, START, END, 4)
assert [item.replay_sequence for item in page.items] == [10, 11]
assert type(page.items[0].trade) is Trade
assert page.items[0].trade.executed_at is first_time
assert page.items[0].trade.aggressor_side is TradeAggressorSide.BUY
assert page.items[0].observation_sources == (SOURCE,)
assert page.next_cursor is None
assert provider.calls == 1
assert connection.enter_calls == 1
assert connection.cursor_calls == 1
assert cursor.enter_calls == 1
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [None]
def test_keyset_query_uses_exact_cursor_tuple() -> None:
cursor_position = START + timedelta(minutes=5)
incoming_cursor = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
executed_at=cursor_position,
replay_sequence=25,
)
row_time = cursor_position
repository, recording_cursor, _, _ = dependencies(
[
make_row(
executed_at=row_time,
first_observed_at=row_time + timedelta(seconds=1),
last_observed_at=row_time + timedelta(seconds=2),
replay_sequence=26,
)
]
)
query = make_query(limit=2, cursor=incoming_cursor)
repository.query_trades(query)
sql, parameters = recording_cursor.calls[0]
assert (
"(executed_at, replay_sequence) > (%s, %s)"
in normalized_sql(sql)
)
assert parameters == (
VENUE,
SYMBOL,
START,
END,
cursor_position,
25,
3,
)
def test_trade_id_rollover_does_not_participate_in_history_order() -> None:
event_time = START + timedelta(minutes=1)
repository, _, _, _ = dependencies(
[
make_row(
trade_id=SIGNED_TRADE_ID_MAX,
executed_at=event_time,
replay_sequence=10,
),
make_row(
trade_id=SIGNED_TRADE_ID_MIN,
executed_at=event_time,
replay_sequence=11,
),
make_row(
trade_id=-1,
executed_at=event_time,
replay_sequence=12,
),
make_row(
trade_id=0,
executed_at=event_time,
replay_sequence=13,
),
]
)
page = repository.query_trades(make_query(limit=4))
assert [item.trade.trade_id for item in page.items] == [
SIGNED_TRADE_ID_MAX,
SIGNED_TRADE_ID_MIN,
-1,
0,
]
def test_limit_plus_one_creates_cursor_from_last_returned_item() -> None:
times = tuple(START + timedelta(minutes=index) for index in (1, 2, 3))
repository, _, _, _ = dependencies(
[
make_row(
trade_id=100 + index,
executed_at=event_time,
first_observed_at=event_time + timedelta(seconds=1),
last_observed_at=event_time + timedelta(seconds=2),
replay_sequence=10 + index,
)
for index, event_time in enumerate(times)
]
)
query = make_query(limit=2)
page = repository.query_trades(query)
assert len(page.items) == 2
assert [item.replay_sequence for item in page.items] == [10, 11]
assert page.next_cursor is not None
assert page.next_cursor.venue == query.venue
assert page.next_cursor.symbol == query.symbol
assert page.next_cursor.time_range is query.time_range
assert page.next_cursor.executed_at == times[1]
assert page.next_cursor.replay_sequence == 11
def test_empty_result_is_valid_without_cursor() -> None:
repository, _, _, _ = dependencies([])
page = repository.query_trades(make_query())
assert page.items == ()
assert page.next_cursor is None
assert page.has_more is False
def test_half_open_range_includes_exact_start() -> None:
repository, _, _, _ = dependencies(
[
make_row(
executed_at=START,
first_observed_at=START,
last_observed_at=START,
)
]
)
page = repository.query_trades(make_query())
assert page.items[0].event_time == START
def test_backend_cannot_return_more_than_limit_plus_one() -> None:
query = make_query(limit=1)
rows = [
make_row(
trade_id=100 + index,
executed_at=START + timedelta(minutes=index + 1),
first_observed_at=START + timedelta(minutes=index + 1, seconds=1),
last_observed_at=START + timedelta(minutes=index + 1, seconds=2),
replay_sequence=10 + index,
)
for index in range(3)
]
repository, _, _, _ = dependencies(rows)
with pytest.raises(MarketDataAccessIntegrityError, match="limit"):
repository.query_trades(query)
@pytest.mark.parametrize(
("overrides", "error_match"),
(
({"venue": "other"}, "invalid Canonical Trade"),
({"symbol": "btc/usd_leverage"}, "invalid Canonical Trade"),
({"trade_id": True}, "invalid Canonical Trade"),
({"trade_id": SIGNED_TRADE_ID_MAX + 1}, "invalid Canonical Trade"),
({"executed_at": END}, "invalid Canonical Trade"),
({"price": Decimal("0")}, "invalid Canonical Trade"),
({"price": Decimal("NaN")}, "invalid Canonical Trade"),
({"quantity": Decimal("0")}, "invalid Canonical Trade"),
({"aggressor_side": "hold"}, "invalid Canonical Trade"),
({"source": " source "}, "invalid Canonical Trade"),
(
{"first_observed_at": START.replace(tzinfo=None)},
"invalid Canonical Trade",
),
(
{
"first_observed_at": START + timedelta(minutes=3),
"last_observed_at": START + timedelta(minutes=2),
},
"invalid Canonical Trade",
),
({"observation_sources": ()}, "invalid Canonical Trade"),
({"observation_sources": []}, "invalid Canonical Trade"),
(
{"observation_sources": [SOURCE, SOURCE]},
"invalid Canonical Trade",
),
(
{"observation_sources": ["recovery", SOURCE]},
"invalid Canonical Trade",
),
({"replay_sequence": True}, "invalid Canonical Trade"),
({"replay_sequence": 0}, "invalid Canonical Trade"),
({"canonical_schema_version": 2}, "invalid Canonical Trade"),
),
)
def test_corrupt_stored_values_raise_integrity_error(
overrides: dict[str, object],
error_match: str,
) -> None:
repository, cursor, connection, _ = dependencies(
[make_row(**overrides)]
)
with pytest.raises(MarketDataAccessIntegrityError, match=error_match):
repository.query_trades(make_query())
assert cursor.exit_exception_types == [MarketDataAccessIntegrityError]
assert connection.exit_exception_types == [MarketDataAccessIntegrityError]
@pytest.mark.parametrize("row", ((), [object()] * 13, (object(),) * 12))
def test_invalid_row_shape_raises_integrity_error(row: object) -> None:
repository, _, _, _ = dependencies([row])
with pytest.raises(MarketDataAccessIntegrityError, match="row"):
repository.query_trades(make_query())
def test_unordered_rows_and_duplicate_global_sequence_are_rejected() -> None:
later = START + timedelta(minutes=2)
earlier = START + timedelta(minutes=1)
unordered_repository, _, _, _ = dependencies(
[
make_row(
executed_at=later,
first_observed_at=later + timedelta(seconds=1),
last_observed_at=later + timedelta(seconds=2),
replay_sequence=10,
),
make_row(
executed_at=earlier,
first_observed_at=earlier + timedelta(seconds=1),
last_observed_at=earlier + timedelta(seconds=2),
replay_sequence=11,
),
]
)
with pytest.raises(MarketDataAccessIntegrityError, match="ordered"):
unordered_repository.query_trades(make_query())
duplicate_repository, _, _, _ = dependencies(
[
make_row(
executed_at=earlier,
first_observed_at=earlier + timedelta(seconds=1),
last_observed_at=earlier + timedelta(seconds=2),
replay_sequence=10,
),
make_row(
trade_id=101,
executed_at=later,
first_observed_at=later + timedelta(seconds=1),
last_observed_at=later + timedelta(seconds=2),
replay_sequence=10,
),
]
)
with pytest.raises(
MarketDataAccessIntegrityError,
match="duplicate replay_sequence",
):
duplicate_repository.query_trades(make_query())
def test_rows_must_strictly_follow_incoming_cursor() -> None:
cursor_time = START + timedelta(minutes=1)
incoming = TradeHistoryCursor(
venue=VENUE,
symbol=SYMBOL,
time_range=HistoricalTimeRange(START, END),
executed_at=cursor_time,
replay_sequence=10,
)
repository, _, _, _ = dependencies(
[make_row(executed_at=cursor_time, replay_sequence=10)]
)
with pytest.raises(MarketDataAccessIntegrityError, match="cursor"):
repository.query_trades(make_query(cursor=incoming))
def test_database_error_is_wrapped_after_context_cleanup() -> None:
repository, cursor, connection, _ = dependencies()
cursor.execute_error = RuntimeError("database failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_trades(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert cursor.exit_exception_types == [RuntimeError]
assert connection.exit_exception_types == [RuntimeError]
def test_provider_error_is_wrapped_without_entering_connection() -> None:
repository, _, connection, provider = dependencies()
provider.error = RuntimeError("provider failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_trades(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert provider.calls == 1
assert connection.enter_calls == 0
def test_keyboard_interrupt_is_not_wrapped_and_reaches_cleanup() -> None:
repository, cursor, connection, _ = dependencies()
cursor.execute_error = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
repository.query_trades(make_query())
assert cursor.exit_exception_types == [KeyboardInterrupt]
assert connection.exit_exception_types == [KeyboardInterrupt]
def test_cleanup_error_obeys_exception_and_base_exception_contract() -> None:
repository, cursor, connection, _ = dependencies([])
cursor.exit_error = RuntimeError("cursor cleanup failed")
with pytest.raises(MarketDataAccessOperationError) as error_info:
repository.query_trades(make_query())
assert isinstance(error_info.value.__cause__, RuntimeError)
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [RuntimeError]
repository, cursor, connection, _ = dependencies([])
cursor.exit_error = KeyboardInterrupt()
with pytest.raises(KeyboardInterrupt):
repository.query_trades(make_query())
assert cursor.exit_exception_types == [None]
assert connection.exit_exception_types == [KeyboardInterrupt]
def test_bound_provider_can_reuse_caller_owned_connection() -> None:
cursor = RecordingCursor([make_row()])
connection = RecordingConnection(cursor)
provider_calls = 0
def bound_provider() -> Any:
nonlocal provider_calls
provider_calls += 1
return nullcontext(connection)
repository = PostgresTradeHistoryRepository(
connection_provider=bound_provider,
)
page = repository.query_trades(make_query())
assert len(page.items) == 1
assert provider_calls == 1
assert connection.enter_calls == 0
assert connection.exit_exception_types == []
assert cursor.exit_exception_types == [None]