@@ -0,0 +1,275 @@
|
||||
"""PostgreSQL auto-reconcile transaction sequencing tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.persistence.auto_reconcile import PostgreSQLAutoReconcileStore
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||
from trex_core.adapters.portfolio.reconcile_warning_store import (
|
||||
PostgreSQLReconcileWarningStore,
|
||||
)
|
||||
from trex_core.application.auto_reconcile import AutoReconcileCommand
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rowcounts=(1, 1), fail_on_insert=False):
|
||||
self.rowcounts = iter(rowcounts)
|
||||
self.rowcount = -1
|
||||
self.fail_on_insert = fail_on_insert
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
normalized = " ".join(sql.split())
|
||||
self.calls.append((normalized, params))
|
||||
if normalized.startswith("UPDATE daily_slot"):
|
||||
self.rowcount = next(self.rowcounts)
|
||||
elif normalized.startswith("DELETE FROM slot_reconcile_warning"):
|
||||
self.rowcount = next(self.rowcounts)
|
||||
elif normalized.startswith("INSERT INTO slot_transition_log") and self.fail_on_insert:
|
||||
raise RuntimeError("audit insert failed")
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
def rollback(self):
|
||||
self.rollbacks += 1
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeConnectionContext:
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def __enter__(self):
|
||||
return self.conn
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
def _command():
|
||||
return AutoReconcileCommand(
|
||||
slot_id=uuid4(),
|
||||
instance_key="inst-a",
|
||||
portfolio_id="pf-a",
|
||||
symbol="019685",
|
||||
expected_qty=1452.0,
|
||||
expected_avg_price=2100.0,
|
||||
target_qty=726.0,
|
||||
target_avg_price=2172.46829187,
|
||||
hub_quantity=726.0,
|
||||
broker_quantity=726,
|
||||
evidence_observed_at=datetime.now(tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _install(monkeypatch, conn):
|
||||
module = SimpleNamespace(connect=lambda dsn, autocommit=False: FakeConnectionContext(conn))
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.auto_reconcile._require_psycopg",
|
||||
lambda _module=None: module,
|
||||
)
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_commits_update_b_clear_and_audit(monkeypatch):
|
||||
cursor = FakeCursor(rowcounts=(1, 1))
|
||||
conn = FakeConnection(cursor)
|
||||
_install(monkeypatch, conn)
|
||||
|
||||
assert PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
|
||||
|
||||
assert conn.commits == 1
|
||||
assert conn.rollbacks == 0
|
||||
statements = [sql for sql, _params in cursor.calls]
|
||||
assert statements[0].startswith("SELECT pg_advisory_xact_lock")
|
||||
assert "status='holding'" in statements[1]
|
||||
assert "avg_price IS NOT DISTINCT FROM" in statements[1]
|
||||
assert "broker_order_id IS NULL" in statements[1]
|
||||
assert statements[2].startswith("DELETE FROM slot_reconcile_warning")
|
||||
assert cursor.calls[2][1][1] == "B"
|
||||
assert statements[3].startswith("INSERT INTO slot_transition_log")
|
||||
assert cursor.calls[3][1][2:4] == (
|
||||
"hub_broker_matched_core_mirror_stale",
|
||||
"auto_reconcile",
|
||||
)
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_cas_failure_rolls_back_without_warning_or_audit(monkeypatch):
|
||||
cursor = FakeCursor(rowcounts=(0,))
|
||||
conn = FakeConnection(cursor)
|
||||
_install(monkeypatch, conn)
|
||||
|
||||
assert not PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
|
||||
|
||||
assert conn.commits == 0
|
||||
assert conn.rollbacks == 1
|
||||
assert len(cursor.calls) == 2
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_audit_failure_rolls_back_slot_and_warning(monkeypatch):
|
||||
cursor = FakeCursor(rowcounts=(1, 1), fail_on_insert=True)
|
||||
conn = FakeConnection(cursor)
|
||||
_install(monkeypatch, conn)
|
||||
|
||||
with pytest.raises(RuntimeError, match="audit insert failed"):
|
||||
PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
|
||||
|
||||
assert conn.commits == 0
|
||||
assert conn.rollbacks == 1
|
||||
assert len(cursor.calls) == 4
|
||||
|
||||
|
||||
def _seed_real_transaction_state(pg_clean):
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
service = SlotService(repo, instance_key="inst-a")
|
||||
warning = PostgreSQLReconcileWarningStore(pg_clean)
|
||||
repo.reconcile_universe("inst-a", {"pf-a": "019685"})
|
||||
slot = repo.find_slot_by_portfolio("inst-a", "pf-a")
|
||||
assert slot is not None
|
||||
pending = service.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy",
|
||||
actor="test",
|
||||
symbol="019685",
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
pending.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.HOLDING,
|
||||
reason="filled",
|
||||
actor="test",
|
||||
qty=1452.0,
|
||||
avg_price=2100.0,
|
||||
)
|
||||
warning.set_warned(
|
||||
str(holding.slot_id),
|
||||
portfolio_id="pf-a",
|
||||
case_code="B",
|
||||
actor="reconcile",
|
||||
)
|
||||
warning.set_warned(
|
||||
str(holding.slot_id),
|
||||
portfolio_id="pf-a",
|
||||
case_code="C",
|
||||
actor="reconcile",
|
||||
)
|
||||
command = AutoReconcileCommand(
|
||||
slot_id=holding.slot_id,
|
||||
instance_key="inst-a",
|
||||
portfolio_id="pf-a",
|
||||
symbol="019685",
|
||||
expected_qty=1452.0,
|
||||
expected_avg_price=2100.0,
|
||||
target_qty=726.0,
|
||||
target_avg_price=2172.46829187,
|
||||
hub_quantity=726.0,
|
||||
broker_quantity=726,
|
||||
evidence_observed_at=datetime.now(tz=UTC),
|
||||
)
|
||||
return repo, warning, holding, command
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_real_transaction_commit(pg_clean):
|
||||
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
|
||||
|
||||
assert PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
|
||||
|
||||
current = repo.fetch_slot(holding.slot_id)
|
||||
assert current is not None
|
||||
assert current.status is SlotState.HOLDING
|
||||
assert current.qty == 726.0
|
||||
assert current.avg_price == pytest.approx(2172.46829187)
|
||||
info = warning.get_warning_info(str(holding.slot_id))
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["C"]
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT actor, reason FROM slot_transition_log "
|
||||
"WHERE slot_id = %s ORDER BY ts DESC LIMIT 1",
|
||||
(holding.slot_id,),
|
||||
).fetchone()
|
||||
assert row == ("auto_reconcile", "hub_broker_matched_core_mirror_stale")
|
||||
|
||||
|
||||
def test_postgresql_auto_reconcile_real_transaction_audit_failure_rolls_back(pg_clean):
|
||||
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
before_audit_row = conn.execute(
|
||||
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
|
||||
(holding.slot_id,),
|
||||
).fetchone()
|
||||
assert before_audit_row is not None
|
||||
before_audit_count = before_audit_row[0]
|
||||
|
||||
with psycopg.connect(pg_clean, autocommit=True) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE FUNCTION trex_test_fail_auto_reconcile_audit()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'forced auto-reconcile audit failure';
|
||||
END
|
||||
$$
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER trex_test_fail_auto_reconcile_audit
|
||||
BEFORE INSERT ON slot_transition_log
|
||||
FOR EACH ROW WHEN (NEW.actor = 'auto_reconcile')
|
||||
EXECUTE FUNCTION trex_test_fail_auto_reconcile_audit()
|
||||
"""
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(psycopg.errors.RaiseException, match="forced auto-reconcile"):
|
||||
PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
|
||||
|
||||
current = repo.fetch_slot(holding.slot_id)
|
||||
assert current is not None
|
||||
assert current.qty == 1452.0
|
||||
assert current.avg_price == 2100.0
|
||||
info = warning.get_warning_info(str(holding.slot_id))
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B", "C"]
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
after_audit_row = conn.execute(
|
||||
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
|
||||
(holding.slot_id,),
|
||||
).fetchone()
|
||||
assert after_audit_row is not None
|
||||
after_audit_count = after_audit_row[0]
|
||||
assert after_audit_count == before_audit_count
|
||||
finally:
|
||||
with psycopg.connect(pg_clean, autocommit=True) as conn:
|
||||
conn.execute(
|
||||
"DROP TRIGGER IF EXISTS trex_test_fail_auto_reconcile_audit ON slot_transition_log"
|
||||
)
|
||||
conn.execute("DROP FUNCTION IF EXISTS trex_test_fail_auto_reconcile_audit()")
|
||||
@@ -117,6 +117,58 @@ def test_get_portfolios_network_error_raises_unavailable():
|
||||
# REQ-123: cost-basis parsing (canonical + diagnostics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_cost_basis_evidence_parses_identity_quantity_and_complete_basis():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/v1/portfolios/pf-1/holdings/cost-basis"
|
||||
return httpx.Response(200, json={
|
||||
"portfolio_id": "pf-1",
|
||||
"symbol": "019685",
|
||||
"current_quantity": "726",
|
||||
"cost_basis_per_unit": "2172.46829187",
|
||||
"total_cost_basis": str(726 * 2172.46829187),
|
||||
"data_completeness_warning": None,
|
||||
})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
|
||||
evidence = adapter.get_cost_basis_evidence("pf-1")
|
||||
assert evidence.portfolio_id == "pf-1"
|
||||
assert evidence.symbol == "019685"
|
||||
assert evidence.current_quantity == 726.0
|
||||
assert evidence.cost_basis_per_unit == pytest.approx(2172.46829187)
|
||||
assert evidence.data_completeness_warning is None
|
||||
assert evidence.observed_at.tzinfo is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{"cost_basis_per_unit": 1, "total_cost_basis": 1},
|
||||
{
|
||||
"portfolio_id": "wrong", "symbol": "019685", "current_quantity": 1,
|
||||
"cost_basis_per_unit": 1, "total_cost_basis": 1,
|
||||
},
|
||||
{
|
||||
"portfolio_id": "pf-1", "symbol": "019685", "current_quantity": 1,
|
||||
"cost_basis_per_unit": "NaN", "total_cost_basis": 1,
|
||||
},
|
||||
])
|
||||
def test_get_cost_basis_evidence_rejects_missing_mismatched_or_nonfinite(payload):
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
|
||||
with pytest.raises(PortfolioHubUnavailableError):
|
||||
adapter.get_cost_basis_evidence("pf-1")
|
||||
|
||||
|
||||
def test_get_cost_basis_evidence_never_uses_legacy_fields():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"avg_cost": 1234, "wac": 1234})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
|
||||
with pytest.raises(PortfolioHubUnavailableError):
|
||||
adapter.get_cost_basis_evidence("pf-1")
|
||||
|
||||
|
||||
def test_get_cost_basis_with_diagnostics_prefers_canonical_when_valid():
|
||||
"""REQ-123 AC-1: cost_basis_per_unit가 있으면 우선 사용."""
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""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)
|
||||
@@ -60,11 +60,15 @@ def test_run_once_uses_authoritative_core_holdings_broker(monkeypatch):
|
||||
projected_broker = object()
|
||||
authoritative_broker = object()
|
||||
unmanaged_store = object()
|
||||
auto_store = SimpleNamespace(apply_holding_mirror=lambda _command: False)
|
||||
captured = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured["broker"] = kwargs["broker"]
|
||||
captured["unmanaged_position_store"] = kwargs.get("unmanaged_position_store")
|
||||
captured["portfolio_items"] = kwargs.get("portfolio_items")
|
||||
captured["cost_basis_port"] = kwargs.get("cost_basis_port")
|
||||
captured["auto_reconcile_store"] = kwargs.get("auto_reconcile_store")
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -79,12 +83,17 @@ def test_run_once_uses_authoritative_core_holdings_broker(monkeypatch):
|
||||
slot_repo=object(),
|
||||
slot_service=SimpleNamespace(instance_key="inst-1"),
|
||||
unmanaged_position_store=unmanaged_store,
|
||||
cost_basis_port=read,
|
||||
auto_reconcile_store=auto_store, # type: ignore[arg-type]
|
||||
)
|
||||
sched._reconcile_slots = lambda _items: None
|
||||
|
||||
assert sched.run_once() is True
|
||||
assert captured["broker"] is authoritative_broker
|
||||
assert captured["unmanaged_position_store"] is unmanaged_store
|
||||
assert captured["portfolio_items"] == read.items
|
||||
assert captured["cost_basis_port"] is read
|
||||
assert captured["auto_reconcile_store"] is auto_store
|
||||
|
||||
|
||||
def test_swap_preserves_warning_for_carried_over_portfolio():
|
||||
|
||||
@@ -24,6 +24,7 @@ _PG_TABLES = (
|
||||
"ledger_outbox",
|
||||
"intent_log",
|
||||
"slot_transition_log",
|
||||
"slot_reconcile_warning",
|
||||
"daily_symbol_entries",
|
||||
"daily_slot",
|
||||
"reconcile_notification_state",
|
||||
|
||||
@@ -83,7 +83,12 @@ def test_daemon_wires_reconciliation_broker_into_snapshot_scheduler():
|
||||
/ "daemon.py"
|
||||
).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
found = False
|
||||
expected = {
|
||||
"core_holdings_broker": "reconciliation_broker",
|
||||
"cost_basis_port": "portfolio_read",
|
||||
"auto_reconcile_store": "auto_reconcile_store",
|
||||
}
|
||||
found: dict[str, str] = {}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
@@ -100,17 +105,12 @@ def test_daemon_wires_reconciliation_broker_into_snapshot_scheduler():
|
||||
):
|
||||
continue
|
||||
for kw in call.keywords:
|
||||
if kw.arg != "core_holdings_broker":
|
||||
if kw.arg not in expected:
|
||||
continue
|
||||
if (
|
||||
isinstance(kw.value, ast.Attribute)
|
||||
and kw.value.attr == "reconciliation_broker"
|
||||
and isinstance(kw.value.value, ast.Name)
|
||||
and kw.value.value.id == "self"
|
||||
):
|
||||
found = True
|
||||
break
|
||||
assert found, (
|
||||
"Daemon must pass self.reconciliation_broker into "
|
||||
"SnapshotScheduler(core_holdings_broker=...)"
|
||||
)
|
||||
found[kw.arg] = kw.value.attr
|
||||
assert found == expected
|
||||
|
||||
Reference in New Issue
Block a user