Build 060.29: implement Market Data Access and Replay
This commit is contained in:
@@ -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)
|
||||
@@ -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(),)),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user