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