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
+9
View File
@@ -29,3 +29,12 @@ TREX_KIS_ALLOW_LIVE=false
# Optional: Redis URL for KIS token cache (restart / multi-process survival, REQ-016). # Optional: Redis URL for KIS token cache (restart / multi-process survival, REQ-016).
# Unset => in-memory token cache only. # Unset => in-memory token cache only.
TREX_KIS_REDIS_URL= TREX_KIS_REDIS_URL=
# --- portfolio-hub ledger (REQ-017/018/019, BAT-006) ---
# When TREX_PORTFOLIO_HUB_BASE_URL is set, fills are recorded via the HTTP
# adapter; otherwise the daemon uses an in-memory ledger (no external writes).
TREX_PORTFOLIO_HUB_BASE_URL=
TREX_PORTFOLIO_HUB_TOKEN=
TREX_PORTFOLIO_HUB_TIMEOUT=5
# Ledger outbox writer tick interval (seconds).
TREX_LEDGER_INTERVAL_SEC=1.0
View File
@@ -0,0 +1,58 @@
"""In-memory ledger stub tests (REQ-017 AC-1)."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.application.ledger_port import (
LedgerConflictError,
LedgerRetryableError,
LedgerTrade,
)
def _trade(execution_id="paper:PAPER-000001:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="PAPER-000001", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
)
def test_record_trade_preserves_fields_and_is_idempotent_on_execution_id():
ledger = InMemoryLedger()
t = _trade()
ledger.record_trade(t)
ledger.record_trade(t) # same execution_id — no duplicate entry
assert ledger.count() == 1
stored = ledger.trades()[0]
assert (stored.asset_id, stored.side, stored.qty, stored.price,
stored.fee, stored.tax, stored.executed_at) == (
"PF-1", "buy", 10, 1000.0, 1.5, 0.0,
datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
)
def test_retryable_failure_then_success():
ledger = InMemoryLedger(fail_n_then_succeed=2)
t = _trade()
with pytest.raises(LedgerRetryableError):
ledger.record_trade(t)
with pytest.raises(LedgerRetryableError):
ledger.record_trade(t)
ledger.record_trade(t) # third attempt succeeds
assert ledger.count() == 1
def test_conflict_surfaces_as_ledger_conflict_error():
ledger = InMemoryLedger(conflict_executions={"paper:PAPER-000001:10"})
with pytest.raises(LedgerConflictError):
ledger.record_trade(_trade())
assert ledger.count() == 0
def test_correlation_id_format():
t = _trade()
assert t.correlation_id == "PF-1:PAPER-000001:paper:PAPER-000001:10"
@@ -0,0 +1,71 @@
"""portfolio-hub HTTP ledger adapter tests (REQ-017 AC-2/3)."""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
import pytest
from trex_core.adapters.ledger.portfolio_hub import PortfolioHubLedger
from trex_core.application.ledger_port import (
LedgerConflictError,
LedgerRetryableError,
LedgerTrade,
)
def _trade(asset="PF-1", execution_id="paper:PAPER-000001:10") -> LedgerTrade:
return LedgerTrade(
asset_id=asset, order_id="PAPER-000001", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, 9, 0, tzinfo=timezone.utc),
)
def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler))
def test_post_contract_includes_execution_id_and_path_uses_asset_id():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = request.read().decode()
captured["auth"] = request.headers.get("authorization")
return httpx.Response(201, json={"ok": True})
adapter = PortfolioHubLedger("https://portfolio-hub.test", token="tok",
client=_client(handler))
adapter.record_trade(_trade(asset="PF-77"))
assert captured["url"] == "https://portfolio-hub.test/v1/assets/PF-77/ledger/trades"
assert "execution_id" in captured["body"]
assert "\"side\": \"buy\"" in captured["body"]
assert captured["auth"] == "Bearer tok"
def test_409_surfaces_as_ledger_conflict_error():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(409, text='{"error":"duplicate execution"}')
adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler))
with pytest.raises(LedgerConflictError):
adapter.record_trade(_trade())
def test_5xx_is_retryable():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="upstream down")
adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler))
with pytest.raises(LedgerRetryableError):
adapter.record_trade(_trade())
def test_network_error_is_retryable():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom")
adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler))
with pytest.raises(LedgerRetryableError):
adapter.record_trade(_trade())
+179
View File
@@ -0,0 +1,179 @@
"""Execution loop ledger-hook tests (REQ-018, REQ-019, dcy-004).
Exercises the full buy/sell lifecycle with the in-memory paper broker and
in-memory outbox + ledger writer, ensuring the slot transition is on the
critical path while the ledger write is eventually-consistent.
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.ledger_outbox import (
InMemoryLedgerOutbox,
LedgerWriter,
OutboxOutcome,
WorkItemStatus,
)
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 4)
def _wire(broker, *, ledger=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
outbox = InMemoryLedgerOutbox()
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox)
writer = LedgerWriter(outbox, ledger or InMemoryLedger())
svc.preallocate_daily_slots(DAY, 5)
return repo, svc, intents, execution, outbox, writer
def _buy(intents, *, iid="b1", portfolio_id="PF-1"):
return intents.submit_buy(BuyIntent(
intent_id=iid, trading_date=DAY, symbol="005930", qty=10, entry_price=1000,
portfolio_id=portfolio_id,
))
def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
sid = _buy(intents).slot_id
r = execution.run_once()
assert r.filled == 1 and r.ledger_enqueued == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
stats = writer.run_once()
assert stats.completed == 1
assert ledger.count() == 1
t = ledger.trades()[0]
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
assert t.execution_id.startswith("paper:PAPER-")
assert t.fee > 0.0 and t.tax == 0.0
def test_sell_fill_enqueues_sell_ledger_item():
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
sid = _buy(intents).slot_id
execution.run_once() # -> holding + buy ledger item
writer.run_once() # drain buy
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
r = execution.run_once() # dispatch sell + fill -> closed + sell ledger item
assert r.ledger_enqueued == 1
assert repo.fetch_slot(sid).status is SlotState.CLOSED
writer.run_once()
sides = [t.side for t in ledger.trades()]
assert sides == ["buy", "sell"]
def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
sid = _buy(intents, iid="no-pf", portfolio_id=None).slot_id
r = execution.run_once()
assert r.filled == 1 and r.ledger_enqueued == 0 and r.ledger_skipped == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
assert outbox.all_items() == []
assert ledger.count() == 0
def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger():
"""REQ-019 AC-1: same execution_id enqueued twice = 1 ledger entry."""
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
_buy(intents).slot_id
execution.run_once()
writer.run_once()
assert ledger.count() == 1
# Re-enqueue the same trade explicitly (simulate accidental re-fill).
first = outbox.all_items()[0]
outbox.enqueue(first.trade) # idempotent on correlation_id
stats = writer.run_once() # still terminal-completed; reports duplicate
assert stats.duplicates >= 1
assert ledger.count() == 1 # no duplicate in the ledger
def test_retryable_failure_then_eventually_completes():
"""REQ-019 AC-2: retry until completed; slot transition unaffected."""
ledger = InMemoryLedger(fail_n_then_succeed=2)
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
sid = _buy(intents).slot_id
execution.run_once()
assert repo.fetch_slot(sid).status is SlotState.HOLDING # slot already transitioned
stats1 = writer.run_once()
assert stats1.retryable == 1 and stats1.completed == 0 and ledger.count() == 0
stats2 = writer.run_once()
assert stats2.retryable == 1 and ledger.count() == 0
stats3 = writer.run_once()
assert stats3.completed == 1 and ledger.count() == 1
# outbox row terminal-completed
[item] = outbox.all_items()
assert item.status is WorkItemStatus.COMPLETED
assert item.last_outcome is OutboxOutcome.COMPLETED
def test_conflict_blocks_without_auto_retry():
"""REQ-019 AC-3: 409 conflict halts auto-retry."""
# The execution_id paper synthesizes is paper:{oid}:{qty}; first dispatched
# order id is PAPER-000001 / qty=10 => execution_id = paper:PAPER-000001:10.
conflict_id = "paper:PAPER-000001:10"
ledger = InMemoryLedger(conflict_executions={conflict_id})
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
_buy(intents)
execution.run_once()
stats = writer.run_once()
assert stats.conflicts == 1
[item] = outbox.all_items()
assert item.status is WorkItemStatus.CONFLICT_BLOCKED
# second writer tick must NOT call the adapter again
stats2 = writer.run_once()
assert stats2.completed == 0 and stats2.retryable == 0
# ledger is still empty (conflict blocked; operator resolves)
assert ledger.count() == 0
def test_duplicate_completed_when_reprocessing_terminal_item():
"""REQ-019 AC-4: a completed item reports duplicate_completed if re-listed."""
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
_buy(intents)
execution.run_once()
writer.run_once()
# Force a duplicate enqueue of the same trade — outbox keeps single row,
# ledger keeps single entry.
[item] = outbox.all_items()
outbox.enqueue(item.trade)
assert len(outbox.all_items()) == 1
# Writer sees no PENDING rows now; stats are all zero (terminal).
stats = writer.run_once()
assert stats.completed == 0 and stats.duplicates == 0 and stats.retryable == 0
assert ledger.count() == 1
def test_slot_transition_proceeds_when_ledger_is_down():
"""REQ-019 AC-5: portfolio-hub outage does not block the fill transition."""
ledger = InMemoryLedger(fail_n_then_succeed=100)
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
sid = _buy(intents).slot_id
execution.run_once()
assert repo.fetch_slot(sid).status is SlotState.HOLDING
writer.run_once() # all retries fail
assert ledger.count() == 0
[item] = outbox.all_items()
assert item.status is WorkItemStatus.PENDING # still waiting for retry
+65
View File
@@ -0,0 +1,65 @@
"""LedgerOutbox + LedgerWriter direct unit tests (REQ-019)."""
from __future__ import annotations
from datetime import datetime, timezone
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.application.ledger_outbox import (
InMemoryLedgerOutbox,
LedgerWriter,
OutboxOutcome,
WorkItemStatus,
)
from trex_core.application.ledger_port import LedgerTrade
def _trade(execution_id="paper:PAPER-1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="PAPER-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
)
def test_enqueue_is_idempotent_on_correlation_id():
outbox = InMemoryLedgerOutbox()
a = outbox.enqueue(_trade())
b = outbox.enqueue(_trade())
assert a.correlation_id == b.correlation_id
assert len(outbox.all_items()) == 1
def test_writer_run_once_drains_pending_and_marks_completed():
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
outbox.enqueue(_trade(execution_id="e-1"))
outbox.enqueue(_trade(execution_id="e-2"))
stats = writer.run_once()
assert stats.completed == 2 and ledger.count() == 2
assert all(it.status is WorkItemStatus.COMPLETED for it in outbox.all_items())
assert all(it.last_outcome is OutboxOutcome.COMPLETED for it in outbox.all_items())
def test_writer_marks_conflict_blocked_and_does_not_retry():
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger(conflict_executions={"e-x"})
writer = LedgerWriter(outbox, ledger)
outbox.enqueue(_trade(execution_id="e-x"))
writer.run_once()
[item] = outbox.all_items()
assert item.status is WorkItemStatus.CONFLICT_BLOCKED
assert item.last_outcome is OutboxOutcome.CONFLICT_BLOCKED
# No pending items remain, so a subsequent run is a no-op.
assert outbox.list_pending() == []
def test_writer_retries_until_success():
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger(fail_n_then_succeed=1)
writer = LedgerWriter(outbox, ledger)
outbox.enqueue(_trade(execution_id="e-r"))
s1 = writer.run_once()
assert s1.retryable == 1 and ledger.count() == 0
s2 = writer.run_once()
assert s2.completed == 1 and ledger.count() == 1
+46
View File
@@ -0,0 +1,46 @@
"""Static import sweep guarding the pure-domain boundary (REQ-017 AC-4, NFR-1).
`trex_core.domain` must NOT depend on portfolio-hub, HTTP, ledger adapters,
or any other infrastructure module. The sweep walks the source tree of
trex_core/domain and inspects every `import` statement.
"""
from __future__ import annotations
import ast
from pathlib import Path
_DOMAIN_ROOT = Path(__file__).resolve().parents[2] / "trex_core" / "domain"
_FORBIDDEN_PREFIXES = (
"httpx",
"fastapi",
"uvicorn",
"psycopg",
"redis",
"trex_core.adapters", # any adapter (broker, ledger, persistence, api)
"trex_core.runtime", # daemon / config
"trex_core.application", # ports/services live one layer up
)
def _iter_imports(path: Path):
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield alias.name
elif isinstance(node, ast.ImportFrom):
if node.module:
yield node.module
def test_domain_has_no_forbidden_imports():
offenders: list[tuple[str, str]] = []
for py in _DOMAIN_ROOT.rglob("*.py"):
for imp in _iter_imports(py):
if any(imp == p or imp.startswith(p + ".") for p in _FORBIDDEN_PREFIXES):
offenders.append((str(py.relative_to(_DOMAIN_ROOT.parent.parent)), imp))
assert offenders == [], (
"trex_core/domain must remain free of adapter/runtime/application imports; "
f"found: {offenders}"
)
+19 -3
View File
@@ -15,7 +15,12 @@ from typing import Optional
import httpx 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)) _KST = timezone(timedelta(hours=9))
_MOCK_HINTS = ("openapivts", "virtual", "mock") _MOCK_HINTS = ("openapivts", "virtual", "mock")
@@ -175,8 +180,19 @@ class KISBroker:
if ccld_qty <= 0: if ccld_qty <= 0:
return FillStatus(state="accepted", raw=row) return FillStatus(state="accepted", raw=row)
if ccld_qty >= ord_qty and ord_qty > 0: if ccld_qty >= ord_qty and ord_qty > 0:
return FillStatus(state="filled", filled_qty=ccld_qty, # Deterministic execution_id (dcy-004 OQ-3, orbit kis.py rule):
avg_price=float(avg) if avg else None, raw=row) # 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, return FillStatus(state="partially_filled", filled_qty=ccld_qty,
avg_price=float(avg) if avg else None, raw=row) avg_price=float(avg) if avg else None, raw=row)
+14 -2
View File
@@ -10,7 +10,12 @@ from __future__ import annotations
import threading import threading
from typing import Optional 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: 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) 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) o.filled = min(o.qty, o.filled + step)
if o.filled >= o.qty: 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) 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: 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 typing import Any, Iterator, Optional
from uuid import UUID, uuid4 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.application.ports import IntentRecord
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
@@ -300,3 +306,115 @@ class _PgIntentRecord:
self.status = status self.status = status
self.slot_id = slot_id self.slot_id = slot_id
self.reason = reason 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);
+25
View File
@@ -34,6 +34,12 @@ class FillStatus:
state: str # accepted | partially_filled | filled | rejected | cancelled | unknown state: str # accepted | partially_filled | filled | rejected | cancelled | unknown
filled_qty: int = 0 filled_qty: int = 0
avg_price: Optional[float] = None 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) raw: dict[str, Any] = field(default_factory=dict)
@property @property
@@ -62,3 +68,22 @@ class BrokerPort(Protocol):
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool: 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.""" """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)
+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 A single background execution loop (invariant A single writer, invariant B
single broker session) that, per tick: 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). submit the order, record broker_order_id (idempotency guard).
poll: observe fills on dispatched pending slots and transition the slot poll: observe fills on dispatched pending slots and transition the slot
(pending_buy->holding / pending_sell->closed), or roll back on reject. (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 reconcile() re-polls dispatched slots on restart so a crash after dispatch
converges without duplicate orders (REQ-014). converges without duplicate orders (REQ-014).
All slot writes go through SlotService (advisory lock). The broker is touched 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 __future__ import annotations
from dataclasses import dataclass 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.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 from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
_MAX_DISPATCH_PER_TICK = 64 _MAX_DISPATCH_PER_TICK = 64
@@ -27,13 +35,22 @@ class ExecutionResult:
dispatched: int = 0 dispatched: int = 0
filled: int = 0 filled: int = 0
rolled_back: 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: 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._svc = slot_service
self._repo = repo self._repo = repo
self._broker = broker self._broker = broker
self._ledger_outbox = ledger_outbox
# ------------------------------------------------------------ # ------------------------------------------------------------
def run_once(self) -> ExecutionResult: def run_once(self) -> ExecutionResult:
@@ -83,7 +100,7 @@ class ExecutionService:
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" 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 "") fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
if fs.is_filled: if fs.is_filled:
self._on_filled(slot, fs) self._on_filled(slot, fs, side, r)
r.filled += 1 r.filled += 1
elif fs.is_terminal_reject: elif fs.is_terminal_reject:
self._rollback(slot, reason=f"order_{fs.state}") self._rollback(slot, reason=f"order_{fs.state}")
@@ -91,7 +108,8 @@ class ExecutionService:
# accepted / partially_filled -> leave for next tick # 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: try:
if slot.status is SlotState.PENDING_BUY: if slot.status is SlotState.PENDING_BUY:
self._svc.transit_slot( self._svc.transit_slot(
@@ -106,8 +124,38 @@ class ExecutionService:
) )
except SlotStateMismatchError: except SlotStateMismatchError:
return # slot moved concurrently; next tick reconciles 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 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: def _rollback(self, slot, *, reason: str) -> None:
try: try:
if slot.status is SlotState.PENDING_BUY: if slot.status is SlotState.PENDING_BUY:
+230
View File
@@ -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]
+87
View File
@@ -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.
"""
...
+23
View File
@@ -31,6 +31,15 @@ class KISConfig:
redis_url: str = "" # REQ-016: optional token cache backend redis_url: str = "" # REQ-016: optional token cache backend
@dataclass(frozen=True)
class PortfolioHubConfig:
"""portfolio-hub ledger adapter config (REQ-017)."""
base_url: str
token: str = ""
timeout: float = 5.0
@dataclass(frozen=True) @dataclass(frozen=True)
class DaemonConfig: class DaemonConfig:
dsn: str dsn: str
@@ -40,6 +49,10 @@ class DaemonConfig:
broker: str # 'paper' | 'kis' broker: str # 'paper' | 'kis'
execution_interval_sec: float execution_interval_sec: float
kis: Optional[KISConfig] = None kis: Optional[KISConfig] = None
# REQ-017/019: ledger writer config (None => in-memory stub ledger,
# appropriate for slice-1 paper-broker integration tests).
portfolio_hub: Optional[PortfolioHubConfig] = None
ledger_interval_sec: float = 1.0
@staticmethod @staticmethod
def from_env() -> "DaemonConfig": def from_env() -> "DaemonConfig":
@@ -61,6 +74,14 @@ class DaemonConfig:
allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"), allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"),
redis_url=os.environ.get("TREX_KIS_REDIS_URL", ""), redis_url=os.environ.get("TREX_KIS_REDIS_URL", ""),
) )
portfolio_hub: Optional[PortfolioHubConfig] = None
pf_base = os.environ.get("TREX_PORTFOLIO_HUB_BASE_URL", "").strip()
if pf_base:
portfolio_hub = PortfolioHubConfig(
base_url=pf_base,
token=os.environ.get("TREX_PORTFOLIO_HUB_TOKEN", "").strip(),
timeout=float(os.environ.get("TREX_PORTFOLIO_HUB_TIMEOUT", "5")),
)
return DaemonConfig( return DaemonConfig(
dsn=dsn, dsn=dsn,
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")), total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
@@ -69,4 +90,6 @@ class DaemonConfig:
broker=broker, broker=broker,
execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")), execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")),
kis=kis, kis=kis,
portfolio_hub=portfolio_hub,
ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")),
) )
+29 -5
View File
@@ -16,14 +16,18 @@ from fastapi import FastAPI
from trex_core.adapters.api.app import create_app from trex_core.adapters.api.app import create_app
from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.ledger import InMemoryLedger, PortfolioHubLedger
from trex_core.adapters.persistence.migrate import apply_migrations from trex_core.adapters.persistence.migrate import apply_migrations
from trex_core.adapters.persistence.postgresql import ( from trex_core.adapters.persistence.postgresql import (
PostgreSQLIntentStore, PostgreSQLIntentStore,
PostgreSQLLedgerOutbox,
PostgreSQLSlotRepository, PostgreSQLSlotRepository,
) )
from trex_core.application.broker_port import BrokerPort from trex_core.application.broker_port import BrokerPort
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import IntentService from trex_core.application.intents import IntentService
from trex_core.application.ledger_outbox import LedgerWriter
from trex_core.application.ledger_port import LedgerPort
from trex_core.domain.slot import SlotService from trex_core.domain.slot import SlotService
from trex_core.runtime.config import DaemonConfig from trex_core.runtime.config import DaemonConfig
@@ -46,13 +50,23 @@ def _build_broker(config: DaemonConfig) -> BrokerPort:
return PaperBroker() return PaperBroker()
def _build_ledger(config: DaemonConfig) -> LedgerPort:
"""portfolio-hub when configured; otherwise in-memory stub (REQ-017)."""
pf = config.portfolio_hub
if pf is None:
return InMemoryLedger()
return PortfolioHubLedger(pf.base_url, token=pf.token or None, timeout=pf.timeout)
def build_app(config: DaemonConfig) -> FastAPI: def build_app(config: DaemonConfig) -> FastAPI:
slot_repo = PostgreSQLSlotRepository(config.dsn) slot_repo = PostgreSQLSlotRepository(config.dsn)
intent_store = PostgreSQLIntentStore(config.dsn) intent_store = PostgreSQLIntentStore(config.dsn)
slot_service = SlotService(slot_repo) slot_service = SlotService(slot_repo)
intent_service = IntentService(slot_service, slot_repo, intent_store) intent_service = IntentService(slot_service, slot_repo, intent_store)
broker = _build_broker(config) broker = _build_broker(config)
execution = ExecutionService(slot_service, slot_repo, broker) outbox = PostgreSQLLedgerOutbox(config.dsn)
execution = ExecutionService(slot_service, slot_repo, broker, ledger_outbox=outbox)
ledger_writer = LedgerWriter(outbox, _build_ledger(config))
base = create_app( base = create_app(
slot_service=slot_service, slot_service=slot_service,
@@ -63,13 +77,20 @@ def build_app(config: DaemonConfig) -> FastAPI:
stop = threading.Event() stop = threading.Event()
def _loop() -> None: def _execution_loop() -> None:
while not stop.wait(config.execution_interval_sec): while not stop.wait(config.execution_interval_sec):
try: try:
execution.run_once() execution.run_once()
except Exception: # noqa: BLE001 - loop must survive transient errors except Exception: # noqa: BLE001 - loop must survive transient errors
continue continue
def _ledger_loop() -> None:
while not stop.wait(config.ledger_interval_sec):
try:
ledger_writer.run_once()
except Exception: # noqa: BLE001 - loop must survive transient errors
continue
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
apply_migrations(config.dsn) apply_migrations(config.dsn)
@@ -77,13 +98,16 @@ def build_app(config: DaemonConfig) -> FastAPI:
datetime.now(tz=timezone.utc).date(), config.total_slots datetime.now(tz=timezone.utc).date(), config.total_slots
) )
execution.reconcile() # restart recovery before serving (REQ-014) execution.reconcile() # restart recovery before serving (REQ-014)
worker = threading.Thread(target=_loop, name="trex-execution", daemon=True) exec_worker = threading.Thread(target=_execution_loop, name="trex-execution", daemon=True)
worker.start() ledger_worker = threading.Thread(target=_ledger_loop, name="trex-ledger", daemon=True)
exec_worker.start()
ledger_worker.start()
try: try:
yield yield
finally: finally:
stop.set() stop.set()
worker.join(timeout=config.execution_interval_sec * 3) exec_worker.join(timeout=config.execution_interval_sec * 3)
ledger_worker.join(timeout=config.ledger_interval_sec * 3)
base.router.lifespan_context = lifespan base.router.lifespan_context = lifespan
return base return base