feat: persist skipped ledger fills
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
df0258bc36
commit
76cde2edd0
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0020"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 완료됐지만 원장 기록 불가인 fill의 durable skip 감사 기록
|
||||
|
||||
## Criterion
|
||||
|
||||
core는 완료된 broker fill이 정상 `ledger_outbox` 경로로 들어갈 수 없을 때 WARN 로그만 남기고 끝내서는 안 된다. genuinely non-recordable 사례(예: `portfolio_id` 누락, `execution_id` 누락, 지원하지 않는 actual execution venue)마다 durable `ledger_skip` 감사 레코드 1건을 멱등하게 유지해야 하며, 이 레코드는 최소한 daemon instance, slot ID, portfolio ID, symbol, order ID, execution ID(있으면), side, qty, price, execution time, `order_venue`, `execution_venue` 또는 명시적 unknown 상태, machine-readable `skip_reason`, 생성/갱신/해결 timestamp, resolution state를 포함해야 한다. 이 경로는 broker·slot·portfolio 수량을 자동 보정하거나 mutate하지 않아야 하며, retryable ledger delivery failure는 계속 `ledger_outbox`와 `LedgerWriter` 재시도 의미론을 사용해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: completed fill이 `missing_portfolio_id`, `missing_execution_id`, `unsupported_execution_venue` 등으로 정상 원장 기록이 불가능하면 slot transition 이후에도 `ledger_skip`에 동일 fill 식별자의 unresolved row가 남고, 같은 fill을 다시 관찰해도 duplicate row가 생기지 않는다. 운영자는 instance/date/portfolio/resolution state 조건으로 unresolved row를 조회할 수 있다.
|
||||
- 실패·경계 조건: completed-but-unrecordable fill이 ephemeral log에만 남음, skip row가 retryable delivery failure row를 대체함, skip 처리 중 slot/broker/portfolio 수량이 암묵적으로 바뀜, unknown execution venue alone(SOR actual NULL)는 정상 outbox 경로 대신 skip으로 빠짐은 FAIL이다.
|
||||
- 검사 방법: execution ledger unit tests, PostgreSQL `ledger_skip` store round-trip/filter/resolve tests, CLI skipped-ledger query tests, targeted Ruff/pytest와 ProofLine validate를 수행한다.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0021"
|
||||
status: active
|
||||
---
|
||||
|
||||
# skipped-ledger 알림과 해결 이벤트
|
||||
|
||||
## Criterion
|
||||
|
||||
unresolved `ledger_skip` 레코드는 best-effort·non-blocking 방식으로 deduplicated `trex.alerts` 이벤트를 발행해야 하며, notifier 부재나 transport 오류가 fill lifecycle을 막아서는 안 된다. 동일 unresolved event는 중복 알림 없이 1회만 알리고, 이후 정상 ledger delivery가 성공하거나 operator가 명시적으로 reconcile/resolve 하면 해당 `ledger_skip` 레코드는 resolved 상태로 전이되고 distinct resolved alert event를 발행해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 새로운 unresolved skip 1건은 `trex.alerts`에 `ledger_skip_unresolved` alarm 1건을 만들고, manual backfill 또는 operator resolve 후에는 동일 skip에 대해 `ledger_skip_resolved` alarm이 별도로 발행된다. 알림 publish 실패는 swallow되며 durable state 전이는 유지된다.
|
||||
- 실패·경계 조건: unresolved skip마다 중복 알림이 반복 발행됨, resolved 전이가 알림 없이 묵살됨, alert publish failure가 fill/backfill/resolve 경로를 abort시킴은 FAIL이다.
|
||||
- 검사 방법: in-memory alarm transport tests, CLI ledger backfill resolution tests, skipped-ledger resolve command tests, PostgreSQL resolution-state verification을 실행한다.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_line_number": 7,
|
||||
"next_ac_number": 20
|
||||
"next_ac_number": 22
|
||||
}
|
||||
|
||||
@@ -286,11 +286,11 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
|
||||
|
||||
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3).
|
||||
|
||||
- ledger skip 가시화(dcy-011/REQ-018, issue #11): `_record_ledger` 가 `portfolio_id` 또는 `execution_id` 누락으로 skip 하면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터**로 노출한다(슬롯 전이는 그대로 진행). portfolio_id 필수화(REQ-007)로 주원인은 제거됐고, 잔여 누락은 운영 점검 쿼리(REQ-055: closed 슬롯 ↔ `ledger_outbox` anti-join, `find_ledger_gap_slots`)로 영구 탐지한다.
|
||||
- ledger skip 가시화(dcy-011/REQ-018, issue #11, issue #68): `_record_ledger` 가 completed fill을 정상 `ledger_outbox` 경로에 올릴 수 없으면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터 + durable `ledger_skip` row + best-effort `trex.alerts` alarm** 으로 남긴다(슬롯 전이는 그대로 진행). unresolved row는 `trex-core ledger skips list` 로 조회하고, manual backfill 또는 operator resolve 후 resolved 상태와 별도 resolved alarm을 남긴다.
|
||||
|
||||
1. fill 감지: `BrokerPort.poll_fill` 이 `state="filled"` 와 결정적 `execution_id`(paper: `paper:{broker_order_id}:{filled_qty}` / KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`), 계산된 `fee`(`calculate_fee(side, amount)`)·`tax`(`calculate_tax(side, amount)`)를 운반. fee/tax 는 단일 정책 지점(`broker_port.py`)에서 env 로 관리한다(dcy-026/REQ-018 CHG-20260612-01): `TREX_KIS_FEE_BPS`(기본 1.5 = 0.015%, 매수·매도 공통)·`TREX_KRX_TAX_SELL_BPS`(기본 20.0 = 0.2%, **매도 증권거래세**; 매수 tax=0). paper·KIS 가 동일 정책을 공유하며, KIS daily-ccld 응답은 per-fill fee/tax 필드를 제공하지 않아 정책 추정값을 사용한다(향후 노출 endpoint 도입 시 브로커 정본 우선).
|
||||
2. 슬롯 전이: `pending_buy→holding`(avg_price/qty) 또는 `pending_sell→closed` 를 advisory lock 안에서 먼저 commit. 이 단계가 critical path.
|
||||
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup). `portfolio_id` 부재면 ledger 기록 skip 후 슬롯 전이는 정상 유지.
|
||||
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 와 결정적 `execution_id`가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재한다. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup)이다. completed fill이 `portfolio_id`/`execution_id` 누락 또는 unsupported actual venue 등 genuinely non-recordable 상태이면 `ledger_outbox` 대신 `ledger_skip` 에 unresolved 감사 row를 남기고 알람을 best-effort 발행한다. unknown actual venue alone(`order_venue=SOR`, `execution_venue=NULL`)는 계속 정상 outbox 경로다.
|
||||
4. `clear_dispatch` 로 `broker_order_id` 해제.
|
||||
|
||||
## Flow: `ledger writer` (백그라운드, BAT-006)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
|
||||
|
||||
|
||||
def test_ledger_skip_store_round_trips_and_dedupes(pg_clean):
|
||||
store = PostgreSQLLedgerSkipStore(pg_clean)
|
||||
slot_id = uuid4()
|
||||
|
||||
record, notify = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-1",
|
||||
execution_id=None,
|
||||
side="buy",
|
||||
qty=10.0,
|
||||
price=1000.0,
|
||||
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
)
|
||||
again, notify_again = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-1",
|
||||
execution_id=None,
|
||||
side="buy",
|
||||
qty=10.0,
|
||||
price=1000.0,
|
||||
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
)
|
||||
|
||||
assert notify is True
|
||||
assert notify_again is False
|
||||
assert again.skip_key == record.skip_key
|
||||
listed = store.list_records(instance_key="inst-1", status="unresolved")
|
||||
assert len(listed) == 1
|
||||
assert listed[0].skip_reason == "missing_execution_id"
|
||||
|
||||
|
||||
def test_ledger_skip_store_filters_and_resolves(pg_clean):
|
||||
store = PostgreSQLLedgerSkipStore(pg_clean)
|
||||
slot_id = uuid4()
|
||||
record, _ = store.record_skip(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
portfolio_id="PF-2",
|
||||
symbol="005930",
|
||||
order_id="O-2",
|
||||
execution_id="E-2",
|
||||
side="sell",
|
||||
qty=5.0,
|
||||
price=2000.0,
|
||||
executed_at=datetime(2026, 8, 24, 2, 0, tzinfo=UTC),
|
||||
order_venue="KRX",
|
||||
execution_venue="NXTX",
|
||||
skip_reason="unsupported_execution_venue",
|
||||
)
|
||||
|
||||
rows = store.list_records(
|
||||
instance_key="inst-1",
|
||||
portfolio_id="PF-2",
|
||||
on_date=date(2026, 8, 24),
|
||||
status="unresolved",
|
||||
)
|
||||
assert [row.skip_key for row in rows] == [record.skip_key]
|
||||
|
||||
resolved = store.resolve_for_fill(
|
||||
instance_key="inst-1",
|
||||
slot_id=slot_id,
|
||||
side="sell",
|
||||
order_id="O-2",
|
||||
execution_id="E-2",
|
||||
resolution_reason="manual_ledger_backfill",
|
||||
)
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].status == "resolved"
|
||||
assert resolved[0].resolution_reason == "manual_ledger_backfill"
|
||||
@@ -26,6 +26,7 @@ _EXPECTED_TABLES = {
|
||||
"ledger_outbox", # BAT-006 / REQ-019
|
||||
"snapshot_pause", # BAT-008 / REQ-032
|
||||
"broker_account_snapshot", # dcy-041 / GitHub #45
|
||||
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ critical path while the ledger write is eventually-consistent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
from trex_core.adapters.broker.kis import KISBroker
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.ledger.in_memory import InMemoryLedger
|
||||
from trex_core.adapters.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.broker_port import FillStatus
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.application.ledger_outbox import (
|
||||
InMemoryLedgerOutbox,
|
||||
@@ -19,18 +23,62 @@ from trex_core.application.ledger_outbox import (
|
||||
OutboxOutcome,
|
||||
WorkItemStatus,
|
||||
)
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
class _SkipStore:
|
||||
def __init__(self):
|
||||
self.records = []
|
||||
self._by_key = {}
|
||||
|
||||
def _wire(broker, *, ledger=None, clock=None):
|
||||
def record_skip(self, **kwargs):
|
||||
record = LedgerSkipRecord(
|
||||
skip_key=f"{kwargs['slot_id']}:{kwargs['side']}:{kwargs['skip_reason']}",
|
||||
instance_key=kwargs["instance_key"],
|
||||
slot_id=kwargs["slot_id"],
|
||||
portfolio_id=kwargs["portfolio_id"],
|
||||
symbol=kwargs["symbol"],
|
||||
order_id=kwargs["order_id"],
|
||||
execution_id=kwargs["execution_id"],
|
||||
side=kwargs["side"],
|
||||
qty=kwargs["qty"],
|
||||
price=kwargs["price"],
|
||||
executed_at=kwargs["executed_at"],
|
||||
order_venue=kwargs["order_venue"],
|
||||
execution_venue=kwargs["execution_venue"],
|
||||
skip_reason=kwargs["skip_reason"],
|
||||
status="unresolved",
|
||||
resolution_reason=None,
|
||||
resolved_at=None,
|
||||
created_at=kwargs["executed_at"],
|
||||
updated_at=kwargs["executed_at"],
|
||||
)
|
||||
should_notify = record.skip_key not in self._by_key
|
||||
self._by_key[record.skip_key] = record
|
||||
self.records = list(self._by_key.values())
|
||||
return record, should_notify
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
|
||||
|
||||
def _wire(broker, *, ledger=None, clock=None, alarm_emitter=None, ledger_skip_store=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
outbox = InMemoryLedgerOutbox(clock=clock) if clock else InMemoryLedgerOutbox()
|
||||
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox)
|
||||
execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
broker,
|
||||
ledger_outbox=outbox,
|
||||
alarm_emitter=alarm_emitter,
|
||||
ledger_skip_store=ledger_skip_store,
|
||||
)
|
||||
writer = (
|
||||
LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock)
|
||||
if clock else LedgerWriter(outbox, ledger or InMemoryLedger())
|
||||
@@ -78,8 +126,10 @@ class MissingExecutionIdBroker(PaperBroker):
|
||||
|
||||
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)
|
||||
repo, _, intents, execution, _, 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
|
||||
@@ -127,8 +177,10 @@ def test_kis_sor_fill_without_actual_venue_enqueues_and_writes_once() -> None:
|
||||
|
||||
def test_sell_fill_enqueues_sell_ledger_item():
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
repo, _, intents, execution, _, 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
|
||||
@@ -143,13 +195,16 @@ def test_sell_fill_enqueues_sell_ledger_item():
|
||||
|
||||
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)
|
||||
repo, svc, _, execution, outbox, _ = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
# dcy-011: buy admission now requires portfolio_id, so create the
|
||||
# pending_buy slot directly (bypass admission) to exercise the
|
||||
# execution-loop ledger skip + visibility (REQ-018 FR-4).
|
||||
from trex_core.domain.slot.models import Slot
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.domain.slot.models import Slot
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
@@ -176,14 +231,80 @@ def test_missing_execution_id_still_skips_malformed_ledger_work_item() -> None:
|
||||
assert outbox.all_items() == []
|
||||
|
||||
|
||||
def test_missing_portfolio_id_persists_skip_and_emits_alert() -> None:
|
||||
transport = InMemoryTransport()
|
||||
skip_store = _SkipStore()
|
||||
repo, svc, _, execution, outbox, _ = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
alarm_emitter=AlarmEmitter(transport),
|
||||
ledger_skip_store=skip_store,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.domain.slot.models import Slot
|
||||
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(
|
||||
anon.slot_id,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy",
|
||||
actor="test",
|
||||
symbol="005930",
|
||||
portfolio_id=None,
|
||||
)
|
||||
|
||||
result = execution.run_once()
|
||||
|
||||
assert result.ledger_skipped == 1
|
||||
assert outbox.all_items() == []
|
||||
assert len(skip_store.records) == 1
|
||||
record = skip_store.records[0]
|
||||
assert record.skip_reason == "missing_portfolio_id"
|
||||
assert record.order_venue == "KRX"
|
||||
alarms = transport.alarm_events()
|
||||
assert len(alarms) == 1
|
||||
_, payload = alarms[0]
|
||||
assert payload["code"] == "ledger_skip_unresolved"
|
||||
assert payload["fields"]["skip_reason"] == "missing_portfolio_id"
|
||||
|
||||
|
||||
def test_missing_execution_id_persists_skip_record() -> None:
|
||||
transport = InMemoryTransport()
|
||||
skip_store = _SkipStore()
|
||||
repo, _, intents, execution, outbox, _ = _wire(
|
||||
MissingExecutionIdBroker(),
|
||||
alarm_emitter=AlarmEmitter(transport),
|
||||
ledger_skip_store=skip_store,
|
||||
)
|
||||
|
||||
sid = _buy(intents, iid="missing-execution-id").slot_id
|
||||
assert sid is not None
|
||||
result = execution.run_once()
|
||||
|
||||
assert result.ledger_skipped == 1
|
||||
assert result.ledger_enqueued == 0
|
||||
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||
assert outbox.all_items() == []
|
||||
assert len(skip_store.records) == 1
|
||||
record = skip_store.records[0]
|
||||
assert record.skip_reason == "missing_execution_id"
|
||||
assert record.portfolio_id == "PF-1"
|
||||
assert record.execution_id is None
|
||||
|
||||
|
||||
def test_ledger_skip_emits_warning(caplog):
|
||||
"""dcy-011 / REQ-018 FR-4 (S-3): ledger skip is logged, not silent."""
|
||||
import logging
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
from trex_core.domain.slot.models import Slot
|
||||
repo, svc, _, execution, _, _ = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from trex_core.domain.slot.models import Slot
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
@@ -197,8 +318,10 @@ def test_ledger_skip_emits_warning(caplog):
|
||||
def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger():
|
||||
"""REQ-019 AC-1: same execution_id processed twice = 1 ledger entry."""
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
_, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
_buy(intents)
|
||||
execution.run_once()
|
||||
writer.run_once()
|
||||
@@ -224,10 +347,10 @@ def test_retryable_failure_then_eventually_completes():
|
||||
dcy-015: retryable rows back off, so advance a shared clock past each
|
||||
next_retry_at between writer passes.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
now = {"t": datetime(2026, 6, 4, tzinfo=timezone.utc)}
|
||||
from datetime import datetime, timedelta
|
||||
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
|
||||
ledger = InMemoryLedger(fail_n_then_succeed=2)
|
||||
repo, svc, intents, execution, outbox, writer = _wire(
|
||||
repo, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0), ledger=ledger, clock=lambda: now["t"],
|
||||
)
|
||||
sid = _buy(intents).slot_id
|
||||
@@ -253,8 +376,10 @@ def test_conflict_blocks_without_auto_retry():
|
||||
# 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)
|
||||
_, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
_buy(intents)
|
||||
execution.run_once()
|
||||
stats = writer.run_once()
|
||||
@@ -271,8 +396,10 @@ def test_conflict_blocks_without_auto_retry():
|
||||
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)
|
||||
_, _, intents, execution, outbox, writer = _wire(
|
||||
PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger,
|
||||
)
|
||||
_buy(intents)
|
||||
execution.run_once()
|
||||
writer.run_once()
|
||||
@@ -290,8 +417,10 @@ def test_duplicate_completed_when_reprocessing_terminal_item():
|
||||
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)
|
||||
repo, _, 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
|
||||
|
||||
@@ -18,6 +18,7 @@ from trex_core.adapters.broker.kis import KISBroker
|
||||
|
||||
_PG_TABLES = (
|
||||
"broker_account_snapshot",
|
||||
"ledger_skip",
|
||||
"killswitch_exclusion",
|
||||
"snapshot_pause",
|
||||
"ledger_outbox",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ledger_outbox import OutboxOutcome
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord
|
||||
from trex_core.runtime import cli
|
||||
|
||||
|
||||
@@ -63,6 +66,33 @@ def _evidence():
|
||||
}
|
||||
|
||||
|
||||
def _skip_record():
|
||||
return LedgerSkipRecord(
|
||||
skip_key="ledger-skip:inst-1:slot-1:sell:12345678:0001234567:kis-daily-ccld:20260824:12345678:0001234567:10:missing_execution_id",
|
||||
instance_key="inst-1",
|
||||
slot_id=UUID(_SLOT_ID),
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="12345678:0001234567",
|
||||
execution_id="kis-daily-ccld:20260824:12345678:0001234567:10",
|
||||
side="sell",
|
||||
qty=10.0,
|
||||
price=70000.0,
|
||||
executed_at=datetime.fromisoformat("2026-08-24T00:01:02+00:00"),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
status="resolved",
|
||||
resolution_reason="manual_ledger_backfill",
|
||||
resolved_at=datetime.fromisoformat("2026-08-24T00:03:02+00:00"),
|
||||
created_at=datetime.fromisoformat("2026-08-24T00:01:02+00:00"),
|
||||
updated_at=datetime.fromisoformat("2026-08-24T00:03:02+00:00"),
|
||||
)
|
||||
|
||||
|
||||
_SLOT_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def test_ledger_backfill_parser_accepts_required_flags():
|
||||
parser = cli._build_parser()
|
||||
args = parser.parse_args([
|
||||
@@ -80,7 +110,7 @@ def test_ledger_backfill_dry_run_emits_trade_and_writes_audit(monkeypatch, tmp_p
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path, dry_run=True), _cfg(), lambda _: True)
|
||||
@@ -99,7 +129,7 @@ def test_ledger_backfill_live_requires_live_flag(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path, live=False), _cfg(live=True), lambda _: True)
|
||||
@@ -115,7 +145,7 @@ def test_ledger_backfill_existing_completed_row_is_idempotent(monkeypatch, tmp_p
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
existing = [{
|
||||
"correlation_id": "PF-1:12345678:0001234567:kis-daily-ccld:20260824:12345678:0001234567:10",
|
||||
"asset_id": "PF-1",
|
||||
@@ -147,12 +177,23 @@ def test_ledger_backfill_existing_completed_row_is_idempotent(monkeypatch, tmp_p
|
||||
def process_correlation_id(self, correlation_id):
|
||||
raise AssertionError("completed execution must not be re-processed")
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
import trex_core.adapters.ledger.portfolio_hub as hub_mod
|
||||
import trex_core.adapters.persistence.postgresql as pg_mod
|
||||
|
||||
monkeypatch.setattr(pg_mod, "PostgreSQLLedgerOutbox", lambda dsn: _Outbox())
|
||||
monkeypatch.setattr(hub_mod, "PortfolioHubLedger", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(cli, "LedgerWriter", _Writer)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path), _cfg(), lambda _: True)
|
||||
|
||||
@@ -165,7 +206,7 @@ def test_ledger_backfill_live_enqueue_and_processes_exact_trade(monkeypatch, tmp
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": "slot-1", "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
class _Outbox:
|
||||
@@ -195,12 +236,23 @@ def test_ledger_backfill_live_enqueue_and_processes_exact_trade(monkeypatch, tmp
|
||||
assert correlation_id == self.seen_outbox.trade.correlation_id
|
||||
return OutboxOutcome.COMPLETED
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return []
|
||||
|
||||
import trex_core.adapters.ledger.portfolio_hub as hub_mod
|
||||
import trex_core.adapters.persistence.postgresql as pg_mod
|
||||
|
||||
monkeypatch.setattr(pg_mod, "PostgreSQLLedgerOutbox", lambda dsn: outbox)
|
||||
monkeypatch.setattr(hub_mod, "PortfolioHubLedger", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(cli, "LedgerWriter", _Writer)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path), _cfg(), lambda _: True)
|
||||
|
||||
@@ -210,3 +262,67 @@ def test_ledger_backfill_live_enqueue_and_processes_exact_trade(monkeypatch, tmp
|
||||
assert outbox.trade is not None
|
||||
assert outbox.trade.order_venue == "SOR"
|
||||
assert outbox.trade.venue is None
|
||||
|
||||
|
||||
def test_ledger_backfill_resolves_matching_skip(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr(cli, "_lookup_ledger_backfill_evidence",
|
||||
lambda cfg, **kwargs: (_evidence(), SimpleNamespace(mode="env", identity_label="kis-config")))
|
||||
monkeypatch.setattr(cli, "_resolve_ledger_backfill_slot",
|
||||
lambda dsn, **kwargs: {"slot_id": _SLOT_ID, "portfolio_id": "PF-1", "status": "inactive", "symbol": "005930"})
|
||||
monkeypatch.setattr(cli, "_lookup_outbox_by_execution_id", lambda dsn, **kwargs: [])
|
||||
|
||||
class _Outbox:
|
||||
def enqueue(self, trade: LedgerTrade):
|
||||
self.trade = trade
|
||||
|
||||
def get(self, correlation_id):
|
||||
return SimpleNamespace(
|
||||
status=SimpleNamespace(value="completed"),
|
||||
attempts=1,
|
||||
last_outcome=SimpleNamespace(value="completed"),
|
||||
last_error=None,
|
||||
)
|
||||
|
||||
class _Writer:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def process_correlation_id(self, _correlation_id):
|
||||
return OutboxOutcome.COMPLETED
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_for_fill(self, **_kwargs):
|
||||
return [_skip_record()]
|
||||
|
||||
published = []
|
||||
|
||||
class _Emitter:
|
||||
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
|
||||
published.append((code, severity, fields))
|
||||
|
||||
class _Transport:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
import trex_core.adapters.ledger.portfolio_hub as hub_mod
|
||||
import trex_core.adapters.persistence.postgresql as pg_mod
|
||||
|
||||
monkeypatch.setattr(pg_mod, "PostgreSQLLedgerOutbox", lambda dsn: _Outbox())
|
||||
monkeypatch.setattr(hub_mod, "PortfolioHubLedger", lambda *args, **kwargs: object())
|
||||
monkeypatch.setattr(cli, "LedgerWriter", _Writer)
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
monkeypatch.setattr(cli, "_default_alarm_emitter", lambda cfg: (_Emitter(), _Transport()))
|
||||
|
||||
rc = cli._cmd_ledger_backfill(_args(tmp_path), _cfg(), lambda _: True)
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert len(out["resolved_skips"]) == 1
|
||||
assert out["resolved_skips"][0]["resolution_reason"] == "manual_ledger_backfill"
|
||||
assert published[0][0] == "ledger_skip_resolved"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord
|
||||
from trex_core.runtime import cli
|
||||
|
||||
|
||||
def _cfg():
|
||||
return SimpleNamespace(dsn="postgresql://x/y", rabbitmq=None)
|
||||
|
||||
|
||||
def _record(*, status="unresolved", resolution_reason=None):
|
||||
return LedgerSkipRecord(
|
||||
skip_key="ledger-skip:inst-1:slot-1:sell:O-1:E-1:missing_execution_id",
|
||||
instance_key="inst-1",
|
||||
slot_id=UUID("00000000-0000-0000-0000-000000000001"),
|
||||
portfolio_id="PF-1",
|
||||
symbol="005930",
|
||||
order_id="O-1",
|
||||
execution_id="E-1",
|
||||
side="sell",
|
||||
qty=10.0,
|
||||
price=70000.0,
|
||||
executed_at=datetime(2026, 8, 24, 0, 1, 2, tzinfo=UTC),
|
||||
order_venue="SOR",
|
||||
execution_venue="unknown",
|
||||
skip_reason="missing_execution_id",
|
||||
status=status,
|
||||
resolution_reason=resolution_reason,
|
||||
resolved_at=(
|
||||
datetime(2026, 8, 24, 0, 5, 0, tzinfo=UTC)
|
||||
if status == "resolved"
|
||||
else None
|
||||
),
|
||||
created_at=datetime(2026, 8, 24, 0, 1, 2, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 8, 24, 0, 5, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_ledger_skips_parser_accepts_list_and_resolve():
|
||||
parser = cli._build_parser()
|
||||
args = parser.parse_args(["ledger", "skips", "list", "--status", "all"])
|
||||
assert args.ledger_cmd == "skips"
|
||||
assert args.ledger_skips_cmd == "list"
|
||||
args = parser.parse_args(["ledger", "skips", "resolve", "--skip-key", "abc"])
|
||||
assert args.ledger_skips_cmd == "resolve"
|
||||
|
||||
|
||||
def test_ledger_skips_list_outputs_records(monkeypatch, capsys):
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def list_records(self, **_kwargs):
|
||||
return [_record()]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
cmd="ledger",
|
||||
ledger_cmd="skips",
|
||||
ledger_skips_cmd="list",
|
||||
date="2026-08-24",
|
||||
portfolio_id="PF-1",
|
||||
status="unresolved",
|
||||
limit=10,
|
||||
instance="inst-1",
|
||||
config=None,
|
||||
config_path=None,
|
||||
env_file=None,
|
||||
json=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_skips_list(args, _cfg())
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["count"] == 1
|
||||
assert out["records"][0]["skip_reason"] == "missing_execution_id"
|
||||
|
||||
|
||||
def test_ledger_skips_resolve_marks_record_and_emits_alert(monkeypatch, capsys):
|
||||
class _Store:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def resolve_by_key(self, skip_key, *, resolution_reason):
|
||||
assert skip_key == "abc"
|
||||
return _record(status="resolved", resolution_reason=resolution_reason)
|
||||
|
||||
published = []
|
||||
|
||||
class _Emitter:
|
||||
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
|
||||
published.append((code, severity, fields))
|
||||
|
||||
class _Transport:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"trex_core.adapters.persistence.ledger_skip_store.PostgreSQLLedgerSkipStore",
|
||||
_Store,
|
||||
)
|
||||
monkeypatch.setattr(cli, "_default_alarm_emitter", lambda cfg: (_Emitter(), _Transport()))
|
||||
args = SimpleNamespace(
|
||||
cmd="ledger",
|
||||
ledger_cmd="skips",
|
||||
ledger_skips_cmd="resolve",
|
||||
skip_key="abc",
|
||||
reason="operator_reconciled",
|
||||
instance="inst-1",
|
||||
config=None,
|
||||
config_path=None,
|
||||
env_file=None,
|
||||
json=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
rc = cli._cmd_ledger_skips_resolve(args, _cfg())
|
||||
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["result"] == "resolved"
|
||||
assert published[0][0] == "ledger_skip_resolved"
|
||||
@@ -14,11 +14,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from time import monotonic
|
||||
from typing import Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -47,7 +47,7 @@ class KISEvidenceLookupError(RuntimeError):
|
||||
"""Read-only daily-fill evidence lookup failed."""
|
||||
|
||||
|
||||
def _balance_http_error_code(status: Optional[int]) -> str:
|
||||
def _balance_http_error_code(status: int | None) -> str:
|
||||
if status == 401:
|
||||
return "authentication_failed"
|
||||
if status == 403:
|
||||
@@ -69,7 +69,7 @@ def resolve_order_venue(venue: str, at: datetime) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_int(v: object) -> Optional[int]:
|
||||
def _safe_int(v: object) -> int | None:
|
||||
try:
|
||||
return int(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
@@ -92,7 +92,7 @@ def _encode_submission_unknown(req: OrderRequest, submitted_at: datetime) -> str
|
||||
return f"{_SUBMISSION_UNKNOWN_PREFIX}{encoded}"
|
||||
|
||||
|
||||
def _decode_submission_unknown(broker_order_id: str) -> Optional[dict]:
|
||||
def _decode_submission_unknown(broker_order_id: str) -> dict | None:
|
||||
if not broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return None
|
||||
encoded = broker_order_id[len(_SUBMISSION_UNKNOWN_PREFIX) :]
|
||||
@@ -138,15 +138,15 @@ class KISBroker:
|
||||
timeout: float = 5.0,
|
||||
allow_live: bool = False,
|
||||
token_cache=None,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._account_ref = account_ref
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._token: Optional[str] = None
|
||||
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
||||
self._token: str | None = None
|
||||
self._token_exp: datetime = datetime.now(tz=UTC)
|
||||
self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
|
||||
self._clock = clock or (lambda: datetime.now(tz=_KST))
|
||||
self._fill_poll_not_before: dict[str, float] = {}
|
||||
@@ -197,12 +197,12 @@ class KISBroker:
|
||||
self._app_secret = credential.app_secret
|
||||
# Invalidate L1 in-memory token so next call re-issues.
|
||||
self._token = None
|
||||
self._token_exp = datetime.min.replace(tzinfo=timezone.utc)
|
||||
self._token_exp = datetime.min.replace(tzinfo=UTC)
|
||||
|
||||
# --- auth ---
|
||||
def _ensure_token(self) -> str:
|
||||
# L1: in-memory
|
||||
if self._token and datetime.now(tz=timezone.utc) < self._token_exp:
|
||||
if self._token and datetime.now(tz=UTC) < self._token_exp:
|
||||
return self._token
|
||||
# L2: shared/persistent cache (Redis) — survives restart / multi-process (REQ-016)
|
||||
if self._token_cache is not None:
|
||||
@@ -224,7 +224,7 @@ class KISBroker:
|
||||
data = resp.json()
|
||||
self._token = data["access_token"]
|
||||
ttl = int(data.get("expires_in", 3600))
|
||||
self._token_exp = datetime.now(tz=timezone.utc) + timedelta(seconds=max(60, ttl - 300))
|
||||
self._token_exp = datetime.now(tz=UTC) + timedelta(seconds=max(60, ttl - 300))
|
||||
if self._token_cache is not None:
|
||||
self._token_cache.store(self._account_ref, self._app_key, self._token, self._token_exp)
|
||||
return self._token
|
||||
@@ -364,8 +364,8 @@ class KISBroker:
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
order_date: Optional[str] = None,
|
||||
submitted_at: Optional[datetime] = None,
|
||||
order_date: str | None = None,
|
||||
submitted_at: datetime | None = None,
|
||||
) -> FillStatus:
|
||||
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
|
||||
@@ -424,7 +424,7 @@ class KISBroker:
|
||||
if row is None:
|
||||
visible_grace = (
|
||||
submitted_at is None
|
||||
or (self._clock().astimezone(timezone.utc) - submitted_at.astimezone(timezone.utc))
|
||||
or (self._clock().astimezone(UTC) - submitted_at.astimezone(UTC))
|
||||
.total_seconds()
|
||||
< _SUBMISSION_LOOKUP_GRACE_SECONDS
|
||||
)
|
||||
@@ -639,11 +639,21 @@ class KISBroker:
|
||||
def _executed_at_from_row(row: dict, fallback_date: str) -> datetime:
|
||||
order_date = str(row.get("ord_dt") or fallback_date).strip()
|
||||
order_time = str(row.get("ord_tmd") or "").strip()[:6]
|
||||
year = int(order_date[0:4])
|
||||
month = int(order_date[4:6])
|
||||
day = int(order_date[6:8])
|
||||
if len(order_time) == 6 and order_time.isdigit():
|
||||
executed_at = datetime.strptime(order_date + order_time, "%Y%m%d%H%M%S")
|
||||
return executed_at.replace(tzinfo=_KST).astimezone(timezone.utc)
|
||||
executed_at = datetime.strptime(order_date, "%Y%m%d")
|
||||
return executed_at.replace(tzinfo=_KST).astimezone(timezone.utc)
|
||||
executed_at = datetime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
int(order_time[0:2]),
|
||||
int(order_time[2:4]),
|
||||
int(order_time[4:6]),
|
||||
tzinfo=_KST,
|
||||
)
|
||||
return executed_at.astimezone(UTC)
|
||||
return datetime(year, month, day, tzinfo=_KST).astimezone(UTC)
|
||||
|
||||
def lookup_fill_evidence(self, *, trading_date: str, execution_id: str) -> dict:
|
||||
"""Read one exact filled/partial-cancel evidence row for a historical execution."""
|
||||
@@ -737,7 +747,7 @@ class KISBroker:
|
||||
if cancelled:
|
||||
return FillStatus(state="cancelled", raw=row) # full external cancel
|
||||
return FillStatus(state="accepted", raw=row)
|
||||
if ccld_qty >= ord_qty and ord_qty > 0:
|
||||
if ccld_qty >= ord_qty > 0:
|
||||
# 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)
|
||||
@@ -760,6 +770,7 @@ class KISBroker:
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
execution_venue=execution_venue,
|
||||
executed_at=KISBroker._executed_at_from_row(row, ord_dt),
|
||||
raw=row,
|
||||
)
|
||||
# 0 < ccld_qty < ord_qty
|
||||
@@ -784,6 +795,7 @@ class KISBroker:
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
execution_venue=execution_venue,
|
||||
executed_at=KISBroker._executed_at_from_row(row, ord_dt),
|
||||
cancelled=True,
|
||||
raw=row,
|
||||
)
|
||||
@@ -827,7 +839,7 @@ class KISBroker:
|
||||
|
||||
quantities: dict[str, int] = {}
|
||||
seen_continuations: set[tuple[str, str]] = set()
|
||||
continuation: Optional[tuple[str, str]] = None
|
||||
continuation: tuple[str, str] | None = None
|
||||
while True:
|
||||
request_headers = headers
|
||||
if continuation is not None:
|
||||
@@ -944,7 +956,7 @@ class KISBroker:
|
||||
raise BrokerBalanceError("malformed_response")
|
||||
return amount
|
||||
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Decimal | None:
|
||||
"""Current traded price `stck_prpr` via KIS inquire-price (REQ-038, REQ-118).
|
||||
|
||||
tr_id: `FHKST01010100` per REQ-038 AC-7 (live and mock share the same tr_id).
|
||||
|
||||
@@ -8,9 +8,8 @@ execution code path that production (KIS) takes.
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from trex_core.adapters.broker.seq_store import SeqStore
|
||||
from trex_core.application.broker_port import (
|
||||
@@ -52,9 +51,9 @@ class PaperBroker:
|
||||
self,
|
||||
*,
|
||||
fill_after_polls: int = 0,
|
||||
qty_per_poll: Optional[int] = None,
|
||||
reject_symbols: Optional[set[str]] = None,
|
||||
seq_store: Optional[SeqStore] = None,
|
||||
qty_per_poll: int | None = None,
|
||||
reject_symbols: set[str] | None = None,
|
||||
seq_store: SeqStore | None = None,
|
||||
) -> None:
|
||||
self._fill_after = fill_after_polls
|
||||
self._qty_per_poll = qty_per_poll
|
||||
@@ -90,8 +89,8 @@ class PaperBroker:
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
order_date: Optional[str] = None,
|
||||
submitted_at: Optional[datetime] = None,
|
||||
order_date: str | None = None,
|
||||
submitted_at: datetime | None = None,
|
||||
) -> FillStatus:
|
||||
with self._lock:
|
||||
o = self._orders.get(broker_order_id)
|
||||
@@ -122,6 +121,7 @@ class PaperBroker:
|
||||
state="filled", filled_qty=o.filled, avg_price=o.price,
|
||||
execution_id=execution_id, fee=fee, tax=tax,
|
||||
execution_venue=o.execution_venue,
|
||||
executed_at=datetime.now(tz=UTC),
|
||||
)
|
||||
return FillStatus(
|
||||
state="partially_filled",
|
||||
@@ -172,7 +172,7 @@ class PaperBroker:
|
||||
with self._lock:
|
||||
self._prices[symbol] = Decimal(str(price))
|
||||
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Decimal | None:
|
||||
"""Return the programmed current price for symbol, or None if unset.
|
||||
|
||||
market accepted for BrokerPort protocol conformance (REQ-118) but
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.adapters.persistence.postgresql import _require_psycopg
|
||||
from trex_core.application.ledger_skip import LedgerSkipRecord, make_ledger_skip_key
|
||||
|
||||
|
||||
class PostgreSQLLedgerSkipStore:
|
||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||
self._psycopg = _require_psycopg(psycopg_module)
|
||||
self._dsn = dsn
|
||||
|
||||
def record_skip(
|
||||
self,
|
||||
*,
|
||||
instance_key: str,
|
||||
slot_id: UUID,
|
||||
portfolio_id: str | None,
|
||||
symbol: str | None,
|
||||
order_id: str | None,
|
||||
execution_id: str | None,
|
||||
side: str,
|
||||
qty: float,
|
||||
price: float,
|
||||
executed_at: datetime,
|
||||
order_venue: str | None,
|
||||
execution_venue: str | None,
|
||||
skip_reason: str,
|
||||
) -> tuple[LedgerSkipRecord, bool]:
|
||||
skip_key = make_ledger_skip_key(
|
||||
instance_key=instance_key,
|
||||
slot_id=slot_id,
|
||||
side=side,
|
||||
order_id=order_id,
|
||||
execution_id=execution_id,
|
||||
skip_reason=skip_reason,
|
||||
)
|
||||
now = datetime.now(tz=UTC)
|
||||
notify = False
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM ledger_skip WHERE skip_key = %s FOR UPDATE",
|
||||
(skip_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
notify = True
|
||||
cur.execute(
|
||||
"INSERT INTO ledger_skip "
|
||||
"(skip_key, instance_key, slot_id, portfolio_id, symbol, order_id, "
|
||||
" execution_id, side, qty, price, executed_at, order_venue, "
|
||||
" execution_venue, skip_reason, status, created_at, updated_at) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
|
||||
" 'unresolved', %s, %s)",
|
||||
(
|
||||
skip_key,
|
||||
instance_key,
|
||||
slot_id,
|
||||
portfolio_id,
|
||||
symbol,
|
||||
order_id,
|
||||
execution_id,
|
||||
side,
|
||||
qty,
|
||||
price,
|
||||
executed_at,
|
||||
order_venue or "unknown",
|
||||
execution_venue or "unknown",
|
||||
skip_reason,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
notify = row[0] == "resolved"
|
||||
cur.execute(
|
||||
"UPDATE ledger_skip SET portfolio_id=%s, symbol=%s, order_id=%s, "
|
||||
"execution_id=%s, qty=%s, price=%s, executed_at=%s, "
|
||||
"order_venue=%s, execution_venue=%s, status='unresolved', "
|
||||
"resolution_reason=NULL, resolved_at=NULL, updated_at=%s "
|
||||
"WHERE skip_key=%s",
|
||||
(
|
||||
portfolio_id,
|
||||
symbol,
|
||||
order_id,
|
||||
execution_id,
|
||||
qty,
|
||||
price,
|
||||
executed_at,
|
||||
order_venue or "unknown",
|
||||
execution_venue or "unknown",
|
||||
now,
|
||||
skip_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
record = self.get(skip_key)
|
||||
assert record is not None
|
||||
return record, notify
|
||||
|
||||
def get(self, skip_key: str) -> LedgerSkipRecord | None:
|
||||
with self._psycopg.connect(self._dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT skip_key, instance_key, slot_id, portfolio_id, symbol, order_id, "
|
||||
"execution_id, side, qty, price, executed_at, order_venue, execution_venue, "
|
||||
"skip_reason, status, resolution_reason, resolved_at, created_at, updated_at "
|
||||
"FROM ledger_skip WHERE skip_key = %s",
|
||||
(skip_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_record(row) if row else None
|
||||
|
||||
def list_records(
|
||||
self,
|
||||
*,
|
||||
instance_key: str | None = None,
|
||||
portfolio_id: str | None = None,
|
||||
on_date: date | None = None,
|
||||
status: str = "unresolved",
|
||||
limit: int = 100,
|
||||
) -> list[LedgerSkipRecord]:
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if status != "all":
|
||||
clauses.append("status = %s")
|
||||
params.append(status)
|
||||
if instance_key is not None:
|
||||
clauses.append("instance_key = %s")
|
||||
params.append(instance_key)
|
||||
if portfolio_id is not None:
|
||||
clauses.append("portfolio_id = %s")
|
||||
params.append(portfolio_id)
|
||||
if on_date is not None:
|
||||
start = datetime.combine(on_date, time.min, tzinfo=UTC)
|
||||
end = start + timedelta(days=1)
|
||||
clauses.append("created_at >= %s AND created_at < %s")
|
||||
params.extend([start, end])
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self._psycopg.connect(self._dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT skip_key, instance_key, slot_id, portfolio_id, symbol, order_id, "
|
||||
"execution_id, side, qty, price, executed_at, order_venue, execution_venue, "
|
||||
"skip_reason, status, resolution_reason, resolved_at, created_at, updated_at "
|
||||
f"FROM ledger_skip {where} ORDER BY created_at DESC LIMIT %s",
|
||||
(*params, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_record(row) for row in rows]
|
||||
|
||||
def resolve_for_fill(
|
||||
self,
|
||||
*,
|
||||
instance_key: str,
|
||||
slot_id: UUID,
|
||||
side: str,
|
||||
order_id: str | None,
|
||||
execution_id: str | None,
|
||||
resolution_reason: str,
|
||||
) -> list[LedgerSkipRecord]:
|
||||
now = datetime.now(tz=UTC)
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
|
||||
if execution_id:
|
||||
cur.execute(
|
||||
"UPDATE ledger_skip SET status='resolved', resolution_reason=%s, "
|
||||
"resolved_at=%s, updated_at=%s "
|
||||
"WHERE status='unresolved' AND instance_key=%s AND slot_id=%s "
|
||||
"AND side=%s AND execution_id=%s "
|
||||
"RETURNING skip_key, instance_key, slot_id, portfolio_id, symbol, "
|
||||
"order_id, execution_id, side, qty, price, executed_at, order_venue, "
|
||||
"execution_venue, skip_reason, status, resolution_reason, resolved_at, "
|
||||
"created_at, updated_at",
|
||||
(
|
||||
resolution_reason,
|
||||
now,
|
||||
now,
|
||||
instance_key,
|
||||
slot_id,
|
||||
side,
|
||||
execution_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE ledger_skip SET status='resolved', resolution_reason=%s, "
|
||||
"resolved_at=%s, updated_at=%s "
|
||||
"WHERE status='unresolved' AND instance_key=%s AND slot_id=%s "
|
||||
"AND side=%s AND order_id=%s "
|
||||
"RETURNING skip_key, instance_key, slot_id, portfolio_id, symbol, "
|
||||
"order_id, execution_id, side, qty, price, executed_at, order_venue, "
|
||||
"execution_venue, skip_reason, status, resolution_reason, resolved_at, "
|
||||
"created_at, updated_at",
|
||||
(
|
||||
resolution_reason,
|
||||
now,
|
||||
now,
|
||||
instance_key,
|
||||
slot_id,
|
||||
side,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
return [self._row_to_record(row) for row in rows]
|
||||
|
||||
def resolve_by_key(self, skip_key: str, *, resolution_reason: str) -> LedgerSkipRecord | None:
|
||||
now = datetime.now(tz=UTC)
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE ledger_skip SET status='resolved', resolution_reason=%s, "
|
||||
"resolved_at=%s, updated_at=%s "
|
||||
"WHERE skip_key=%s AND status='unresolved' "
|
||||
"RETURNING skip_key, instance_key, slot_id, portfolio_id, symbol, order_id, "
|
||||
"execution_id, side, qty, price, executed_at, order_venue, execution_venue, "
|
||||
"skip_reason, status, resolution_reason, resolved_at, created_at, updated_at",
|
||||
(resolution_reason, now, now, skip_key),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return self._row_to_record(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
def _row_to_record(row: tuple) -> LedgerSkipRecord:
|
||||
return LedgerSkipRecord(
|
||||
skip_key=row[0],
|
||||
instance_key=row[1],
|
||||
slot_id=row[2],
|
||||
portfolio_id=row[3],
|
||||
symbol=row[4],
|
||||
order_id=row[5],
|
||||
execution_id=row[6],
|
||||
side=row[7],
|
||||
qty=float(row[8]),
|
||||
price=float(row[9]),
|
||||
executed_at=row[10],
|
||||
order_venue=row[11],
|
||||
execution_venue=row[12],
|
||||
skip_reason=row[13],
|
||||
status=row[14],
|
||||
resolution_reason=row[15],
|
||||
resolved_at=row[16],
|
||||
created_at=row[17],
|
||||
updated_at=row[18],
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS ledger_skip (
|
||||
skip_key TEXT PRIMARY KEY,
|
||||
instance_key TEXT NOT NULL,
|
||||
slot_id UUID NOT NULL,
|
||||
portfolio_id TEXT NULL,
|
||||
symbol TEXT NULL,
|
||||
order_id TEXT NULL,
|
||||
execution_id TEXT NULL,
|
||||
side TEXT NOT NULL CHECK (side IN ('buy', 'sell')),
|
||||
qty DOUBLE PRECISION NOT NULL,
|
||||
price DOUBLE PRECISION NOT NULL,
|
||||
executed_at TIMESTAMPTZ NOT NULL,
|
||||
order_venue TEXT NOT NULL,
|
||||
execution_venue TEXT NOT NULL,
|
||||
skip_reason TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('unresolved', 'resolved')),
|
||||
resolution_reason TEXT NULL,
|
||||
resolved_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_skip_status_created
|
||||
ON ledger_skip (status, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_skip_instance_portfolio_created
|
||||
ON ledger_skip (instance_key, portfolio_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_skip_execution_id
|
||||
ON ledger_skip (execution_id);
|
||||
@@ -107,6 +107,7 @@ class FillStatus:
|
||||
# Broker-authoritative execution exchange. For SOR orders this is KRX or
|
||||
# NXT; None means the polling surface did not establish it.
|
||||
execution_venue: Optional[str] = None
|
||||
executed_at: Optional[datetime] = None
|
||||
# dcy-013 / REQ-012: an external cancel was detected.
|
||||
# order had a partial fill before the cancel, state stays "partially_filled"
|
||||
# and this flag is set so execution records the filled portion and returns
|
||||
|
||||
@@ -25,9 +25,9 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable, Optional
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.broker_port import (
|
||||
@@ -39,6 +39,7 @@ from trex_core.application.broker_port import (
|
||||
)
|
||||
from trex_core.application.ledger_outbox import LedgerOutbox
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.ledger_skip import publish_ledger_skip_alarm
|
||||
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
|
||||
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
|
||||
from trex_core.runtime.logging_setup import set_correlation_id
|
||||
@@ -57,7 +58,7 @@ _log = logging.getLogger(__name__)
|
||||
# `slot.strategy_id` so that slot_filled_sell events reach the correct
|
||||
# per-strategy queue instead of being NO_ROUTEd to the DLQ.
|
||||
SlotEventEmitter = Callable[
|
||||
[str, object, Optional[str]], None
|
||||
[str, object, str | None], None
|
||||
] # (event_type, slot, routing_strategy_id) -> None
|
||||
|
||||
_MAX_DISPATCH_PER_TICK = 64
|
||||
@@ -81,13 +82,14 @@ class ExecutionService:
|
||||
slot_service: SlotService,
|
||||
repo,
|
||||
broker: BrokerPort,
|
||||
ledger_outbox: Optional[LedgerOutbox] = None,
|
||||
event_emitter: Optional[SlotEventEmitter] = None,
|
||||
stale_pending_threshold_sec: Optional[int] = None,
|
||||
ledger_outbox: LedgerOutbox | None = None,
|
||||
event_emitter: SlotEventEmitter | None = None,
|
||||
stale_pending_threshold_sec: int | None = None,
|
||||
instance_key: str = "default",
|
||||
alarm_emitter: Optional[AlarmEmitter] = None,
|
||||
balance_reader: Optional[Callable[[], list[BalanceItem]]] = None,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
alarm_emitter: AlarmEmitter | None = None,
|
||||
ledger_skip_store=None,
|
||||
balance_reader: Callable[[], list[BalanceItem]] | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._svc = slot_service
|
||||
self._repo = repo
|
||||
@@ -108,11 +110,12 @@ class ExecutionService:
|
||||
)
|
||||
# dcy-033 / REQ-100: best-effort alarm emitter (None = no alarms sent).
|
||||
self._alarm_emitter = alarm_emitter
|
||||
self._ledger_skip_store = ledger_skip_store
|
||||
# One emission per durable unresolved state/attempt in this process.
|
||||
# A restart intentionally emits once again, covering a crash between a
|
||||
# durable cancel reservation and best-effort alarm publication.
|
||||
self._order_alarm_keys: set[tuple[str, str, int]] = set()
|
||||
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
|
||||
self._clock = clock or (lambda: datetime.now(tz=UTC))
|
||||
# dcy-041 / GitHub #45: pending-sell quantity may use a bounded
|
||||
# account snapshot only through this injected reader. Reconciliation
|
||||
# remains wired directly to the live broker.
|
||||
@@ -137,7 +140,7 @@ class ExecutionService:
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _dispatch(self, r: ExecutionResult) -> None:
|
||||
sell_balance: Optional[dict[str, int]] = None # lazy, once per tick
|
||||
sell_balance: dict[str, int] | None = None # lazy, once per tick
|
||||
for slot in self._repo.list_undispatched_pending(
|
||||
self._instance_key, _MAX_DISPATCH_PER_TICK
|
||||
):
|
||||
@@ -266,7 +269,7 @@ class ExecutionService:
|
||||
r.rolled_back += 1
|
||||
|
||||
def _poll(self, r: ExecutionResult) -> None:
|
||||
now = self._clock().astimezone(timezone.utc)
|
||||
now = self._clock().astimezone(UTC)
|
||||
for slot in self._repo.list_dispatched_pending(self._instance_key):
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
|
||||
@@ -716,7 +719,7 @@ class ExecutionService:
|
||||
},
|
||||
)
|
||||
|
||||
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
|
||||
def _emit_event(self, event_type: str, slot, routing_strategy_id: str | None = None) -> None:
|
||||
if self._event_emitter is None:
|
||||
return
|
||||
try:
|
||||
@@ -754,6 +757,14 @@ class ExecutionService:
|
||||
slot.symbol,
|
||||
side,
|
||||
)
|
||||
self._persist_ledger_skip(
|
||||
slot,
|
||||
fs,
|
||||
side,
|
||||
broker_order_id,
|
||||
skip_reason="missing_portfolio_id",
|
||||
price=resolved_price,
|
||||
)
|
||||
return
|
||||
if fs.execution_id is None:
|
||||
# Broker contract regression — slot already transitioned; surface for ops.
|
||||
@@ -765,6 +776,14 @@ class ExecutionService:
|
||||
slot.portfolio_id,
|
||||
side,
|
||||
)
|
||||
self._persist_ledger_skip(
|
||||
slot,
|
||||
fs,
|
||||
side,
|
||||
broker_order_id,
|
||||
skip_reason="missing_execution_id",
|
||||
price=resolved_price,
|
||||
)
|
||||
return
|
||||
# REQ-095 KD-3: use pre-resolved price when available; fall back to
|
||||
# fs.avg_price for any direct callers that do not pass resolved_price.
|
||||
@@ -787,6 +806,15 @@ class ExecutionService:
|
||||
execution_venue,
|
||||
slot.venue,
|
||||
)
|
||||
self._persist_ledger_skip(
|
||||
slot,
|
||||
fs,
|
||||
side,
|
||||
broker_order_id,
|
||||
skip_reason="unsupported_execution_venue",
|
||||
price=price,
|
||||
execution_venue=execution_venue,
|
||||
)
|
||||
return
|
||||
trade = LedgerTrade(
|
||||
asset_id=str(slot.portfolio_id),
|
||||
@@ -797,12 +825,68 @@ class ExecutionService:
|
||||
price=price,
|
||||
fee=float(fs.fee),
|
||||
tax=float(fs.tax),
|
||||
executed_at=datetime.now(tz=timezone.utc),
|
||||
executed_at=fs.executed_at or self._clock(),
|
||||
venue=execution_venue,
|
||||
order_venue=slot.venue,
|
||||
)
|
||||
self._ledger_outbox.enqueue(trade)
|
||||
r.ledger_enqueued += 1
|
||||
self._resolve_ledger_skip(slot, side, broker_order_id, str(fs.execution_id))
|
||||
|
||||
def _persist_ledger_skip(
|
||||
self,
|
||||
slot,
|
||||
fs,
|
||||
side: str,
|
||||
broker_order_id: str,
|
||||
*,
|
||||
skip_reason: str,
|
||||
price: float | None,
|
||||
execution_venue: str | None = None,
|
||||
) -> None:
|
||||
if self._ledger_skip_store is None:
|
||||
return
|
||||
record, should_notify = self._ledger_skip_store.record_skip(
|
||||
instance_key=self._instance_key,
|
||||
slot_id=slot.slot_id,
|
||||
portfolio_id=slot.portfolio_id,
|
||||
symbol=slot.symbol,
|
||||
order_id=str(broker_order_id) if broker_order_id else None,
|
||||
execution_id=str(fs.execution_id) if fs.execution_id is not None else None,
|
||||
side=side,
|
||||
qty=float(fs.filled_qty),
|
||||
price=(
|
||||
float(price)
|
||||
if price is not None
|
||||
else (float(fs.avg_price) if fs.avg_price is not None else 0.0)
|
||||
),
|
||||
executed_at=fs.executed_at or self._clock(),
|
||||
order_venue=getattr(slot, "venue", None) or "unknown",
|
||||
execution_venue=execution_venue or getattr(fs, "execution_venue", None) or "unknown",
|
||||
skip_reason=skip_reason,
|
||||
)
|
||||
if should_notify:
|
||||
publish_ledger_skip_alarm(self._alarm_emitter, record, resolved=False)
|
||||
|
||||
def _resolve_ledger_skip(
|
||||
self,
|
||||
slot,
|
||||
side: str,
|
||||
broker_order_id: str,
|
||||
execution_id: str | None,
|
||||
) -> None:
|
||||
if self._ledger_skip_store is None:
|
||||
return
|
||||
records = self._ledger_skip_store.resolve_for_fill(
|
||||
instance_key=self._instance_key,
|
||||
slot_id=slot.slot_id,
|
||||
side=side,
|
||||
order_id=str(broker_order_id) if broker_order_id else None,
|
||||
execution_id=execution_id,
|
||||
resolution_reason="ledger_enqueued",
|
||||
)
|
||||
for record in records:
|
||||
publish_ledger_skip_alarm(self._alarm_emitter, record, resolved=True)
|
||||
|
||||
def cancel_pending_slot(self, slot_id) -> str:
|
||||
"""Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071).
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LedgerSkipRecord:
|
||||
skip_key: str
|
||||
instance_key: str
|
||||
slot_id: UUID
|
||||
portfolio_id: str | None
|
||||
symbol: str | None
|
||||
order_id: str | None
|
||||
execution_id: str | None
|
||||
side: str
|
||||
qty: float
|
||||
price: float
|
||||
executed_at: datetime
|
||||
order_venue: str
|
||||
execution_venue: str
|
||||
skip_reason: str
|
||||
status: str
|
||||
resolution_reason: str | None
|
||||
resolved_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
def make_ledger_skip_key(
|
||||
*,
|
||||
instance_key: str,
|
||||
slot_id: UUID,
|
||||
side: str,
|
||||
order_id: str | None,
|
||||
execution_id: str | None,
|
||||
skip_reason: str,
|
||||
) -> str:
|
||||
return ":".join(
|
||||
[
|
||||
"ledger-skip",
|
||||
instance_key,
|
||||
str(slot_id),
|
||||
side,
|
||||
order_id or "-",
|
||||
execution_id or "-",
|
||||
skip_reason,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def ledger_skip_alarm_fields(record: LedgerSkipRecord) -> dict[str, object]:
|
||||
return {
|
||||
"skip_key": record.skip_key,
|
||||
"instance_key": record.instance_key,
|
||||
"slot_id": str(record.slot_id),
|
||||
"portfolio_id": record.portfolio_id,
|
||||
"symbol": record.symbol,
|
||||
"order_id": record.order_id,
|
||||
"execution_id": record.execution_id,
|
||||
"side": record.side,
|
||||
"qty": record.qty,
|
||||
"price": record.price,
|
||||
"executed_at": record.executed_at.isoformat(),
|
||||
"order_venue": record.order_venue,
|
||||
"execution_venue": record.execution_venue,
|
||||
"skip_reason": record.skip_reason,
|
||||
"status": record.status,
|
||||
"resolution_reason": record.resolution_reason,
|
||||
"resolved_at": record.resolved_at.isoformat() if record.resolved_at else None,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"updated_at": record.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def publish_ledger_skip_alarm(
|
||||
alarm_emitter: AlarmEmitter | None,
|
||||
record: LedgerSkipRecord,
|
||||
*,
|
||||
resolved: bool,
|
||||
) -> None:
|
||||
if alarm_emitter is None:
|
||||
return
|
||||
code = "ledger_skip_resolved" if resolved else "ledger_skip_unresolved"
|
||||
title = (
|
||||
f"원장 skip 해소: slot={record.slot_id} reason={record.resolution_reason or 'resolved'}"
|
||||
if resolved
|
||||
else f"원장 skip 발생: slot={record.slot_id} reason={record.skip_reason}"
|
||||
)
|
||||
alarm_emitter.publish_alarm(
|
||||
code,
|
||||
title,
|
||||
fields=ledger_skip_alarm_fields(record),
|
||||
severity="info" if resolved else "critical",
|
||||
)
|
||||
+144
-24
@@ -12,6 +12,8 @@ Subcommands:
|
||||
sync — sync slot qty to broker ground truth (direct-DB; REQ-105)
|
||||
verify — re-check C-case reconcile-warning (direct-DB; REQ-106)
|
||||
ledger backfill — evidence-backed historical ledger recovery
|
||||
ledger skips list — query durable skipped-ledger records
|
||||
ledger skips resolve — explicitly resolve a skipped-ledger record
|
||||
|
||||
universe/slots/enter/exit go through the daemon's RabbitMQ RPC surface so the
|
||||
single-writer admission + invariants are preserved (no direct slot writes,
|
||||
@@ -28,24 +30,27 @@ live accounts require `--yes --live` (double ack); `--dry-run` previews
|
||||
before/after without writing. sync is direct-DB (no RPC).
|
||||
verify (REQ-106): read-centric (no --yes/--live gate needed).
|
||||
ledger backfill: verifies KIS daily-ccld evidence, reconstructs one ledger
|
||||
trade, enqueues it through ledger_outbox, and immediately drives the
|
||||
LedgerWriter path with an audit JSON report.
|
||||
trade, enqueues it through ledger_outbox, immediately drives the
|
||||
LedgerWriter path, and resolves matching durable skipped-ledger records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json as _json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.ledger_outbox import LedgerWriter, OutboxOutcome
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.ledger_skip import ledger_skip_alarm_fields, publish_ledger_skip_alarm
|
||||
from trex_core.runtime.config import (
|
||||
DaemonConfig,
|
||||
DaemonInstanceConfig,
|
||||
@@ -55,6 +60,7 @@ from trex_core.runtime.config import (
|
||||
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
_DEFAULT_LEDGER_BACKFILL_AUDIT_DIR = "artifacts/validation/ledger_backfill"
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -91,6 +97,14 @@ def _default_rpc(cfg: DaemonConfig):
|
||||
return _rpc
|
||||
|
||||
|
||||
def _default_alarm_emitter(cfg: DaemonConfig):
|
||||
from trex_core.adapters.messaging import InMemoryTransport, RabbitMQTransport
|
||||
|
||||
mq = getattr(cfg, "rabbitmq", None)
|
||||
transport = RabbitMQTransport(mq.url, timeout=mq.timeout) if mq else InMemoryTransport()
|
||||
return AlarmEmitter(transport), transport
|
||||
|
||||
|
||||
def _default_confirm(prompt: str) -> bool:
|
||||
try:
|
||||
return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes")
|
||||
@@ -376,7 +390,7 @@ def _sanitize_path_component(value: str, *, limit: int = 80) -> str:
|
||||
|
||||
def _ledger_backfill_audit_path(args, execution_id: str) -> Path:
|
||||
base = Path(getattr(args, "audit_dir", _DEFAULT_LEDGER_BACKFILL_AUDIT_DIR))
|
||||
stamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
name = (
|
||||
f"{stamp}--{_sanitize_path_component(_resolve_instance(args))}"
|
||||
f"--{_sanitize_path_component(execution_id)}.json"
|
||||
@@ -393,27 +407,20 @@ def _write_ledger_backfill_audit(args, report: dict) -> str:
|
||||
|
||||
|
||||
def _lookup_ledger_backfill_evidence(cfg: DaemonConfig, *, trading_date: str, execution_id: str):
|
||||
from trex_core.runtime.broker_provider import (
|
||||
BrokerCredentialUnavailable,
|
||||
build_broker_provider,
|
||||
)
|
||||
from trex_core.runtime.broker_provider import build_broker_provider
|
||||
|
||||
broker, source = build_broker_provider(cfg).get_broker_for_read()
|
||||
lookup = getattr(broker, "lookup_fill_evidence", None)
|
||||
if not callable(lookup):
|
||||
raise RuntimeError("configured broker does not support KIS ledger backfill evidence lookup")
|
||||
try:
|
||||
raise TypeError("configured broker does not support KIS ledger backfill evidence lookup")
|
||||
evidence = lookup(trading_date=trading_date, execution_id=execution_id)
|
||||
except BrokerCredentialUnavailable:
|
||||
raise
|
||||
return evidence, source
|
||||
|
||||
|
||||
def _resolve_ledger_backfill_slot(dsn: str, *, instance: str, symbol: str) -> dict:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT slot_id::text, portfolio_id, status "
|
||||
"FROM daily_slot "
|
||||
@@ -439,8 +446,7 @@ def _resolve_ledger_backfill_slot(dsn: str, *, instance: str, symbol: str) -> di
|
||||
def _lookup_outbox_by_execution_id(dsn: str, *, execution_id: str) -> list[dict]:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT correlation_id, asset_id, order_id, execution_id, status, "
|
||||
"attempts, last_outcome, last_error "
|
||||
@@ -630,6 +636,33 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
_add_common(p_backfill)
|
||||
|
||||
p_skips = p_ledger_sub.add_parser(
|
||||
"skips",
|
||||
help="Query or resolve durable skipped-ledger records",
|
||||
)
|
||||
p_skips_sub = p_skips.add_subparsers(dest="ledger_skips_cmd", required=True)
|
||||
p_skips_list = p_skips_sub.add_parser(
|
||||
"list",
|
||||
help="List skipped-ledger records by instance/date/portfolio/state",
|
||||
)
|
||||
p_skips_list.add_argument("--date", default=None, help="UTC date filter (YYYY-MM-DD)")
|
||||
p_skips_list.add_argument("--portfolio-id", dest="portfolio_id", default=None)
|
||||
p_skips_list.add_argument(
|
||||
"--status",
|
||||
choices=("unresolved", "resolved", "all"),
|
||||
default="unresolved",
|
||||
)
|
||||
p_skips_list.add_argument("--limit", type=int, default=100)
|
||||
_add_common(p_skips_list)
|
||||
|
||||
p_skips_resolve = p_skips_sub.add_parser(
|
||||
"resolve",
|
||||
help="Mark one skipped-ledger record resolved and emit a resolved alert",
|
||||
)
|
||||
p_skips_resolve.add_argument("--skip-key", required=True)
|
||||
p_skips_resolve.add_argument("--reason", default="operator_reconciled")
|
||||
_add_common(p_skips_resolve)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -696,8 +729,7 @@ def _cmd_sync(args, cfg, confirm) -> int:
|
||||
# Determine target slot_ids
|
||||
import psycopg
|
||||
if args.portfolio_id:
|
||||
with psycopg.connect(cfg.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with psycopg.connect(cfg.dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT slot_id FROM daily_slot "
|
||||
"WHERE instance_key = %s AND portfolio_id = %s LIMIT 1",
|
||||
@@ -711,8 +743,7 @@ def _cmd_sync(args, cfg, confirm) -> int:
|
||||
else:
|
||||
# --all: target all warned slots (B case) plus all inactive slots with broker holding
|
||||
warned = warn_store.list_warned()
|
||||
with psycopg.connect(cfg.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with psycopg.connect(cfg.dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT slot_id FROM daily_slot "
|
||||
"WHERE instance_key = %s AND status IN ('inactive', 'holding')",
|
||||
@@ -820,7 +851,7 @@ def _cmd_verify(args, cfg) -> int:
|
||||
items = hub.get_portfolios(daemon_id)
|
||||
store.swap(items)
|
||||
except Exception: # noqa: BLE001 — degrade: verify with empty store
|
||||
pass
|
||||
_log.info("verify snapshot preload skipped", exc_info=True)
|
||||
|
||||
if args.portfolio_id:
|
||||
target_pids = [args.portfolio_id]
|
||||
@@ -903,8 +934,7 @@ def _cmd_report(args, cfg) -> int:
|
||||
holdings: list[Holding] = []
|
||||
pending: list[PendingOrder] = []
|
||||
trades: list[TradeRow] = []
|
||||
with psycopg.connect(cfg.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
with psycopg.connect(cfg.dsn) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT symbol, portfolio_id, qty, avg_price FROM daily_slot "
|
||||
"WHERE status = 'holding' ORDER BY symbol"
|
||||
@@ -989,9 +1019,69 @@ def _cmd_report(args, cfg) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _render_ledger_skip_record(record) -> dict[str, object]:
|
||||
return ledger_skip_alarm_fields(record)
|
||||
|
||||
|
||||
def _cmd_ledger_skips_list(args, cfg) -> int:
|
||||
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
|
||||
|
||||
instance = _resolve_instance(args)
|
||||
on_date = _parse_iso_date(args.date) if args.date else None
|
||||
records = PostgreSQLLedgerSkipStore(cfg.dsn).list_records(
|
||||
instance_key=instance,
|
||||
portfolio_id=args.portfolio_id,
|
||||
on_date=on_date,
|
||||
status=args.status,
|
||||
limit=args.limit,
|
||||
)
|
||||
payload = {
|
||||
"instance": instance,
|
||||
"status": args.status,
|
||||
"count": len(records),
|
||||
"records": [_render_ledger_skip_record(record) for record in records],
|
||||
}
|
||||
if args.json:
|
||||
_emit(payload, as_json=True)
|
||||
else:
|
||||
print(
|
||||
f"instance={instance} status={args.status} count={len(records)} "
|
||||
f"portfolio_id={args.portfolio_id or '*'} date={args.date or '*'}"
|
||||
)
|
||||
for record in records:
|
||||
print(
|
||||
f" {record.skip_key}\t{record.status}\t{record.skip_reason}\t"
|
||||
f"slot={record.slot_id}\tportfolio={record.portfolio_id or '-'}\t"
|
||||
f"execution={record.execution_id or '-'}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_ledger_skips_resolve(args, cfg) -> int:
|
||||
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
|
||||
|
||||
store = PostgreSQLLedgerSkipStore(cfg.dsn)
|
||||
record = store.resolve_by_key(args.skip_key, resolution_reason=args.reason)
|
||||
if record is None:
|
||||
_fail(f"unresolved ledger skip not found: {args.skip_key}", args.json)
|
||||
return 1
|
||||
emitter, transport = _default_alarm_emitter(cfg)
|
||||
try:
|
||||
publish_ledger_skip_alarm(emitter, record, resolved=True)
|
||||
finally:
|
||||
transport.close()
|
||||
payload = {"result": "resolved", "record": _render_ledger_skip_record(record)}
|
||||
if args.json:
|
||||
_emit(payload, as_json=True)
|
||||
else:
|
||||
print(f"resolved {record.skip_key} reason={record.resolution_reason}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_ledger_backfill(args, cfg, confirm) -> int:
|
||||
from trex_core.adapters.broker.kis import KISEvidenceLookupError
|
||||
from trex_core.adapters.ledger.portfolio_hub import PortfolioHubLedger
|
||||
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLLedgerOutbox
|
||||
from trex_core.runtime.broker_provider import BrokerCredentialUnavailable
|
||||
|
||||
@@ -1020,6 +1110,7 @@ def _cmd_ledger_backfill(args, cfg, confirm) -> int:
|
||||
"trade": None,
|
||||
"outbox_precheck": None,
|
||||
"result": None,
|
||||
"resolved_skips": [],
|
||||
"audit_path": None,
|
||||
}
|
||||
|
||||
@@ -1084,7 +1175,7 @@ def _cmd_ledger_backfill(args, cfg, confirm) -> int:
|
||||
"execution_id": trade.execution_id,
|
||||
"existing": existing,
|
||||
}
|
||||
except (BrokerCredentialUnavailable, KISEvidenceLookupError, RuntimeError) as exc:
|
||||
except (BrokerCredentialUnavailable, KISEvidenceLookupError, RuntimeError, TypeError) as exc:
|
||||
report["result"] = {"status": "rejected", "reason": str(exc)}
|
||||
report["audit_path"] = _write_ledger_backfill_audit(args, report)
|
||||
if args.json:
|
||||
@@ -1172,6 +1263,23 @@ def _cmd_ledger_backfill(args, cfg, confirm) -> int:
|
||||
"last_outcome": item.last_outcome.value if item and item.last_outcome else None,
|
||||
"last_error": item.last_error if item is not None else None,
|
||||
}
|
||||
if exit_code == 0:
|
||||
resolved = PostgreSQLLedgerSkipStore(cfg.dsn).resolve_for_fill(
|
||||
instance_key=instance,
|
||||
slot_id=uuid.UUID(str(slot_binding["slot_id"])),
|
||||
side=trade.side,
|
||||
order_id=trade.order_id,
|
||||
execution_id=trade.execution_id,
|
||||
resolution_reason="manual_ledger_backfill",
|
||||
)
|
||||
report["resolved_skips"] = [_render_ledger_skip_record(record) for record in resolved]
|
||||
if resolved:
|
||||
emitter, transport = _default_alarm_emitter(cfg)
|
||||
try:
|
||||
for record in resolved:
|
||||
publish_ledger_skip_alarm(emitter, record, resolved=True)
|
||||
finally:
|
||||
transport.close()
|
||||
report["audit_path"] = _write_ledger_backfill_audit(args, report)
|
||||
if args.json:
|
||||
_emit(report, as_json=True)
|
||||
@@ -1219,6 +1327,18 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
|
||||
if args.cmd == "ledger" and getattr(args, "ledger_cmd", None) == "backfill":
|
||||
return _cmd_ledger_backfill(args, cfg, confirm)
|
||||
if (
|
||||
args.cmd == "ledger"
|
||||
and getattr(args, "ledger_cmd", None) == "skips"
|
||||
and getattr(args, "ledger_skips_cmd", None) == "list"
|
||||
):
|
||||
return _cmd_ledger_skips_list(args, cfg)
|
||||
if (
|
||||
args.cmd == "ledger"
|
||||
and getattr(args, "ledger_cmd", None) == "skips"
|
||||
and getattr(args, "ledger_skips_cmd", None) == "resolve"
|
||||
):
|
||||
return _cmd_ledger_skips_resolve(args, cfg)
|
||||
|
||||
# direct-DB control commands (pause/resume stay direct-DB, OQ-3).
|
||||
if args.cmd in ("pause", "resume", "paused"):
|
||||
|
||||
@@ -35,6 +35,7 @@ from trex_core.adapters.persistence.migrate import apply_migrations
|
||||
from trex_core.adapters.persistence.account_snapshot import (
|
||||
PostgreSQLAccountSnapshotStore,
|
||||
)
|
||||
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
|
||||
from trex_core.adapters.persistence.postgresql import (
|
||||
PgStrategyStateStore,
|
||||
PostgreSQLIntentStore,
|
||||
@@ -188,6 +189,7 @@ class Daemon:
|
||||
)
|
||||
|
||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||
self.ledger_skip_store = PostgreSQLLedgerSkipStore(config.dsn)
|
||||
# BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler.
|
||||
# (Created before the ledger writer so option-D write-back can target it.)
|
||||
self.snapshot_store = SnapshotStore()
|
||||
@@ -303,6 +305,7 @@ class Daemon:
|
||||
stale_pending_threshold_sec=config.pending_stale_threshold_sec,
|
||||
instance_key=config.instance_key,
|
||||
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
|
||||
ledger_skip_store=self.ledger_skip_store,
|
||||
balance_reader=(
|
||||
self.account_snapshot_service.holdings_for_sell
|
||||
if self.account_snapshot_service is not None
|
||||
|
||||
Reference in New Issue
Block a user