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:
@@ -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())
|
||||
Reference in New Issue
Block a user