feat(intents): dcy-011 buy intent portfolio_id 필수 계약 + ledger skip 가시화 (BAT-015)

dcy-011 전 주기 delivery. issue #6(buy 식별자 정정) + issue #11(ledger silent skip) 흡수.
거버넌스: 기존 Implemented REQ는 change-req, 신규 기능만 신규 REQ(OQ-3).

- REQ-007 (change-req): buy admission portfolio_id 필수화 + SnapshotStore symbol
  resolve + unknown_portfolio 거부 (hard cutover, OQ-1/OQ-2)
- REQ-021 (change-req): buy 메시지 payload symbol 제거
- REQ-018 (change-req): _record_ledger skip 가시화 (WARN + ledger_skipped 카운터)
- REQ-055 (신규): closed↔ledger_outbox anti-join 운영 점검 find_ledger_gap_slots

코드: intents.py(BuyIntent portfolio_id 필수 + resolve_symbol 주입 + _admit_buy
reject 분기), messaging_handlers.py, runtime/daemon.py(_resolve_symbol 배선),
execution.py(skip WARN 로그), adapters/persistence/ops_checks.py(신규 anti-join).

검증: 단위 282 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 314 passed, ruff clean.
BAT-015(standard) release-candidate. srs/index Next ID → REQ-056.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:57:51 +09:00
co-authored by Claude Opus 4.8
parent a24ebb242e
commit aee3512383
27 changed files with 729 additions and 33 deletions
@@ -0,0 +1,47 @@
"""Operational integrity checks — read-only (REQ-055 / dcy-011 FR-5, issue #11).
`find_ledger_gap_slots` detects `closed` slots that produced NO ledger_outbox
row (the silent-skip symptom): either `portfolio_id IS NULL` (skip at fill) or
no outbox row carries the slot's portfolio_id as `asset_id`. Read-only — no
schema change; `daily_slot` is durable so this is a permanent detector even
without an outbox marker row (dcy-010 OQ-4).
"""
from __future__ import annotations
from datetime import date
from typing import Any, Optional
from trex_core.adapters.persistence.postgresql import _require_psycopg
_LEDGER_GAP_SQL = """
SELECT s.slot_id, s.trading_date, s.symbol, s.portfolio_id, s.broker_order_id
FROM daily_slot s
WHERE s.status = 'closed'
AND (%(td)s::date IS NULL OR s.trading_date = %(td)s)
AND (
s.portfolio_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM ledger_outbox o WHERE o.asset_id = s.portfolio_id
)
)
ORDER BY s.trading_date, s.slot_id
"""
_COLS = ("slot_id", "trading_date", "symbol", "portfolio_id", "broker_order_id")
def find_ledger_gap_slots(
dsn: str,
*,
trading_date: Optional[date] = None,
psycopg_module: Any = None,
) -> list[dict]:
"""Return closed slots with no corresponding ledger_outbox row (FR-5).
Pass `trading_date` to scope to one day; omit for all days.
"""
psycopg = _require_psycopg(psycopg_module)
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(_LEDGER_GAP_SQL, {"td": trading_date})
return [dict(zip(_COLS, row)) for row in cur.fetchall()]