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,63 @@
# BAT-015 Design
- Status: confirmed
- Batch: BAT-015
- Profile: standard
## 설계 목표
- buy intent 식별자 계약을 정식 식별자(portfolio_id=asset_id) 기준으로 hard cutover 하면서, symbol 입력 제거에 따른 다수 호출부 회귀를 통제한다.
- issue #11 의 ledger silent skip 을 무성 → 가시화로 전환하되 전이 동작과 outbox 스키마는 불변으로 유지한다(OQ-4).
## 1. buy admission 계약 (REQ-007)
### BuyIntent 구조
- `portfolio_id: str` 를 필수 위치 인자로 승격(intent_id, trading_date 다음). `symbol: Optional[str] = None` 으로 강등 — 클라이언트 입력이 아니라 admission 이 universe 에서 resolve 해 슬롯에 싣는 값.
- 나머지 필드(qty/allocated_capital/entry_price/strategy_id/thresholds/extra)는 불변.
### symbol resolve 주입 경계
- 결정: `IntentService(resolve_symbol=callable|None)` 콜백 주입(SnapshotStore 직접 의존 대신).
- 근거: (a) application 계층이 portfolio.SnapshotStore 에 직접 결합되지 않아 테스트 더블 주입이 쉽다. (b) `resolve_symbol=None` 폴백을 두면 symbol resolve 가 필요 없는 기존 단위 테스트는 portfolio_id 추가만으로 무회귀(R-1 통제). 프로덕션 daemon 은 항상 `_resolve_symbol`(SnapshotStore.get) 주입.
- `_resolve_symbol(portfolio_id) -> Optional[str]`. 부재 시 falsy.
### `_admit_buy` reject 분기 (FR-1/FR-2/FR-3)
1. `not intent.portfolio_id` → `reject(portfolio_id_required)` (FR-1, 근본 원인 제거).
2. resolver 주입됨: `symbol = resolve_symbol(portfolio_id)`; falsy 면 `reject(unknown_portfolio)` (FR-3, 단일 reject 정책 OQ-1 — symbol fallback 없음).
3. resolver 미주입 + `intent.symbol` 도 falsy → `reject(unknown_portfolio)` (호출 대상 미상).
4. resolve 성공 시 기존 idle-slot 배정·`transit_slot(IDLE→PENDING_BUY, symbol=symbol, portfolio_id=...)` 흐름 유지. extra 에도 resolved symbol 기록.
## 2. buy 메시지 payload 계약 (REQ-021)
- buy 핸들러는 payload 에서 `symbol` 을 읽지 않는다. `portfolio_id = _opt_str(body.get("portfolio_id")) or ""` 로 매핑(빈 문자열 → admission 의 portfolio_id_required 로 일관 거부).
- symbol 은 핸들러에서 BuyIntent 에 싣지 않음(서버 resolve 가 admission 책임).
## 3. daemon 배선 (REQ-007)
- `runtime/daemon.py::_resolve_symbol(self, portfolio_id)` → `asset.symbol if (asset := self.snapshot_store.get(portfolio_id)) else None`.
- `IntentService(..., resolve_symbol=self._resolve_symbol)` 주입.
## 4. ledger skip 가시화 (REQ-018, issue #11)
- `_record_ledger` 두 조기 반환 경로 모두에서 `r.ledger_skipped += 1` 후 `_log.warning(...)`:
- portfolio_id 누락: `reason=missing_portfolio_id` (slot_id/symbol/side).
- execution_id 누락: `reason=missing_execution_id` (slot_id/portfolio_id/side, 브로커 계약 회귀 방어 케이스).
- 슬롯 전이는 그대로(관측만 추가, OQ-4). outbox 마커 행/스키마 변경 없음.
- `execution.py` 에 `import logging` + `_log = logging.getLogger(__name__)` 추가.
## 5. 운영 점검 쿼리 (REQ-055, FR-5)
- `adapters/persistence/ops_checks.py::find_ledger_gap_slots(dsn, *, trading_date=None, psycopg_module=None)` — 읽기 전용.
- anti-join SQL: `daily_slot s WHERE s.status='closed' AND (td IS NULL OR trading_date=td) AND (portfolio_id IS NULL OR NOT EXISTS(SELECT 1 FROM ledger_outbox o WHERE o.asset_id=s.portfolio_id))`.
- 반환: `slot_id/trading_date/symbol/portfolio_id/broker_order_id` dict 리스트. `daily_slot` durable → outbox 마커 없이 영구 탐지(OQ-4 위임).
- 스키마 변경 없음. 알람/주기 실행 배선은 운영 절차(Out of Scope).
## 책임 분리
- 런타임 관측(REQ-018, WARN+카운터): "지금 skip 났다"를 즉시 노출.
- 사후 탐지(REQ-055, anti-join): durable 슬롯 기준으로 누락을 영구·집계 탐지.
- 둘 다 outbox 마커 행 없이 성립 → outbox 전송 큐 의미 오염 회피(OQ-4 근거).
## 하위호환 / cutover (NFR-3)
- 호환 창구 없음(OQ-2). symbol 기반 호출은 portfolio_id 누락 시 명확히 reject.
- 호출부 전환 전략:
- 비-핸들러 intent/동시성/repository 테스트: BuyIntent 에 portfolio_id 추가(resolver None → intent.symbol 폴백).
- 핸들러/RabbitMQ e2e 테스트: resolve_symbol 람다 + payload portfolio_id 동시 전환.
- ledger skip 테스트: admission 우회(`transit_slot(portfolio_id=None)`)로 skip 전제 재구성 + caplog 검증.
## 문서 (FR-6)
- `docs/interface-contract.md`: buy payload portfolio_id 필수 + symbol 제거/서버 resolve.
- `docs/runtime-flows.md`: buy admission step 에 portfolio_id 필수 + symbol resolve + unknown_portfolio + ledger skip 가시화 단락.
@@ -0,0 +1,40 @@
# BAT-015 Implementation
- Status: completed
- Batch: BAT-015
## 구현 요약
- dcy-011 buy intent 계약 hard cutover + ledger skip 가시화 + 운영 점검 쿼리를 단일 standard batch 로 구현. issue #6/#11 흡수.
## 변경 내역
### REQ-007 — buy admission portfolio_id 필수 + symbol resolve
- `trex_core/application/intents.py`:
- `BuyIntent`: `portfolio_id: str` 를 required 위치 인자로 승격, `symbol: Optional[str] = None` 로 강등(서버 resolve 결과를 담는 필드).
- `IntentService.__init__(..., resolve_symbol=None)` — `callable(portfolio_id) -> Optional[str]` 주입점.
- `_admit_buy`: ① `not portfolio_id` → `reject(portfolio_id_required)`; ② resolver 주입 시 `symbol = resolve_symbol(portfolio_id)`, falsy 면 `reject(unknown_portfolio)`; ③ resolver 미주입 + symbol falsy → `reject(unknown_portfolio)`; 이후 `transit_slot(symbol=symbol, portfolio_id=..., extra={..., "symbol": symbol})`.
- `trex_core/runtime/daemon.py`: `_resolve_symbol(self, portfolio_id)` → `asset.symbol if (asset := self.snapshot_store.get(portfolio_id)) else None`; `IntentService(..., resolve_symbol=self._resolve_symbol)` 배선.
### REQ-021 — buy 메시지 payload symbol 제거
- `trex_core/application/messaging_handlers.py`: buy 핸들러가 `symbol` 을 더 이상 읽지 않음; `portfolio_id=_opt_str(body.get("portfolio_id")) or ""` 매핑.
### REQ-018 — ledger skip 가시화 (issue #11)
- `trex_core/application/execution.py`: `import logging` + `_log = logging.getLogger(__name__)`; `_record_ledger` 두 skip 경로(portfolio_id 누락 `reason=missing_portfolio_id`, execution_id 누락 `reason=missing_execution_id`)에서 `r.ledger_skipped += 1` + `_log.warning(...)`. 슬롯 전이 동작 불변, outbox 스키마 무변경(OQ-4).
### REQ-055 — closed↔outbox anti-join 운영 점검 (신규)
- `trex_core/adapters/persistence/ops_checks.py`(신규): `find_ledger_gap_slots(dsn, *, trading_date=None, psycopg_module=None)` 읽기 전용. `status='closed'` 슬롯 중 `portfolio_id IS NULL` 또는 대응 `ledger_outbox` 행 부재(anti-join) 식별 → `slot_id/trading_date/symbol/portfolio_id/broker_order_id` dict 리스트. 스키마 변경 없음.
### FR-6 — 문서 정합
- `docs/interface-contract.md`: buy payload `portfolio_id` 필수 + `symbol` 제거(서버 resolve). Last Updated → BAT-015.
- `docs/runtime-flows.md`: buy admission step 에 portfolio_id 필수 + symbol resolve + unknown_portfolio + ledger skip 가시화 단락. Last Updated → BAT-015.
## 테스트 마이그레이션 (hard cutover, NFR-3)
- 비-핸들러(resolver None → intent.symbol 폴백, portfolio_id 추가만): `test_execution.py`, `test_intent_concurrency.py`, `test_messaging_snapshot_consumer.py`, `test_postgresql_repository.py`.
- 핸들러/e2e(resolver 람다 + payload portfolio_id): `test_messaging_intent_consumer.py`(+ 신규 3: without_portfolio_id_rejected / unknown_portfolio_rejected / symbol_resolved_from_universe), `test_rabbitmq_intent_e2e.py`.
- ledger skip(admission 우회 + caplog): `test_execution_ledger.py`(skip 테스트 재작성 + 신규 `test_ledger_skip_emits_warning`).
- 신규 PG 게이트: `tests/adapters/persistence/test_ops_checks.py`(gap 탐지 / 정상 0건).
## 검증 실행
- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q` → `282 passed / 32 skipped`(`::test_sandbox_token_and_submit` 는 LIVE `.env`(sandbox URL 아님)로 deselect — 본 batch 무관).
- 인프라 게이트: `TREX_POSTGRESQL_TEST_DSN=...(trex_core_test) TREX_RABBITMQ_TEST_URL=... pytest -q` → `314 passed`(PG anti-join 점검 + buy intent RabbitMQ e2e + 마이그레이션 round-trip 포함).
- `ruff check trex_core tests` → All checks passed.
@@ -0,0 +1,39 @@
# BAT-015: buy intent portfolio_id 필수 계약 + symbol 서버 resolve + ledger skip 가시화 + 누락 점검 쿼리
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-011
- Included REQ: REQ-007 (CHG-20260608-01), REQ-018 (CHG-20260608-01), REQ-021 (CHG-20260608-01), REQ-055
## Summary
- dcy-011(buy intent — portfolio_id 필수 / symbol 서버 resolve + ledger silent skip 가시화)을 단일 standard batch 로 delivery 한다. issue #6(buy 식별자 정정)과 issue #11(portfolio_id 없는 체결의 ledger silent skip)을 한 주기로 흡수한다.
- buy intent 계약을 hard cutover: `portfolio_id` 필수화 + admission 시 `SnapshotStore` universe 에서 `symbol` resolve + universe 부재 시 `unknown_portfolio` 거부(REQ-007), buy 메시지 payload 에서 `symbol` 제거(REQ-021), `_record_ledger` skip 가시화(WARN 로그 + `ExecutionResult.ledger_skipped` 카운터, REQ-018), closed↔`ledger_outbox` anti-join 운영 점검 `find_ledger_gap_slots` 신규(REQ-055).
- 거버넌스: dcy-011 OQ-3 결정에 따라 기존 Implemented REQ(007/018/021)는 `change-req`, 기존 REQ 가 다루지 않는 신규 기능(FR-5 운영 점검 쿼리)만 신규 REQ-055.
## Scope
- In Scope:
- `BuyIntent` portfolio_id 필수화 + symbol Optional(서버 resolve), `IntentService(resolve_symbol=...)` 주입, `_admit_buy` portfolio_id_required/unknown_portfolio reject + symbol resolve (REQ-007)
- buy 메시지 핸들러 payload 에서 symbol 제거·portfolio_id 매핑 (REQ-021)
- `runtime/daemon.py` `_resolve_symbol`(SnapshotStore.get) 배선 (REQ-007)
- `_record_ledger` 두 skip 경로(portfolio_id/execution_id 누락) WARN 로그 + 카운터 가시화 (REQ-018)
- `adapters/persistence/ops_checks.py::find_ledger_gap_slots` 읽기 전용 anti-join 점검 (REQ-055)
- `docs/interface-contract.md` + `docs/runtime-flows.md` 정합 (FR-6, REQ-007/021/018 본문에 흡수)
- Out of Scope:
- sell intent 계약 변경(이미 portfolio_id/slot 기반)
- universe refresh 주기/스케줄 재설계
- symbol-only 레거시 호환 창구(OQ-2 hard cutover)
- `ledger_outbox` skip 마커 행 도입(OQ-4 — 로그+메트릭만, 영구 탐지는 REQ-055 anti-join 위임)
- 과거 누락분 데이터 복구(별도 운영 이슈)
- skip 점검 쿼리의 알람/주기 실행 배선(쿼리/함수가 산출물; 운영 배포 절차)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- Source discovery 단일(dcy-011). OQ-1~OQ-5 모두 Answered.
- hard cutover(NFR-3): symbol 기반 호출부(테스트 다수) 동시 전환. `IntentService(resolve_symbol=None)` 는 `intent.symbol` 로 폴백 → 비-핸들러 테스트 churn 최소화, 프로덕션 daemon 은 항상 SnapshotStore resolve.
- ledger skip 가시화(REQ-018)는 전이 동작 불변, 관측만 추가(OQ-4).
- REQ-055 anti-join 은 `daily_slot` durable 특성에 의존(outbox 마커 없이 영구 탐지).
@@ -0,0 +1,53 @@
# BAT-015 Planning
- Status: confirmed
- Batch: BAT-015
- Profile: standard
## Included REQ
- REQ-007 (Interface, change-req CHG-20260608-01): buy admission 계약 — `portfolio_id` 필수화, admission 시 `SnapshotStore` 에서 symbol resolve, universe 부재 시 `unknown_portfolio` 거부. symbol-필수/portfolio_id-optional 폐기(hard cutover).
- REQ-021 (Integration, change-req CHG-20260608-01): buy 메시지 payload 계약에서 `symbol` 제거, `portfolio_id` 필수.
- REQ-018 (Integration, change-req CHG-20260608-01): `_record_ledger` ledger skip 가시화(WARN 로그 + `ledger_skipped` 카운터, 두 skip 경로).
- REQ-055 (Exception, 신규): closed↔`ledger_outbox` anti-join 운영 점검 `find_ledger_gap_slots`(읽기 전용).
## Delivery Plan
1. REQ-007: `BuyIntent`(portfolio_id 필수 + symbol Optional 서버 resolve), `IntentService(resolve_symbol=...)`, `_admit_buy`(portfolio_id_required → resolve → unknown_portfolio). intent 단위 + 동시성 회귀.
2. REQ-021: buy 핸들러 payload 파싱(symbol 제거, portfolio_id 매핑). 핸들러 단위 + RabbitMQ intent e2e.
3. REQ-007 배선: `runtime/daemon.py::_resolve_symbol`(SnapshotStore.get) + IntentService 주입.
4. REQ-018: `_record_ledger` 두 skip 경로 WARN 로그 + 카운터. caplog 단위.
5. REQ-055: `adapters/persistence/ops_checks.py::find_ledger_gap_slots` anti-join SQL. PG 게이트 단위.
6. FR-6 문서: interface-contract / runtime-flows 정합.
7. 전체 회귀 + ruff + 인프라 게이트(PG + RabbitMQ).
## Dependencies
- Internal:
- dcy-002/BAT-002 intent 제출(REQ-007) — admission 변경 대상.
- dcy-006/BAT-007 messaging 핸들러(REQ-021) — buy payload 파싱 변경 대상.
- dcy-004/BAT-006 fill→ledger(REQ-018) — `_record_ledger` skip 가시화 대상.
- dcy-005/BAT-008 SnapshotStore(REQ-027/028) — portfolio_id→symbol resolve 소스.
- REQ-019 ledger outbox 스키마 — REQ-055 anti-join 대상(스키마 변경 없음).
- External:
- PostgreSQL(anti-join 점검). RabbitMQ(buy intent e2e).
## Design Gate
- Design Required Before Implementation: yes
- Reason: (a) buy 계약 hard cutover 는 `BuyIntent` 구조 + admission + 메시지 핸들러 + daemon 배선 + 다수 테스트 표면에 동시 영향 — symbol 서버 resolve 의 주입 경계(`resolve_symbol` 콜백 vs SnapshotStore 직결)와 폴백 규칙을 설계에서 고정해야 회귀를 막는다. (b) reject 정책 분기(portfolio_id_required vs unknown_portfolio)와 universe 부재 판정 경로를 명확히 해야 admission 의미가 일관된다. (c) ledger skip 가시화(REQ-018)와 anti-join 점검(REQ-055)의 책임 분리(런타임 관측 vs 사후 탐지) + OQ-4 결정(outbox 마커 미도입)을 설계에서 못박아야 스키마 오염을 피한다. → design.md 동일 턴 scaffold.
## Milestones
1. M1 — REQ-007 BuyIntent + IntentService resolve_symbol + _admit_buy reject/resolve, intent 단위 green
2. M2 — REQ-021 buy 핸들러 payload portfolio_id 기반, 핸들러 단위 + e2e green
3. M3 — REQ-007 daemon _resolve_symbol 배선
4. M4 — REQ-018 _record_ledger 가시화, caplog 단위 green
5. M5 — REQ-055 find_ledger_gap_slots, PG 게이트 단위 green
6. M6 — FR-6 문서 정합 + 전체 회귀 + ruff + 인프라 게이트
## Risks
- R-1 (High): hard cutover 가 symbol 기반 기존 테스트/호출부 다수를 깬다. 대응: `IntentService(resolve_symbol=None)` 는 `intent.symbol` 폴백 → 비-핸들러 테스트는 portfolio_id 추가만으로 무회귀; 핸들러/e2e 는 resolver + payload portfolio_id 동시 전환. 모든 호출부 식별·전환(NFR-3).
- R-2 (Medium): 매수 시점 universe 부재 시 정상 매수가 unknown_portfolio 로 거부. 대응: 단일 reject 정책(OQ-1) + 진단 reason/로그. 거래일 refresh 정합은 운영 점검(R-4와 연계).
- R-3 (Low, OQ-4): skip 마커 행 미도입 → 영구 탐지는 REQ-055 anti-join 으로 충족. outbox 스키마/dispatcher 무변경.
- R-4 (Medium, issue #11): 잔존/과거 누락 체결 탐지 지연. 대응: skip 가시화(REQ-018) + anti-join 점검(REQ-055). 과거분 복구는 범위 밖.
## Notes
- hard cutover 1차 게이트: 전체 회귀가 green 이어야 호출부 전환 누락이 없음을 보장.
- REQ-055 anti-join 은 PG 게이트(실 PostgreSQL) 통합으로 검증; gap 탐지 + 정상 0건.
- buy intent e2e(RabbitMQ)는 payload portfolio_id + resolver 경로 확인.
@@ -0,0 +1,65 @@
# BAT-015 Verification
- Status: verified
- Batch: BAT-015
## Acceptance Mapping
### REQ-007 (buy admission portfolio_id 필수 + symbol resolve, change-req)
| AC | Evidence |
|----|----------|
| AC-1 idle→pending_buy admit (portfolio_id 기반) | tests/application/test_messaging_intent_consumer.py::test_buy_symbol_resolved_from_universe + test_intent_concurrency.py::test_concurrent_buys_admit_exactly_idle_count |
| AC-2 sell 전이 (회귀 불변) | tests/application/test_execution_ledger.py (sell fill) + 전체 회귀 |
| AC-3 멱등 재전송 | test_intent_concurrency.py::test_duplicate_intent_id_admits_once_under_concurrency |
| AC-4 동시성 단일 writer | test_intent_concurrency.py::test_concurrent_buys_admit_exactly_idle_count |
| AC-5 결정 재조회 | 전체 회귀(IntentService.lookup) |
| portfolio_id_required 거부 (CHG delta) | test_messaging_intent_consumer.py::test_buy_without_portfolio_id_rejected |
| unknown_portfolio 거부 (CHG delta) | test_messaging_intent_consumer.py::test_buy_unknown_portfolio_rejected |
| symbol 서버 resolve (CHG delta) | test_messaging_intent_consumer.py::test_buy_symbol_resolved_from_universe |
### REQ-021 (buy payload symbol 제거, change-req)
| AC | Evidence |
|----|----------|
| buy payload portfolio_id 매핑(symbol 미사용) | test_messaging_intent_consumer.py (3 buy 테스트) |
| RabbitMQ buy intent e2e | tests/adapters/messaging/test_rabbitmq_intent_e2e.py (인프라 게이트) |
### REQ-018 (ledger skip 가시화, change-req)
| AC | Evidence |
|----|----------|
| AC-1 buy fill → 원장 1건 + holding | tests/application/test_execution_ledger.py::test_buy_fill_enqueues_one_ledger_item_and_writer_records_it |
| AC-2 sell fill → 원장 1건 + closed | test_execution_ledger.py::test_sell_fill_enqueues_sell_ledger_item |
| AC-3 FillStatus execution_id/fee | 위 buy 테스트 |
| AC-4 skip 시 전이 정상 + **가시화(WARN+카운터)** | test_execution_ledger.py::test_portfolio_id_absent_skips_ledger_but_transitions_slot + ::test_ledger_skip_emits_warning (caplog) |
| AC-5 단일 기록 주체 | test_domain_import_sweep + execution.py 단일 enqueue 지점 |
### REQ-055 (closed↔outbox anti-join 운영 점검, 신규)
| AC | Evidence |
|----|----------|
| AC-1 portfolio_id NULL closed 포함 (S-4) | tests/adapters/persistence/test_ops_checks.py::test_detects_only_gap_slots (PG 게이트) |
| AC-2 portfolio_id 있으나 outbox 부재 포함 | ::test_detects_only_gap_slots |
| AC-3 정상(outbox 일치) 제외 → 0건 | ::test_no_gaps_when_all_recorded |
| AC-4 비-closed 제외 | ::test_detects_only_gap_slots |
## Evidence
- Test:
- 단위 전체 회귀: `282 passed / 32 skipped`(`::test_sandbox_token_and_submit` deselect — LIVE `.env` sandbox URL 아님, 본 batch 무관).
- 인프라 게이트(PG trex_core_test + RabbitMQ): `314 passed`(PG anti-join 점검 gap 탐지/정상 0건 + buy intent RabbitMQ e2e + 마이그레이션 round-trip 포함).
- `ruff check trex_core tests` → All checks passed.
- Logs: `282 passed, 32 skipped, 1 deselected` / `314 passed, 1 deselected ~15.5s`.
## 성공 기준(dcy-011) 대응
- S-1(portfolio_id 누락 거부 + portfolio_id 만으로 resolve admit): test_buy_without_portfolio_id_rejected + test_buy_symbol_resolved_from_universe.
- S-2(매수 슬롯 portfolio_id 귀속 + outbox/ledger 정합): buy fill 원장 테스트 + e2e.
- S-3(ledger skip 무성 금지, WARN 100%): test_ledger_skip_emits_warning(caplog).
- S-4(점검 쿼리 누락 정확 식별, 정상 0건): test_detects_only_gap_slots + test_no_gaps_when_all_recorded.
- S-5(코드-문서 정합): interface-contract / runtime-flows 갱신(FR-6).
## Carried-Over / 비차단
- 매수 시점 universe(SnapshotStore) portfolio_id 존재 보장은 운영 런타임 데이터(거래일 refresh 정합) — 부재는 `unknown_portfolio` reject 로 안전 처리(R-2).
- REQ-055 점검 쿼리의 알람/주기 실행 배선은 운영 배포 절차(Out of Scope) — 쿼리/함수가 산출물.
- 과거 누락분 데이터 복구는 본 주기 범위 밖(필요 시 별도 운영 이슈, R-4).
## Result
- Ready for Release: true
- Blocking Issues:
- 없음. hard cutover 호출부 전환 누락은 전체 회귀(314 passed) green 으로 부재 확인. LIVE sandbox 토큰 테스트 deselect 는 `.env` 환경 의존(본 batch 무관).
+1
View File
@@ -22,3 +22,4 @@
| BAT-012 | standard | release-candidate | REQ-043, REQ-044, REQ-045, REQ-046, REQ-047 | dcy-008 | [bat-012](./bat-012_20260607_kill-switch-control/) | | BAT-012 | standard | release-candidate | REQ-043, REQ-044, REQ-045, REQ-046, REQ-047 | dcy-008 | [bat-012](./bat-012_20260607_kill-switch-control/) |
| BAT-013 | standard | release-candidate | REQ-048, REQ-049, REQ-050, REQ-051 | dcy-009 | [bat-013](./bat-013_20260607_multi-instance-topology/) | | BAT-013 | standard | release-candidate | REQ-048, REQ-049, REQ-050, REQ-051 | dcy-009 | [bat-013](./bat-013_20260607_multi-instance-topology/) |
| BAT-014 | batch-lite | release-candidate | REQ-052, REQ-053, REQ-054 | dcy-010 | [bat-014](./bat-014_20260608_safety-sell-cooldown/) | | BAT-014 | batch-lite | release-candidate | REQ-052, REQ-053, REQ-054 | dcy-010 | [bat-014](./bat-014_20260608_safety-sell-cooldown/) |
| BAT-015 | standard | release-candidate | REQ-007 (CHG-20260608-01), REQ-018 (CHG-20260608-01), REQ-021 (CHG-20260608-01), REQ-055 | dcy-011 | [bat-015](./bat-015_20260608_buy-intent-portfolio-id-contract/) |
@@ -5,10 +5,10 @@
- 문서 ID: dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve - 문서 ID: dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve
- 이슈명: buy intent — portfolio_id를 필수 키로, symbol은 universe(SnapshotStore)에서 resolve (+ portfolio_id 누락 체결의 ledger silent skip 가시화) - 이슈명: buy intent — portfolio_id를 필수 키로, symbol은 universe(SnapshotStore)에서 resolve (+ portfolio_id 누락 체결의 ledger silent skip 가시화)
- 작성 시각(KST): 2026-06-08 11:08 KST - 작성 시각(KST): 2026-06-08 11:08 KST
- 마지막 갱신 시각(KST): 2026-06-08 12:23 KST - 마지막 갱신 시각(KST): 2026-06-08 14:10 KST
- 대체됨: 없음 - 대체됨: 없음
- 후속 Discovery 참조: 없음 - 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 없음 - 생성된 REQ 참조: REQ-007(change-req CHG-20260608-01), REQ-018(change-req CHG-20260608-01), REQ-021(change-req CHG-20260608-01), REQ-055(신규) — delivery: BAT-015
# 1. 계획 상태 요약 # 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
+5 -3
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft - Status: draft
- Owner: project maintainers - Owner: project maintainers
- Last Updated (KST): 2026-06-07 (BAT-013) - Last Updated (KST): 2026-06-08 (BAT-015)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인) - Primary Runtime: daemon (HTTP API) + library (slot 도메인)
@@ -81,11 +81,13 @@
{ "intent_type": "buy", { "intent_type": "buy",
"intent_id": "<correlation_id 와 동일>", "intent_id": "<correlation_id 와 동일>",
"trading_date": "YYYY-MM-DD", "trading_date": "YYYY-MM-DD",
"symbol": str, "qty": float?, "allocated_capital": float?, "portfolio_id": str, # REQUIRED (dcy-011/REQ-007) — canonical key(asset_id)
"qty": float?, "allocated_capital": float?,
"entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?, "entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?,
"portfolio_id": str?, "forced_close_at": "ISO-8601"?, "forced_close_at": "ISO-8601"?,
"stop_loss_price": number?, "take_profit_price": number? } "stop_loss_price": number?, "take_profit_price": number? }
``` ```
- **`portfolio_id` 필수(dcy-011/REQ-007/021)**: 매수의 정식 식별자. 누락 시 admission 에서 `portfolio_id_required` 로 거부. **`symbol` 은 client payload 에 없다** — core 가 admission 시 universe(`SnapshotStore`)에서 `portfolio_id → symbol` 을 resolve 해 슬롯/KIS 주문에 사용한다. universe 에 portfolio_id 가 없으면 `unknown_portfolio` 로 거부(하위호환·symbol fallback 없음, hard cutover).
- `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성). - `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성).
sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":...}`. sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":...}`.
+4 -2
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft - Status: draft
- Owner: project maintainers - Owner: project maintainers
- Last Updated (KST): 2026-06-08 (BAT-014) - Last Updated (KST): 2026-06-08 (BAT-015)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인) - Primary Runtime: daemon (HTTP API) + library (slot 도메인)
@@ -55,7 +55,7 @@
1. 전략 모듈이 RPC 요청을 `trex.intent.req` 큐로 publish — payload `{intent_type, intent_id, ...}`, properties `correlation_id` = intent 멱등키, `reply_to`. 1. 전략 모듈이 RPC 요청을 `trex.intent.req` 큐로 publish — payload `{intent_type, intent_id, ...}`, properties `correlation_id` = intent 멱등키, `reply_to`.
2. `trex-intent-rpc` 스레드(단일, prefetch=1)가 메시지 수신 → `IntentService.submit_buy/sell` 호출. 2. `trex-intent-rpc` 스레드(단일, prefetch=1)가 메시지 수신 → `IntentService.submit_buy/sell` 호출.
3. `intent_log.try_insert(intent_id, ...)` 가 dedup 게이트 — 중복 메시지는 기존 decision 재반환(슬롯 무전이). 3. `intent_log.try_insert(intent_id, ...)` 가 dedup 게이트 — 중복 메시지는 기존 decision 재반환(슬롯 무전이).
4. 신규면 admission: `find_idle_slot` → `SlotService.transit_slot(idle→pending_buy)` → Postgres durable commit. 4. 신규면 admission: **portfolio_id 필수 검사**(누락 시 `portfolio_id_required` 거부) → **symbol resolve**(`SnapshotStore.get(portfolio_id).symbol`, universe 부재 시 `unknown_portfolio` 거부, dcy-011/REQ-007) → `find_idle_slot` → `SlotService.transit_slot(idle→pending_buy, symbol=resolved, portfolio_id)` → Postgres durable commit. (buy payload 에 client symbol 없음)
5. admission 응답(`status, slot_id?, reason?`)을 `reply_to` 큐로 publish → manual ack. 5. admission 응답(`status, slot_id?, reason?`)을 `reply_to` 큐로 publish → manual ack.
6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다. 6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다.
7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱. 7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱.
@@ -197,6 +197,8 @@
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3). 집행 루프 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`)로 영구 탐지한다.
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`(BrokerPort `calculate_fee(side, amount)`), `tax=0` 을 운반. 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`(BrokerPort `calculate_fee(side, amount)`), `tax=0` 을 운반.
2. 슬롯 전이: `pending_buy→holding`(avg_price/qty) 또는 `pending_sell→closed` 를 advisory lock 안에서 먼저 commit. 이 단계가 critical path. 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 결정) 가 있으면 `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 후 슬롯 전이는 정상 유지.
@@ -0,0 +1,87 @@
# req-055: closed 슬롯 ↔ ledger_outbox 누락 운영 점검 쿼리
- Status: Implemented
- Type: Exception
- Priority: Medium
- Owner: project maintainers
## Intent
portfolio_id 없는 체결의 ledger silent skip(issue #11)으로 발생하는 회계 누락을 운영자가 탐지·집계할 수 있도록, `closed` 슬롯 중 대응 `ledger_outbox` 행이 없는 케이스를 anti-join 으로 식별하는 읽기 전용 운영 점검을 제공한다(dcy-011 FR-5). `daily_slot` 이 durable 하므로 outbox 마커 행 없이도 영구 탐지가 성립한다(dcy-010 OQ-4 위임).
## Requirement
- `find_ledger_gap_slots(dsn, *, trading_date=None)` 읽기 전용 함수를 제공한다(`trex_core/adapters/persistence/ops_checks.py`).
- gap 정의: `status='closed'` 슬롯 중 `portfolio_id IS NULL` 이거나 `ledger_outbox` 에 `asset_id = slot.portfolio_id` 행이 없는 슬롯.
- 결과는 `slot_id`/`trading_date`/`symbol`/`portfolio_id`/`broker_order_id` 를 포함한다. `trading_date` 인자로 일자 범위를 한정할 수 있다(미지정 시 전체).
- 스키마 변경 없음(읽기 전용 SELECT). 주기/온디맨드 실행으로 누락을 탐지·집계할 수 있어야 한다.
## Acceptance Criteria
1. Given `closed` 슬롯 중 `portfolio_id IS NULL` 인 슬롯, When `find_ledger_gap_slots`, Then 그 슬롯이 결과에 포함된다(S-4).
2. Given `closed` 슬롯 중 portfolio_id 는 있으나 대응 outbox 행이 없는 슬롯, When 호출, Then 결과에 포함된다.
3. Given `closed` 슬롯 + 대응 `ledger_outbox` 행(asset_id 일치)이 있는 슬롯, When 호출, Then 결과에 포함되지 않는다(정상 케이스 0건).
4. Given `holding` 등 비-closed 슬롯, When 호출, Then 결과에서 제외된다(closed 한정).
## Impacted Area
- Docs: `docs/runtime-flows.md`(ledger skip 가시화 + 점검 쿼리 참조)
- Modules: `trex_core/adapters/persistence/ops_checks.py`(신규, 읽기 전용)
- Tests: `tests/adapters/persistence/test_ops_checks.py`(PG 게이트)
## Notes
- OQ-4 결정: outbox 마커 행 미도입 → slots↔outbox anti-join 으로 영구 탐지(REQ-019 outbox 스키마 변경 없음).
- 알람/주기 실행 연결은 운영 배포 절차로 다룬다(쿼리/함수가 산출물).
## Change Log
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- dcy-011(confirmed) → REQ-055 생성(draft-req, Proposed). issue #11 제안 ③: closed↔outbox 대조 운영 점검(FR-5). 기존 REQ 가 다루지 않는 신규 기능이라 신규 REQ(OQ-3 결정).
#### Intent
- ledger 누락 영구 탐지 수단을 운영자에게 제공.
#### Acceptance Criteria Delta
- Added: AC-1~4 전체
#### Delivery Trace Delta
- Batches: BAT-015
- Releases: none
#### Revalidation Impact
- Reverification Needed: no
- Status Recommendation: keep-current
### CHG-20260608-02
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- Proposed → Approved (confirm-req). 필수 필드·구체 AC·dcy-011 FR-5 정합 확인.
### CHG-20260608-03
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- Approved → Implemented (confirm-req-implemented, 근거 BAT-015 release-candidate). `find_ledger_gap_slots` 구현 + PG 게이트 단위(gap 탐지/정상 0건) green. 전체 인프라 게이트 314 passed.
#### Delivery Trace Delta
- Batches: BAT-015 (release-candidate)
- Releases: none
@@ -149,3 +149,43 @@
- AC-3: 위 buy 테스트가 FillStatus.execution_id/fee/tax 운반 직접 검증. - AC-3: 위 buy 테스트가 FillStatus.execution_id/fee/tax 운반 직접 검증.
- AC-4: tests/application/test_execution_ledger.py::test_portfolio_id_absent_skips_ledger_but_transitions_slot. - AC-4: tests/application/test_execution_ledger.py::test_portfolio_id_absent_skips_ledger_but_transitions_slot.
- AC-5: tests/domain/test_domain_import_sweep.py + grep 결과(trex_core/application/execution.py 단일 enqueue 지점). - AC-5: tests/domain/test_domain_import_sweep.py + grep 결과(trex_core/application/execution.py 단일 enqueue 지점).
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-011/issue #11) `_record_ledger` 의 ledger skip 을 무성에서 **가시화**로 전환: portfolio_id/execution_id 누락 skip 시 WARN 로그 + `ExecutionResult.ledger_skipped` 카운터 노출(슬롯 전이는 그대로). BAT-015 재구현·재검증 완료 → Implemented 유지.
#### Intent
- portfolio_id 없는 체결의 ledger silent skip(회계 누락)을 운영자가 탐지 가능하게(issue #11 제안 ②). OQ-4 결정으로 outbox 마커 행은 도입하지 않고 로그+메트릭만; 영구 탐지는 REQ-055 점검 쿼리에 위임.
#### Acceptance Criteria Delta
- Updated:
- AC-4: skip 시 무성 반환 → WARN 로그 + 카운터 가시화(두 skip 경로 모두).
#### Impacted Area Delta
- Modules: `trex_core/application/execution.py`(`_record_ledger` 로그 + `_log` 추가)
- Docs: `docs/runtime-flows.md`
- Tests: tests/application/test_execution_ledger.py::test_ledger_skip_emits_warning
#### Delivery Trace Delta
- Batches: BAT-015
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-015 에서 완료)
- Existing Implementation Invalidated: no (관측만 추가, 전이 동작 불변)
- Status Recommendation: Implemented (change-req → BAT-015 재검증)
- Follow-up Action: confirm-req-implemented(BAT-015).
#### Validation Plan
- BAT-015: ledger skip WARN 로그 단위(caplog) + 회귀.
@@ -142,3 +142,43 @@
- AC-3: tests/application/test_messaging_intent_consumer.py + test_messaging_event_publisher.py 조합. - AC-3: tests/application/test_messaging_intent_consumer.py + test_messaging_event_publisher.py 조합.
- AC-4: tests/application/test_messaging_event_publisher.py::test_event_for_strategy_a_does_not_reach_strategy_b_queue + tests/adapters/messaging/test_rabbitmq_transport.py::test_event_publish_and_per_strategy_consume. - AC-4: tests/application/test_messaging_event_publisher.py::test_event_for_strategy_a_does_not_reach_strategy_b_queue + tests/adapters/messaging/test_rabbitmq_transport.py::test_event_publish_and_per_strategy_consume.
- AC-5: tests/adapters/messaging/test_topology.py. - AC-5: tests/adapters/messaging/test_topology.py.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-011) buy intent **메시지 계약**에서 `symbol` 필드 제거, `portfolio_id` 필수화. buy 핸들러는 payload 에서 symbol 을 읽지 않고 portfolio_id 만 매핑(서버 resolve 는 REQ-007). BAT-015 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 요청 매핑 원칙(payload 식별자)을 정식 식별자(portfolio_id) 기준으로 정정(issue #6). buy 핸들러 payload 파싱 변경.
#### Acceptance Criteria Delta
- Updated:
- buy payload 계약: `symbol` 제거, `portfolio_id` 필수.
#### Impacted Area Delta
- Modules: `trex_core/application/messaging_handlers.py`(buy 핸들러)
- Docs: `docs/interface-contract.md`
- Tests: tests/application/test_messaging_intent_consumer.py + tests/adapters/messaging/test_rabbitmq_intent_e2e.py
#### Delivery Trace Delta
- Batches: BAT-015
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-015 에서 완료)
- Existing Implementation Invalidated: yes (symbol 기반 buy payload)
- Status Recommendation: Implemented (change-req → BAT-015 재검증)
- Follow-up Action: confirm-req-implemented(BAT-015).
#### Validation Plan
- BAT-015: buy payload portfolio_id 기반 핸들러 단위 + RabbitMQ e2e.
@@ -153,3 +153,45 @@
#### Validation Plan #### Validation Plan
- BAT-002 verification evidence 재참조로 충분. - BAT-002 verification evidence 재참조로 충분.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-011) buy intent admission 계약 변경: `portfolio_id` 필수화 + admission 시 universe(`SnapshotStore`) 에서 `symbol` resolve + universe 부재 시 `unknown_portfolio` 거부. 기존 symbol-필수/portfolio_id-optional 폐기(hard cutover, 하위호환 없음). BAT-015 에서 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 정식 식별자(asset_id=portfolio_id) 기반 원장 귀속·safety 보호 범위 정합(issue #6). `_admit_buy` 에 portfolio_id_required/unknown_portfolio reject + symbol resolve(`resolve_symbol` 주입) 추가.
#### Acceptance Criteria Delta
- Added:
- buy 는 portfolio_id 필수(누락 시 `portfolio_id_required`), symbol 은 서버 resolve(`SnapshotStore`), universe 부재 시 `unknown_portfolio` 거부.
- Updated:
- 기존 admission AC 의 symbol 입력 전제 → portfolio_id 기반으로 정정.
#### Impacted Area Delta
- Modules: `trex_core/application/intents.py`(BuyIntent portfolio_id 필수+symbol resolve, IntentService.resolve_symbol), `messaging_handlers.py`(buy payload), `runtime/daemon.py`(resolver 배선)
- Docs: `docs/interface-contract.md`, `docs/runtime-flows.md`
- Tests: intent/messaging buy 테스트 portfolio_id 기반 전환
#### Delivery Trace Delta
- Batches: BAT-015
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-015 에서 완료)
- Existing Implementation Invalidated: yes (symbol-필수 admission 경로)
- Status Recommendation: Implemented (change-req → BAT-015 재구현·재검증)
- Follow-up Action: confirm-req-implemented(BAT-015) 로 Implemented 동기화.
#### Validation Plan
- BAT-015 verification: portfolio_id_required/unknown_portfolio/symbol resolve 단위 + 전체 회귀(314 passed).
+3 -2
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-055` - Current: `REQ-056`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## Requirement Register
@@ -135,11 +135,12 @@
| REQ-052 | safety-sell per-slot 재시도 쿨다운 + 영속 거부 경고 | Integration | Implemented | High | project maintainers | [req-052](./Integration/req-052_safety-sell-per-slot-cooldown.md) | | REQ-052 | safety-sell per-slot 재시도 쿨다운 + 영속 거부 경고 | Integration | Implemented | High | project maintainers | [req-052](./Integration/req-052_safety-sell-per-slot-cooldown.md) |
| REQ-053 | safety-sell 재시도 쿨다운 env 설정 2종 | Configuration | Implemented | Medium | project maintainers | [req-053](./Configuration/req-053_safety-sell-retry-cooldown-env.md) | | REQ-053 | safety-sell 재시도 쿨다운 env 설정 2종 | Configuration | Implemented | Medium | project maintainers | [req-053](./Configuration/req-053_safety-sell-retry-cooldown-env.md) |
| REQ-054 | safety-sell 쿨다운 runtime-flows 정합 | Documentation | Implemented | Low | project maintainers | [req-054](./Documentation/req-054_safety-sell-cooldown-baseline-alignment.md) | | REQ-054 | safety-sell 쿨다운 runtime-flows 정합 | Documentation | Implemented | Low | project maintainers | [req-054](./Documentation/req-054_safety-sell-cooldown-baseline-alignment.md) |
| REQ-055 | closed 슬롯 ↔ ledger_outbox 누락 운영 점검 쿼리 | Exception | Implemented | Medium | project maintainers | [req-055](./Exception/req-055_ledger-gap-ops-check.md) |
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-06-08 :: REQ-007/018/021 change-req + REQ-055 생성·Implemented (dcy-011, BAT-015): buy intent 계약 hard cutover — portfolio_id 필수화 + symbol 서버 resolve(SnapshotStore) + unknown_portfolio/portfolio_id_required 거부(REQ-007), buy payload symbol 제거(REQ-021), ledger skip 가시화(WARN+ledger_skipped 카운터, REQ-018), closed↔outbox anti-join 운영 점검 `find_ledger_gap_slots` 신규(REQ-055). 전체 인프라 게이트 314 passed, ruff clean. (change-req/draft-req/confirm-req-implemented)
- 2026-06-08 :: REQ-052~054 Approved → Implemented (confirm-req-implemented): BAT-014(batch-lite) verify 완료(신규 8 단위, 전체 278 passed, ruff clean). SafetySellService per-slot 쿨다운(skip/만료재시도/경고/prune, market_close 제외), SafetySellConfig env 2종(min_retry_interval_sec 600/max_retry_warn_count 3), runtime-flows 정합. (confirm-req-implemented) - 2026-06-08 :: REQ-052~054 Approved → Implemented (confirm-req-implemented): BAT-014(batch-lite) verify 완료(신규 8 단위, 전체 278 passed, ruff clean). SafetySellService per-slot 쿨다운(skip/만료재시도/경고/prune, market_close 제외), SafetySellConfig env 2종(min_retry_interval_sec 600/max_retry_warn_count 3), runtime-flows 정합. (confirm-req-implemented)
- 2026-06-08 :: REQ-052~054 Proposed → Approved (confirm-req): dcy-010 safety-sell 쿨다운 3개 전원 승인 게이트 통과. dcy-010(confirmed) FR/OQ 와 모순 없음, 필수 필드·구체 AC 완비. 피드백 채널·DB 영속은 Out of Scope 비핵심. (confirm-req) - 2026-06-08 :: REQ-052~054 Proposed → Approved (confirm-req): dcy-010 safety-sell 쿨다운 3개 전원 승인 게이트 통과. dcy-010(confirmed) FR/OQ 와 모순 없음, 필수 필드·구체 AC 완비. 피드백 채널·DB 영속은 Out of Scope 비핵심. (confirm-req)
- 2026-06-08 :: REQ-052~054 생성 (draft-req): dcy-010(safety-sell 영속 거부 재시도 백오프, confirmed) → 3개 REQ 초안. per-slot 쿨다운+경고(052), 쿨다운 env 2종(053), runtime-flows 정합(054). OQ-1(600s)/OQ-2(3회 경고)/OQ-3(단일 per-slot) Answered. (draft-req) - 2026-06-08 :: REQ-052~054 생성 (draft-req): dcy-010(safety-sell 영속 거부 재시도 백오프, confirmed) → 3개 REQ 초안. per-slot 쿨다운+경고(052), 쿨다운 env 2종(053), runtime-flows 정합(054). OQ-1(600s)/OQ-2(3회 경고)/OQ-3(단일 per-slot) Answered. (draft-req)
- 2026-06-07 :: REQ-048~051 Approved → Implemented (confirm-req-implemented): BAT-013 verify 완료(신규 9 단위 + RabbitMQ 라우팅 2, 단위 271 passed, 인프라 게이트 301 passed(directed 격리 S-2 + broadcast dual S-3 + default 무회귀 S-4), ruff clean). per-instance TOML 로더+`--config`, instance_key 동적 큐명(default 리터럴 하위호환), trex.direct/trex.broadcast exchange+바인딩, baseline 3문서 정합. (confirm-req-implemented) - 2026-06-07 :: REQ-048~051 Approved → Implemented (confirm-req-implemented): BAT-013 verify 완료(신규 9 단위 + RabbitMQ 라우팅 2, 단위 271 passed, 인프라 게이트 301 passed(directed 격리 S-2 + broadcast dual S-3 + default 무회귀 S-4), ruff clean). per-instance TOML 로더+`--config`, instance_key 동적 큐명(default 리터럴 하위호환), trex.direct/trex.broadcast exchange+바인딩, baseline 3문서 정합. (confirm-req-implemented)
- 2026-06-07 :: REQ-048~051 Proposed → Approved (confirm-req): dcy-009 multi-instance 4개 전원 승인 게이트 통과. dcy-009(confirmed) FR/OQ 와 모순 없음, 필수 필드·구체 AC 완비. strategy publish 변경·TOML 추가필드는 비핵심 후속. (confirm-req)
@@ -52,7 +52,8 @@ def test_intent_admission_rpc_dedups_duplicate_correlation_id_over_rabbitmq(mq_u
"""REQ-023 AC-1: same correlation_id processed twice ⇒ slot transitions ONCE.""" """REQ-023 AC-1: same correlation_id processed twice ⇒ slot transitions ONCE."""
repo = InMemorySlotRepository() repo = InMemorySlotRepository()
svc = SlotService(repo) svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore()) intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930") # dcy-011: universe resolve
svc.preallocate_daily_slots(DAY, 3) svc.preallocate_daily_slots(DAY, 3)
handler = make_intent_rpc_handler(intents) handler = make_intent_rpc_handler(intents)
@@ -77,7 +78,7 @@ def test_intent_admission_rpc_dedups_duplicate_correlation_id_over_rabbitmq(mq_u
cid = "buy-dup-rmq" cid = "buy-dup-rmq"
payload = { payload = {
"intent_type": "buy", "intent_id": cid, "intent_type": "buy", "intent_id": cid,
"trading_date": DAY.isoformat(), "symbol": "005930", "qty": 10, "trading_date": DAY.isoformat(), "portfolio_id": "PF-1", "qty": 10,
} }
r1 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0) r1 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
r2 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0) r2 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
@@ -0,0 +1,59 @@
"""Ledger-gap operational check tests (REQ-055 / dcy-011 FR-5, issue #11).
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). Inserts closed slots + outbox rows
directly and asserts find_ledger_gap_slots detects only the gaps.
"""
from __future__ import annotations
import uuid
from datetime import date
from trex_core.adapters.persistence.ops_checks import find_ledger_gap_slots
_DAY = date(2026, 6, 8)
def _insert_slot(conn, *, status, portfolio_id, symbol="005930"):
sid = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute(
"INSERT INTO daily_slot (slot_id, trading_date, status, symbol, portfolio_id) "
"VALUES (%s, %s, %s, %s, %s)",
(sid, _DAY, status, symbol, portfolio_id),
)
return sid
def _insert_outbox(conn, *, asset_id, order_id="O1", execution_id="E1"):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO ledger_outbox (correlation_id, asset_id, order_id, execution_id, "
"side, qty, price) VALUES (%s, %s, %s, %s, 'buy', 1, 1000)",
(f"{asset_id}:{order_id}:{execution_id}", asset_id, order_id, execution_id),
)
def test_detects_only_gap_slots(pg_clean):
import psycopg
dsn = pg_clean
with psycopg.connect(dsn, autocommit=True) as conn:
gap_null = _insert_slot(conn, status="closed", portfolio_id=None) # gap (no pf)
gap_no_outbox = _insert_slot(conn, status="closed", portfolio_id="PF-GAP") # gap (no outbox row)
ok = _insert_slot(conn, status="closed", portfolio_id="PF-OK") # not a gap
_insert_outbox(conn, asset_id="PF-OK")
_insert_slot(conn, status="holding", portfolio_id=None) # not closed → ignored
gaps = find_ledger_gap_slots(dsn, trading_date=_DAY)
gap_ids = {str(g["slot_id"]) for g in gaps}
assert gap_null in gap_ids
assert gap_no_outbox in gap_ids
assert ok not in gap_ids
def test_no_gaps_when_all_recorded(pg_clean):
import psycopg
dsn = pg_clean
with psycopg.connect(dsn, autocommit=True) as conn:
_insert_slot(conn, status="closed", portfolio_id="PF-1")
_insert_outbox(conn, asset_id="PF-1")
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
@@ -32,7 +32,7 @@ def test_preallocate_and_capacity(pg_clean):
def test_buy_intent_persists_transition(pg_clean): def test_buy_intent_persists_transition(pg_clean):
repo, svc, intents = _wire(pg_clean) repo, svc, intents = _wire(pg_clean)
svc.preallocate_daily_slots(DAY, 3) svc.preallocate_daily_slots(DAY, 3)
d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", trading_date=DAY, symbol="005930", qty=10)) d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", trading_date=DAY, symbol="005930", qty=10, portfolio_id="PF-1"))
assert d.admitted assert d.admitted
slot = repo.fetch_slot(d.slot_id) slot = repo.fetch_slot(d.slot_id)
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930" assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
@@ -41,8 +41,8 @@ def test_buy_intent_persists_transition(pg_clean):
def test_buy_intent_idempotent_on_pg(pg_clean): def test_buy_intent_idempotent_on_pg(pg_clean):
repo, svc, intents = _wire(pg_clean) repo, svc, intents = _wire(pg_clean)
svc.preallocate_daily_slots(DAY, 3) svc.preallocate_daily_slots(DAY, 3)
d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930")) d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930")) d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
assert d1.slot_id == d2.slot_id and d2.status == "admitted" assert d1.slot_id == d2.slot_id and d2.status == "admitted"
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY] pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1 # no double transition assert len(pending) == 1 # no double transition
@@ -51,7 +51,7 @@ def test_buy_intent_idempotent_on_pg(pg_clean):
def test_full_lifecycle_recovery_bumps_entry_count(pg_clean): def test_full_lifecycle_recovery_bumps_entry_count(pg_clean):
repo, svc, intents = _wire(pg_clean) repo, svc, intents = _wire(pg_clean)
svc.preallocate_daily_slots(DAY, 1) svc.preallocate_daily_slots(DAY, 1)
d = intents.submit_buy(BuyIntent(intent_id="pg-life", trading_date=DAY, symbol="005930")) d = intents.submit_buy(BuyIntent(intent_id="pg-life", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
sid = d.slot_id sid = d.slot_id
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t") svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
intents.submit_sell(SellIntent(intent_id="pg-sell", slot_id=sid)) intents.submit_sell(SellIntent(intent_id="pg-sell", slot_id=sid))
+1 -1
View File
@@ -26,7 +26,7 @@ def _wire(broker):
def _buy(intents, iid="b1", symbol="005930", qty=10): def _buy(intents, iid="b1", symbol="005930", qty=10):
return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000)) return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000, portfolio_id="PF-1"))
def test_buy_lifecycle_to_holding(): def test_buy_lifecycle_to_holding():
+22 -1
View File
@@ -80,7 +80,13 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
ledger = InMemoryLedger() ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0), repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger) ledger=ledger)
sid = _buy(intents, iid="no-pf", portfolio_id=None).slot_id # 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).
idle = repo.find_idle_slot(DAY)
svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930", portfolio_id=None)
sid = idle.slot_id
r = execution.run_once() r = execution.run_once()
assert r.filled == 1 and r.ledger_enqueued == 0 and r.ledger_skipped == 1 assert r.filled == 1 and r.ledger_enqueued == 0 and r.ledger_skipped == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING assert repo.fetch_slot(sid).status is SlotState.HOLDING
@@ -88,6 +94,21 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
assert ledger.count() == 0 assert ledger.count() == 0
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)
idle = repo.find_idle_slot(DAY)
svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930", portfolio_id=None)
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
msgs = [r.getMessage() for r in caplog.records]
assert any("ledger skip" in m and "missing_portfolio_id" in m for m in msgs)
def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger(): def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger():
"""REQ-019 AC-1: same execution_id processed twice = 1 ledger entry.""" """REQ-019 AC-1: same execution_id processed twice = 1 ledger entry."""
ledger = InMemoryLedger() ledger = InMemoryLedger()
+2 -2
View File
@@ -26,7 +26,7 @@ def test_concurrent_buys_admit_exactly_idle_count():
results_lock = threading.Lock() results_lock = threading.Lock()
def worker(i: int) -> None: def worker(i: int) -> None:
d = intents.submit_buy(BuyIntent(intent_id=f"buy-{i}", trading_date=DAY, symbol="005930", qty=1)) d = intents.submit_buy(BuyIntent(intent_id=f"buy-{i}", trading_date=DAY, symbol="005930", qty=1, portfolio_id=f"PF-{i}"))
with results_lock: with results_lock:
results.append(d.status) results.append(d.status)
@@ -56,7 +56,7 @@ def test_duplicate_intent_id_admits_once_under_concurrency():
lock = threading.Lock() lock = threading.Lock()
def worker() -> None: def worker() -> None:
d = intents.submit_buy(BuyIntent(intent_id="same-id", trading_date=DAY, symbol="005930")) d = intents.submit_buy(BuyIntent(intent_id="same-id", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
with lock: with lock:
statuses.append(d.status) statuses.append(d.status)
@@ -22,18 +22,21 @@ DAY = date(2026, 6, 5)
def _wire(): def _wire():
repo = InMemorySlotRepository() repo = InMemorySlotRepository()
svc = SlotService(repo) svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore()) # dcy-011 / REQ-007: buy resolves symbol from the universe via resolver.
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
svc.preallocate_daily_slots(DAY, 3) svc.preallocate_daily_slots(DAY, 3)
return repo, svc, intents return repo, svc, intents
def _buy_req(intent_id: str, symbol: str = "005930") -> RpcRequest: def _buy_req(intent_id: str, portfolio_id: str = "PF-1") -> RpcRequest:
# dcy-011 / REQ-021: buy payload carries portfolio_id (required); no symbol.
return RpcRequest( return RpcRequest(
payload={ payload={
"intent_type": "buy", "intent_type": "buy",
"intent_id": intent_id, "intent_id": intent_id,
"trading_date": DAY.isoformat(), "trading_date": DAY.isoformat(),
"symbol": symbol, "portfolio_id": portfolio_id,
"qty": 10, "qty": 10,
}, },
correlation_id=intent_id, correlation_id=intent_id,
@@ -119,7 +122,7 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
payload={ payload={
"intent_type": "buy", "intent_type": "buy",
"trading_date": DAY.isoformat(), "trading_date": DAY.isoformat(),
"symbol": "005930", "portfolio_id": "PF-1",
"qty": 5, "qty": 5,
}, },
correlation_id="cid-only", correlation_id="cid-only",
@@ -130,3 +133,43 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
assert r1.payload == r2.payload assert r1.payload == r2.payload
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY] pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1 assert len(pending) == 1
# ---- dcy-011 / REQ-007/021: portfolio_id required + symbol resolve ----------
def test_buy_without_portfolio_id_rejected():
"""FR-1 / S-1: missing portfolio_id → portfolio_id_required reject."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
req = RpcRequest(
payload={"intent_type": "buy", "intent_id": "no-pf",
"trading_date": DAY.isoformat(), "qty": 5},
correlation_id="no-pf",
)
resp = handler(req)
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "portfolio_id_required"
def test_buy_unknown_portfolio_rejected():
"""FR-3 / S-1: portfolio_id absent from universe → unknown_portfolio reject."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: None) # universe empty
svc.preallocate_daily_slots(DAY, 3)
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req("u1", portfolio_id="PF-X"))
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "unknown_portfolio"
def test_buy_symbol_resolved_from_universe():
"""FR-2 / S-1: symbol is resolved server-side from portfolio_id (no client symbol)."""
repo, _, intents = _wire() # resolver → "005930"
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req("r1", portfolio_id="PF-9"))
assert resp.payload["status"] == "admitted"
from uuid import UUID
slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
assert slot.symbol == "005930" and slot.portfolio_id == "PF-9"
@@ -36,7 +36,7 @@ def test_snapshot_returns_all_slots_and_capacity():
repo, svc, intents = _wire() repo, svc, intents = _wire()
intents.submit_buy(BuyIntent( intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
strategy_id="A", strategy_id="A", portfolio_id="PF-1",
)) ))
handler = make_snapshot_rpc_handler(repo, svc) handler = make_snapshot_rpc_handler(repo, svc)
resp = handler(_req()) resp = handler(_req())
@@ -51,6 +51,7 @@ def test_snapshot_open_view_filters_to_active_slots():
repo, svc, intents = _wire() repo, svc, intents = _wire()
intents.submit_buy(BuyIntent( intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A", intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
portfolio_id="PF-1",
)) ))
handler = make_snapshot_rpc_handler(repo, svc) handler = make_snapshot_rpc_handler(repo, svc)
resp = handler(_req(view="open")) resp = handler(_req(view="open"))
@@ -63,9 +64,11 @@ def test_snapshot_strategy_id_filter():
repo, svc, intents = _wire() repo, svc, intents = _wire()
intents.submit_buy(BuyIntent( intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A", intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
portfolio_id="PF-1",
)) ))
intents.submit_buy(BuyIntent( intents.submit_buy(BuyIntent(
intent_id="b2", trading_date=DAY, symbol="000660", qty=5, strategy_id="B", intent_id="b2", trading_date=DAY, symbol="000660", qty=5, strategy_id="B",
portfolio_id="PF-2",
)) ))
handler = make_snapshot_rpc_handler(repo, svc) handler = make_snapshot_rpc_handler(repo, svc)
resp = handler(_req(view="open", strategy_id="A")) resp = handler(_req(view="open", strategy_id="A"))
@@ -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()]
+16 -2
View File
@@ -18,6 +18,7 @@ single producer for ledger work items (NFR-3, single record subject).
""" """
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
@@ -27,6 +28,8 @@ from trex_core.application.ledger_outbox import LedgerOutbox
from trex_core.application.ledger_port import LedgerTrade 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
_log = logging.getLogger(__name__)
# REQ-021/023: event emission hook. Runtime wires a callable that publishes # REQ-021/023: event emission hook. Runtime wires a callable that publishes
# slot transition events through MessagingTransport. Application layer stays # slot transition events through MessagingTransport. Application layer stays
# transport-agnostic (NFR-1). Slot type is duck-typed to keep the application # transport-agnostic (NFR-1). Slot type is duck-typed to keep the application
@@ -155,12 +158,23 @@ class ExecutionService:
if self._ledger_outbox is None: if self._ledger_outbox is None:
return return
if not slot.portfolio_id: if not slot.portfolio_id:
# REQ-018 AC-4: slot transition still proceeded; record skip for ops. # dcy-011 / REQ-018 (issue #11): ledger skip must NOT be silent.
# Slot transition still proceeded; surface for ops (FR-4).
r.ledger_skipped += 1 r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s symbol=%s side=%s reason=missing_portfolio_id "
"(trade NOT recorded to portfolio-hub)",
slot.slot_id, slot.symbol, side,
)
return return
if fs.execution_id is None: if fs.execution_id is None:
# Broker contract regression — log via counter; slot already transitioned. # Broker contract regression — slot already transitioned; surface for ops.
r.ledger_skipped += 1 r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s portfolio_id=%s side=%s reason=missing_execution_id "
"(trade NOT recorded to portfolio-hub)",
slot.slot_id, slot.portfolio_id, side,
)
return return
price = float(fs.avg_price) if fs.avg_price is not None else 0.0 price = float(fs.avg_price) if fs.avg_price is not None else 0.0
trade = LedgerTrade( trade = LedgerTrade(
+25 -4
View File
@@ -45,15 +45,19 @@ class IntentDecision:
@dataclass(frozen=True) @dataclass(frozen=True)
class BuyIntent: class BuyIntent:
# dcy-011 / REQ-007: portfolio_id (= asset_id) is the required canonical key.
# symbol is no longer a client input — it is resolved server-side from the
# universe (SnapshotStore) at admission. It remains here as the resolved
# value carried onto the slot.
intent_id: str intent_id: str
trading_date: date trading_date: date
symbol: str portfolio_id: str
symbol: Optional[str] = None
qty: Optional[float] = None qty: Optional[float] = None
allocated_capital: Optional[float] = None allocated_capital: Optional[float] = None
entry_price: Optional[float] = None entry_price: Optional[float] = None
strategy_id: Optional[str] = None strategy_id: Optional[str] = None
sell_strategy_id: Optional[str] = None sell_strategy_id: Optional[str] = None
portfolio_id: Optional[str] = None
forced_close_at: Optional[datetime] = None forced_close_at: Optional[datetime] = None
stop_loss_price: Optional[Decimal] = None # REQ-035 stop_loss_price: Optional[Decimal] = None # REQ-035
take_profit_price: Optional[Decimal] = None # REQ-035 take_profit_price: Optional[Decimal] = None # REQ-035
@@ -77,10 +81,15 @@ class IntentService:
slot_service: SlotService, slot_service: SlotService,
slot_repo: SlotRepository, slot_repo: SlotRepository,
intent_store: IntentStore, intent_store: IntentStore,
resolve_symbol=None,
) -> None: ) -> None:
self._svc = slot_service self._svc = slot_service
self._repo = slot_repo self._repo = slot_repo
self._intents = intent_store self._intents = intent_store
# dcy-011 / REQ-007: portfolio_id -> symbol resolver (e.g. SnapshotStore-backed).
# callable(portfolio_id) -> Optional[str]. When set, admission resolves symbol
# from the universe and rejects unknown_portfolio if absent.
self._resolve_symbol = resolve_symbol
# ------------------------------------------------------------ # ------------------------------------------------------------
def lookup(self, intent_id: str) -> Optional[IntentDecision]: def lookup(self, intent_id: str) -> Optional[IntentDecision]:
@@ -139,6 +148,18 @@ class IntentService:
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason) return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
def _admit_buy(self, intent: BuyIntent) -> IntentDecision: def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
# dcy-011 / REQ-007: portfolio_id is required; symbol is resolved from the
# universe (no client symbol). FR-1/FR-2/FR-3.
if not intent.portfolio_id:
return IntentDecision(intent.intent_id, "rejected", None, "portfolio_id_required")
symbol = intent.symbol
if self._resolve_symbol is not None:
symbol = self._resolve_symbol(intent.portfolio_id)
if not symbol:
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
elif not symbol:
# No resolver wired and no symbol carried — cannot determine the order target.
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
# Retry on the last-idle-slot race: another intent may claim the slot # Retry on the last-idle-slot race: another intent may claim the slot
# between our find and transit (SlotStateMismatchError). # between our find and transit (SlotStateMismatchError).
for _ in range(_MAX_IDLE_RETRY): for _ in range(_MAX_IDLE_RETRY):
@@ -152,7 +173,7 @@ class IntentService:
SlotState.PENDING_BUY, SlotState.PENDING_BUY,
reason="buy_intent", reason="buy_intent",
actor="intent_api", actor="intent_api",
symbol=intent.symbol, symbol=symbol,
qty=intent.qty, qty=intent.qty,
allocated_capital=intent.allocated_capital, allocated_capital=intent.allocated_capital,
entry_price=intent.entry_price, entry_price=intent.entry_price,
@@ -162,7 +183,7 @@ class IntentService:
forced_close_at=intent.forced_close_at, forced_close_at=intent.forced_close_at,
stop_loss_price=intent.stop_loss_price, stop_loss_price=intent.stop_loss_price,
take_profit_price=intent.take_profit_price, take_profit_price=intent.take_profit_price,
extra={"intent_id": intent.intent_id, "symbol": intent.symbol}, extra={"intent_id": intent.intent_id, "symbol": symbol},
) )
except SlotStateMismatchError: except SlotStateMismatchError:
continue # slot was claimed concurrently; try another idle slot continue # slot was claimed concurrently; try another idle slot
+4 -2
View File
@@ -71,16 +71,18 @@ def make_intent_rpc_handler(
intent_type = str(body.get("intent_type") or "").lower() intent_type = str(body.get("intent_type") or "").lower()
try: try:
if intent_type == "buy": if intent_type == "buy":
# dcy-011 / REQ-021: buy payload carries portfolio_id (required);
# symbol is NOT a client field — admission resolves it from the
# universe. portfolio_id absence is rejected at admission (FR-1).
buy = BuyIntent( buy = BuyIntent(
intent_id=intent_id, intent_id=intent_id,
trading_date=_parse_date(body.get("trading_date")), trading_date=_parse_date(body.get("trading_date")),
symbol=str(body.get("symbol") or ""), portfolio_id=_opt_str(body.get("portfolio_id")) or "",
qty=_opt_float(body.get("qty")), qty=_opt_float(body.get("qty")),
allocated_capital=_opt_float(body.get("allocated_capital")), allocated_capital=_opt_float(body.get("allocated_capital")),
entry_price=_opt_float(body.get("entry_price")), entry_price=_opt_float(body.get("entry_price")),
strategy_id=_opt_str(body.get("strategy_id")), strategy_id=_opt_str(body.get("strategy_id")),
sell_strategy_id=_opt_str(body.get("sell_strategy_id")), sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
portfolio_id=_opt_str(body.get("portfolio_id")),
forced_close_at=_opt_dt(body.get("forced_close_at")), forced_close_at=_opt_dt(body.get("forced_close_at")),
stop_loss_price=_opt_decimal(body.get("stop_loss_price")), stop_loss_price=_opt_decimal(body.get("stop_loss_price")),
take_profit_price=_opt_decimal(body.get("take_profit_price")), take_profit_price=_opt_decimal(body.get("take_profit_price")),
+11 -1
View File
@@ -140,7 +140,10 @@ class Daemon:
self.slot_repo = PostgreSQLSlotRepository(config.dsn) self.slot_repo = PostgreSQLSlotRepository(config.dsn)
self.intent_store = PostgreSQLIntentStore(config.dsn) self.intent_store = PostgreSQLIntentStore(config.dsn)
self.slot_service = SlotService(self.slot_repo) self.slot_service = SlotService(self.slot_repo)
self.intent_service = IntentService(self.slot_service, self.slot_repo, self.intent_store) self.intent_service = IntentService(
self.slot_service, self.slot_repo, self.intent_store,
resolve_symbol=self._resolve_symbol,
)
self.broker = _build_broker(config) self.broker = _build_broker(config)
self.outbox = PostgreSQLLedgerOutbox(config.dsn) self.outbox = PostgreSQLLedgerOutbox(config.dsn)
self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config)) self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config))
@@ -185,6 +188,13 @@ class Daemon:
config=config.safety_sell, config=config.safety_sell,
) )
# ------------------------------------------------------------ buy symbol resolve
def _resolve_symbol(self, portfolio_id: str):
"""dcy-011 / REQ-007: resolve symbol from the universe snapshot for a
portfolio_id at buy admission (None → unknown_portfolio reject)."""
asset = self.snapshot_store.get(portfolio_id)
return asset.symbol if asset is not None else None
# ------------------------------------------------------------ event hook # ------------------------------------------------------------ event hook
def _make_event_emitter(self): def _make_event_emitter(self):
def emit(event_type: str, slot) -> None: def emit(event_type: str, slot) -> None: