Build 060.26: complete Integration and Regression
This commit is contained in:
@@ -73,6 +73,25 @@ def test_accepts_first_trade() -> None:
|
||||
assert state.last_trade is trade
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trade_id",
|
||||
(
|
||||
-(2**31) - 1,
|
||||
2**31,
|
||||
),
|
||||
)
|
||||
def test_rejects_out_of_range_first_trade_id(
|
||||
trade_id: int,
|
||||
) -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
|
||||
with pytest.raises(ValueError, match="signed 32-bit"):
|
||||
state.accept(_trade(trade_id=trade_id))
|
||||
|
||||
assert state.last_trade_id is None
|
||||
assert state.last_trade is None
|
||||
|
||||
|
||||
def test_first_accepted_trade_becomes_checkpoint() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
trade = _trade()
|
||||
@@ -121,6 +140,49 @@ def test_accepts_trade_with_gap() -> None:
|
||||
assert state.last_trade is trade_after_gap
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("first_trade_id", "next_trade_id"),
|
||||
(
|
||||
(2**31 - 1, -(2**31)),
|
||||
(-1, 0),
|
||||
),
|
||||
)
|
||||
def test_accepts_next_trade_across_signed_rollover(
|
||||
first_trade_id: int,
|
||||
next_trade_id: int,
|
||||
) -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
first_trade = _trade(trade_id=first_trade_id)
|
||||
next_trade = _trade(trade_id=next_trade_id)
|
||||
|
||||
state.accept(first_trade)
|
||||
result = state.accept(next_trade)
|
||||
|
||||
assert result is next_trade
|
||||
assert state.last_trade_id == next_trade_id
|
||||
assert state.last_trade is next_trade
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_trade_id", "older_trade_id"),
|
||||
(
|
||||
(-(2**31), 2**31 - 1),
|
||||
(0, -1),
|
||||
),
|
||||
)
|
||||
def test_rejects_reverse_direction_across_signed_rollover(
|
||||
current_trade_id: int,
|
||||
older_trade_id: int,
|
||||
) -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
state.accept(_trade(trade_id=current_trade_id))
|
||||
|
||||
with pytest.raises(TradeOrderingError):
|
||||
state.accept(_trade(trade_id=older_trade_id))
|
||||
|
||||
assert state.last_trade_id == current_trade_id
|
||||
|
||||
|
||||
def test_checkpoint_preserves_trade_identity() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
trade = _trade()
|
||||
@@ -158,6 +220,26 @@ def test_identical_duplicate_does_not_change_checkpoint() -> None:
|
||||
assert state.last_trade_id == original_trade.trade_id
|
||||
|
||||
|
||||
def test_duplicate_from_rest_and_websocket_is_same_market_trade() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
websocket_trade = _trade(
|
||||
trade_id=-2_036_886_697,
|
||||
source="dzengi_websocket_trade",
|
||||
)
|
||||
rest_trade = _trade(
|
||||
trade_id=-2_036_886_697,
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
state.accept(websocket_trade)
|
||||
|
||||
result = state.accept(rest_trade)
|
||||
|
||||
assert result is None
|
||||
assert state.last_trade is websocket_trade
|
||||
assert state.last_trade_id == websocket_trade.trade_id
|
||||
|
||||
|
||||
def test_raises_consistency_error_for_conflicting_duplicate() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
original_trade = _trade(
|
||||
@@ -167,6 +249,7 @@ def test_raises_consistency_error_for_conflicting_duplicate() -> None:
|
||||
conflicting_trade = _trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50001.00"),
|
||||
source="dzengi_websocket_trade",
|
||||
)
|
||||
|
||||
state.accept(original_trade)
|
||||
|
||||
@@ -13,6 +13,12 @@ from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
||||
TradeConsistencyError,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_consistency_controller import (
|
||||
TradeStreamConsistencyController,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
TradeStreamStateStore,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
@@ -281,6 +287,47 @@ def test_returns_trades_in_normalized_order() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("before_rollover", "after_rollover"),
|
||||
(
|
||||
(2**31 - 1, -(2**31)),
|
||||
(-1, 0),
|
||||
),
|
||||
)
|
||||
def test_recovers_trades_in_rollover_aware_order(
|
||||
before_rollover: int,
|
||||
after_rollover: int,
|
||||
) -> None:
|
||||
source = StubTradesDocumentSource(
|
||||
document=[
|
||||
_raw_trade(trade_id=after_rollover),
|
||||
_raw_trade(trade_id=before_rollover),
|
||||
]
|
||||
)
|
||||
consistency_controller = StubConsistencyController()
|
||||
controller = TradeRecoveryController(
|
||||
document_source=source,
|
||||
consistency_controller=consistency_controller,
|
||||
)
|
||||
|
||||
result = controller.recover(_request())
|
||||
|
||||
assert tuple(
|
||||
trade.trade_id
|
||||
for trade in result.recovered_trades
|
||||
) == (
|
||||
before_rollover,
|
||||
after_rollover,
|
||||
)
|
||||
assert [
|
||||
trade.trade_id
|
||||
for trade in consistency_controller.received_trades
|
||||
] == [
|
||||
before_rollover,
|
||||
after_rollover,
|
||||
]
|
||||
|
||||
|
||||
def test_excludes_duplicate_rejected_by_consistency_controller() -> None:
|
||||
source = StubTradesDocumentSource(
|
||||
document=[
|
||||
@@ -375,6 +422,45 @@ def test_preserves_consistency_controller_returned_instance() -> None:
|
||||
assert result.recovered_trades[0] is replacement_trade
|
||||
|
||||
|
||||
def test_rest_recovery_deduplicates_same_websocket_market_trade() -> None:
|
||||
trade_id = -2_036_886_697
|
||||
timestamp = 1_700_000_000_000
|
||||
state_store = TradeStreamStateStore()
|
||||
consistency_controller = TradeStreamConsistencyController(
|
||||
state_store,
|
||||
)
|
||||
websocket_trade = Trade(
|
||||
symbol="BTCUSD",
|
||||
trade_id=trade_id,
|
||||
price=Decimal("50000.00"),
|
||||
quantity=Decimal("0.25"),
|
||||
executed_at=datetime.fromtimestamp(
|
||||
timestamp / 1000,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
aggressor_side=TradeAggressorSide.BUY,
|
||||
source="dzengi_websocket_trade",
|
||||
)
|
||||
consistency_controller.accept(websocket_trade)
|
||||
controller = TradeRecoveryController(
|
||||
document_source=StubTradesDocumentSource(
|
||||
document=[
|
||||
_raw_trade(
|
||||
trade_id=trade_id,
|
||||
timestamp=timestamp,
|
||||
),
|
||||
]
|
||||
),
|
||||
consistency_controller=consistency_controller,
|
||||
)
|
||||
|
||||
result = controller.recover(_request())
|
||||
|
||||
assert result.is_empty is True
|
||||
state = state_store.get("BTCUSD")
|
||||
assert state.last_trade is websocket_trade
|
||||
|
||||
|
||||
def test_propagates_source_error() -> None:
|
||||
expected_error = RuntimeError("source error")
|
||||
|
||||
@@ -572,4 +658,4 @@ def test_controller_does_not_create_additional_consistency_state() -> None:
|
||||
assert controller.__dict__ == {
|
||||
"_document_source": controller._document_source,
|
||||
"_consistency_controller": consistency_controller,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,68 @@ def test_sorts_arbitrary_order_by_trade_id() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_sorts_across_int32_max_to_int32_min_rollover() -> None:
|
||||
before_rollover = _trade(trade_id=2**31 - 1)
|
||||
after_rollover = _trade(trade_id=-(2**31))
|
||||
|
||||
result = normalize_recovered_trades(
|
||||
(
|
||||
after_rollover,
|
||||
before_rollover,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == (
|
||||
before_rollover,
|
||||
after_rollover,
|
||||
)
|
||||
|
||||
|
||||
def test_sorts_sequence_spanning_int32_sign_boundary() -> None:
|
||||
trades_by_id = {
|
||||
trade_id: _trade(trade_id=trade_id)
|
||||
for trade_id in (
|
||||
2**31 - 2,
|
||||
2**31 - 1,
|
||||
-(2**31),
|
||||
-(2**31) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
result = normalize_recovered_trades(
|
||||
(
|
||||
trades_by_id[-(2**31) + 1],
|
||||
trades_by_id[2**31 - 1],
|
||||
trades_by_id[2**31 - 2],
|
||||
trades_by_id[-(2**31)],
|
||||
)
|
||||
)
|
||||
|
||||
assert tuple(trade.trade_id for trade in result) == (
|
||||
2**31 - 2,
|
||||
2**31 - 1,
|
||||
-(2**31),
|
||||
-(2**31) + 1,
|
||||
)
|
||||
|
||||
|
||||
def test_sorts_across_minus_one_to_zero_rollover() -> None:
|
||||
before_rollover = _trade(trade_id=-1)
|
||||
after_rollover = _trade(trade_id=0)
|
||||
|
||||
result = normalize_recovered_trades(
|
||||
(
|
||||
after_rollover,
|
||||
before_rollover,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == (
|
||||
before_rollover,
|
||||
after_rollover,
|
||||
)
|
||||
|
||||
|
||||
def test_preserves_stable_order_for_equal_trade_ids() -> None:
|
||||
first_duplicate = _trade(
|
||||
trade_id=100,
|
||||
@@ -221,4 +283,4 @@ def test_returns_new_tuple_for_tuple_input() -> None:
|
||||
result = normalize_recovered_trades(source)
|
||||
|
||||
assert result == source
|
||||
assert result is not source
|
||||
assert result is not source
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.trade_id_sequence import (
|
||||
SIGNED_TRADE_ID_MAX,
|
||||
SIGNED_TRADE_ID_MIN,
|
||||
is_trade_id_newer,
|
||||
is_trade_id_same_or_newer,
|
||||
trade_id_relative_offset,
|
||||
validate_signed_trade_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trade_id",
|
||||
(
|
||||
SIGNED_TRADE_ID_MIN,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
SIGNED_TRADE_ID_MAX,
|
||||
),
|
||||
)
|
||||
def test_validate_signed_trade_id_accepts_full_range(
|
||||
trade_id: int,
|
||||
) -> None:
|
||||
assert validate_signed_trade_id(trade_id) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trade_id",
|
||||
(
|
||||
SIGNED_TRADE_ID_MIN - 1,
|
||||
SIGNED_TRADE_ID_MAX + 1,
|
||||
),
|
||||
)
|
||||
def test_validate_signed_trade_id_rejects_out_of_range(
|
||||
trade_id: int,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="signed 32-bit"):
|
||||
validate_signed_trade_id(trade_id)
|
||||
|
||||
|
||||
def test_validate_signed_trade_id_rejects_boolean() -> None:
|
||||
with pytest.raises(TypeError, match="must be an integer"):
|
||||
validate_signed_trade_id(True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reference_trade_id", "candidate_trade_id"),
|
||||
(
|
||||
(100, 101),
|
||||
(-100, -99),
|
||||
(SIGNED_TRADE_ID_MAX, SIGNED_TRADE_ID_MIN),
|
||||
(-1, 0),
|
||||
),
|
||||
)
|
||||
def test_rollover_aware_contract_recognizes_next_id(
|
||||
reference_trade_id: int,
|
||||
candidate_trade_id: int,
|
||||
) -> None:
|
||||
assert (
|
||||
trade_id_relative_offset(
|
||||
candidate_trade_id,
|
||||
reference_trade_id,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert is_trade_id_newer(
|
||||
candidate_trade_id,
|
||||
reference_trade_id,
|
||||
) is True
|
||||
assert is_trade_id_newer(
|
||||
reference_trade_id,
|
||||
candidate_trade_id,
|
||||
) is False
|
||||
|
||||
|
||||
def test_same_id_is_same_or_newer_but_not_newer() -> None:
|
||||
assert is_trade_id_same_or_newer(-100, -100) is True
|
||||
assert is_trade_id_newer(-100, -100) is False
|
||||
|
||||
|
||||
def test_half_cycle_distance_is_rejected_as_ambiguous() -> None:
|
||||
with pytest.raises(ValueError, match="exactly half"):
|
||||
trade_id_relative_offset(
|
||||
SIGNED_TRADE_ID_MIN,
|
||||
0,
|
||||
)
|
||||
@@ -535,21 +535,40 @@ def test_validate_empty_rest_agg_trade_values() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("aggregate_trade_id", "timestamp"),
|
||||
"aggregate_trade_id",
|
||||
[
|
||||
(0, 1000),
|
||||
(-1, 1000),
|
||||
(1, 0),
|
||||
(1, -1),
|
||||
-(2**31),
|
||||
-2_037_115_004,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
2**31 - 1,
|
||||
],
|
||||
)
|
||||
def test_reject_non_positive_rest_agg_trade_integer_value(
|
||||
def test_accept_signed_rest_agg_trade_id(
|
||||
aggregate_trade_id: int,
|
||||
timestamp: int,
|
||||
) -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=aggregate_trade_id,
|
||||
),
|
||||
)
|
||||
|
||||
assert validate_rest_agg_trade_values(trades) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timestamp",
|
||||
[
|
||||
0,
|
||||
-1,
|
||||
],
|
||||
)
|
||||
def test_reject_non_positive_rest_agg_trade_timestamp(
|
||||
timestamp: int,
|
||||
) -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
timestamp=timestamp,
|
||||
),
|
||||
)
|
||||
@@ -561,6 +580,43 @@ def test_reject_non_positive_rest_agg_trade_integer_value(
|
||||
validate_rest_agg_trade_values(trades)
|
||||
|
||||
|
||||
def test_reject_boolean_rest_agg_trade_id() -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=True,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=r"\$\[0\]\.aggregateTradeId должно быть целым числом",
|
||||
):
|
||||
validate_rest_agg_trade_values(trades)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"aggregate_trade_id",
|
||||
(
|
||||
-(2**31) - 1,
|
||||
2**31,
|
||||
),
|
||||
)
|
||||
def test_reject_out_of_range_rest_agg_trade_id(
|
||||
aggregate_trade_id: int,
|
||||
) -> None:
|
||||
trades = (
|
||||
_valid_rest_agg_trade(
|
||||
aggregate_trade_id=aggregate_trade_id,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match="signed 32-bit",
|
||||
):
|
||||
validate_rest_agg_trade_values(trades)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
@@ -684,4 +740,4 @@ def test_rest_agg_trade_value_error_reports_item_index() -> None:
|
||||
TradeValueError,
|
||||
match=r"\$\[1\]\.quantity должно быть больше нуля",
|
||||
):
|
||||
validate_rest_agg_trade_values(trades)
|
||||
validate_rest_agg_trade_values(trades)
|
||||
|
||||
@@ -85,17 +85,45 @@ def test_validate_websocket_trade_values_does_not_modify_event() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"trade_id",
|
||||
[
|
||||
0,
|
||||
-(2**31),
|
||||
-2_037_129_153,
|
||||
-1,
|
||||
-123456,
|
||||
0,
|
||||
1,
|
||||
2**31 - 1,
|
||||
],
|
||||
)
|
||||
def test_validate_websocket_trade_values_rejects_non_positive_trade_id(
|
||||
def test_validate_websocket_trade_values_accepts_signed_trade_id(
|
||||
trade_id: int,
|
||||
) -> None:
|
||||
validate_dzengi_websocket_trade_values(
|
||||
_event(trade_id=trade_id)
|
||||
)
|
||||
|
||||
|
||||
def test_validate_websocket_trade_values_rejects_boolean_trade_id() -> None:
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=r"\$\.payload\.id должно быть целым числом",
|
||||
):
|
||||
validate_dzengi_websocket_trade_values(
|
||||
_event(trade_id=True)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trade_id",
|
||||
(
|
||||
-(2**31) - 1,
|
||||
2**31,
|
||||
),
|
||||
)
|
||||
def test_validate_websocket_trade_values_rejects_out_of_range_trade_id(
|
||||
trade_id: int,
|
||||
) -> None:
|
||||
with pytest.raises(
|
||||
TradeValueError,
|
||||
match=r"\$\.payload\.id должно быть целым числом больше нуля",
|
||||
match="signed 32-bit",
|
||||
):
|
||||
validate_dzengi_websocket_trade_values(
|
||||
_event(trade_id=trade_id)
|
||||
@@ -272,4 +300,4 @@ def test_validate_websocket_trade_values_rejects_empty_order_id(
|
||||
):
|
||||
validate_dzengi_websocket_trade_values(
|
||||
_event(order_id=order_id)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user