Implement BAT-006: portfolio-hub ledger integration (REQ-017~019)

- REQ-017: LedgerPort contract (application/ledger_port.py) + portfolio-hub
  adapter + in-memory stub (adapters/ledger/), TREX_PORTFOLIO_HUB_* config
- REQ-018: fill→ledger recording in execution loop; FillStatus carries
  execution_id (deterministic synth) + fee; paper/kis adapters updated
- REQ-019: ledger outbox + retry worker (application/ledger_outbox.py),
  correlation_id idempotency, outcome vocabulary; 002_create_ledger_outbox.sql
- Tests: in-memory/portfolio-hub ledger, execution ledger hook, outbox,
  domain import sweep

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 22:57:31 +09:00
co-authored by Claude Opus 4.8
parent 6277d610ea
commit 13837acc60
20 changed files with 1224 additions and 15 deletions
View File
@@ -0,0 +1,58 @@
"""In-memory ledger stub tests (REQ-017 AC-1)."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.application.ledger_port import (
LedgerConflictError,
LedgerRetryableError,
LedgerTrade,
)
def _trade(execution_id="paper:PAPER-000001:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="PAPER-000001", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
)
def test_record_trade_preserves_fields_and_is_idempotent_on_execution_id():
ledger = InMemoryLedger()
t = _trade()
ledger.record_trade(t)
ledger.record_trade(t) # same execution_id — no duplicate entry
assert ledger.count() == 1
stored = ledger.trades()[0]
assert (stored.asset_id, stored.side, stored.qty, stored.price,
stored.fee, stored.tax, stored.executed_at) == (
"PF-1", "buy", 10, 1000.0, 1.5, 0.0,
datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
)
def test_retryable_failure_then_success():
ledger = InMemoryLedger(fail_n_then_succeed=2)
t = _trade()
with pytest.raises(LedgerRetryableError):
ledger.record_trade(t)
with pytest.raises(LedgerRetryableError):
ledger.record_trade(t)
ledger.record_trade(t) # third attempt succeeds
assert ledger.count() == 1
def test_conflict_surfaces_as_ledger_conflict_error():
ledger = InMemoryLedger(conflict_executions={"paper:PAPER-000001:10"})
with pytest.raises(LedgerConflictError):
ledger.record_trade(_trade())
assert ledger.count() == 0
def test_correlation_id_format():
t = _trade()
assert t.correlation_id == "PF-1:PAPER-000001:paper:PAPER-000001:10"
@@ -0,0 +1,71 @@
"""portfolio-hub HTTP ledger adapter tests (REQ-017 AC-2/3)."""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
import pytest
from trex_core.adapters.ledger.portfolio_hub import PortfolioHubLedger
from trex_core.application.ledger_port import (
LedgerConflictError,
LedgerRetryableError,
LedgerTrade,
)
def _trade(asset="PF-1", execution_id="paper:PAPER-000001:10") -> LedgerTrade:
return LedgerTrade(
asset_id=asset, order_id="PAPER-000001", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
)
def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler))
def test_post_contract_includes_execution_id_and_path_uses_asset_id():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = request.read().decode()
captured["auth"] = request.headers.get("authorization")
return httpx.Response(201, json={"ok": True})
adapter = PortfolioHubLedger("https://portfolio-hub.test", token="tok",
client=_client(handler))
adapter.record_trade(_trade(asset="PF-77"))
assert captured["url"] == "https://portfolio-hub.test/v1/assets/PF-77/ledger/trades"
assert "execution_id" in captured["body"]
assert "\"side\": \"buy\"" in captured["body"]
assert captured["auth"] == "Bearer tok"
def test_409_surfaces_as_ledger_conflict_error():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(409, text='{"error":"duplicate execution"}')
adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler))
with pytest.raises(LedgerConflictError):
adapter.record_trade(_trade())
def test_5xx_is_retryable():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="upstream down")
adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler))
with pytest.raises(LedgerRetryableError):
adapter.record_trade(_trade())
def test_network_error_is_retryable():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom")
adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler))
with pytest.raises(LedgerRetryableError):
adapter.record_trade(_trade())