327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""Issue #94 holding mirror auto-reconciliation tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from trex_core.adapters.persistence.auto_reconcile import InMemoryAutoReconcileStore
|
|
from trex_core.application.auto_reconcile import (
|
|
AutoReconcileCommand,
|
|
HoldingsCostBasisEvidence,
|
|
)
|
|
from trex_core.application.broker_port import BalanceItem
|
|
from trex_core.application.portfolio import (
|
|
InMemoryReconcileNotificationStore,
|
|
InMemoryReconcileWarningStore,
|
|
PortfolioItem,
|
|
)
|
|
from trex_core.application.reconciliation import reconcile_core_holdings
|
|
from trex_core.domain.slot import SlotService, SlotState
|
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
|
|
|
|
|
class CountingBroker:
|
|
def __init__(self, quantity: int = 726) -> None:
|
|
self.quantity = quantity
|
|
self.balance_reads = 0
|
|
self.order_calls = 0
|
|
|
|
def name(self):
|
|
return "counting"
|
|
|
|
def get_balance(self):
|
|
self.balance_reads += 1
|
|
return [BalanceItem(symbol="019685", hldg_qty=self.quantity)]
|
|
|
|
def get_current_price(self, symbol):
|
|
return 1.0
|
|
|
|
def submit_order(self, request):
|
|
self.order_calls += 1
|
|
raise AssertionError("auto reconcile must not submit orders")
|
|
|
|
def poll_fill(self, *args, **kwargs):
|
|
raise AssertionError("auto reconcile must not poll orders")
|
|
|
|
def cancel_order(self, *args, **kwargs):
|
|
raise AssertionError("auto reconcile must not cancel orders")
|
|
|
|
|
|
class EvidencePort:
|
|
def __init__(self, evidence: HoldingsCostBasisEvidence) -> None:
|
|
self.evidence = evidence
|
|
self.calls = 0
|
|
|
|
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
|
|
self.calls += 1
|
|
assert portfolio_id == "pf-019685"
|
|
return self.evidence
|
|
|
|
|
|
class RecordingAlarm:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, dict]] = []
|
|
|
|
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
|
|
self.calls.append((code, fields or {}))
|
|
return True
|
|
|
|
|
|
def _holding(repo: InMemorySlotRepository, svc: SlotService):
|
|
repo.reconcile_universe("default", {"pf-019685": "019685"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-019685")
|
|
assert slot is not None
|
|
pending = svc.transit_slot(
|
|
slot.slot_id,
|
|
SlotState.INACTIVE,
|
|
SlotState.PENDING_BUY,
|
|
reason="buy",
|
|
actor="test",
|
|
symbol="019685",
|
|
)
|
|
return svc.transit_slot(
|
|
pending.slot_id,
|
|
SlotState.PENDING_BUY,
|
|
SlotState.HOLDING,
|
|
reason="filled",
|
|
actor="test",
|
|
avg_price=2100.0,
|
|
qty=1452.0,
|
|
)
|
|
|
|
|
|
def _evidence(**overrides) -> HoldingsCostBasisEvidence:
|
|
values = {
|
|
"portfolio_id": "pf-019685",
|
|
"symbol": "019685",
|
|
"current_quantity": 726.0,
|
|
"cost_basis_per_unit": 2172.46829187,
|
|
"total_cost_basis": 726.0 * 2172.46829187,
|
|
"data_completeness_warning": None,
|
|
"observed_at": datetime.now(tz=UTC),
|
|
}
|
|
values.update(overrides)
|
|
return HoldingsCostBasisEvidence(**values)
|
|
|
|
|
|
def _run(
|
|
*,
|
|
evidence=None,
|
|
mutate_slot=None,
|
|
auto_store_wrapper=None,
|
|
notification=False,
|
|
portfolio_items=None,
|
|
):
|
|
if portfolio_items is None:
|
|
portfolio_items = [PortfolioItem("pf-019685", "019685", 0, 726.0)]
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn = InMemoryReconcileWarningStore()
|
|
slot = _holding(repo, svc)
|
|
if mutate_slot is not None:
|
|
repo.update_slot(mutate_slot(slot))
|
|
slot = repo.fetch_slot(slot.slot_id)
|
|
assert slot is not None
|
|
warn.set_warned(
|
|
str(slot.slot_id),
|
|
portfolio_id="pf-019685",
|
|
case_code="C",
|
|
actor="test",
|
|
reason="preserve-me",
|
|
)
|
|
base_store = InMemoryAutoReconcileStore(repo, warn)
|
|
auto_store = auto_store_wrapper(base_store, repo) if auto_store_wrapper else base_store
|
|
broker = CountingBroker()
|
|
alarm = RecordingAlarm() if notification else None
|
|
notifications = InMemoryReconcileNotificationStore() if notification else None
|
|
port = EvidencePort(evidence or _evidence())
|
|
decisions = reconcile_core_holdings(
|
|
slot_repo=repo,
|
|
slot_service=svc,
|
|
broker=broker, # type: ignore[arg-type]
|
|
instance_key="default",
|
|
warn_store=warn,
|
|
alarm_emitter=alarm, # type: ignore[arg-type]
|
|
notification_store=notifications,
|
|
portfolio_items=portfolio_items,
|
|
cost_basis_port=port,
|
|
auto_reconcile_store=auto_store,
|
|
)
|
|
return repo, warn, slot, broker, port, decisions, alarm
|
|
|
|
|
|
def test_auto_reconcile_updates_qty_avg_audit_and_only_clears_b():
|
|
repo, warn, slot, broker, port, decisions, _alarm = _run()
|
|
|
|
current = repo.fetch_slot(slot.slot_id)
|
|
assert current is not None
|
|
assert decisions[str(slot.slot_id)] == "auto_synced"
|
|
assert current.qty == 726.0
|
|
assert current.avg_price == pytest.approx(2172.46829187)
|
|
assert current.status is SlotState.HOLDING
|
|
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["C"]
|
|
audit = repo.transition_log_for(slot.slot_id)[-1]
|
|
assert audit.actor == "auto_reconcile"
|
|
assert audit.reason == "hub_broker_matched_core_mirror_stale"
|
|
assert audit.extra["before_qty"] == 1452.0
|
|
assert audit.extra["after_qty"] == 726.0
|
|
assert broker.balance_reads == 1
|
|
assert broker.order_calls == 0
|
|
assert port.calls == 1
|
|
|
|
|
|
def test_auto_reconcile_repeat_is_idempotent():
|
|
repo, warn, slot, broker, port, decisions, _alarm = _run()
|
|
audit_count = len(repo.transition_log_for(slot.slot_id))
|
|
|
|
second = reconcile_core_holdings(
|
|
slot_repo=repo,
|
|
slot_service=SlotService(repo),
|
|
broker=broker, # type: ignore[arg-type]
|
|
instance_key="default",
|
|
warn_store=warn,
|
|
portfolio_items=[PortfolioItem("pf-019685", "019685", 0, 726.0)],
|
|
cost_basis_port=port,
|
|
auto_reconcile_store=InMemoryAutoReconcileStore(repo, warn),
|
|
)
|
|
|
|
assert second[str(slot.slot_id)] == "ok"
|
|
assert len(repo.transition_log_for(slot.slot_id)) == audit_count
|
|
assert decisions[str(slot.slot_id)] == "auto_synced"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"evidence",
|
|
[
|
|
_evidence(data_completeness_warning="history incomplete"),
|
|
_evidence(symbol="WRONG"),
|
|
_evidence(current_quantity=725.0, total_cost_basis=725.0 * 2172.46829187),
|
|
_evidence(observed_at=datetime.now(tz=UTC) - timedelta(minutes=2)),
|
|
_evidence(cost_basis_per_unit=float("nan"), total_cost_basis=float("nan")),
|
|
],
|
|
)
|
|
def test_auto_reconcile_invalid_evidence_fails_closed(evidence):
|
|
repo, warn, slot, broker, port, decisions, _alarm = _run(evidence=evidence)
|
|
|
|
current = repo.fetch_slot(slot.slot_id)
|
|
assert current is not None
|
|
assert decisions[str(slot.slot_id)] == "qty_warning"
|
|
assert current.qty == 1452.0
|
|
assert current.avg_price == 2100.0
|
|
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
|
|
assert repo.transition_log_for(slot.slot_id)[-1].actor == "test"
|
|
assert broker.balance_reads == 1
|
|
assert broker.order_calls == 0
|
|
assert port.calls == 1
|
|
|
|
|
|
def test_auto_reconcile_pending_dispatch_metadata_fails_closed_without_evidence_read():
|
|
repo, warn, slot, _broker, port, decisions, _alarm = _run(
|
|
mutate_slot=lambda value: replace(value, broker_order_id="pending-order")
|
|
)
|
|
|
|
assert decisions[str(slot.slot_id)] == "qty_warning"
|
|
assert repo.fetch_slot(slot.slot_id).qty == 1452.0
|
|
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
|
|
assert port.calls == 0
|
|
|
|
|
|
def test_auto_reconcile_duplicate_hub_symbol_fails_closed():
|
|
repo, warn, slot, _broker, port, decisions, _alarm = _run(
|
|
portfolio_items=[
|
|
PortfolioItem("pf-019685", "019685", 0, 726.0),
|
|
PortfolioItem("pf-other", "019685", 0, 0.0),
|
|
]
|
|
)
|
|
|
|
current = repo.fetch_slot(slot.slot_id)
|
|
assert current is not None
|
|
assert decisions[str(slot.slot_id)] == "qty_warning"
|
|
assert current.qty == 1452.0
|
|
warning_info = warn.get_warning_info(str(slot.slot_id))
|
|
assert warning_info is not None
|
|
assert warning_info["case_codes"] == ["B", "C"]
|
|
assert port.calls == 0
|
|
|
|
|
|
def test_auto_reconcile_cas_race_preserves_intervening_state_and_records():
|
|
class RacingStore:
|
|
def __init__(self, delegate, repo):
|
|
self.delegate = delegate
|
|
self.repo = repo
|
|
|
|
def apply_holding_mirror(self, command):
|
|
current = self.repo.fetch_slot(command.slot_id)
|
|
self.repo.update_slot(replace(current, avg_price=9999.0))
|
|
return self.delegate.apply_holding_mirror(command)
|
|
|
|
repo, warn, slot, _broker, _port, decisions, _alarm = _run(
|
|
auto_store_wrapper=lambda delegate, repo: RacingStore(delegate, repo)
|
|
)
|
|
|
|
current = repo.fetch_slot(slot.slot_id)
|
|
assert current is not None
|
|
assert decisions[str(slot.slot_id)] == "qty_warning"
|
|
assert current.qty == 1452.0
|
|
assert current.avg_price == 9999.0
|
|
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
|
|
assert repo.transition_log_for(slot.slot_id)[-1].actor == "test"
|
|
|
|
|
|
def test_in_memory_auto_reconcile_audit_failure_rolls_back_all_records(monkeypatch):
|
|
repo = InMemorySlotRepository()
|
|
service = SlotService(repo)
|
|
warning = InMemoryReconcileWarningStore()
|
|
slot = _holding(repo, service)
|
|
assert slot.portfolio_id is not None
|
|
assert slot.symbol is not None
|
|
assert slot.qty is not None
|
|
warning.set_warned(
|
|
str(slot.slot_id), portfolio_id=slot.portfolio_id, case_code="B", actor="reconcile"
|
|
)
|
|
warning.set_warned(
|
|
str(slot.slot_id), portfolio_id=slot.portfolio_id, case_code="C", actor="reconcile"
|
|
)
|
|
before_slot = repo.fetch_slot(slot.slot_id)
|
|
before_warning = warning.get_warning_info(str(slot.slot_id))
|
|
before_log = repo.transition_log_for(slot.slot_id)
|
|
|
|
def fail_audit(_record):
|
|
raise RuntimeError("audit insert failed")
|
|
|
|
monkeypatch.setattr(repo, "append_transition_log", fail_audit)
|
|
with pytest.raises(RuntimeError, match="audit insert failed"):
|
|
InMemoryAutoReconcileStore(repo, warning).apply_holding_mirror(
|
|
AutoReconcileCommand(
|
|
slot_id=slot.slot_id,
|
|
instance_key=slot.instance_key,
|
|
portfolio_id=slot.portfolio_id,
|
|
symbol=slot.symbol,
|
|
expected_qty=slot.qty,
|
|
expected_avg_price=slot.avg_price,
|
|
target_qty=726.0,
|
|
target_avg_price=2172.46829187,
|
|
hub_quantity=726.0,
|
|
broker_quantity=726,
|
|
evidence_observed_at=datetime.now(tz=UTC),
|
|
)
|
|
)
|
|
|
|
assert repo.fetch_slot(slot.slot_id) == before_slot
|
|
assert warning.get_warning_info(str(slot.slot_id)) == before_warning
|
|
assert repo.transition_log_for(slot.slot_id) == before_log
|
|
|
|
|
|
def test_auto_reconcile_emits_detected_then_one_authoritative_resolved_event():
|
|
_repo, _warn, slot, _broker, _port, decisions, alarm = _run(notification=True)
|
|
|
|
assert decisions[str(slot.slot_id)] == "auto_synced"
|
|
assert [code for code, _fields in alarm.calls] == [
|
|
"reconcile_mismatch_detected",
|
|
"reconcile_mismatch_resolved",
|
|
]
|
|
assert all("account" not in str(fields).lower() for _code, fields in alarm.calls)
|