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
+9
View File
@@ -0,0 +1,9 @@
"""Ledger adapters (REQ-017, dcy-004).
- InMemoryLedger: deterministic stub for tests (no network).
- PortfolioHubLedger: HTTP adapter for the external portfolio-hub ledger.
"""
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.ledger.portfolio_hub import PortfolioHubLedger
__all__ = ["InMemoryLedger", "PortfolioHubLedger"]
+65
View File
@@ -0,0 +1,65 @@
"""In-memory ledger — deterministic stub for tests (REQ-017 AC-1)."""
from __future__ import annotations
import threading
from trex_core.application.ledger_port import (
LedgerConflictError,
LedgerRetryableError,
LedgerTrade,
LedgerWriteResult,
)
class InMemoryLedger:
"""Server-side idempotent stub.
- records every accepted trade keyed by execution_id (mirrors orbit
post_ledger_trade idempotency).
- failure injection lets tests model portfolio-hub outages without HTTP:
fail_n_then_succeed -- raise LedgerRetryableError this many times,
then accept.
conflict_executions -- execution_ids that raise LedgerConflictError
(simulates portfolio-hub 409).
"""
def __init__(
self,
*,
fail_n_then_succeed: int = 0,
conflict_executions: set[str] | None = None,
) -> None:
self._trades: dict[str, LedgerTrade] = {}
self._order: list[LedgerTrade] = []
self._lock = threading.Lock()
self._fail_remaining = fail_n_then_succeed
self._conflicts = set(conflict_executions or set())
def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult:
with self._lock:
if trade.execution_id in self._conflicts:
raise LedgerConflictError(
f"ledger conflict for execution_id={trade.execution_id}"
)
if self._fail_remaining > 0:
self._fail_remaining -= 1
raise LedgerRetryableError("simulated transient failure")
if trade.execution_id in self._trades:
# idempotent: same execution_id => no new entry, report dedup.
return LedgerWriteResult.DUPLICATE_OK
self._trades[trade.execution_id] = trade
self._order.append(trade)
return LedgerWriteResult.NEW
# --- introspection for tests ---
def trades(self) -> list[LedgerTrade]:
with self._lock:
return list(self._order)
def count(self) -> int:
with self._lock:
return len(self._order)
def has_execution(self, execution_id: str) -> bool:
with self._lock:
return execution_id in self._trades
@@ -0,0 +1,91 @@
"""portfolio-hub ledger HTTP adapter (REQ-017, dcy-004).
Mirrors trex-orbit `PortfolioHubClient.post_ledger_trade`:
POST /v1/assets/{asset_id}/ledger/trades
body keys: side, qty, price, fee, tax, executed_at, execution_id, order_id
- 2xx => recorded (or duplicate accepted idempotently)
- 409 => LedgerConflictError (operator resolution)
- 4xx (other)/5xx => LedgerRetryableError (outbox retries)
- httpx error => LedgerRetryableError
"""
from __future__ import annotations
from typing import Optional
import httpx
from trex_core.application.ledger_port import (
LedgerConflictError,
LedgerRetryableError,
LedgerTrade,
LedgerWriteResult,
)
class PortfolioHubLedger:
"""portfolio-hub ledger adapter (single endpoint, single responsibility)."""
def __init__(
self,
base_url: str,
*,
token: Optional[str] = None,
timeout: float = 5.0,
client: Optional[httpx.Client] = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._token = token
self._timeout = timeout
self._client = client # injected client (e.g. MockTransport) for tests
def _headers(self) -> dict[str, str]:
h = {"content-type": "application/json; charset=utf-8"}
if self._token:
h["authorization"] = f"Bearer {self._token}"
return h
@staticmethod
def _payload(trade: LedgerTrade) -> dict:
return {
"side": trade.side,
"qty": trade.qty,
"price": trade.price,
"fee": trade.fee,
"tax": trade.tax,
"executed_at": trade.executed_at.isoformat() if trade.executed_at else None,
"execution_id": trade.execution_id,
"order_id": trade.order_id,
}
def _post(self, url: str, json: dict) -> httpx.Response:
if self._client is not None:
return self._client.post(url, json=json, headers=self._headers(),
timeout=self._timeout)
return httpx.post(url, json=json, headers=self._headers(),
timeout=self._timeout)
def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult:
url = f"{self._base_url}/v1/assets/{trade.asset_id}/ledger/trades"
try:
resp = self._post(url, json=self._payload(trade))
except httpx.HTTPError as exc:
raise LedgerRetryableError(f"portfolio-hub network error: {exc!s}") from exc
if 200 <= resp.status_code < 300:
# orbit convention: response body may carry a "duplicate" flag when
# the server has already recorded this execution_id idempotently.
try:
body = resp.json() if resp.content else {}
except ValueError:
body = {}
if isinstance(body, dict) and body.get("duplicate") is True:
return LedgerWriteResult.DUPLICATE_OK
return LedgerWriteResult.NEW
if resp.status_code == 409:
raise LedgerConflictError(
f"portfolio-hub 409 conflict for execution_id={trade.execution_id}: "
f"{resp.text[:200]}"
)
raise LedgerRetryableError(
f"portfolio-hub {resp.status_code} response: {resp.text[:200]}"
)