From aee35123838e4ada2b86a5dfe764233ca83422fb Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Mon, 8 Jun 2026 12:57:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(intents):=20dcy-011=20buy=20intent=20portf?= =?UTF-8?q?olio=5Fid=20=ED=95=84=EC=88=98=20=EA=B3=84=EC=95=BD=20+=20ledge?= =?UTF-8?q?r=20skip=20=EA=B0=80=EC=8B=9C=ED=99=94=20(BAT-015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../design.md | 63 ++++++++++++++ .../implementation.md | 40 +++++++++ .../index.md | 39 +++++++++ .../planning.md | 53 +++++++++++ .../verification.md | 65 ++++++++++++++ docs/batches/index.md | 1 + ...nt-portfolio-id-required-symbol-resolve.md | 4 +- docs/interface-contract.md | 8 +- docs/runtime-flows.md | 6 +- .../Exception/req-055_ledger-gap-ops-check.md | 87 +++++++++++++++++++ .../req-018_fill-to-ledger-recording.md | 40 +++++++++ ...messaging-topology-and-contract-mapping.md | 40 +++++++++ .../req-007_intent-submission-api.md | 42 +++++++++ docs/srs/index.md | 5 +- .../messaging/test_rabbitmq_intent_e2e.py | 5 +- tests/adapters/persistence/test_ops_checks.py | 59 +++++++++++++ .../persistence/test_postgresql_repository.py | 8 +- tests/application/test_execution.py | 2 +- tests/application/test_execution_ledger.py | 23 ++++- tests/application/test_intent_concurrency.py | 4 +- .../test_messaging_intent_consumer.py | 51 ++++++++++- .../test_messaging_snapshot_consumer.py | 5 +- trex_core/adapters/persistence/ops_checks.py | 47 ++++++++++ trex_core/application/execution.py | 18 +++- trex_core/application/intents.py | 29 ++++++- trex_core/application/messaging_handlers.py | 6 +- trex_core/runtime/daemon.py | 12 ++- 27 files changed, 729 insertions(+), 33 deletions(-) create mode 100644 docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/design.md create mode 100644 docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/implementation.md create mode 100644 docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/index.md create mode 100644 docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/planning.md create mode 100644 docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/verification.md create mode 100644 docs/srs/Exception/req-055_ledger-gap-ops-check.md create mode 100644 tests/adapters/persistence/test_ops_checks.py create mode 100644 trex_core/adapters/persistence/ops_checks.py diff --git a/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/design.md b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/design.md new file mode 100644 index 0000000..f5cf74a --- /dev/null +++ b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/design.md @@ -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 가시화 단락. diff --git a/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/implementation.md b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/implementation.md new file mode 100644 index 0000000..5d339a6 --- /dev/null +++ b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/implementation.md @@ -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. diff --git a/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/index.md b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/index.md new file mode 100644 index 0000000..7d9277d --- /dev/null +++ b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/index.md @@ -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 마커 없이 영구 탐지). diff --git a/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/planning.md b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/planning.md new file mode 100644 index 0000000..8953eb5 --- /dev/null +++ b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/planning.md @@ -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 경로 확인. diff --git a/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/verification.md b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/verification.md new file mode 100644 index 0000000..b060f18 --- /dev/null +++ b/docs/batches/bat-015_20260608_buy-intent-portfolio-id-contract/verification.md @@ -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 무관). diff --git a/docs/batches/index.md b/docs/batches/index.md index 6c4ce16..380cd1e 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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-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-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/) | diff --git a/docs/discovery/dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md b/docs/discovery/dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md index cd76f55..89e459e 100644 --- a/docs/discovery/dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md +++ b/docs/discovery/dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md @@ -5,10 +5,10 @@ - 문서 ID: dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve - 이슈명: buy intent — portfolio_id를 필수 키로, symbol은 universe(SnapshotStore)에서 resolve (+ portfolio_id 누락 체결의 ledger silent skip 가시화) - 작성 시각(KST): 2026-06-08 11:08 KST -- 마지막 갱신 시각(KST): 2026-06-08 12:23 KST +- 마지막 갱신 시각(KST): 2026-06-08 14:10 KST - 대체됨: 없음 - 후속 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. 계획 상태 요약 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index bc0f26d..dc326e0 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - 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 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -81,11 +81,13 @@ { "intent_type": "buy", "intent_id": "", "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?, - "portfolio_id": str?, "forced_close_at": "ISO-8601"?, + "forced_close_at": "ISO-8601"?, "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(트리거 비활성). sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":...}`. diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index c2322eb..865687f 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - 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 - 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`. 2. `trex-intent-rpc` 스레드(단일, prefetch=1)가 메시지 수신 → `IntentService.submit_buy/sell` 호출. 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. 6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다. 7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱. @@ -197,6 +197,8 @@ 집행 루프 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` 을 운반. 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 후 슬롯 전이는 정상 유지. diff --git a/docs/srs/Exception/req-055_ledger-gap-ops-check.md b/docs/srs/Exception/req-055_ledger-gap-ops-check.md new file mode 100644 index 0000000..6ba3708 --- /dev/null +++ b/docs/srs/Exception/req-055_ledger-gap-ops-check.md @@ -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 diff --git a/docs/srs/Integration/req-018_fill-to-ledger-recording.md b/docs/srs/Integration/req-018_fill-to-ledger-recording.md index 9618a56..89c09d0 100644 --- a/docs/srs/Integration/req-018_fill-to-ledger-recording.md +++ b/docs/srs/Integration/req-018_fill-to-ledger-recording.md @@ -149,3 +149,43 @@ - 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-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) + 회귀. diff --git a/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md b/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md index 98e7554..0568898 100644 --- a/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md +++ b/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md @@ -142,3 +142,43 @@ - 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-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. diff --git a/docs/srs/Interface/req-007_intent-submission-api.md b/docs/srs/Interface/req-007_intent-submission-api.md index 3ac5651..44abd2f 100644 --- a/docs/srs/Interface/req-007_intent-submission-api.md +++ b/docs/srs/Interface/req-007_intent-submission-api.md @@ -153,3 +153,45 @@ #### Validation Plan - 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). diff --git a/docs/srs/index.md b/docs/srs/index.md index 5490537..a344fd7 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-055` +- Current: `REQ-056` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## 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-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-055 | closed 슬롯 ↔ ledger_outbox 누락 운영 점검 쿼리 | Exception | Implemented | Medium | project maintainers | [req-055](./Exception/req-055_ledger-gap-ops-check.md) | ## 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 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-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) diff --git a/tests/adapters/messaging/test_rabbitmq_intent_e2e.py b/tests/adapters/messaging/test_rabbitmq_intent_e2e.py index a182f54..83256c0 100644 --- a/tests/adapters/messaging/test_rabbitmq_intent_e2e.py +++ b/tests/adapters/messaging/test_rabbitmq_intent_e2e.py @@ -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.""" repo = InMemorySlotRepository() 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) 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" payload = { "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) r2 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0) diff --git a/tests/adapters/persistence/test_ops_checks.py b/tests/adapters/persistence/test_ops_checks.py new file mode 100644 index 0000000..204ce28 --- /dev/null +++ b/tests/adapters/persistence/test_ops_checks.py @@ -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) == [] diff --git a/tests/adapters/persistence/test_postgresql_repository.py b/tests/adapters/persistence/test_postgresql_repository.py index 76a07fb..8b46010 100644 --- a/tests/adapters/persistence/test_postgresql_repository.py +++ b/tests/adapters/persistence/test_postgresql_repository.py @@ -32,7 +32,7 @@ def test_preallocate_and_capacity(pg_clean): def test_buy_intent_persists_transition(pg_clean): repo, svc, intents = _wire(pg_clean) 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 slot = repo.fetch_slot(d.slot_id) 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): repo, svc, intents = _wire(pg_clean) svc.preallocate_daily_slots(DAY, 3) - d1 = 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")) + 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", portfolio_id="PF-1")) 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] 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): repo, svc, intents = _wire(pg_clean) 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 svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t") intents.submit_sell(SellIntent(intent_id="pg-sell", slot_id=sid)) diff --git a/tests/application/test_execution.py b/tests/application/test_execution.py index 5db4d1a..4030fea 100644 --- a/tests/application/test_execution.py +++ b/tests/application/test_execution.py @@ -26,7 +26,7 @@ def _wire(broker): 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(): diff --git a/tests/application/test_execution_ledger.py b/tests/application/test_execution_ledger.py index e83ea82..b389544 100644 --- a/tests/application/test_execution_ledger.py +++ b/tests/application/test_execution_ledger.py @@ -80,7 +80,13 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot(): ledger = InMemoryLedger() repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0), ledger=ledger) - sid = _buy(intents, iid="no-pf", portfolio_id=None).slot_id + # 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() assert r.filled == 1 and r.ledger_enqueued == 0 and r.ledger_skipped == 1 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 +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(): """REQ-019 AC-1: same execution_id processed twice = 1 ledger entry.""" ledger = InMemoryLedger() diff --git a/tests/application/test_intent_concurrency.py b/tests/application/test_intent_concurrency.py index 026998a..9726764 100644 --- a/tests/application/test_intent_concurrency.py +++ b/tests/application/test_intent_concurrency.py @@ -26,7 +26,7 @@ def test_concurrent_buys_admit_exactly_idle_count(): results_lock = threading.Lock() 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: results.append(d.status) @@ -56,7 +56,7 @@ def test_duplicate_intent_id_admits_once_under_concurrency(): lock = threading.Lock() 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: statuses.append(d.status) diff --git a/tests/application/test_messaging_intent_consumer.py b/tests/application/test_messaging_intent_consumer.py index caeb1e1..78a687e 100644 --- a/tests/application/test_messaging_intent_consumer.py +++ b/tests/application/test_messaging_intent_consumer.py @@ -22,18 +22,21 @@ DAY = date(2026, 6, 5) def _wire(): repo = InMemorySlotRepository() 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) 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( payload={ "intent_type": "buy", "intent_id": intent_id, "trading_date": DAY.isoformat(), - "symbol": symbol, + "portfolio_id": portfolio_id, "qty": 10, }, correlation_id=intent_id, @@ -119,7 +122,7 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload(): payload={ "intent_type": "buy", "trading_date": DAY.isoformat(), - "symbol": "005930", + "portfolio_id": "PF-1", "qty": 5, }, 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 pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY] 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" diff --git a/tests/application/test_messaging_snapshot_consumer.py b/tests/application/test_messaging_snapshot_consumer.py index 15a2803..300f17e 100644 --- a/tests/application/test_messaging_snapshot_consumer.py +++ b/tests/application/test_messaging_snapshot_consumer.py @@ -36,7 +36,7 @@ def test_snapshot_returns_all_slots_and_capacity(): repo, svc, intents = _wire() intents.submit_buy(BuyIntent( 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) resp = handler(_req()) @@ -51,6 +51,7 @@ def test_snapshot_open_view_filters_to_active_slots(): repo, svc, intents = _wire() intents.submit_buy(BuyIntent( intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A", + portfolio_id="PF-1", )) handler = make_snapshot_rpc_handler(repo, svc) resp = handler(_req(view="open")) @@ -63,9 +64,11 @@ def test_snapshot_strategy_id_filter(): repo, svc, intents = _wire() intents.submit_buy(BuyIntent( intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A", + portfolio_id="PF-1", )) intents.submit_buy(BuyIntent( intent_id="b2", trading_date=DAY, symbol="000660", qty=5, strategy_id="B", + portfolio_id="PF-2", )) handler = make_snapshot_rpc_handler(repo, svc) resp = handler(_req(view="open", strategy_id="A")) diff --git a/trex_core/adapters/persistence/ops_checks.py b/trex_core/adapters/persistence/ops_checks.py new file mode 100644 index 0000000..5186da0 --- /dev/null +++ b/trex_core/adapters/persistence/ops_checks.py @@ -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()] diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index e815d6a..4d8d6cd 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -18,6 +18,7 @@ single producer for ledger work items (NFR-3, single record subject). """ from __future__ import annotations +import logging from dataclasses import dataclass from datetime import datetime, timezone 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.domain.slot import SlotService, SlotState, SlotStateMismatchError +_log = logging.getLogger(__name__) + # REQ-021/023: event emission hook. Runtime wires a callable that publishes # slot transition events through MessagingTransport. Application layer stays # 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: return 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 + _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 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 + _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 price = float(fs.avg_price) if fs.avg_price is not None else 0.0 trade = LedgerTrade( diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index e8760f8..95efeb3 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -45,15 +45,19 @@ class IntentDecision: @dataclass(frozen=True) 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 trading_date: date - symbol: str + portfolio_id: str + symbol: Optional[str] = None qty: Optional[float] = None allocated_capital: Optional[float] = None entry_price: Optional[float] = None strategy_id: Optional[str] = None sell_strategy_id: Optional[str] = None - portfolio_id: Optional[str] = None forced_close_at: Optional[datetime] = None stop_loss_price: Optional[Decimal] = None # REQ-035 take_profit_price: Optional[Decimal] = None # REQ-035 @@ -77,10 +81,15 @@ class IntentService: slot_service: SlotService, slot_repo: SlotRepository, intent_store: IntentStore, + resolve_symbol=None, ) -> None: self._svc = slot_service self._repo = slot_repo 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]: @@ -139,6 +148,18 @@ class IntentService: return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason) 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 # between our find and transit (SlotStateMismatchError). for _ in range(_MAX_IDLE_RETRY): @@ -152,7 +173,7 @@ class IntentService: SlotState.PENDING_BUY, reason="buy_intent", actor="intent_api", - symbol=intent.symbol, + symbol=symbol, qty=intent.qty, allocated_capital=intent.allocated_capital, entry_price=intent.entry_price, @@ -162,7 +183,7 @@ class IntentService: forced_close_at=intent.forced_close_at, stop_loss_price=intent.stop_loss_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: continue # slot was claimed concurrently; try another idle slot diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 9c624af..55e512e 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -71,16 +71,18 @@ def make_intent_rpc_handler( intent_type = str(body.get("intent_type") or "").lower() try: 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( intent_id=intent_id, 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")), allocated_capital=_opt_float(body.get("allocated_capital")), entry_price=_opt_float(body.get("entry_price")), strategy_id=_opt_str(body.get("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")), stop_loss_price=_opt_decimal(body.get("stop_loss_price")), take_profit_price=_opt_decimal(body.get("take_profit_price")), diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 1fa2d47..73e0843 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -140,7 +140,10 @@ class Daemon: self.slot_repo = PostgreSQLSlotRepository(config.dsn) self.intent_store = PostgreSQLIntentStore(config.dsn) 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.outbox = PostgreSQLLedgerOutbox(config.dsn) self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config)) @@ -185,6 +188,13 @@ class Daemon: 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 def _make_event_emitter(self): def emit(event_type: str, slot) -> None: