Build 060.18: implement Trade Stream Consistency Controller
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# app/tests/unit/market_data/acquisition/consistency/test_trade_stream_consistency_controller.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.consistency.trade_stream_consistency_controller import (
|
||||
TradeStreamConsistencyController,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
||||
TradeConsistencyError,
|
||||
TradeOrderingError,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
)
|
||||
|
||||
|
||||
def _trade(
|
||||
*,
|
||||
symbol: str = "BTCUSD",
|
||||
trade_id: int = 100,
|
||||
price: Decimal = Decimal("50000.00"),
|
||||
quantity: Decimal = Decimal("0.25"),
|
||||
aggressor_side: TradeAggressorSide = TradeAggressorSide.BUY,
|
||||
) -> Trade:
|
||||
return Trade(
|
||||
symbol=symbol,
|
||||
trade_id=trade_id,
|
||||
price=price,
|
||||
quantity=quantity,
|
||||
executed_at=datetime(
|
||||
2026,
|
||||
1,
|
||||
1,
|
||||
12,
|
||||
0,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
aggressor_side=aggressor_side,
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
def test_creates_state_for_first_symbol() -> None:
|
||||
controller = TradeStreamConsistencyController()
|
||||
|
||||
trade = _trade(symbol="BTCUSD")
|
||||
|
||||
result = controller.accept(trade)
|
||||
|
||||
assert result == trade
|
||||
|
||||
|
||||
def test_reuses_state_for_same_symbol() -> None:
|
||||
controller = TradeStreamConsistencyController()
|
||||
|
||||
first_trade = _trade(
|
||||
symbol="BTCUSD",
|
||||
trade_id=100,
|
||||
)
|
||||
second_trade = _trade(
|
||||
symbol="BTCUSD",
|
||||
trade_id=101,
|
||||
)
|
||||
|
||||
controller.accept(first_trade)
|
||||
|
||||
result = controller.accept(second_trade)
|
||||
|
||||
assert result == second_trade
|
||||
|
||||
|
||||
def test_keeps_independent_state_per_symbol() -> None:
|
||||
controller = TradeStreamConsistencyController()
|
||||
|
||||
btc_trade = _trade(
|
||||
symbol="BTCUSD",
|
||||
trade_id=100,
|
||||
)
|
||||
eth_trade = _trade(
|
||||
symbol="ETHUSD",
|
||||
trade_id=100,
|
||||
)
|
||||
|
||||
btc_result = controller.accept(btc_trade)
|
||||
eth_result = controller.accept(eth_trade)
|
||||
|
||||
assert btc_result == btc_trade
|
||||
assert eth_result == eth_trade
|
||||
|
||||
|
||||
def test_returns_none_for_duplicate_trade() -> None:
|
||||
controller = TradeStreamConsistencyController()
|
||||
|
||||
trade = _trade()
|
||||
|
||||
controller.accept(trade)
|
||||
|
||||
result = controller.accept(trade)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_propagates_ordering_error() -> None:
|
||||
controller = TradeStreamConsistencyController()
|
||||
|
||||
controller.accept(
|
||||
_trade(trade_id=100),
|
||||
)
|
||||
|
||||
with pytest.raises(TradeOrderingError):
|
||||
controller.accept(
|
||||
_trade(trade_id=99),
|
||||
)
|
||||
|
||||
|
||||
def test_propagates_consistency_error() -> None:
|
||||
controller = TradeStreamConsistencyController()
|
||||
|
||||
controller.accept(
|
||||
_trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50000.00"),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(TradeConsistencyError):
|
||||
controller.accept(
|
||||
_trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50001.00"),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
||||
TradeConsistencyError,
|
||||
TradeOrderingError,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||
TradeStreamState,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
)
|
||||
|
||||
|
||||
def _trade(
|
||||
*,
|
||||
symbol: str = "BTCUSD",
|
||||
trade_id: int = 100,
|
||||
price: Decimal = Decimal("50000.00"),
|
||||
quantity: Decimal = Decimal("0.25"),
|
||||
executed_at: datetime | None = None,
|
||||
aggressor_side: TradeAggressorSide = TradeAggressorSide.BUY,
|
||||
source: str = "dzengi",
|
||||
) -> Trade:
|
||||
return Trade(
|
||||
symbol=symbol,
|
||||
trade_id=trade_id,
|
||||
price=price,
|
||||
quantity=quantity,
|
||||
executed_at=executed_at
|
||||
or datetime(
|
||||
2026,
|
||||
1,
|
||||
1,
|
||||
12,
|
||||
0,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
aggressor_side=aggressor_side,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def test_trade_stream_state_uses_slots() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
|
||||
assert not hasattr(state, "__dict__")
|
||||
|
||||
|
||||
def test_accepts_first_trade() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
trade = _trade()
|
||||
|
||||
result = state.accept(trade)
|
||||
|
||||
assert result == trade
|
||||
assert state.last_trade_id == trade.trade_id
|
||||
|
||||
|
||||
def test_accepts_trade_with_greater_trade_id() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
first_trade = _trade(trade_id=100)
|
||||
second_trade = _trade(trade_id=101)
|
||||
|
||||
state.accept(first_trade)
|
||||
result = state.accept(second_trade)
|
||||
|
||||
assert result == second_trade
|
||||
assert state.last_trade_id == second_trade.trade_id
|
||||
|
||||
|
||||
def test_accepts_trade_with_gap() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
first_trade = _trade(trade_id=100)
|
||||
trade_after_gap = _trade(trade_id=105)
|
||||
|
||||
state.accept(first_trade)
|
||||
result = state.accept(trade_after_gap)
|
||||
|
||||
assert result == trade_after_gap
|
||||
assert state.last_trade_id == trade_after_gap.trade_id
|
||||
|
||||
|
||||
def test_returns_none_for_identical_duplicate() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
trade = _trade()
|
||||
|
||||
state.accept(trade)
|
||||
result = state.accept(trade)
|
||||
|
||||
assert result is None
|
||||
assert state.last_trade_id == trade.trade_id
|
||||
|
||||
|
||||
def test_raises_consistency_error_for_conflicting_duplicate() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
original_trade = _trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50000.00"),
|
||||
)
|
||||
conflicting_trade = _trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50001.00"),
|
||||
)
|
||||
|
||||
state.accept(original_trade)
|
||||
|
||||
with pytest.raises(TradeConsistencyError):
|
||||
state.accept(conflicting_trade)
|
||||
|
||||
|
||||
def test_raises_ordering_error_for_older_trade() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
current_trade = _trade(trade_id=100)
|
||||
older_trade = _trade(trade_id=99)
|
||||
|
||||
state.accept(current_trade)
|
||||
|
||||
with pytest.raises(TradeOrderingError):
|
||||
state.accept(older_trade)
|
||||
|
||||
|
||||
def test_returns_none_for_duplicate_still_inside_window() -> None:
|
||||
state = TradeStreamState(
|
||||
symbol="BTCUSD",
|
||||
deduplication_window_size=3,
|
||||
)
|
||||
first_trade = _trade(trade_id=100)
|
||||
second_trade = _trade(trade_id=101)
|
||||
third_trade = _trade(trade_id=102)
|
||||
|
||||
state.accept(first_trade)
|
||||
state.accept(second_trade)
|
||||
state.accept(third_trade)
|
||||
|
||||
result = state.accept(first_trade)
|
||||
|
||||
assert result is None
|
||||
assert state.last_trade_id == third_trade.trade_id
|
||||
|
||||
|
||||
def test_raises_ordering_error_after_trade_leaves_window() -> None:
|
||||
state = TradeStreamState(
|
||||
symbol="BTCUSD",
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
first_trade = _trade(trade_id=100)
|
||||
second_trade = _trade(trade_id=101)
|
||||
third_trade = _trade(trade_id=102)
|
||||
|
||||
state.accept(first_trade)
|
||||
state.accept(second_trade)
|
||||
state.accept(third_trade)
|
||||
|
||||
with pytest.raises(TradeOrderingError):
|
||||
state.accept(first_trade)
|
||||
|
||||
|
||||
def test_rejects_unexpected_symbol() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
trade = _trade(symbol="ETHUSD")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
state.accept(trade)
|
||||
|
||||
|
||||
def test_rejects_empty_symbol() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
TradeStreamState(symbol="")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"window_size",
|
||||
[
|
||||
0,
|
||||
-1,
|
||||
],
|
||||
)
|
||||
def test_rejects_non_positive_window_size(
|
||||
window_size: int,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
TradeStreamState(
|
||||
symbol="BTCUSD",
|
||||
deduplication_window_size=window_size,
|
||||
)
|
||||
Reference in New Issue
Block a user