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