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
+53 -5
View File
@@ -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: