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
+19 -3
View File
@@ -15,7 +15,12 @@ from typing import Optional
import httpx
from trex_core.application.broker_port import FillStatus, OrderAck, OrderRequest
from trex_core.application.broker_port import (
FillStatus,
OrderAck,
OrderRequest,
calculate_fee,
)
_KST = timezone(timedelta(hours=9))
_MOCK_HINTS = ("openapivts", "virtual", "mock")
@@ -175,8 +180,19 @@ class KISBroker:
if ccld_qty <= 0:
return FillStatus(state="accepted", raw=row)
if ccld_qty >= ord_qty and ord_qty > 0:
return FillStatus(state="filled", filled_qty=ccld_qty,
avg_price=float(avg) if avg else None, raw=row)
# Deterministic execution_id (dcy-004 OQ-3, orbit kis.py rule):
# kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}
ord_dt = str(row.get("ord_dt") or today)
ord_gno_brno = str(row.get("ord_gno_brno") or "")
row_odno = str(row.get("odno") or odno)
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
fill_price = float(avg) if avg else 0.0
fee = calculate_fee(side, ccld_qty * fill_price)
return FillStatus(
state="filled", filled_qty=ccld_qty,
avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=0.0, raw=row,
)
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
avg_price=float(avg) if avg else None, raw=row)
+14 -2
View File
@@ -10,7 +10,12 @@ from __future__ import annotations
import threading
from typing import Optional
from trex_core.application.broker_port import FillStatus, OrderAck, OrderRequest
from trex_core.application.broker_port import (
FillStatus,
OrderAck,
OrderRequest,
calculate_fee,
)
class _Order:
@@ -72,7 +77,14 @@ class PaperBroker:
step = self._qty_per_poll if self._qty_per_poll is not None else (o.qty - o.filled)
o.filled = min(o.qty, o.filled + step)
if o.filled >= o.qty:
return FillStatus(state="filled", filled_qty=o.filled, avg_price=o.price)
# Deterministic execution_id (dcy-004 OQ-3):
# paper:{broker_order_id}:{filled_qty}
execution_id = f"paper:{o.oid}:{o.filled}"
fee = calculate_fee(o.side, o.filled * o.price)
return FillStatus(
state="filled", filled_qty=o.filled, avg_price=o.price,
execution_id=execution_id, fee=fee, tax=0.0,
)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
+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]}"
)
@@ -17,6 +17,12 @@ from datetime import date, datetime, timezone
from typing import Any, Iterator, Optional
from uuid import UUID, uuid4
from trex_core.application.ledger_outbox import (
LedgerWorkItem,
OutboxOutcome,
WorkItemStatus,
)
from trex_core.application.ledger_port import LedgerTrade
from trex_core.application.ports import IntentRecord
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
@@ -300,3 +306,115 @@ class _PgIntentRecord:
self.status = status
self.slot_id = slot_id
self.reason = reason
# --- ledger outbox (BAT-006 / REQ-019) ---------------------------------------
_OUTBOX_COLUMNS = (
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
"created_at, updated_at"
)
class PostgreSQLLedgerOutbox:
"""ledger_outbox-backed LedgerOutbox. correlation_id PK is the dedup gate."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO ledger_outbox "
"(correlation_id, asset_id, order_id, execution_id, side, "
" qty, price, fee, tax, executed_at) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id, trade.asset_id, trade.order_id,
trade.execution_id, trade.side,
trade.qty, trade.price, trade.fee, trade.tax,
trade.executed_at,
),
)
conn.commit()
existing = self.get(trade.correlation_id)
assert existing is not None
return existing
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT {_OUTBOX_COLUMNS} FROM ledger_outbox "
"WHERE status = 'pending' ORDER BY created_at LIMIT %s",
(limit,),
)
return [self._row_to_item(r) for r in cur.fetchall()]
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT {_OUTBOX_COLUMNS} FROM ledger_outbox WHERE correlation_id = %s",
(correlation_id,),
)
row = cur.fetchone()
return self._row_to_item(row) if row else None
def mark_outcome(
self,
correlation_id: str,
outcome: OutboxOutcome,
*,
error: Optional[str] = None,
) -> LedgerWorkItem:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
if outcome is OutboxOutcome.COMPLETED:
status_value = WorkItemStatus.COMPLETED.value
attempts_delta = 1
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
status_value = WorkItemStatus.CONFLICT_BLOCKED.value
attempts_delta = 1
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
status_value = WorkItemStatus.COMPLETED.value
attempts_delta = 0
else: # retryable
status_value = WorkItemStatus.PENDING.value
attempts_delta = 1
cur.execute(
"UPDATE ledger_outbox SET status=%s, attempts=attempts+%s, "
"last_outcome=%s, last_error=%s, updated_at=%s "
"WHERE correlation_id=%s",
(
status_value, attempts_delta, outcome.value, error,
datetime.now(tz=timezone.utc), correlation_id,
),
)
conn.commit()
row = self.get(correlation_id)
assert row is not None
return row
@staticmethod
def _row_to_item(row: tuple) -> LedgerWorkItem:
f = lambda v: float(v) if v is not None else 0.0 # noqa: E731
trade = LedgerTrade(
asset_id=row[1], order_id=row[2], execution_id=row[3], side=row[4],
qty=f(row[5]), price=f(row[6]), fee=f(row[7]), tax=f(row[8]),
executed_at=row[9],
)
return LedgerWorkItem(
correlation_id=row[0],
trade=trade,
status=WorkItemStatus(row[10]),
attempts=int(row[11]) if row[11] is not None else 0,
last_outcome=OutboxOutcome(row[12]) if row[12] else None,
last_error=row[13],
created_at=row[14],
updated_at=row[15],
)
@@ -0,0 +1,33 @@
-- BAT-006 / REQ-019: ledger outbox work-item store (dcy-004 OQ-2).
--
-- Each row is one fill that needs to be recorded in the portfolio-hub
-- ledger. `correlation_id` ({asset_id}:{order_id}:{execution_id}) is the
-- local dedup key — the same fill enqueued twice must NOT produce two
-- ledger entries. Outcomes are classified per dcy-004 FR-4:
-- completed | duplicate_completed | retryable_failure | conflict_blocked.
CREATE TABLE IF NOT EXISTS ledger_outbox (
correlation_id TEXT PRIMARY KEY,
asset_id TEXT NOT NULL,
order_id TEXT NOT NULL,
execution_id TEXT NOT NULL,
side TEXT NOT NULL CHECK (side IN ('buy','sell')),
qty NUMERIC(18, 4) NOT NULL,
price NUMERIC(18, 4) NOT NULL,
fee NUMERIC(18, 4) NOT NULL DEFAULT 0,
tax NUMERIC(18, 4) NOT NULL DEFAULT 0,
executed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','completed','conflict_blocked')),
attempts INTEGER NOT NULL DEFAULT 0,
last_outcome TEXT
CHECK (last_outcome IS NULL OR last_outcome IN (
'completed','duplicate_completed','retryable_failure','conflict_blocked'
)),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
);
CREATE INDEX IF NOT EXISTS idx_ledger_outbox_status_created
ON ledger_outbox (status, created_at);