feat: auto-reconcile stale holding mirrors

Closes #94
This commit is contained in:
Hermes Agent
2026-09-14 12:55:04 +09:00
parent adf131f19c
commit c70ac0fb24
19 changed files with 1181 additions and 16 deletions
@@ -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()")
+52
View File
@@ -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가 있으면 우선 사용."""