Build 060.19: implement Trade Recovery subsystem

This commit is contained in:
2026-07-21 22:10:40 +03:00
parent a891302eaf
commit cf58fc223c
18 changed files with 11340 additions and 0 deletions

View File

@@ -0,0 +1,575 @@
# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_controller.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiTradesDocumentSource,
)
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
TradeConsistencyError,
)
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
from src.market_data.acquisition.recovery.trade_recovery_controller import (
TradeRecoveryController,
)
from src.market_data.acquisition.recovery.trade_recovery_request import (
TradeRecoveryRequest,
)
class StubTradesDocumentSource(
DzengiTradesDocumentSource,
):
def __init__(
self,
document: object,
) -> None:
super().__init__()
self.document = document
self.calls: list[
tuple[
str,
int | None,
int | None,
int | None,
]
] = []
def fetch_trades_document(
self,
symbol: str,
*,
start_time: int | None = None,
end_time: int | None = None,
limit: int | None = None,
) -> object:
self.calls.append(
(
symbol,
start_time,
end_time,
limit,
)
)
return self.document
class StubConsistencyController:
def __init__(self) -> None:
self.received_trades: list[Trade] = []
def accept(
self,
trade: Trade,
) -> Trade | None:
self.received_trades.append(trade)
return trade
def _request(
*,
symbol: str = "BTCUSD",
start_time: int = 1_700_000_000_000,
end_time: int = 1_700_000_001_000,
limit: int | None = 500,
) -> TradeRecoveryRequest:
return TradeRecoveryRequest(
symbol=symbol,
start_time=start_time,
end_time=end_time,
limit=limit,
)
def _raw_trade(
*,
trade_id: int,
price: str = "50000.00",
quantity: str = "0.25",
timestamp: int = 1_700_000_000_000,
buyer_is_maker: bool = False,
) -> dict[str, object]:
return {
"a": trade_id,
"p": price,
"q": quantity,
"T": timestamp,
"m": buyer_is_maker,
}
def test_requests_rest_document_with_recovery_parameters() -> None:
source = StubTradesDocumentSource(document=[])
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
controller.recover(
_request(
symbol="BTCUSD",
start_time=100,
end_time=200,
limit=250,
)
)
assert source.calls == [
(
"BTCUSD",
100,
200,
250,
)
]
def test_returns_empty_result_for_empty_document() -> None:
source = StubTradesDocumentSource(document=[])
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
result = controller.recover(
_request(),
)
assert result.symbol == "BTCUSD"
assert result.requested_start_time == 1_700_000_000_000
assert result.requested_end_time == 1_700_000_001_000
assert result.recovered_trades == ()
assert result.recovered_count == 0
assert result.is_empty is True
def test_converts_rest_document_to_canonical_trades() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(
trade_id=100,
price="50000.50",
quantity="0.125",
timestamp=1_700_000_000_123,
buyer_is_maker=False,
),
]
)
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
result = controller.recover(
_request(),
)
assert result.recovered_count == 1
trade = result.recovered_trades[0]
assert trade.symbol == "BTCUSD"
assert trade.trade_id == 100
assert trade.price == Decimal("50000.50")
assert trade.quantity == Decimal("0.125")
assert trade.aggressor_side is TradeAggressorSide.BUY
assert trade.source == "dzengi"
assert trade.executed_at.tzinfo is not None
def test_maps_buyer_is_maker_to_sell_aggressor_side() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(
trade_id=100,
buyer_is_maker=True,
),
]
)
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
result = controller.recover(
_request(),
)
assert (
result.recovered_trades[0].aggressor_side
is TradeAggressorSide.SELL
)
def test_normalizes_trades_before_consistency_check() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(trade_id=103),
_raw_trade(trade_id=100),
_raw_trade(trade_id=102),
_raw_trade(trade_id=101),
]
)
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
controller.recover(
_request(),
)
assert [
trade.trade_id
for trade in consistency_controller.received_trades
] == [
100,
101,
102,
103,
]
def test_returns_trades_in_normalized_order() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(trade_id=102),
_raw_trade(trade_id=100),
_raw_trade(trade_id=101),
]
)
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
) == (
100,
101,
102,
)
def test_excludes_duplicate_rejected_by_consistency_controller() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(trade_id=100),
_raw_trade(trade_id=101),
_raw_trade(trade_id=102),
]
)
class DuplicateRejectingConsistencyController:
def __init__(self) -> None:
self.received_trade_ids: list[int] = []
def accept(
self,
trade: Trade,
) -> Trade | None:
self.received_trade_ids.append(trade.trade_id)
if trade.trade_id == 101:
return None
return trade
consistency_controller = DuplicateRejectingConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
result = controller.recover(
_request(),
)
assert consistency_controller.received_trade_ids == [
100,
101,
102,
]
assert tuple(
trade.trade_id
for trade in result.recovered_trades
) == (
100,
102,
)
def test_preserves_consistency_controller_returned_instance() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(trade_id=100),
]
)
replacement_trade = Trade(
symbol="BTCUSD",
trade_id=100,
price=Decimal("60000.00"),
quantity=Decimal("1.00"),
executed_at=datetime(
2026,
1,
1,
12,
0,
tzinfo=timezone.utc,
),
aggressor_side=TradeAggressorSide.SELL,
source="test",
)
class ReplacingConsistencyController:
def accept(
self,
trade: Trade,
) -> Trade | None:
return replacement_trade
controller = TradeRecoveryController(
document_source=source,
consistency_controller=ReplacingConsistencyController(),
)
result = controller.recover(
_request(),
)
assert result.recovered_trades == (replacement_trade,)
assert result.recovered_trades[0] is replacement_trade
def test_propagates_source_error() -> None:
expected_error = RuntimeError("source error")
class FailingSource(
DzengiTradesDocumentSource,
):
def fetch_trades_document(
self,
symbol: str,
*,
start_time: int | None = None,
end_time: int | None = None,
limit: int | None = None,
) -> object:
raise expected_error
controller = TradeRecoveryController(
document_source=FailingSource(),
consistency_controller=StubConsistencyController(),
)
with pytest.raises(RuntimeError) as exc_info:
controller.recover(
_request(),
)
assert exc_info.value is expected_error
def test_propagates_schema_validation_error() -> None:
controller = TradeRecoveryController(
document_source=StubTradesDocumentSource(
document={
"unexpected": "document",
}
),
consistency_controller=StubConsistencyController(),
)
with pytest.raises(Exception):
controller.recover(
_request(),
)
def test_propagates_consistency_error() -> None:
expected_error = TradeConsistencyError()
class FailingConsistencyController:
def accept(
self,
trade: Trade,
) -> Trade | None:
raise expected_error
controller = TradeRecoveryController(
document_source=StubTradesDocumentSource(
document=[
_raw_trade(trade_id=100),
]
),
consistency_controller=FailingConsistencyController(),
)
with pytest.raises(TradeConsistencyError) as exc_info:
controller.recover(
_request(),
)
assert exc_info.value is expected_error
def test_does_not_call_consistency_controller_for_empty_document() -> None:
source = StubTradesDocumentSource(document=[])
class FailingIfCalledConsistencyController:
def accept(
self,
trade: Trade,
) -> Trade | None:
raise AssertionError(
"Consistency controller must not be called."
)
controller = TradeRecoveryController(
document_source=source,
consistency_controller=FailingIfCalledConsistencyController(),
)
result = controller.recover(
_request(),
)
assert result.recovered_trades == ()
def test_uses_injected_consistency_controller_instance() -> None:
source = StubTradesDocumentSource(
document=[],
)
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
assert (
controller._consistency_controller # type: ignore[attr-defined]
is consistency_controller
)
def test_uses_same_consistency_controller_for_all_trades() -> None:
source = StubTradesDocumentSource(
document=[
_raw_trade(trade_id=102),
_raw_trade(trade_id=100),
_raw_trade(trade_id=101),
]
)
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=source,
consistency_controller=consistency_controller,
)
result = controller.recover(
_request(),
)
assert [
trade.trade_id
for trade in consistency_controller.received_trades
] == [
100,
101,
102,
]
assert tuple(
trade.trade_id
for trade in result.recovered_trades
) == (
100,
101,
102,
)
def test_returns_recovered_trades_as_tuple() -> None:
controller = TradeRecoveryController(
document_source=StubTradesDocumentSource(
document=[
_raw_trade(trade_id=100),
]
),
consistency_controller=StubConsistencyController(),
)
result = controller.recover(
_request(),
)
assert isinstance(result.recovered_trades, tuple)
def test_empty_recovery_returns_empty_tuple() -> None:
controller = TradeRecoveryController(
document_source=StubTradesDocumentSource(
document=[],
),
consistency_controller=StubConsistencyController(),
)
result = controller.recover(
_request(),
)
assert result.recovered_trades == ()
assert isinstance(result.recovered_trades, tuple)
def test_controller_does_not_create_additional_consistency_state() -> None:
consistency_controller = StubConsistencyController()
controller = TradeRecoveryController(
document_source=StubTradesDocumentSource(
document=[],
),
consistency_controller=consistency_controller,
)
assert controller.__dict__ == {
"_document_source": controller._document_source,
"_consistency_controller": consistency_controller,
}

View File

@@ -0,0 +1,224 @@
# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_normalizer.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
from src.market_data.acquisition.recovery.trade_recovery_normalizer import (
normalize_recovered_trades,
)
def _trade(
*,
trade_id: int,
price: Decimal = Decimal("50000.00"),
) -> Trade:
return Trade(
symbol="BTCUSD",
trade_id=trade_id,
price=price,
quantity=Decimal("0.25"),
executed_at=datetime(
2026,
1,
1,
12,
0,
tzinfo=timezone.utc,
),
aggressor_side=TradeAggressorSide.BUY,
source="dzengi",
)
def test_returns_empty_tuple_for_empty_input() -> None:
result = normalize_recovered_trades(())
assert result == ()
assert isinstance(result, tuple)
def test_keeps_already_sorted_trades() -> None:
first_trade = _trade(trade_id=100)
second_trade = _trade(trade_id=101)
third_trade = _trade(trade_id=102)
result = normalize_recovered_trades(
(
first_trade,
second_trade,
third_trade,
)
)
assert result == (
first_trade,
second_trade,
third_trade,
)
def test_sorts_reverse_order_by_trade_id() -> None:
first_trade = _trade(trade_id=100)
second_trade = _trade(trade_id=101)
third_trade = _trade(trade_id=102)
result = normalize_recovered_trades(
(
third_trade,
second_trade,
first_trade,
)
)
assert result == (
first_trade,
second_trade,
third_trade,
)
def test_sorts_arbitrary_order_by_trade_id() -> None:
trade_100 = _trade(trade_id=100)
trade_101 = _trade(trade_id=101)
trade_102 = _trade(trade_id=102)
trade_103 = _trade(trade_id=103)
result = normalize_recovered_trades(
(
trade_102,
trade_100,
trade_103,
trade_101,
)
)
assert result == (
trade_100,
trade_101,
trade_102,
trade_103,
)
def test_preserves_stable_order_for_equal_trade_ids() -> None:
first_duplicate = _trade(
trade_id=100,
price=Decimal("50000.00"),
)
second_duplicate = _trade(
trade_id=100,
price=Decimal("50001.00"),
)
result = normalize_recovered_trades(
(
first_duplicate,
second_duplicate,
)
)
assert result == (
first_duplicate,
second_duplicate,
)
def test_does_not_remove_identical_duplicates() -> None:
trade = _trade(trade_id=100)
result = normalize_recovered_trades(
(
trade,
trade,
)
)
assert result == (
trade,
trade,
)
def test_accepts_list_input() -> None:
first_trade = _trade(trade_id=100)
second_trade = _trade(trade_id=101)
result = normalize_recovered_trades(
[
second_trade,
first_trade,
]
)
assert result == (
first_trade,
second_trade,
)
def test_accepts_generator_input() -> None:
trades = (
_trade(trade_id=trade_id)
for trade_id in (
102,
100,
101,
)
)
result = normalize_recovered_trades(trades)
assert tuple(
trade.trade_id
for trade in result
) == (
100,
101,
102,
)
def test_does_not_modify_source_list() -> None:
first_trade = _trade(trade_id=100)
second_trade = _trade(trade_id=101)
source = [
second_trade,
first_trade,
]
normalize_recovered_trades(source)
assert source == [
second_trade,
first_trade,
]
def test_returns_tuple_for_non_tuple_input() -> None:
result = normalize_recovered_trades(
[
_trade(trade_id=100),
]
)
assert isinstance(result, tuple)
def test_returns_new_tuple_for_tuple_input() -> None:
source = (
_trade(trade_id=100),
_trade(trade_id=101),
)
result = normalize_recovered_trades(source)
assert result == source
assert result is not source

View File

@@ -0,0 +1,277 @@
# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_request.py
from __future__ import annotations
import pytest
from src.market_data.acquisition.recovery.trade_recovery_request import (
TradeRecoveryRequest,
)
def test_creates_valid_request() -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
limit=500,
)
assert request.symbol == "BTCUSD"
assert request.start_time == 1_700_000_000_000
assert request.end_time == 1_700_000_001_000
assert request.limit == 500
def test_allows_equal_start_and_end_time() -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_000_000,
)
assert request.start_time == request.end_time
def test_allows_none_limit() -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
limit=None,
)
assert request.limit is None
@pytest.mark.parametrize(
"limit",
[
1,
1000,
],
)
def test_allows_limit_boundaries(
limit: int,
) -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
limit=limit,
)
assert request.limit == limit
def test_rejects_non_string_symbol() -> None:
with pytest.raises(
TypeError,
match="symbol должен иметь тип str",
):
TradeRecoveryRequest(
symbol=123, # type: ignore[arg-type]
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
)
@pytest.mark.parametrize(
"symbol",
[
"",
" ",
],
)
def test_rejects_empty_symbol(
symbol: str,
) -> None:
with pytest.raises(
ValueError,
match="symbol не должен быть пустым",
):
TradeRecoveryRequest(
symbol=symbol,
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
)
@pytest.mark.parametrize(
"start_time",
[
1.5,
"1700000000000",
None,
True,
],
)
def test_rejects_invalid_start_time_type(
start_time: object,
) -> None:
with pytest.raises(
TypeError,
match="start_time должен иметь тип int",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=start_time, # type: ignore[arg-type]
end_time=1_700_000_001_000,
)
@pytest.mark.parametrize(
"end_time",
[
1.5,
"1700000001000",
None,
True,
],
)
def test_rejects_invalid_end_time_type(
end_time: object,
) -> None:
with pytest.raises(
TypeError,
match="end_time должен иметь тип int",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=end_time, # type: ignore[arg-type]
)
def test_rejects_negative_start_time() -> None:
with pytest.raises(
ValueError,
match="start_time не должен быть отрицательным",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=-1,
end_time=1_000,
)
def test_rejects_negative_end_time() -> None:
with pytest.raises(
ValueError,
match="end_time не должен быть отрицательным",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=0,
end_time=-1,
)
def test_rejects_start_time_greater_than_end_time() -> None:
with pytest.raises(
ValueError,
match="start_time не должен быть больше end_time",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=2_000,
end_time=1_000,
)
def test_allows_window_shorter_than_one_hour() -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=0,
end_time=3_599_999,
)
assert request.end_time - request.start_time == 3_599_999
@pytest.mark.parametrize(
"end_time",
[
3_600_000,
3_600_001,
],
)
def test_rejects_window_of_one_hour_or_more(
end_time: int,
) -> None:
with pytest.raises(
ValueError,
match="Диапазон восстановления должен быть меньше одного часа",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=0,
end_time=end_time,
)
@pytest.mark.parametrize(
"limit",
[
1.5,
"100",
True,
],
)
def test_rejects_invalid_limit_type(
limit: object,
) -> None:
with pytest.raises(
TypeError,
match="limit должен иметь тип int или None",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
limit=limit, # type: ignore[arg-type]
)
@pytest.mark.parametrize(
"limit",
[
0,
-1,
1001,
],
)
def test_rejects_limit_outside_allowed_range(
limit: int,
) -> None:
with pytest.raises(
ValueError,
match="limit должен находиться в диапазоне от 1 до 1000",
):
TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
limit=limit,
)
def test_request_is_immutable() -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
)
with pytest.raises(AttributeError):
request.symbol = "ETHUSD" # type: ignore[misc]
def test_request_uses_slots() -> None:
request = TradeRecoveryRequest(
symbol="BTCUSD",
start_time=1_700_000_000_000,
end_time=1_700_000_001_000,
)
assert not hasattr(request, "__dict__")

View File

@@ -0,0 +1,199 @@
# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_result.py
from __future__ import annotations
from dataclasses import FrozenInstanceError
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
from src.market_data.acquisition.recovery.trade_recovery_result import (
TradeRecoveryResult,
)
def _trade(
*,
trade_id: int,
symbol: str = "BTCUSD",
) -> Trade:
return Trade(
symbol=symbol,
trade_id=trade_id,
price=Decimal("50000.00"),
quantity=Decimal("0.25"),
executed_at=datetime(
2026,
1,
1,
12,
0,
tzinfo=timezone.utc,
),
aggressor_side=TradeAggressorSide.BUY,
source="dzengi",
)
def test_creates_empty_result() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
assert result.symbol == "BTCUSD"
assert result.requested_start_time == 100
assert result.requested_end_time == 200
assert result.recovered_trades == ()
def test_empty_result_has_zero_recovered_count() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
assert result.recovered_count == 0
def test_empty_result_is_empty() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
assert result.is_empty is True
def test_empty_result_has_no_first_trade() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
assert result.first_trade is None
def test_empty_result_has_no_last_trade() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
assert result.last_trade is None
def test_non_empty_result_reports_recovered_count() -> None:
trades = (
_trade(trade_id=100),
_trade(trade_id=101),
_trade(trade_id=102),
)
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=trades,
)
assert result.recovered_count == 3
def test_non_empty_result_is_not_empty() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(
_trade(trade_id=100),
),
)
assert result.is_empty is False
def test_returns_first_recovered_trade() -> None:
first_trade = _trade(trade_id=100)
second_trade = _trade(trade_id=101)
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(
first_trade,
second_trade,
),
)
assert result.first_trade is first_trade
def test_returns_last_recovered_trade() -> None:
first_trade = _trade(trade_id=100)
second_trade = _trade(trade_id=101)
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(
first_trade,
second_trade,
),
)
assert result.last_trade is second_trade
def test_single_trade_is_both_first_and_last() -> None:
trade = _trade(trade_id=100)
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(trade,),
)
assert result.first_trade is trade
assert result.last_trade is trade
def test_result_is_frozen() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
with pytest.raises(FrozenInstanceError):
result.symbol = "ETHUSD" # type: ignore[misc]
def test_result_uses_slots() -> None:
result = TradeRecoveryResult(
symbol="BTCUSD",
requested_start_time=100,
requested_end_time=200,
recovered_trades=(),
)
assert not hasattr(result, "__dict__")