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:
@@ -34,6 +34,12 @@ class FillStatus:
|
||||
state: str # accepted | partially_filled | filled | rejected | cancelled | unknown
|
||||
filled_qty: int = 0
|
||||
avg_price: Optional[float] = None
|
||||
# REQ-018: deterministic execution_id + broker-computed fee/tax carried
|
||||
# all the way to the ledger writer. tax defaults to 0 (precise tax model
|
||||
# is a follow-up Discovery, dcy-004 OQ-3).
|
||||
execution_id: Optional[str] = None
|
||||
fee: float = 0.0
|
||||
tax: float = 0.0
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
@@ -62,3 +68,22 @@ class BrokerPort(Protocol):
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||
"""Best-effort cancel. Returns True if the broker accepted the cancel."""
|
||||
...
|
||||
|
||||
|
||||
# --- shared fee model (REQ-018, orbit calculate_fee pattern) -----------------
|
||||
# Conservative KR equities approximation (bps). Brokers can override, but we
|
||||
# need a single default so paper and KIS produce comparable ledger entries.
|
||||
_FEE_BPS_BUY = 1.5 # 0.015 % broker commission on buys
|
||||
_FEE_BPS_SELL = 1.5 # 0.015 % broker commission on sells (tax is tracked separately)
|
||||
|
||||
|
||||
def calculate_fee(side: str, amount: float) -> float:
|
||||
"""Estimate broker fee for a notional `amount` (qty * price).
|
||||
|
||||
Mirrors orbit `calculate_fee(side, amount)` shape — a single point of
|
||||
fee policy so all brokers and the ledger agree on the number.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0.0
|
||||
bps = _FEE_BPS_BUY if side == "buy" else _FEE_BPS_SELL
|
||||
return round(amount * bps / 10_000.0, 4)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Order execution service (REQ-013, REQ-014, dcy-003).
|
||||
"""Order execution service (REQ-013, REQ-014, REQ-018, dcy-003, dcy-004).
|
||||
|
||||
A single background execution loop (invariant A single writer, invariant B
|
||||
single broker session) that, per tick:
|
||||
@@ -6,17 +6,25 @@ single broker session) that, per tick:
|
||||
submit the order, record broker_order_id (idempotency guard).
|
||||
poll: observe fills on dispatched pending slots and transition the slot
|
||||
(pending_buy->holding / pending_sell->closed), or roll back on reject.
|
||||
On a successful fill, enqueue a LedgerWorkItem (slot transition is
|
||||
kept ON the critical path; the ledger write is eventually-consistent
|
||||
via a separate writer — REQ-019).
|
||||
reconcile() re-polls dispatched slots on restart so a crash after dispatch
|
||||
converges without duplicate orders (REQ-014).
|
||||
|
||||
All slot writes go through SlotService (advisory lock). The broker is touched
|
||||
only here, never by the domain or the HTTP API.
|
||||
only here, never by the domain or the HTTP API. The ledger outbox is the
|
||||
single producer for ledger work items (NFR-3, single record subject).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
||||
from trex_core.application.ledger_outbox import LedgerOutbox
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
|
||||
|
||||
_MAX_DISPATCH_PER_TICK = 64
|
||||
@@ -27,13 +35,22 @@ class ExecutionResult:
|
||||
dispatched: int = 0
|
||||
filled: int = 0
|
||||
rolled_back: int = 0
|
||||
ledger_enqueued: int = 0 # REQ-019: ledger work items appended this tick
|
||||
ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id
|
||||
|
||||
|
||||
class ExecutionService:
|
||||
def __init__(self, slot_service: SlotService, repo, broker: BrokerPort) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
slot_service: SlotService,
|
||||
repo,
|
||||
broker: BrokerPort,
|
||||
ledger_outbox: Optional[LedgerOutbox] = None,
|
||||
) -> None:
|
||||
self._svc = slot_service
|
||||
self._repo = repo
|
||||
self._broker = broker
|
||||
self._ledger_outbox = ledger_outbox
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def run_once(self) -> ExecutionResult:
|
||||
@@ -83,7 +100,7 @@ class ExecutionService:
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
|
||||
if fs.is_filled:
|
||||
self._on_filled(slot, fs)
|
||||
self._on_filled(slot, fs, side, r)
|
||||
r.filled += 1
|
||||
elif fs.is_terminal_reject:
|
||||
self._rollback(slot, reason=f"order_{fs.state}")
|
||||
@@ -91,7 +108,8 @@ class ExecutionService:
|
||||
# accepted / partially_filled -> leave for next tick
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _on_filled(self, slot, fs) -> None:
|
||||
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
|
||||
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
|
||||
try:
|
||||
if slot.status is SlotState.PENDING_BUY:
|
||||
self._svc.transit_slot(
|
||||
@@ -106,8 +124,38 @@ class ExecutionService:
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
return # slot moved concurrently; next tick reconciles
|
||||
# REQ-018: enqueue ledger work item AFTER the slot transition is durable.
|
||||
# Slot transition is the critical path; ledger write is async (REQ-019).
|
||||
self._record_ledger(slot, fs, side, broker_order_id, r)
|
||||
self._repo.clear_dispatch(slot.slot_id) # done with this order
|
||||
|
||||
def _record_ledger(self, slot, fs, side: str, broker_order_id: str, r: ExecutionResult) -> None:
|
||||
"""Append a LedgerWorkItem for the just-filled trade (REQ-018 + REQ-019)."""
|
||||
if self._ledger_outbox is None:
|
||||
return
|
||||
if not slot.portfolio_id:
|
||||
# REQ-018 AC-4: slot transition still proceeded; record skip for ops.
|
||||
r.ledger_skipped += 1
|
||||
return
|
||||
if fs.execution_id is None:
|
||||
# Broker contract regression — log via counter; slot already transitioned.
|
||||
r.ledger_skipped += 1
|
||||
return
|
||||
price = float(fs.avg_price) if fs.avg_price is not None else 0.0
|
||||
trade = LedgerTrade(
|
||||
asset_id=str(slot.portfolio_id),
|
||||
order_id=str(broker_order_id),
|
||||
execution_id=str(fs.execution_id),
|
||||
side=side,
|
||||
qty=float(fs.filled_qty),
|
||||
price=price,
|
||||
fee=float(fs.fee),
|
||||
tax=float(fs.tax),
|
||||
executed_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
self._ledger_outbox.enqueue(trade)
|
||||
r.ledger_enqueued += 1
|
||||
|
||||
def _rollback(self, slot, *, reason: str) -> None:
|
||||
try:
|
||||
if slot.status is SlotState.PENDING_BUY:
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Ledger outbox + writer (REQ-019, dcy-004 FR-3/FR-4).
|
||||
|
||||
The execution fill path does NOT call portfolio-hub directly. Instead it
|
||||
enqueues a durable `LedgerWorkItem` keyed by `correlation_id`
|
||||
(`{asset_id}:{order_id}:{execution_id}`). A separate `LedgerWriter` drains
|
||||
the outbox into the `LedgerPort` adapter and classifies each attempt as one
|
||||
of:
|
||||
|
||||
completed -- adapter accepted (new ledger entry).
|
||||
duplicate_completed -- the correlation_id was already done in this outbox
|
||||
(local dedup, no adapter call).
|
||||
retryable_failure -- transient adapter failure; left pending for next tick.
|
||||
conflict_blocked -- portfolio-hub 409; operator must resolve, no retry.
|
||||
|
||||
This separation (orbit `LedgerWriter` pattern) keeps slot transitions on the
|
||||
critical path while making ledger writes eventually-consistent and idempotent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional, Protocol
|
||||
|
||||
from trex_core.application.ledger_port import (
|
||||
LedgerConflictError,
|
||||
LedgerPort,
|
||||
LedgerRetryableError,
|
||||
LedgerTrade,
|
||||
LedgerWriteResult,
|
||||
)
|
||||
|
||||
|
||||
class OutboxOutcome(str, Enum):
|
||||
COMPLETED = "completed"
|
||||
DUPLICATE_COMPLETED = "duplicate_completed"
|
||||
RETRYABLE_FAILURE = "retryable_failure"
|
||||
CONFLICT_BLOCKED = "conflict_blocked"
|
||||
|
||||
|
||||
class WorkItemStatus(str, Enum):
|
||||
PENDING = "pending" # awaiting next writer tick
|
||||
COMPLETED = "completed" # adapter accepted (terminal success)
|
||||
CONFLICT_BLOCKED = "conflict_blocked" # 409 — operator resolves (terminal failure)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LedgerWorkItem:
|
||||
"""One outbox row.
|
||||
|
||||
Identity is `correlation_id`; the outbox MUST upsert/dedupe on it so the
|
||||
same fill enqueued twice produces a single row.
|
||||
"""
|
||||
|
||||
correlation_id: str
|
||||
trade: LedgerTrade
|
||||
status: WorkItemStatus = WorkItemStatus.PENDING
|
||||
attempts: int = 0
|
||||
last_outcome: Optional[OutboxOutcome] = None
|
||||
last_error: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class LedgerOutbox(Protocol):
|
||||
"""Durable work-item store backing the ledger writer."""
|
||||
|
||||
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
|
||||
"""Enqueue a trade. Idempotent on correlation_id (returns the
|
||||
existing row unchanged if it is already present)."""
|
||||
...
|
||||
|
||||
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
|
||||
"""Return pending work items in FIFO order, up to `limit`."""
|
||||
...
|
||||
|
||||
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
|
||||
...
|
||||
|
||||
def mark_outcome(
|
||||
self,
|
||||
correlation_id: str,
|
||||
outcome: OutboxOutcome,
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
) -> LedgerWorkItem:
|
||||
"""Record the outcome of a writer attempt and advance status."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class WriterStats:
|
||||
completed: int = 0
|
||||
duplicates: int = 0
|
||||
retryable: int = 0
|
||||
conflicts: int = 0
|
||||
|
||||
|
||||
class LedgerWriter:
|
||||
"""Drains the outbox into a `LedgerPort`. One process-wide writer.
|
||||
|
||||
Local dedup is by correlation_id: if a work item is already marked
|
||||
`completed`, the writer reports `duplicate_completed` without touching
|
||||
the adapter (REQ-019 AC-4).
|
||||
"""
|
||||
|
||||
def __init__(self, outbox: LedgerOutbox, ledger: LedgerPort) -> None:
|
||||
self._outbox = outbox
|
||||
self._ledger = ledger
|
||||
|
||||
def enqueue_fill(self, trade: LedgerTrade) -> LedgerWorkItem:
|
||||
return self._outbox.enqueue(trade)
|
||||
|
||||
def run_once(self, limit: int = 32) -> WriterStats:
|
||||
stats = WriterStats()
|
||||
for item in self._outbox.list_pending(limit):
|
||||
outcome = self._process(item)
|
||||
if outcome is OutboxOutcome.COMPLETED:
|
||||
stats.completed += 1
|
||||
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
|
||||
stats.duplicates += 1
|
||||
elif outcome is OutboxOutcome.RETRYABLE_FAILURE:
|
||||
stats.retryable += 1
|
||||
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
|
||||
stats.conflicts += 1
|
||||
return stats
|
||||
|
||||
def _process(self, item: LedgerWorkItem) -> OutboxOutcome:
|
||||
# Local dedup: another writer pass (or restart) already completed this.
|
||||
existing = self._outbox.get(item.correlation_id)
|
||||
if existing is not None and existing.status is WorkItemStatus.COMPLETED:
|
||||
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED)
|
||||
return OutboxOutcome.DUPLICATE_COMPLETED
|
||||
try:
|
||||
self._ledger.record_trade(item.trade)
|
||||
except LedgerConflictError as exc:
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc)
|
||||
)
|
||||
return OutboxOutcome.CONFLICT_BLOCKED
|
||||
except LedgerRetryableError as exc:
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE, error=str(exc)
|
||||
)
|
||||
return OutboxOutcome.RETRYABLE_FAILURE
|
||||
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.COMPLETED)
|
||||
return OutboxOutcome.COMPLETED
|
||||
|
||||
|
||||
# --- in-memory outbox (tests + slice-1 runtime) ------------------------------
|
||||
|
||||
class InMemoryLedgerOutbox:
|
||||
"""Process-local outbox. Thread-safe; idempotent on correlation_id."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
import threading
|
||||
|
||||
self._rows: dict[str, LedgerWorkItem] = {}
|
||||
self._order: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
|
||||
with self._lock:
|
||||
cid = trade.correlation_id
|
||||
existing = self._rows.get(cid)
|
||||
if existing is not None:
|
||||
return existing
|
||||
row = LedgerWorkItem(
|
||||
correlation_id=cid,
|
||||
trade=trade,
|
||||
status=WorkItemStatus.PENDING,
|
||||
created_at=self._now(),
|
||||
updated_at=self._now(),
|
||||
)
|
||||
self._rows[cid] = row
|
||||
self._order.append(cid)
|
||||
return row
|
||||
|
||||
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
|
||||
with self._lock:
|
||||
out: list[LedgerWorkItem] = []
|
||||
for cid in self._order:
|
||||
row = self._rows[cid]
|
||||
if row.status is WorkItemStatus.PENDING:
|
||||
out.append(row)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
|
||||
with self._lock:
|
||||
return self._rows.get(correlation_id)
|
||||
|
||||
def mark_outcome(
|
||||
self,
|
||||
correlation_id: str,
|
||||
outcome: OutboxOutcome,
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
) -> LedgerWorkItem:
|
||||
with self._lock:
|
||||
row = self._rows[correlation_id]
|
||||
if outcome is OutboxOutcome.COMPLETED:
|
||||
new_status = WorkItemStatus.COMPLETED
|
||||
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
|
||||
new_status = WorkItemStatus.CONFLICT_BLOCKED
|
||||
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
|
||||
# already terminal; keep COMPLETED status
|
||||
new_status = WorkItemStatus.COMPLETED
|
||||
else: # retryable
|
||||
new_status = WorkItemStatus.PENDING
|
||||
updated = replace(
|
||||
row,
|
||||
status=new_status,
|
||||
attempts=row.attempts + (0 if outcome is OutboxOutcome.DUPLICATE_COMPLETED else 1),
|
||||
last_outcome=outcome,
|
||||
last_error=error,
|
||||
updated_at=self._now(),
|
||||
)
|
||||
self._rows[correlation_id] = updated
|
||||
return updated
|
||||
|
||||
# --- introspection ---
|
||||
def all_items(self) -> list[LedgerWorkItem]:
|
||||
with self._lock:
|
||||
return [self._rows[cid] for cid in self._order]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Ledger port + DTOs (REQ-017, dcy-004).
|
||||
|
||||
Application-layer contract for recording trades to an external ledger
|
||||
(portfolio-hub). Domain stays free of network/HTTP dependencies (NFR-1);
|
||||
concrete adapters live in trex_core.adapters.ledger.
|
||||
|
||||
The port models the trex-orbit `post_ledger_trade` pattern:
|
||||
POST /v1/assets/{asset_id}/ledger/trades (execution_id idempotency, 409 conflict).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Protocol
|
||||
|
||||
|
||||
class LedgerWriteResult(str, Enum):
|
||||
"""Outcome reported by a `LedgerPort.record_trade` call.
|
||||
|
||||
NEW -- adapter recorded a fresh ledger entry for this execution_id.
|
||||
DUPLICATE_OK -- adapter accepted but recognized execution_id as already
|
||||
recorded (idempotent dedup; no new entry created).
|
||||
|
||||
Errors (LedgerConflictError, LedgerRetryableError) are raised; they are
|
||||
not part of this enum.
|
||||
"""
|
||||
NEW = "new"
|
||||
DUPLICATE_OK = "duplicate_ok"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LedgerTrade:
|
||||
"""One ledger trade record (dcy-004 FR-1 contract)."""
|
||||
|
||||
asset_id: str # == slot.portfolio_id (dcy-004 OQ-1)
|
||||
order_id: str # local order identifier (e.g. broker_order_id)
|
||||
execution_id: str # deterministic, idempotency key (dcy-004 OQ-3)
|
||||
side: str # 'buy' | 'sell'
|
||||
qty: float
|
||||
price: float
|
||||
fee: float = 0.0
|
||||
tax: float = 0.0
|
||||
executed_at: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def correlation_id(self) -> str:
|
||||
"""Local dedup key — `{asset_id}:{order_id}:{execution_id}` (REQ-019)."""
|
||||
return f"{self.asset_id}:{self.order_id}:{self.execution_id}"
|
||||
|
||||
|
||||
class LedgerError(Exception):
|
||||
"""Base class for ledger adapter failures."""
|
||||
|
||||
|
||||
class LedgerConflictError(LedgerError):
|
||||
"""portfolio-hub returned 409 conflict (duplicate execution_id collision
|
||||
that is not a safe duplicate — operator must resolve, REQ-019 AC-3)."""
|
||||
|
||||
|
||||
class LedgerRetryableError(LedgerError):
|
||||
"""Transient ledger failure (network, 5xx, timeout). The outbox retry
|
||||
loop should re-attempt this work item on the next tick (REQ-019 AC-2)."""
|
||||
|
||||
|
||||
class LedgerPort(Protocol):
|
||||
"""Ledger adapter boundary (REQ-017 AC-1/2).
|
||||
|
||||
Implementations must be idempotent on `execution_id`: calling
|
||||
`record_trade` twice with the same trade should not produce two ledger
|
||||
entries. portfolio-hub does this server-side via 409; the in-memory
|
||||
stub mimics the same contract for deterministic tests.
|
||||
"""
|
||||
|
||||
def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult:
|
||||
"""Append a trade to the ledger.
|
||||
|
||||
Returns:
|
||||
LedgerWriteResult.NEW on a fresh entry,
|
||||
LedgerWriteResult.DUPLICATE_OK when the adapter recognized the
|
||||
execution_id as already recorded.
|
||||
|
||||
Raises:
|
||||
LedgerConflictError: ledger reports an unrecoverable conflict (409).
|
||||
LedgerRetryableError: transient failure; outbox should retry.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user