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,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"]
|
||||
@@ -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]}"
|
||||
)
|
||||
Reference in New Issue
Block a user