Build 060.26: complete Integration and Regression

This commit is contained in:
2026-07-31 14:14:30 +03:00
parent 60bec1eaf9
commit cb8acfe5fe
24 changed files with 4732 additions and 45 deletions

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)
)
)

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import pytest
from tests.support.live_trade_stream import (
DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS,
LIVE_REST_URL_ENV,
LIVE_SYMBOLS_ENV,
LIVE_TRADE_TIMEOUT_ENV,
LIVE_WEBSOCKET_URL_ENV,
RUN_LIVE_TESTS_ENV,
LiveTestConfigurationError,
build_live_trade_stream_settings,
load_live_trade_stream_test_config,
)
REST_URL = "https://api-adapter.dzengi.com"
WEBSOCKET_URL = "wss://api-adapter.dzengi.com/connect"
SYMBOL = "BTC/USD_LEVERAGE"
def make_environment() -> dict[str, str]:
return {
RUN_LIVE_TESTS_ENV: "1",
LIVE_REST_URL_ENV: REST_URL,
LIVE_WEBSOCKET_URL_ENV: WEBSOCKET_URL,
LIVE_SYMBOLS_ENV: SYMBOL,
}
@pytest.mark.parametrize(
"opt_in",
(
"",
"0",
),
)
def test_live_config_is_disabled_without_exact_opt_in(
opt_in: str,
) -> None:
assert load_live_trade_stream_test_config(
{
RUN_LIVE_TESTS_ENV: opt_in,
}
) is None
def test_invalid_live_opt_in_is_rejected() -> None:
with pytest.raises(
LiveTestConfigurationError,
match="must be exactly 1",
):
load_live_trade_stream_test_config(
{
RUN_LIVE_TESTS_ENV: "true",
}
)
@pytest.mark.parametrize(
"missing_name",
(
LIVE_REST_URL_ENV,
LIVE_WEBSOCKET_URL_ENV,
LIVE_SYMBOLS_ENV,
),
)
def test_enabled_live_config_requires_every_explicit_value(
missing_name: str,
) -> None:
environment = make_environment()
del environment[missing_name]
with pytest.raises(
LiveTestConfigurationError,
match=missing_name,
):
load_live_trade_stream_test_config(environment)
@pytest.mark.parametrize(
("name", "value"),
(
(
LIVE_REST_URL_ENV,
"http://api-adapter.dzengi.com",
),
(
LIVE_REST_URL_ENV,
"https://key@example.com",
),
(
LIVE_WEBSOCKET_URL_ENV,
"ws://api-adapter.dzengi.com/connect",
),
(
LIVE_WEBSOCKET_URL_ENV,
"wss://api-adapter.dzengi.com/not-connect",
),
),
)
def test_live_config_rejects_unsafe_or_wrong_endpoints(
name: str,
value: str,
) -> None:
environment = make_environment()
environment[name] = value
with pytest.raises(LiveTestConfigurationError):
load_live_trade_stream_test_config(environment)
def test_live_config_requires_exactly_one_symbol() -> None:
environment = make_environment()
environment[LIVE_SYMBOLS_ENV] = (
"BTC/USD_LEVERAGE,ETH/USD_LEVERAGE"
)
with pytest.raises(
LiveTestConfigurationError,
match="exactly one symbol",
):
load_live_trade_stream_test_config(environment)
def test_live_config_uses_bounded_default_trade_timeout() -> None:
config = load_live_trade_stream_test_config(
make_environment()
)
assert config is not None
assert (
config.trade_timeout_seconds
== DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS
== 600.0
)
@pytest.mark.parametrize(
"timeout_value",
(
"0",
"-1",
"nan",
"inf",
"not-a-number",
),
)
def test_live_config_rejects_invalid_trade_timeout(
timeout_value: str,
) -> None:
environment = make_environment()
environment[LIVE_TRADE_TIMEOUT_ENV] = timeout_value
with pytest.raises(LiveTestConfigurationError):
load_live_trade_stream_test_config(environment)
def test_live_settings_are_explicit_and_credential_free() -> None:
config = load_live_trade_stream_test_config(
make_environment()
)
assert config is not None
settings = build_live_trade_stream_settings(config)
assert settings.exchange_base_url == REST_URL
assert settings.exchange_api_key == ""
assert settings.exchange_api_secret == ""
assert settings.trade_stream.enabled is True
assert settings.trade_stream.websocket_url == WEBSOCKET_URL
assert settings.trade_stream.symbols == (SYMBOL,)

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import asyncio
import pytest
from tests.support.trade_stream_runtime import (
run_scenario,
wait_until_or_runtime_exit,
)
def test_wait_until_or_runtime_exit_returns_when_condition_is_reached() -> None:
async def scenario() -> None:
condition_reached = False
async def keep_runtime_alive() -> None:
await asyncio.Event().wait()
runtime_task = asyncio.create_task(
keep_runtime_alive(),
)
try:
condition_reached = True
await wait_until_or_runtime_exit(
lambda: condition_reached,
runtime_task=runtime_task,
timeout_seconds=0.5,
)
assert runtime_task.done() is False
finally:
runtime_task.cancel()
with pytest.raises(asyncio.CancelledError):
await runtime_task
run_scenario(scenario())
def test_wait_until_or_runtime_exit_propagates_runtime_error_immediately() -> None:
expected_error = RuntimeError("runtime failed")
async def scenario() -> None:
async def fail_runtime() -> None:
await asyncio.sleep(0)
raise expected_error
runtime_task = asyncio.create_task(
fail_runtime(),
)
with pytest.raises(RuntimeError) as exc_info:
await asyncio.wait_for(
wait_until_or_runtime_exit(
lambda: False,
runtime_task=runtime_task,
timeout_seconds=10.0,
),
timeout=0.5,
)
assert exc_info.value is expected_error
run_scenario(scenario())
def test_wait_until_or_runtime_exit_rejects_clean_early_runtime_exit() -> None:
async def scenario() -> None:
async def finish_runtime() -> None:
await asyncio.sleep(0)
runtime_task = asyncio.create_task(
finish_runtime(),
)
with pytest.raises(
RuntimeError,
match="exited before the expected live condition",
):
await asyncio.wait_for(
wait_until_or_runtime_exit(
lambda: False,
runtime_task=runtime_task,
timeout_seconds=10.0,
),
timeout=0.5,
)
run_scenario(scenario())