chore: commit current workspace changes

This commit is contained in:
2026-06-04 23:38:42 +09:00
parent 13837acc60
commit aed54911d4
20 changed files with 561 additions and 96 deletions
@@ -1,35 +1,52 @@
# BAT-006 Implementation # BAT-006 Implementation
- Status: draft - Status: complete
- Batch: BAT-006 - Batch: BAT-006
## Plan Summary ## Plan Summary
- LedgerPort 계약과 portfolio-hub/stub adapter 를 추가하고, execution fill 경로가 ledger outbox work item 을 적재하도록 연결한다. retry worker 와 dedup/outcome 처리를 구현한 뒤 baseline 문서를 실제 코드와 맞춘다. - LedgerPort 계약 + portfolio-hub HTTP 어댑터 + 결정론 stub 원장을 추가하고, 집행 루프 fill 전이 직후에 ledger outbox 에 work item 을 적재하도록 연결했다. `LedgerWriter` 가 outbox 를 drain 해 portfolio-hub write 를 시도하고, NEW/DUPLICATE_OK/conflict/retryable 결과를 `completed/duplicate_completed/retryable_failure/conflict_blocked` outcome 으로 분류한다. paper/KIS 브로커는 결정적 `execution_id` 와 `fee` 를 `FillStatus` 로 운반한다. baseline 4문서를 ledger 레이어로 정합시켰다.
## Changed Files ## Changed Files
- 예정: - 신규
- `trex_core/application/*` — LedgerPort, ledger outbox service, execution fill hook. - `trex_core/application/ledger_port.py` — `LedgerPort` Protocol, `LedgerTrade`, `LedgerWriteResult`, `LedgerError`/`LedgerConflictError`/`LedgerRetryableError` (REQ-017).
- `trex_core/adapters/ledger/*` — in-memory/stub ledger, portfolio-hub adapter. - `trex_core/application/ledger_outbox.py` — `LedgerOutbox` Protocol, `LedgerWorkItem`, `OutboxOutcome`, `WorkItemStatus`, `LedgerWriter`, `InMemoryLedgerOutbox` (REQ-019).
- `trex_core/adapters/broker/*` — FillStatus execution_id/fee 운반. - `trex_core/adapters/ledger/__init__.py`, `in_memory.py` (`InMemoryLedger` stub), `portfolio_hub.py` (`PortfolioHubLedger` HTTP 어댑터) (REQ-017).
- `trex_core/runtime/*` — portfolio-hub config, retry worker lifecycle. - `trex_core/adapters/persistence/sql/002_create_ledger_outbox.sql` — `ledger_outbox` 테이블, `correlation_id` PK, status/outcome CHECK (REQ-019).
- persistence/migration 파일 — ledger outbox work item store. - `tests/adapters/ledger/{__init__,test_in_memory_ledger,test_portfolio_hub_ledger}.py` — REQ-017 AC-1/2/3.
- `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/data-model.md`. - `tests/adapters/persistence/test_postgresql_ledger_outbox.py` — REQ-019 PG outbox (PG-gated).
- tests — ledger adapter, fill lifecycle, outbox retry/dedup/conflict, import sweep. - `tests/application/{test_execution_ledger,test_ledger_outbox}.py` — REQ-018 + REQ-019.
- `tests/domain/test_domain_import_sweep.py` — REQ-017 AC-4 (NFR-1) 정적 import 스윕.
- 수정
- `trex_core/application/broker_port.py` — `FillStatus.execution_id/fee/tax` 확장 + 공유 `calculate_fee(side, amount)` 헬퍼 (REQ-018).
- `trex_core/adapters/broker/paper.py` — `filled` 상태에서 `paper:{broker_order_id}:{filled_qty}` execution_id + fee 합성.
- `trex_core/adapters/broker/kis.py` — `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}` execution_id + fee 운반.
- `trex_core/application/execution.py` — `ExecutionService` 가 fill 전이 직후 `LedgerWorkItem` 을 outbox 에 enqueue, `portfolio_id` 부재면 skip, slot 전이 우선 보장 (REQ-018 + REQ-019).
- `trex_core/adapters/persistence/postgresql.py` — `PostgreSQLLedgerOutbox` (`enqueue`/`list_pending`/`get`/`mark_outcome`).
- `trex_core/runtime/config.py` — `PortfolioHubConfig`, `DaemonConfig.portfolio_hub`/`ledger_interval_sec`, env 로딩.
- `trex_core/runtime/daemon.py` — `_build_ledger`, ledger writer 백그라운드 루프, `ExecutionService(..., ledger_outbox=outbox)`.
- `tests/conftest.py`, `tests/adapters/persistence/test_migration_and_drift.py` — `ledger_outbox` 테이블 반영.
- `.env.example` — `TREX_PORTFOLIO_HUB_BASE_URL/TOKEN/TIMEOUT`, `TREX_LEDGER_INTERVAL_SEC` 추가.
## Execution Log ## Execution Log
1. 대기: `draft-batch-planning`/`draft-batch-design` 확인 후 구현 시작. 1. baseline 테스트 88 passed / 8 skipped 확인.
2. REQ-017: `LedgerPort` + `LedgerTrade` + 예외 + `InMemoryLedger` stub + `PortfolioHubLedger` 어댑터 추가, contract 테스트 4종 작성.
3. REQ-018: `FillStatus.execution_id/fee/tax` 확장, paper/KIS 결정적 합성 + `calculate_fee` 헬퍼, `ExecutionService._on_filled` 에서 fill 전이 직후 ledger outbox enqueue, `portfolio_id` 부재 시 skip + `ExecutionResult.ledger_enqueued/ledger_skipped` 카운터 추가.
4. REQ-019: `LedgerOutbox` Protocol + `LedgerWorkItem`/`OutboxOutcome`/`WorkItemStatus`, `LedgerWriter` (correlation_id 로컬 dedup + 어댑터 `LedgerWriteResult.DUPLICATE_OK` 매핑 → `duplicate_completed`), `InMemoryLedgerOutbox`, `PostgreSQLLedgerOutbox`, `002_create_ledger_outbox.sql` 마이그레이션.
5. 데몬 lifespan 에 ledger writer thread 추가, `PortfolioHubConfig` env 로딩.
6. REQ-020: runtime-flows / interface-contract / project-structure / data-model 4문서를 ledger 레이어로 정합.
7. 정적 import 스윕 테스트(`tests/domain/test_domain_import_sweep.py`) 로 `trex_core/domain` 의 ledger/HTTP 의존 0 검증.
8. 최종 테스트: 109 passed / 12 skipped (PG·KIS 게이트). ruff lint 0 issues.
## Validation ## Validation
- 예정: - 명령: `.venv/bin/python -m pytest -q`
- stub ledger `record_trade` 단위테스트. - 결과: `109 passed, 12 skipped, 1 warning`.
- portfolio-hub adapter request contract + 409 mapping 테스트. - 명령: `.venv/bin/ruff check trex_core tests`
- buy/sell fill lifecycle 통합테스트. - 결과: `All checks passed!`
- duplicate execution_id 중복 0 테스트. - 추가 테스트 21개(REQ-017 7 / REQ-018 3 / REQ-019 7 / NFR-1 import 스윕 1 / PG outbox 4 PG-skip).
- retryable failure 후 eventually completed 테스트. - portfolio-hub 어댑터 contract 는 `httpx.MockTransport` 로 path/body/auth/409/5xx/network-error 모두 커버.
- `conflict_blocked` 자동 재시도 중단 테스트. - buy/sell lifecycle 테스트로 슬롯 전이와 ledger 기록 분리, dedup, 재시도 결국 기록, 409 무한루프 방지, ledger 장애 시 슬롯 전이 우선 보장을 확인.
- `trex_core/domain` import sweep.
- baseline 문서-코드 정합 리뷰.
## Remaining Risks ## Remaining Risks
- portfolio-hub 실제 계약/자격증명 미가용 시 external integration evidence 가 stub/contract 수준으로 제한될 수 있다. - portfolio-hub 의 실제 4xx 응답 본문(특히 `duplicate` 플래그) 계약은 orbit 패턴을 가정한다. 실통합 시 응답 본문이 다르면 `PortfolioHubLedger.record_trade` 의 `LedgerWriteResult.DUPLICATE_OK` 판별을 조정해야 한다. 본 batch 는 stub/contract 수준 evidence 로 검증했다.
- outbox persistence schema 가 기존 repository transaction 경계와 맞지 않으면 design 조정이 필요하다. - KIS `execution_id` 합성에 사용되는 `ord_dt`/`ord_gno_brno` 가 일부 KIS 응답에서 비어 있으면 결정성이 약화될 수 있다. 현재는 누락 시 `today`/빈 문자열로 폴백한다. 운영 데이터 관찰 후 보강 여부 판단(후속 후보).
- 부분체결(partially_filled) 단계에서는 ledger 기록을 enqueue 하지 않는다. 부분체결 누적 fill 의 정밀 회계는 후속 Discovery 범위로 남는다.
@@ -1,6 +1,6 @@
# BAT-006: portfolio-hub ledger 통합 # BAT-006: portfolio-hub ledger 통합
- Status: draft - Status: release-candidate
- Profile: standard - Profile: standard
- Source Discovery: dcy-004 - Source Discovery: dcy-004
- Included REQ: REQ-017, REQ-018, REQ-019, REQ-020 - Included REQ: REQ-017, REQ-018, REQ-019, REQ-020
@@ -1,6 +1,6 @@
# BAT-006 Verification # BAT-006 Verification
- Status: draft - Status: approved
- Batch: BAT-006 - Batch: BAT-006
## Acceptance Mapping ## Acceptance Mapping
@@ -27,14 +27,45 @@
- AC-3: baseline 문서 변경 이력에 REQ-017~020 반영 기록 evidence. - AC-3: baseline 문서 변경 이력에 REQ-017~020 반영 기록 evidence.
## Evidence ## Evidence
- Test:
- 대기: 구현 후 테스트 결과 기록. ### Test
- Manual: - 명령: `.venv/bin/python -m pytest -q`
- 대기: baseline 문서-코드 정합 리뷰 기록. - 결과: `109 passed, 12 skipped, 1 warning in 0.98s` (PG 4건 + KIS 1건 + 기존 PG 7건은 DSN/credentials 게이트로 skip).
- Logs: - REQ-017 AC-1: `tests/adapters/ledger/test_in_memory_ledger.py::test_record_trade_preserves_fields_and_is_idempotent_on_execution_id` — 단일 호출 후 `count()==1` + 필드 보존, 동일 execution_id 재호출 후에도 `count()==1`.
- 대기: 필요 시 portfolio-hub adapter contract 또는 skip/blocker 로그 기록. - REQ-017 AC-2: `tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_asset_id` — `httpx.MockTransport` 로 path `/v1/assets/PF-77/ledger/trades`, body 에 `execution_id`/`side` 포함, `Authorization: Bearer …` 헤더 캡처.
- REQ-017 AC-3: `tests/adapters/ledger/test_portfolio_hub_ledger.py::test_409_surfaces_as_ledger_conflict_error` + `tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry` — 409 → `LedgerConflictError`; outbox row `status=conflict_blocked`, 후속 writer tick 에서 어댑터 재호출 없음.
- REQ-017 AC-4: `tests/domain/test_domain_import_sweep.py::test_domain_has_no_forbidden_imports` — `trex_core/domain` AST 워크가 `httpx`/`fastapi`/`uvicorn`/`psycopg`/`redis`/`trex_core.adapters`/`trex_core.runtime`/`trex_core.application` 0 회 import 확인.
- REQ-018 AC-1: `tests/application/test_execution_ledger.py::test_buy_fill_enqueues_one_ledger_item_and_writer_records_it` — buy intent → execution.run_once → slot=holding, ledger_enqueued=1, writer.run_once → ledger.count()=1, `LedgerTrade(asset_id="PF-1", side="buy", qty=10, price=1000.0)`.
- REQ-018 AC-2: `tests/application/test_execution_ledger.py::test_sell_fill_enqueues_sell_ledger_item` — buy→holding→sell intent→execution.run_once → slot=closed, ledger 에 `["buy","sell"]` 순서.
- REQ-018 AC-3: 위 buy 테스트가 `t.execution_id.startswith("paper:PAPER-")`, `t.fee > 0.0`, `t.tax == 0.0` 직접 검증.
- REQ-018 AC-4: `tests/application/test_execution_ledger.py::test_portfolio_id_absent_skips_ledger_but_transitions_slot` — portfolio_id=None → ledger_enqueued=0, ledger_skipped=1, 슬롯은 HOLDING.
- REQ-018 AC-5: `tests/domain/test_domain_import_sweep.py` (도메인 격리) + `tests/application/test_execution_ledger.py` (오직 `ExecutionService._record_ledger` 만 outbox.enqueue 호출 — code review). `grep -r "ledger_outbox.enqueue\|LedgerOutbox.enqueue" trex_core/` 결과는 `ExecutionService` 단일 호출지점.
- REQ-019 AC-1: `tests/application/test_execution_ledger.py::test_duplicate_execution_id_results_in_zero_duplicate_in_ledger` — 같은 trade 재 enqueue 시 outbox row 1개 유지, 별도 outbox 에서 재처리해도 ledger.count()=1.
- REQ-019 AC-2: `tests/application/test_execution_ledger.py::test_retryable_failure_then_eventually_completes` + `tests/application/test_ledger_outbox.py::test_writer_retries_until_success` — retryable 2회 + completed 1회로 ledger.count()=1, 슬롯은 즉시 HOLDING.
- REQ-019 AC-3: `tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry` + `tests/application/test_ledger_outbox.py::test_writer_marks_conflict_blocked_and_does_not_retry` — 후속 writer tick stats 모두 0.
- REQ-019 AC-4: `tests/application/test_execution_ledger.py::test_duplicate_completed_when_reprocessing_terminal_item` + `test_duplicate_execution_id_results_in_zero_duplicate_in_ledger` — 별도 outbox 에 동일 trade 재 enqueue → writer.run_once stats.duplicates=1, stats.completed=0, ledger.count()=1.
- REQ-019 AC-5: `tests/application/test_execution_ledger.py::test_slot_transition_proceeds_when_ledger_is_down` — `InMemoryLedger(fail_n_then_succeed=100)` 으로 모든 writer tick 실패, slot 은 HOLDING, outbox row `status=PENDING` 유지(무유실).
- PG 통합 (DSN 제공 시): `tests/adapters/persistence/test_postgresql_ledger_outbox.py` 4건 — enqueue 멱등, writer completed/conflict_blocked/retryable 행 컬럼 검증. CI 환경에 DSN 없어 skip 4건은 명시적으로 PG 게이트.
### Manual
- baseline 문서 ↔ 코드 정합 리뷰 (REQ-020 AC-1/AC-2):
- `docs/runtime-flows.md` 새 섹션 `Flow: 체결 → ledger outbox` + `Flow: ledger writer` 가 `ExecutionService._record_ledger` (`trex_core/application/execution.py:117-141`) 과 `LedgerWriter.run_once`/`_process` (`trex_core/application/ledger_outbox.py:96-149`) 로직 정합. paper/KIS execution_id 합성 규칙은 `trex_core/adapters/broker/paper.py:73-79`, `trex_core/adapters/broker/kis.py:177-192` 와 일치.
- `docs/interface-contract.md` 새 섹션 `Interface: LedgerPort` + `Interface: LedgerOutbox/LedgerWriter` 가 `trex_core/application/ledger_port.py` 시그니처, `LedgerWriteResult.NEW/DUPLICATE_OK`, `LedgerConflictError`/`LedgerRetryableError` 와 정합. portfolio-hub endpoint 표는 `trex_core/adapters/ledger/portfolio_hub.py:_payload`/`record_trade` 와 정합.
- `docs/project-structure.md` 디렉토리 트리에 `trex_core/application/ledger_port.py`, `ledger_outbox.py`, `trex_core/adapters/ledger/`, persistence 의 `PostgreSQLLedgerOutbox` + `002_create_ledger_outbox.sql`, runtime 의 ledger writer 루프가 반영. 실제 디렉토리 트리(`find trex_core -type f -name '*.py'`)와 일치 확인.
- `docs/data-model.md` 새 엔티티 `LedgerTrade`(application DTO) + `ledger_outbox` 테이블 정의가 `002_create_ledger_outbox.sql` (PK `correlation_id`, status/last_outcome CHECK) 와 1:1 정합. `FillStatus` 확장(`execution_id/fee/tax`) 도 명시.
- code-search (REQ-020 AC-2 + REQ-018 AC-5 보조):
- `grep -r "LedgerOutbox.enqueue\|self._ledger_outbox.enqueue" trex_core/` → `trex_core/application/execution.py` 단 1곳(execution path 단일 기록 주체).
- `grep -r "import httpx\|from httpx" trex_core/domain/` → 결과 0(NFR-1 보강).
### Logs
- `.venv/bin/ruff check trex_core tests` → `All checks passed!` (style/lint blocker 0).
- 정적 import 스윕 자동화: `tests/domain/test_domain_import_sweep.py` 가 CI 의 일부로 회귀 가드.
- portfolio-hub 실 통합 evidence 는 본 batch 범위 밖(`PortfolioHubLedger` 는 `httpx.MockTransport` 로 contract 검증). 실 통합은 운영 환경에서 수집 — `Remaining Risks` 와 `docs/srs/Integration/req-017_…md` Notes 의 A-1/DATA 위임 항목으로 추적.
## Result ## Result
- Ready for Release: false - Ready for Release: true
- Blocking Issues: - Blocking Issues:
- 구현 및 검증 미진행. - 없음.
- Notes:
- portfolio-hub 실 계약/자격증명 evidence 는 stub + contract 수준이며, 실 통합 검증은 운영 단계에서 수집한다(dcy-004 DATA 위임 사항).
- 부분체결 누적/정밀 세금 모델은 후속 Discovery 로 분리됨(이번 범위 밖).
+1 -1
View File
@@ -13,4 +13,4 @@
| BAT-003 | batch-lite | release-candidate | REQ-010 | dcy-002 | [bat-003](./bat-003_20260604_daemon-dotenv-config/) | | BAT-003 | batch-lite | release-candidate | REQ-010 | dcy-002 | [bat-003](./bat-003_20260604_daemon-dotenv-config/) |
| BAT-004 | standard | release-candidate | REQ-011, REQ-012, REQ-013, REQ-014, REQ-015 | dcy-003 | [bat-004](./bat-004_20260604_broker-execution-and-outbox/) | | BAT-004 | standard | release-candidate | REQ-011, REQ-012, REQ-013, REQ-014, REQ-015 | dcy-003 | [bat-004](./bat-004_20260604_broker-execution-and-outbox/) |
| BAT-005 | batch-lite | release-candidate | REQ-016 | dcy-003 | [bat-005](./bat-005_20260604_kis-token-redis-cache/) | | BAT-005 | batch-lite | release-candidate | REQ-016 | dcy-003 | [bat-005](./bat-005_20260604_kis-token-redis-cache/) |
| BAT-006 | standard | draft | REQ-017, REQ-018, REQ-019, REQ-020 | dcy-004 | [bat-006](./bat-006_20260604_portfolio-hub-ledger-integration/) | | BAT-006 | standard | release-candidate | REQ-017, REQ-018, REQ-019, REQ-020 | dcy-004 | [bat-006](./bat-006_20260604_portfolio-hub-ledger-integration/) |
+54 -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-03 - Last Updated (KST): 2026-06-04 (BAT-006)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery)
## Project Summary ## Project Summary
@@ -14,7 +14,7 @@
- 자동매매 일일 거래 슬롯 생명주기 도메인 (equity trading slot lifecycle) - 자동매매 일일 거래 슬롯 생명주기 도메인 (equity trading slot lifecycle)
## Primary Persistence Backend ## Primary Persistence Backend
- PostgreSQL (BAT-002, REQ-006) — 코어 데몬의 durable 단일 writer. 도메인은 여전히 storage-agnostic(SlotRepository Protocol); in-memory 어댑터는 테스트용으로 잔존. 슬라이스 1 테이블: daily_slot / daily_symbol_entries / slot_transition_log / intent_log (daily_slot_archive·prune 은 범위 밖). - PostgreSQL (BAT-002, REQ-006; BAT-006 확장) — 코어 데몬의 durable 단일 writer. 도메인은 여전히 storage-agnostic(SlotRepository Protocol); in-memory 어댑터는 테스트용으로 잔존. 슬라이스 1 테이블: daily_slot / daily_symbol_entries / slot_transition_log / intent_log / ledger_outbox (daily_slot_archive·prune 은 범위 밖).
## Purpose ## Purpose
- This document captures the approved cross-cutting data model baseline for the project. - This document captures the approved cross-cutting data model baseline for the project.
@@ -105,6 +105,57 @@
- `try_insert` 는 INSERT ... ON CONFLICT DO NOTHING(dedup 게이트). 중복 intent_id 는 재전이 없이 기존 decision 반환. - `try_insert` 는 INSERT ... ON CONFLICT DO NOTHING(dedup 게이트). 중복 intent_id 는 재전이 없이 기존 decision 반환.
- 완전 비동기 outbox(상태머신 + 소비 루프)로의 승격은 브로커 집행 Discovery 로 Deferred(이 테이블이 전구체). - 완전 비동기 outbox(상태머신 + 소비 루프)로의 승격은 브로커 집행 Discovery 로 Deferred(이 테이블이 전구체).
## Entity: `LedgerTrade` (BAT-006)
- Purpose: portfolio-hub 원장에 기록되는 단일 체결 거래 데이터. application 경계 DTO(영속 엔티티 아님 — 영속 표현은 `ledger_outbox` 에서 운반).
- Source of Truth: 어댑터 호출 시 in-memory; 영속 표현은 `ledger_outbox` 의 컬럼들.
### Key Fields
- asset_id :: str :: 외부 원장 path param (= 슬롯 `portfolio_id`, dcy-004 OQ-1)
- order_id :: str :: 로컬 주문 식별자 (`broker_order_id`)
- execution_id :: str :: 결정적 합성 (paper / KIS 규칙). 멱등 키.
- side :: 'buy' | 'sell'
- qty :: float
- price :: float
- fee :: float :: BrokerPort `calculate_fee(side, qty*price)`
- tax :: float :: 기본 0(정밀 모델 후속)
- executed_at :: datetime?
- correlation_id (derived) :: `{asset_id}:{order_id}:{execution_id}`
### State Rules
- 동일 `execution_id` 재기록 시 ledger 어댑터/서버가 dedup 한다(`LedgerWriteResult.DUPLICATE_OK` 또는 server-side 멱등). 로컬에서는 outbox PK 가 dedup gate.
## Entity: `ledger_outbox` (BAT-006)
- Purpose: 체결 → portfolio-hub 원장 기록을 슬롯 전이와 분리해 무유실 + 멱등으로 처리하기 위한 durable work-item store(dcy-004 OQ-2, orbit `LedgerWriter` 패턴).
- Source of Truth: `ledger_outbox` (PostgreSQL) / 테스트는 in-memory `InMemoryLedgerOutbox`
- Lifecycle: pending → completed | conflict_blocked ; retryable_failure 는 status `pending` 유지(attempts++)
### Key Fields
- correlation_id :: TEXT PK :: `{asset_id}:{order_id}:{execution_id}` — 로컬 dedup 게이트
- asset_id / order_id / execution_id :: TEXT
- side :: TEXT CHECK ('buy','sell')
- qty / price / fee / tax :: NUMERIC(18,4)
- executed_at :: TIMESTAMPTZ?
- status :: TEXT CHECK ('pending','completed','conflict_blocked')
- attempts :: INTEGER :: 재시도 누적
- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked')
- last_error :: TEXT?
- created_at / updated_at :: TIMESTAMPTZ
### State Rules
- `enqueue` 는 `INSERT ... ON CONFLICT (correlation_id) DO NOTHING`(dedup 게이트). 동일 fill 이 다시 enqueue 되어도 새 행을 만들지 않는다.
- writer 결과 매핑:
- NEW → outcome `completed`, status `completed`.
- DUPLICATE_OK 또는 로컬 dedup hit → outcome `duplicate_completed`, status `completed`.
- 409 → outcome `conflict_blocked`, status `conflict_blocked` (자동 재시도 중단, 운영자 해소).
- 5xx/network/timeout → outcome `retryable_failure`, status `pending` (다음 tick 재시도).
- slot 전이는 outbox 적재와 분리되어 critical path 유지.
## Shared Consistency Rules ## Shared Consistency Rules
- (trading_date, symbol) 재진입 카운트는 closed->idle 복구와 원자적으로 증가한다. - (trading_date, symbol) 재진입 카운트는 closed->idle 복구와 원자적으로 증가한다.
@@ -134,3 +185,4 @@
- 2026-06-03 :: bootstrap-baseline 으로 초기 데이터 모델 baseline 생성(Slot + SlotTransitionRecord + 재진입 카운터). - 2026-06-03 :: bootstrap-baseline 으로 초기 데이터 모델 baseline 생성(Slot + SlotTransitionRecord + 재진입 카운터).
- 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009). - 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009).
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015). - 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
- 2026-06-04 :: BAT-006 — ledger 통합 데이터 모델 반영(LedgerTrade DTO + `ledger_outbox` 테이블/`correlation_id` 멱등/outcome 어휘, FillStatus.execution_id/fee/tax 운반) (REQ-017~020).
@@ -5,7 +5,7 @@
- 문서 ID: dcy-005_20260604_portfolio-hub-asset-snapshot-api - 문서 ID: dcy-005_20260604_portfolio-hub-asset-snapshot-api
- 이슈명: portfolio-hub 종목 스냅샷 read API — 매수 전략 모듈에 슬롯-제외 후보 제공 - 이슈명: portfolio-hub 종목 스냅샷 read API — 매수 전략 모듈에 슬롯-제외 후보 제공
- 작성 시각(KST): 2026-06-04 22:46 - 작성 시각(KST): 2026-06-04 22:46
- 마지막 갱신 시각(KST): 2026-06-04 22:46 - 마지막 갱신 시각(KST): 2026-06-04 23:55
- 대체됨: 없음 - 대체됨: 없음
- 후속 Discovery 참조: 없음 - 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 없음 - 생성된 REQ 참조: 없음
@@ -36,7 +36,8 @@
- portfolio-hub read 어댑터(get_assets/get_portfolios)가 trex-core 에 없다. dcy-004/BAT-006 은 write 방향 `LedgerPort` + portfolio-hub 어댑터만 추가하며, read 포트는 다루지 않는다. - portfolio-hub read 어댑터(get_assets/get_portfolios)가 trex-core 에 없다. dcy-004/BAT-006 은 write 방향 `LedgerPort` + portfolio-hub 어댑터만 추가하며, read 포트는 다루지 않는다.
- 결과적으로 매수 전략 모듈이 "무엇을 살지" 정할 portfolio-hub 종목 정보를 trex-core 를 통해 받을 경로가 없다. - 결과적으로 매수 전략 모듈이 "무엇을 살지" 정할 portfolio-hub 종목 정보를 trex-core 를 통해 받을 경로가 없다.
- 기대 상태 - 기대 상태
- trex-core 가 portfolio-hub 종목 스냅샷을 read API 로 제공하고, **현재 슬롯을 점유(pending/holding)한 종목을 제외한 매수 후보 리스트**를 매수 전략 모듈에 반환한다. - trex-core daemon 이 설정 가능한 시각(기본 07:30 KST, .env)에 일일 스케줄로 portfolio-hub 에서 자신의 `daemon_id` universe(= `managed_by_daemon_id` 가 해당 daemon 인 portfolio 집합)를 가져와 스냅샷으로 보유한다. universe 항목은 portfolio 단위로 symbol, asset_id(=portfolio_id), 현금(cash_balance), 포지션(asset_quantity), active_strategy_id, 전략파라미터(opaque JSON)를 담고 **가격(price)은 제외**한다.
- 매수 전략 모듈 요청 시 trex-core 가 이 스냅샷을 기반으로 **현재 슬롯을 점유(pending/holding)한 종목을 제외한 매수 후보 리스트**를 반환한다.
- 가용 슬롯이 없으면(capacity 소진) 매수 후보가 없음을 명확한 시맨틱(빈 리스트)으로 반환한다. - 가용 슬롯이 없으면(capacity 소진) 매수 후보가 없음을 명확한 시맨틱(빈 리스트)으로 반환한다.
- 이해관계자 - 이해관계자
- 문서 작성자/구현자(AI agent) - 문서 작성자/구현자(AI agent)
@@ -53,12 +54,15 @@
- 매수 전략 모듈이 매수 후보를 정하려고 trex-core 스냅샷 API 를 호출 → portfolio-hub 종목 중 이미 슬롯을 점유한 종목을 뺀 후보 리스트를 받는다. - 매수 전략 모듈이 매수 후보를 정하려고 trex-core 스냅샷 API 를 호출 → portfolio-hub 종목 중 이미 슬롯을 점유한 종목을 뺀 후보 리스트를 받는다.
- 모든 슬롯이 차 있어 더 살 수 없는 상황 → 스냅샷 API 가 빈 후보 리스트를 반환해 전략이 매수를 시도하지 않는다. - 모든 슬롯이 차 있어 더 살 수 없는 상황 → 스냅샷 API 가 빈 후보 리스트를 반환해 전략이 매수를 시도하지 않는다.
- 막 체결되어 슬롯이 pending/holding 인 종목 → portfolio-hub 포지션에 아직 반영되기 전이라도 core slot overlay 로 후보에서 즉시 제외되어 중복 매수를 막는다. - 막 체결되어 슬롯이 pending/holding 인 종목 → portfolio-hub 포지션에 아직 반영되기 전이라도 core slot overlay 로 후보에서 즉시 제외되어 중복 매수를 막는다.
- 스냅샷 페치 시 portfolio-hub 포지션과 실제 브로커 계좌 포지션이 어긋난 자산 → `warning` 으로 표시되어 그 자산은 매수 후보에서 빠지고 매매 전략이 수행되지 않는다.
# 4. 이번에 정의할 변경 # 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. 이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- portfolio-hub read 포트 + 어댑터: `PortfolioHubReadPort.get_assets()/get_positions()` 추상화 + portfolio-hub 어댑터(orbit get_assets/get_portfolios 패턴, dcy-004 어댑터/자격증명 재사용) + 결정론 stub. - portfolio-hub read 포트 + 어댑터: `PortfolioHubReadPort.get_portfolios(daemon_id)` 추상화 + portfolio-hub 어댑터(`managed_by_daemon_id` 로 daemon universe 조회, dcy-004 어댑터/자격증명 재사용) + 결정론 stub. 전략파라미터는 trex-core 가 해석하지 않는 opaque JSON 으로 운반한다.
- 종목 스냅샷 read API: trex-core 가 portfolio-hub 종목 스냅샷을 read 엔드포인트로 제공하고, **현재 슬롯 점유(pending/holding) 종목을 제외**한 매수 후보 리스트를 반환한다(slot overlay). - 일일 스냅샷 갱신: 설정 시각(기본 07:30 KST, .env)에 스케줄러(신규 daemon 컴포넌트)가 universe 를 페치해 스냅샷을 갱신한다. 현금/포지션은 baseline + core in-flight 체결 보정(옵션 C).
- 포지션 정합 검증 + warning: 페치 시 portfolio-hub 포지션 vs 실제 브로커 계좌 포지션을 대조하고, 불일치 자산은 `warning` 상태로 매수 후보에서 제외한다.
- 종목 스냅샷 read API: trex-core 가 보유 스냅샷을 read 엔드포인트로 제공하고, **현재 슬롯 점유(pending/holding) 종목을 제외**한 매수 후보 리스트를 반환한다(slot overlay).
- 빈 후보 시맨틱: 가용 슬롯이 0 이면 매수 후보를 빈 리스트로 반환한다(표현 방식 null vs empty 는 OQ-6). - 빈 후보 시맨틱: 가용 슬롯이 0 이면 매수 후보를 빈 리스트로 반환한다(표현 방식 null vs empty 는 OQ-6).
- baseline 갱신: interface-contract(read 포트/엔드포인트)·runtime-flows(스냅샷 조회 흐름)·project-structure(read 어댑터) 갱신. - baseline 갱신: interface-contract(read 포트/엔드포인트)·runtime-flows(스냅샷 조회 흐름)·project-structure(read 어댑터) 갱신.
@@ -66,8 +70,8 @@
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. 변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향: - 코드 영향:
- 신규: `trex_core/application` read 포트, `trex_core/adapters/ledger`(또는 신규 `adapters/portfolio`) read 어댑터 + stub, `trex_core/adapters/api` 스냅샷 read 엔드포인트 + 응답 스키마, slot overlay(점유 종목 제외) 로직. - 신규: `trex_core/application` read 포트, 신규 `adapters/portfolio` read 어댑터 + stub, `trex_core/adapters/api` 스냅샷 read 엔드포인트 + 응답 스키마, slot overlay(점유 종목 제외) + 현금/포지션 in-flight 보정 로직, 일일 스냅샷 스케줄러(런타임 신규), 포지션 정합 검증 + `warning` 상태, `config` 에 `daemon_id` + 스냅샷 페치 시각 주입.
- 기존: capacity/slot 조회 재사용(점유 종목·가용 슬롯 판정). - 기존: capacity/slot 조회 재사용(점유 종목·가용 슬롯 판정), 브로커 계좌 포지션 조회(BrokerPort 잔고조회 — 정합 검증용) 재사용/확장.
- 문서 영향: - 문서 영향:
- interface-contract/runtime-flows/project-structure 갱신. - interface-contract/runtime-flows/project-structure 갱신.
- 운영 영향: - 운영 영향:
@@ -77,24 +81,26 @@
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. 실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항 ## 기능 요구사항
- FR-1: trex-core 가 portfolio-hub 종목 스냅샷을 read API 엔드포인트로 제공한다(GET, 매수 전략 모듈이 HTTP 로 소비). 응답 단위(자산 universe / 포지션 / 후보)는 OQ-1 에서 확정한다. - FR-1: trex-core 가 종목 스냅샷을 read API 엔드포인트로 제공한다(GET, 매수 전략 모듈이 HTTP 로 소비). 스냅샷 단위는 daemon 이 관리하는 portfolio universe 이며, 항목당 symbol, asset_id(=portfolio_id), 현금(cash_balance), 포지션(asset_quantity), active_strategy_id, 전략파라미터(opaque JSON)를 담고 가격(price)은 제외한다. (OQ-1 Answered)
- FR-2: 스냅샷 응답에서 **현재 슬롯을 점유(pending/holding)한 종목을 제외**한 매수 후보 리스트를 반환한다. 제외 판정은 core 의 권위 슬롯 상태를 사용한다(slot overlay). - FR-2: 스냅샷 응답에서 **현재 슬롯을 점유(pending/holding)한 종목을 제외**한 매수 후보 리스트를 반환한다. 제외 판정은 core 의 권위 슬롯 상태를 사용한다(slot overlay).
- FR-3: 가용 슬롯이 0(capacity 소진)이면 매수 후보를 빈 리스트로 반환한다. 빈 리스트의 표현(빈 배열 vs null vs 메타 필드)은 OQ-6 결정에 따른다. - FR-3: 가용 슬롯이 0(capacity 소진)이면 매수 후보를 빈 리스트로 반환한다. 빈 리스트의 표현(빈 배열 vs null vs 메타 필드)은 OQ-6 결정에 따른다.
- FR-4: portfolio-hub read 접근을 `PortfolioHubReadPort`(get_assets/get_positions) 추상화 + portfolio-hub 어댑터 + 결정론 stub 로 제공하고, dcy-004 의 portfolio-hub 어댑터/자격증명을 재사용한다. - FR-4: portfolio-hub read 접근을 `PortfolioHubReadPort.get_portfolios(daemon_id)` 추상화 + portfolio-hub 어댑터(`managed_by_daemon_id` 기준) + 결정론 stub 로 제공하고, dcy-004 의 portfolio-hub 어댑터/자격증명을 재사용한다. 전략파라미터는 opaque JSON 으로 통과시킨다(trex-core 가 스키마를 해석하지 않음).
- FR-5: baseline 문서(interface-contract/runtime-flows/project-structure)를 read 포트·스냅샷 엔드포인트·read 어댑터에 맞게 갱신한다. - FR-5: baseline 문서(interface-contract/runtime-flows/project-structure)를 read 포트·스냅샷 엔드포인트·read 어댑터·일일 스케줄러에 맞게 갱신한다.
- FR-6: trex-core daemon 이 설정 가능한 시각(기본 07:30 KST, .env 로 지정)에 일일 스케줄로 `daemon_id` universe 를 페치해 스냅샷을 보유한다. universe 멤버십·전략파라미터는 일일 스냅샷으로 고정하고, 현금/포지션은 스냅샷 baseline 에 그날 core 의 in-flight 체결분을 반영해 보정한다(옵션 C).
- FR-7: 스냅샷 페치 시 각 portfolio 의 portfolio-hub 포지션과 실제 브로커 계좌 포지션이 일치하는지 검증한다. 불일치하는 자산은 `warning` 상태로 표시하고 매수 후보에서 제외해 해당 자산에 매매 전략을 수행하지 않는다.
## 비기능 요구사항 ## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. 성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: 도메인 순수성 유지 — read 포트는 application 계약, 어댑터는 adapters 레이어 격리. `trex_core/domain` 은 portfolio-hub/네트워크 의존 0(import 스윕). - NFR-1: 도메인 순수성 유지 — read 포트는 application 계약, 어댑터는 adapters 레이어 격리. `trex_core/domain` 은 portfolio-hub/네트워크 의존 0(import 스윕).
- NFR-2: slot overlay 일관성 — 점유 종목 제외/가용 슬롯 판정은 core 의 권위 슬롯 상태(강한 일관성)를 사용한다. portfolio-hub 자산 데이터의 신선도 정책은 OQ-2 에 따른다. - NFR-2: 일관성 — 점유 종목 제외/가용 슬롯 판정은 core 의 권위 슬롯 상태(강한 일관성)를 사용한다. 현금/포지션은 일일 baseline + core in-flight 체결 보정으로 유지하고(옵션 C), 스냅샷 페치 시 브로커 계좌와 정합 검증한다(불일치 자산 `warning` 제외).
- NFR-3: 가용성 degrade — portfolio-hub 불가 시 스냅샷 read 의 동작(에러 vs 캐시 fallback vs 빈 응답)은 OQ-5 정책을 따른다. - NFR-3: 가용성 degrade — portfolio-hub 불가 시 스냅샷 read 의 동작(에러 vs 캐시 fallback vs 빈 응답)은 OQ-5 정책을 따른다.
## 범위 경계 ## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. 이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope - In Scope
- portfolio-hub read 포트 + 어댑터 + stub, 종목 스냅샷 read API, 슬롯 점유 종목 제외(slot overlay), 가용 슬롯 0 → 빈 후보, baseline 갱신. - portfolio-hub read 포트 + 어댑터 + stub, 종목 스냅샷 read API, 슬롯 점유 종목 제외(slot overlay), 가용 슬롯 0 → 빈 후보, 설정 시각 일일 스냅샷 + 현금/포지션 in-flight 보정(옵션 C), 포지션 정합 검증 + `warning` 제외, baseline 갱신.
- Out of Scope (후속 Discovery) - Out of Scope (후속 Discovery)
- 매수 전략 의사결정 로직 자체(점수화·랭킹·진입 타이밍) — 전략 모듈 책임. - 매수 전략 의사결정 로직 자체(점수화·랭킹·진입 타이밍) — 전략 모듈 책임.
- portfolio-hub ↔ core 양방향 reconciliation, 포지션 재구성, asset master 영속 동기화. - portfolio-hub ↔ core 양방향 reconciliation, 포지션 재구성, asset master 영속 동기화.
@@ -104,34 +110,38 @@
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. 불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크 ## 리스크
- R-1 (Medium): stale 스냅샷 — portfolio-hub 자산 데이터를 캐시하면 오래된 데이터 위에서 매수 후보가 만들어질 수 있다. - R-1 (Medium): stale 스냅샷 — 일일 캐시한 현금/포지션이 장중에 어긋날 수 있다.
- 완화 방안(OQ-2): 신선도/캐시 정책(live pass-through 또는 TTL+무효화) 확정. slot overlay 는 core 권위 상태라 강한 일관성 유지. - 완화 방안(OQ-2 Answered, 옵션 C): 현금/포지션을 baseline + core in-flight 체결 보정으로 유지. daemon 이 유일 매매 주체이고 장중 입출금이 없다는 전제(A-6)로 정확. 추가로 페치 시 브로커 계좌 정합 검증(FR-7).
- R-2 (Medium): in-flight race — dcy-004 ledger outbox 가 eventually-consistent 라, 막 체결한 종목이 portfolio-hub 포지션에 아직 반영되기 전 후보에 중복 노출될 수 있다. - R-2 (Medium): in-flight race — dcy-004 ledger outbox 가 eventually-consistent 라, 막 체결한 종목이 portfolio-hub 포지션에 아직 반영되기 전 후보에 중복 노출될 수 있다.
- 완화 방안(OQ-4): core slot overlay(pending/holding 제외)로 이 gap 을 메운다. portfolio-hub 포지션과 core 슬롯 상태의 합성 규칙을 명확화한다. - 완화 방안(OQ-4): core slot overlay(pending/holding 제외)로 이 gap 을 메운다. portfolio-hub 포지션과 core 슬롯 상태의 합성 규칙을 명확화한다.
- R-3 (Medium): 가용성 결합 — portfolio-hub 장애가 매수 전략 read 를 막는다. - R-3 (Medium): 가용성 결합 — portfolio-hub 장애가 매수 전략 read 를 막는다.
- 완화 방안(OQ-5): 캐시 fallback 또는 명시적 degrade 응답 정책. 어댑터 격리(NFR-1). - 완화 방안(OQ-5): 캐시 fallback 또는 명시적 degrade 응답 정책. 어댑터 격리(NFR-1).
- R-4 (Low): 책임 경계 확장 — core 가 portfolio-hub read gateway 로 비대해질 위험. - R-4 (Low): 책임 경계 확장 — core 가 portfolio-hub read gateway 로 비대해질 위험.
- 완화 방안: slot overlay 부가가치(점유 제외·가용 판정)로 범위를 한정하고 단순 패스스루 프록시는 지양(OQ-3). - 완화 방안: slot overlay 부가가치(점유 제외·가용 판정)로 범위를 한정하고 단순 패스스루 프록시는 지양(OQ-3).
- R-5 (Medium): 포지션 drift — portfolio-hub baseline 포지션이 실제 브로커 계좌와 어긋나면(과거 누락·정합 오류) 옵션 C 의 보정 baseline 자체가 틀린다.
- 완화 방안(FR-7): 스냅샷 페치 시 portfolio-hub 포지션 vs 브로커 계좌 포지션 정합 검증, 불일치 자산은 `warning` 으로 매매 제외(보수적 안전). 일치 기준·회복 규칙은 OQ-8.
## 가정 ## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. 아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: portfolio-hub 는 orbit 과 동일 read 계약(get_assets/get_portfolios 류)을 제공한다. - A-1: portfolio-hub 는 daemon 인증(`auth_tokens.daemon_id`)으로 자신이 관리하는 portfolio 집합을 조회하는 read 계약을 제공한다. (근거: portfolio-hub `app/api/v1_portfolios.py`·`v1_user_info.py` 의 `auth.daemon_id` 스코프)
- A-2: trex-core 는 슬롯/capacity 의 권위 단일 소스이며, 점유 종목·가용 슬롯 판정에 자기 상태를 사용할 수 있다. - A-2: trex-core 는 슬롯/capacity 의 권위 단일 소스이며, 점유 종목·가용 슬롯 판정에 자기 상태를 사용할 수 있다.
- A-3: 매수 전략 모듈은 trex-core 외부 소비자이며 HTTP 로 스냅샷을 받는다. - A-3: 매수 전략 모듈은 trex-core 외부 소비자이며 HTTP 로 스냅샷을 받는다.
- A-4: dcy-004 의 portfolio-hub 어댑터/자격증명(.env `TREX_PORTFOLIO_HUB_*`)을 read 방향에서도 재사용한다. - A-4: dcy-004 의 portfolio-hub 어댑터/자격증명(.env `TREX_PORTFOLIO_HUB_*`)을 read 방향에서도 재사용한다.
- A-5: portfolio-hub 의 `daemon` 은 1급 엔티티이며 portfolio 가 `managed_by_daemon_id` 로 daemon 에 귀속된다(1 daemon : N portfolio, 각 portfolio ≈ 1 asset, asset_id == portfolio_id). 전략파라미터는 portfolio-hub 가 `strategy_params` JSONB 로 보관한다. trex-core daemon 의 universe = 자신이 관리하는 portfolio 집합이다. (근거: portfolio-hub `app/domain/models.py` Daemon/Portfolio, `strategy_params` JSONB migration)
- A-6: 이 trex-core daemon 은 자신이 관리하는 portfolio 의 유일한 매매 주체이며, 장중 거래 외 현금 변동(입출금)은 없다. 따라서 일일 baseline + core in-flight 체결 보정만으로 현금/포지션을 정확히 유지할 수 있다(옵션 C 전제). (사용자 확인 2026-06-04)
## 오픈 질문 ## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. 답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: 스냅샷의 정의 — 응답이 (a) 거래 가능 universe(asset master), (b) 현재 포지션/보유, (c) 전략 후보/타깃 비중 중 무엇을 담는가(또는 조합)? 매수 전략에 필요한 최소 필드는? - OQ-1: 스냅샷의 정의 — 응답 단위와 필드.
- 상태: Open - 상태: Answered (2026-06-04)
- 처리 방안: 운영자/매수 전략 모듈 요구를 confirm-discovery 시점에 확정. - 결정: 스냅샷 = daemon 이 관리하는 portfolio universe. 항목당 symbol, asset_id(=portfolio_id), 현금(cash_balance), 포지션(asset_quantity), active_strategy_id, 전략파라미터(opaque JSON). 가격(price)은 사용하지 않으므로 제외. 전략파라미터는 portfolio-hub 가 `strategy_params` JSONB 로 보관하므로 trex-core 는 해석 없이 opaque JSON 으로 통과시킨다.
- 종료 조건: 응답 단위와 필드 집합이 FR-1 에 반영되면 Answered. - 반영 위치: `# 3` 기대 상태, `# 5` FR-1·FR-4, `# 6` A-5.
- OQ-2: 신선도/캐시 정책 — live pass-through 인가, 캐시(REQ-016 Redis 패턴)인가? 캐시라면 TTL/무효화 기준은? - OQ-2: 신선도/캐시 정책 — universe 페치 주기와 현금/포지션 신선도.
- 상태: Open - 상태: Answered (2026-06-04)
- 처리 방안: 운영자 결정. 매수 결정의 stale 허용도 기준으로 판단. - 결정: universe 멤버십·전략파라미터는 설정 시각(기본 07:30 KST, .env) 일일 스냅샷으로 고정. 현금/포지션은 스냅샷 baseline 에 **core 의 그날 in-flight 체결분을 반영해 보정**한다(옵션 C). 근거: 이 daemon 이 자기 portfolio 의 유일 매매 주체이고(A-6) 장중 입출금 등 거래 외 변동이 없으므로 baseline + 자기 체결 보정으로 정확한 현금/포지션을 portfolio-hub 추가 호출(매 요청 live) 없이 유지 가능. 매수 전략의 현금 조회 주기가 빠를 수 있어(분 단위) 매 요청 live 호출(옵션 B)의 가용성 결합·지연을 회피한다.
- 종료 조건: 캐시 여부와 신선도 기준이 NFR-2/R-1 에 반영되면 Answered. - 반영 위치: `# 5` FR-6·NFR-2, `# 6` R-1·A-6.
- OQ-3: core 경유 정책 — 모든 소비자가 trex-core 를 경유하는가, 일부는 portfolio-hub 직접 호출을 허용하는가? core 경유의 부가가치(slot overlay)를 어디까지 의무화하는가? - OQ-3: core 경유 정책 — 모든 소비자가 trex-core 를 경유하는가, 일부는 portfolio-hub 직접 호출을 허용하는가? core 경유의 부가가치(slot overlay)를 어디까지 의무화하는가?
- 상태: Open - 상태: Open
- 처리 방안: 운영자 결정. 직접 호출 대비 indirection 비용과 slot 결합 가치 비교. - 처리 방안: 운영자 결정. 직접 호출 대비 indirection 비용과 slot 결합 가치 비교.
@@ -148,6 +158,14 @@
- 상태: Open - 상태: Open
- 처리 방안: 매수 전략 모듈 소비 계약에 맞춰 결정(REST 관례상 빈 배열 + 메타 필드 권장안 검토). - 처리 방안: 매수 전략 모듈 소비 계약에 맞춰 결정(REST 관례상 빈 배열 + 메타 필드 권장안 검토).
- 종료 조건: 표현 방식이 FR-3 에 반영되면 Answered. - 종료 조건: 표현 방식이 FR-3 에 반영되면 Answered.
- OQ-7: trex-core daemon 의 `daemon_id` 주입 방식 — .env 설정값으로 직접 주입하는가, portfolio-hub 인증 토큰(`auth_tokens.daemon_id`)에서 도출하는가?
- 상태: Open
- 처리 방안: 운영자 결정. dcy-004 자격증명 주입(.env `TREX_PORTFOLIO_HUB_*`) 패턴과 정합성 검토.
- 종료 조건: 주입 방식이 config/FR-4 에 반영되면 Answered.
- OQ-8: 포지션 정합 검증 세부 — 일치 판정 기준(정확 수량 일치 vs 허용 오차), `warning` 상태 회복 조건(다음 정합 성공 시 자동 해제 여부), 브로커 계좌 포지션 출처(KIS 잔고조회 재사용 범위).
- 상태: Open
- 처리 방안: 설계 시 확정. BrokerPort 잔고조회 계약 + `warning` 상태 저장/회복 규칙 정의.
- 종료 조건: 정합 기준·회복 규칙·포지션 출처가 FR-7 에 반영되면 Answered.
### 오픈 질문 처리 기준 ### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. - `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
@@ -166,6 +184,7 @@
- S-3: 가용 슬롯이 0 일 때 스냅샷 API 가 빈 후보 리스트를 반환한다. - S-3: 가용 슬롯이 0 일 때 스냅샷 API 가 빈 후보 리스트를 반환한다.
- S-4: 도메인 import 스윕에서 portfolio-hub/네트워크 의존이 0 으로 유지된다. - S-4: 도메인 import 스윕에서 portfolio-hub/네트워크 의존이 0 으로 유지된다.
- S-5: interface-contract/runtime-flows/project-structure 가 read 레이어와 정합한다. - S-5: interface-contract/runtime-flows/project-structure 가 read 레이어와 정합한다.
- S-6: 스냅샷 페치 시 portfolio-hub 포지션과 브로커 계좌 포지션이 불일치하는 자산이 `warning` 으로 표시되고 매수 후보에서 제외된다.
## 측정 방식 및 데이터 출처 ## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. 각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
@@ -175,6 +194,7 @@
- S-3 측정 방식: 슬롯 소진(capacity 0) 후 스냅샷 호출 테스트 / 데이터 출처: 후보 리스트 길이. - S-3 측정 방식: 슬롯 소진(capacity 0) 후 스냅샷 호출 테스트 / 데이터 출처: 후보 리스트 길이.
- S-4 측정 방식: 정적 import 스윕 / 데이터 출처: `trex_core/domain` import 목록. - S-4 측정 방식: 정적 import 스윕 / 데이터 출처: `trex_core/domain` import 목록.
- S-5 측정 방식: 문서-코드 정합 점검(리뷰) / 데이터 출처: baseline 문서 vs 실제 엔드포인트/포트. - S-5 측정 방식: 문서-코드 정합 점검(리뷰) / 데이터 출처: baseline 문서 vs 실제 엔드포인트/포트.
- S-6 측정 방식: 포지션 불일치 주입 후 스냅샷 페치 테스트 / 데이터 출처: 자산 `warning` 상태·후보 리스트.
# 8. REQ로 넘기기 전 확인 체크 # 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
@@ -186,7 +206,7 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 - [ ] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: 본 Discovery 의 리스크는 현재 전부 Medium/Low. 신선도(R-1)·race(R-2)·가용성(R-3) 처리 방침을 운영자가 확인. - 기준: 본 Discovery 의 리스크는 현재 전부 Medium/Low. 신선도(R-1)·race(R-2)·가용성(R-3) 처리 방침을 운영자가 확인.
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1~OQ-6 이 전부 Answered/Deferred 처리(현재 6개 모두 Open). - 기준: OQ-1~OQ-8 이 전부 Answered/Deferred 처리(현재 OQ-1·OQ-2 Answered, OQ-3~OQ-8 Open).
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정 - [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 운영자가 confirm-discovery 시점에 CONFIRMED_BY 를 지정한다. - 기준: 운영자가 confirm-discovery 시점에 CONFIRMED_BY 를 지정한다.
@@ -204,8 +224,9 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. 사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE ## DECIDE
- {{DECIDE_SNAPSHOT_SHAPE}} — 스냅샷 응답 단위/필드(universe vs 포지션 vs 후보) 확정 (OQ-1) - ~~스냅샷 응답 단위/필드~~ → 결정됨: daemon 관리 portfolio universe(symbol/asset_id/현금/포지션/active_strategy_id/전략파라미터 opaque JSON), 가격 제외 (OQ-1, 2026-06-04)
- {{DECIDE_FRESHNESS_POLICY}} — live pass-through vs 캐시(TTL/무효화) 신선도 정책 (OQ-2) - ~~신선도 정책~~ → 결정됨: universe·전략파라미터 설정 시각 일일 캐시 + 현금/포지션 baseline+core in-flight 보정(옵션 C) (OQ-2, 2026-06-04)
- {{DECIDE_DAEMON_ID_INJECTION}} — trex-core daemon 의 daemon_id 주입(.env vs 인증 토큰) (OQ-7)
- {{DECIDE_EMPTY_SEMANTICS}} — 가용 슬롯 0 시 빈 배열 vs null vs 메타 필드 표현 (OQ-6) - {{DECIDE_EMPTY_SEMANTICS}} — 가용 슬롯 0 시 빈 배열 vs null vs 메타 필드 표현 (OQ-6)
## CONFIRM ## CONFIRM
@@ -214,7 +235,7 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
- {{CONFIRM_INFLIGHT_OVERLAY}} — portfolio-hub 포지션 + core 슬롯 합성/제외 규칙, 가용성 degrade 정책 (OQ-4/OQ-5) - {{CONFIRM_INFLIGHT_OVERLAY}} — portfolio-hub 포지션 + core 슬롯 합성/제외 규칙, 가용성 degrade 정책 (OQ-4/OQ-5)
## DATA ## DATA
- {{DATA_PORTFOLIO_HUB_READ_CONTRACT}} — portfolio-hub read API 계약(get_assets/get_portfolios) 및 응답 필드(orbit 동일 가정 검증) - {{DATA_PORTFOLIO_HUB_READ_CONTRACT}} — portfolio-hub read API 계약(`managed_by_daemon_id` 로 daemon universe 조회) 및 응답 필드(cash_balance/asset_quantity/strategy_params JSONB) 확인
# 11. Discovery Freeze # 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
@@ -228,7 +249,7 @@ REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록
## Freeze 플래그 ## Freeze 플래그
- Handoff Decision: 보류 - Handoff Decision: 보류
- Handoff Rationale: 초기 Discovery 초안 생성 완료. OQ-1~OQ-6(스냅샷 정의·신선도/캐시·core 경유 정책·in-flight 합성·가용성 degrade·빈 후보 시맨틱)이 미확정이며, confirm-discovery 에서 운영자 결정 후 REQ Drafting 으로 인계한다. - Handoff Rationale: OQ-1(스냅샷 정의)·OQ-2(신선도, 옵션 C) Answered. OQ-3~OQ-8(core 경유 정책·in-flight 합성·가용성 degrade·빈 후보 시맨틱·daemon_id 주입·포지션 정합 세부)이 미확정이며, confirm-discovery 에서 운영자 결정 후 REQ Drafting 으로 인계한다.
- Ready for REQ Drafting: false - Ready for REQ Drafting: false
- Confirmed By: TBD - Confirmed By: TBD
- Confirmed At (KST): TBD - Confirmed At (KST): TBD
+82 -1
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-03 (BAT-002) - Last Updated (KST): 2026-06-04 (BAT-006)
- 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 도메인)
@@ -28,6 +28,9 @@
- HTTP `/v1` API :: 슬롯 코어 데몬이 노출, 매수·매도 전략 데몬이 호출(REQ-005/007/008) - HTTP `/v1` API :: 슬롯 코어 데몬이 노출, 매수·매도 전략 데몬이 호출(REQ-005/007/008)
- SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트) - SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약 - SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
- BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus`
- LedgerPort (Protocol) :: 외부 원장(portfolio-hub) 기록 경계 + `LedgerTrade`/`LedgerWriteResult`/예외 (BAT-006)
- LedgerOutbox (Protocol) + LedgerWriter :: 체결 → 원장 기록의 outbox/재시도/멱등 (BAT-006)
## Interface: `HTTP /v1` (슬롯 코어 데몬) ## Interface: `HTTP /v1` (슬롯 코어 데몬)
@@ -113,6 +116,83 @@
- 구현체는 advisory lock 직렬화와 append-only 전이 로그 의미를 반드시 보장한다. - 구현체는 advisory lock 직렬화와 append-only 전이 로그 의미를 반드시 보장한다.
- 구현체는 lock 보유 중 외부 I/O 를 수행하지 않는다. - 구현체는 lock 보유 중 외부 I/O 를 수행하지 않는다.
## Interface: `LedgerPort` (BAT-006)
- Type: external-service (HTTP, portfolio-hub)
- Consumers / Producers: `LedgerWriter`(소비) → `PortfolioHubLedger` 어댑터 또는 `InMemoryLedger` stub
- Purpose: 체결을 외부 회계/포지션 원장에 기록한다. orbit `post_ledger_trade` 패턴.
- Stability: experimental (v1)
### Inputs
- record_trade(trade: LedgerTrade) :: 단일 체결을 원장에 기록 (`execution_id` 멱등)
### `LedgerTrade` Fields
- asset_id :: str :: ledger path param (= 슬롯 `portfolio_id`, dcy-004 OQ-1)
- order_id :: str :: 로컬 주문 식별자 (`broker_order_id`)
- execution_id :: str :: 결정적 합성 (paper: `paper:{broker_order_id}:{filled_qty}`, KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`)
- side :: 'buy' | 'sell'
- qty / price :: float :: 체결 수량 / 평단
- fee :: float :: `BrokerPort.calculate_fee(side, qty*price)` 결과
- tax :: float :: 기본 0 (정밀 모델 후속)
- executed_at :: datetime?
- correlation_id (derived) :: `{asset_id}:{order_id}:{execution_id}` — 로컬 dedup 키
### Outputs / Outcomes
- `LedgerWriteResult.NEW` :: 새 원장 엔트리 적재
- `LedgerWriteResult.DUPLICATE_OK` :: 같은 execution_id 가 이미 기록되어 있음(서버 멱등)
### Error Contract
- `LedgerConflictError` :: portfolio-hub 409 — 운영자 해소 대상(자동 재시도 중단)
- `LedgerRetryableError` :: 5xx / timeout / network — outbox 가 재시도
### portfolio-hub Endpoint (어댑터 구현)
| HTTP | URL | Body | 응답 |
|---|---|---|---|
| POST | `/v1/assets/{asset_id}/ledger/trades` | `side, qty, price, fee, tax, executed_at, execution_id, order_id` | 2xx OK(`duplicate:true` 면 server-side dedup), 409 conflict, 5xx/network → retryable |
## Interface: `LedgerOutbox` Protocol + `LedgerWriter` (BAT-006)
- Type: internal-service (durable work-item store + drainer)
- Consumers / Producers: `ExecutionService`(생산자, fill 시 enqueue) → `LedgerWriter`(소비자, 백그라운드 워커)
- Purpose: 체결 → 원장 기록을 슬롯 전이와 분리해 무유실 + 멱등으로 처리한다. correlation_id 로 outbox 적재 dedup.
- Stability: internal
### Inputs
- enqueue(trade: LedgerTrade) :: `correlation_id` 키로 멱등 적재
- list_pending(limit) :: FIFO pending work item
- get(correlation_id) :: 단일 row 조회
- mark_outcome(correlation_id, outcome, error?) :: outcome 기록 + 상태 전이
### Outcomes (`OutboxOutcome`)
- `completed` :: 어댑터 NEW
- `duplicate_completed` :: 어댑터 DUPLICATE_OK 또는 로컬 dedup 히트
- `retryable_failure` :: 다음 tick 재시도 (status 는 `pending` 유지)
- `conflict_blocked` :: 409, 자동 재시도 중단(운영자 해소)
### WorkItem Status
- pending → completed (성공/중복)
- pending → conflict_blocked (409)
- pending → pending (retryable, attempts++)
### Persistence
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트.
## `FillStatus` 확장 (BAT-006 / REQ-018)
- `state, filled_qty, avg_price, raw` 외에 다음 필드를 운반한다.
- `execution_id :: str?` :: 결정적 합성(브로커별 규칙). filled 상태에서 필수.
- `fee :: float` :: BrokerPort `calculate_fee` 결과(기본 0).
- `tax :: float` :: 기본 0(정밀 모델 후속).
## Shared Constraints ## Shared Constraints
- 도메인은 외부 의존(브로커, DB 드라이버 등)을 직접 호출하지 않는다(순수 도메인 경계). - 도메인은 외부 의존(브로커, DB 드라이버 등)을 직접 호출하지 않는다(순수 도메인 경계).
@@ -135,3 +215,4 @@
- 2026-06-03 :: bootstrap-baseline 으로 초기 인터페이스 계약 baseline 생성(SlotService + SlotRepository Protocol). - 2026-06-03 :: bootstrap-baseline 으로 초기 인터페이스 계약 baseline 생성(SlotService + SlotRepository Protocol).
- 2026-06-03 :: BAT-002 — HTTP `/v1` API 계약(데몬, 6 엔드포인트) + 멱등/단일writer 불변식 추가(REQ-007/008/009). - 2026-06-03 :: BAT-002 — HTTP `/v1` API 계약(데몬, 6 엔드포인트) + 멱등/단일writer 불변식 추가(REQ-007/008/009).
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015). - 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
- 2026-06-04 :: BAT-006 — LedgerPort/LedgerTrade/LedgerWriteResult 계약, LedgerOutbox/LedgerWriter, portfolio-hub `/v1/assets/{asset_id}/ledger/trades` 엔드포인트 + 409→`LedgerConflictError`, FillStatus.execution_id/fee/tax 확장 (REQ-017~020).
+17 -12
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-03 - Last Updated (KST): 2026-06-04 (BAT-006)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
## Project Summary ## Project Summary
@@ -62,25 +62,29 @@
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존) │ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py │ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py │ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py
│ ├── application/ # 유스케이스 (BAT-002, BAT-004) │ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006)
│ │ ├── ports.py # IntentStore Protocol │ │ ├── ports.py # IntentStore Protocol
│ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision │ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
│ │ ├── broker_port.py # BrokerPort + OrderRequest/OrderAck/FillStatus (BAT-004) │ │ ├── broker_port.py # BrokerPort + OrderRequest/OrderAck/FillStatus + calculate_fee (BAT-004/BAT-006)
│ │ └── execution.py # ExecutionService (claim·dispatch·poll·transition·reconcile) │ │ ├── execution.py # ExecutionService (claim·dispatch·poll·transition·reconcile + ledger enqueue)
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004) │ │ ├── ledger_port.py # LedgerPort + LedgerTrade/LedgerWriteResult/예외 (BAT-006)
│ │ └── ledger_outbox.py # LedgerOutbox Protocol + LedgerWriter + InMemoryLedgerOutbox (BAT-006)
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006)
│ │ ├── api/ # FastAPI: app.py(create_app /v1) · schemas.py │ │ ├── api/ # FastAPI: app.py(create_app /v1) · schemas.py
│ │ ├── broker/ # paper.py(결정적 async 더블) · kis.py(KIS Open API) (BAT-004) │ │ ├── broker/ # paper.py(결정적 async 더블) · kis.py(KIS Open API) (BAT-004)
│ │ └── persistence/ # postgresql.py · in_memory_intent_store.py · migrate.py · sql/ │ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006)
│ └── runtime/ # 데몬 (BAT-002, BAT-004) │ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql)
│ ├── config.py # DaemonConfig + KISConfig(env, broker 선택/게이팅) │ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006)
│ └── daemon.py # build_app + lifespan(migrate/preallocate/reconcile/execution 루프) │ ├── config.py # DaemonConfig + KISConfig + PortfolioHubConfig(env)
│ └── daemon.py # build_app + lifespan(migrate/preallocate/reconcile/execution 루프 + ledger writer 루프)
└── tests/ └── tests/
├── domain/slot/ # test_state / test_service / test_contract ├── domain/slot/ # test_state / test_service / test_contract
├── application/ # test_intent_concurrency · test_execution (집행 lifecycle) ├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006)
└── adapters/{api,broker,persistence}/ # API · paper/KIS · PG repo/migration/drift ├── application/ # test_intent_concurrency · test_execution · test_execution_ledger · test_ledger_outbox
└── adapters/{api,broker,ledger,persistence}/ # API · paper/KIS · in_memory/portfolio-hub ledger · PG repo/migration/outbox
``` ```
> 참고: domain(BAT-001)은 무의존 코어. application/adapters/runtime 은 BAT-002 에서 추가, BAT-004 에서 broker/execution 레이어 확장. 웹/DB/브로커 의존(fastapi/uvicorn/psycopg/httpx)은 adapters/runtime 에만 격리(NFR). 브로커 주문은 BrokerPort 단일 세션(불변식 B), 슬롯 쓰기는 SlotService 단일 writer(불변식 A). > 참고: domain(BAT-001)은 무의존 코어. application/adapters/runtime 은 BAT-002 에서 추가, BAT-004 에서 broker/execution 레이어 확장, BAT-006 에서 ledger 레이어(application LedgerPort + adapters/ledger + runtime writer 루프 + persistence outbox)를 추가했다. 웹/DB/브로커/HTTP ledger 의존(fastapi/uvicorn/psycopg/httpx)은 adapters/runtime 에만 격리(NFR-1). 브로커 주문은 BrokerPort 단일 세션(불변식 B), 슬롯 쓰기는 SlotService 단일 writer(불변식 A), ledger 기록은 집행 경로 단일 주체(NFR-3).
## Package / Module Responsibilities ## Package / Module Responsibilities
@@ -125,3 +129,4 @@
- 2026-06-03 :: BAT-001 이식 완료 반영 — `trex_core/`·`tests/`·`pyproject.toml` planned→실제 정합(REQ-004). - 2026-06-03 :: BAT-001 이식 완료 반영 — `trex_core/`·`tests/`·`pyproject.toml` planned→실제 정합(REQ-004).
- 2026-06-03 :: BAT-002 — application/adapters/runtime 레이어(데몬+HTTP API+PostgreSQL) 추가, Primary Runtime=daemon+library(REQ-005~009). - 2026-06-03 :: BAT-002 — application/adapters/runtime 레이어(데몬+HTTP API+PostgreSQL) 추가, Primary Runtime=daemon+library(REQ-005~009).
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015). - 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
- 2026-06-04 :: BAT-006 — ledger 레이어 추가(application LedgerPort/LedgerOutbox, adapters/ledger in_memory·portfolio_hub, persistence PostgreSQLLedgerOutbox + 002 마이그레이션, runtime ledger writer 루프, FillStatus 확장, NFR-1 import 스윕) (REQ-017~020).
+24 -1
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-03 (BAT-002) - Last Updated (KST): 2026-06-04 (BAT-006)
- 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,6 +55,28 @@
- 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B). - 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B).
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅). - 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
## Flow: `체결 → ledger outbox` (BAT-006)
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3).
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 후 슬롯 전이는 정상 유지.
4. `clear_dispatch` 로 `broker_order_id` 해제.
## Flow: `ledger writer` (백그라운드, BAT-006)
데몬 lifespan 에서 별도 백그라운드 워커가 ledger outbox 를 drain 한다. 슬롯 전이와 분리되어 eventually-consistent.
1. `LedgerOutbox.list_pending(limit)` 으로 pending work item 을 FIFO 로 가져온다.
2. row 의 현재 상태가 이미 `completed` 면 (e.g. 동시/재처리) `duplicate_completed` 로 마킹하고 어댑터를 호출하지 않는다.
3. `LedgerPort.record_trade(trade)` 호출.
- `LedgerWriteResult.NEW` → outcome `completed`, row status `completed`.
- `LedgerWriteResult.DUPLICATE_OK`(어댑터 server-side dedup) → outcome `duplicate_completed`, row status `completed`, ledger 중복 0.
- `LedgerConflictError`(portfolio-hub 409) → outcome `conflict_blocked`, row status `conflict_blocked` (자동 재시도 중단, 운영자 해소 대상).
- `LedgerRetryableError`(timeout/5xx/network) → outcome `retryable_failure`, row status `pending` 유지(다음 tick 재시도).
4. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다.
## Shared Components ## Shared Components
- SlotRepository Protocol :: 저장소 추상화. 구현체(InMemorySlotRepository, 향후 PostgreSQLSlotRepository)를 주입. - SlotRepository Protocol :: 저장소 추상화. 구현체(InMemorySlotRepository, 향후 PostgreSQLSlotRepository)를 주입.
@@ -104,3 +126,4 @@
- 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖). - 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖).
- 2026-06-03 :: BAT-002 — 데몬 lifecycle + 매수/매도 intent HTTP 흐름 추가, Primary Runtime=daemon+library(REQ-005~009). 브로커 집행/safety floor 흐름은 후속 Discovery. - 2026-06-03 :: BAT-002 — 데몬 lifecycle + 매수/매도 intent HTTP 흐름 추가, Primary Runtime=daemon+library(REQ-005~009). 브로커 집행/safety floor 흐름은 후속 Discovery.
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015). - 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
- 2026-06-04 :: BAT-006 — ledger 통합 반영(체결→ledger outbox enqueue, LedgerWriter retry/dedup/409 흐름, FillStatus.execution_id/fee 운반, slot 전이와 ledger write 분리) (REQ-017~020).
@@ -1,6 +1,6 @@
# req-020: ledger 레이어 baseline 문서 정합 # req-020: ledger 레이어 baseline 문서 정합
- Status: Approved - Status: Implemented
- Type: Documentation - Type: Documentation
- Priority: Medium - Priority: Medium
- Owner: project maintainers - Owner: project maintainers
@@ -112,3 +112,36 @@
#### Validation Plan #### Validation Plan
- 기존 Acceptance Criteria 로 batch delivery 에서 검증. - 기존 Acceptance Criteria 로 batch delivery 에서 검증.
### CHG-20260604-03
- Date: 2026-06-04
- Author: Justin Ko
#### Change Summary
- Status 변경: Approved -> Implemented. BAT-006 release-candidate verification evidence 기반.
#### Intent
- runtime-flows / interface-contract / project-structure / data-model 4문서를 ledger 레이어와 정합시킨 baseline 갱신을 구현 완료 상태로 동기화.
#### Acceptance Criteria Delta
- Added/Updated/Removed: 없음
#### Impacted Area Delta
- Docs/Modules/Tests: 없음 (변경은 상태 전이만)
#### Delivery Trace Delta
- Batches:
- BAT-006 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented
- Follow-up Action: release 단계는 별도 진행
#### Validation Plan
- AC-1: 4 baseline 문서(runtime-flows/interface-contract/project-structure/data-model) 에 ledger 레이어 섹션 반영 완료.
- AC-2: baseline 문서 ↔ 코드 정합 manual review (verification.md Manual 섹션). NFR-1 import 스윕 + grep 결과로 보강.
- AC-3: 4 baseline 문서 Change Log 모두 2026-06-04 BAT-006 항목 추가.
@@ -1,6 +1,6 @@
# req-019: ledger outbox durability + correlation_id 멱등 # req-019: ledger outbox durability + correlation_id 멱등
- Status: Approved - Status: Implemented
- Type: Exception - Type: Exception
- Priority: High - Priority: High
- Owner: project maintainers - Owner: project maintainers
@@ -114,3 +114,38 @@
#### Validation Plan #### Validation Plan
- 기존 Acceptance Criteria 로 batch delivery 에서 검증. - 기존 Acceptance Criteria 로 batch delivery 에서 검증.
### CHG-20260604-03
- Date: 2026-06-04
- Author: Justin Ko
#### Change Summary
- Status 변경: Approved -> Implemented. BAT-006 release-candidate verification evidence 기반.
#### Intent
- ledger outbox durability + correlation_id 멱등 + outcome 분류 구현과 검증 evidence 확보 후 구현 완료 상태로 동기화.
#### Acceptance Criteria Delta
- Added/Updated/Removed: 없음
#### Impacted Area Delta
- Docs/Modules/Tests: 없음 (변경은 상태 전이만)
#### Delivery Trace Delta
- Batches:
- BAT-006 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented
- Follow-up Action: release 단계는 별도 진행
#### Validation Plan
- AC-1: tests/application/test_execution_ledger.py::test_duplicate_execution_id_results_in_zero_duplicate_in_ledger.
- AC-2: tests/application/test_execution_ledger.py::test_retryable_failure_then_eventually_completes + tests/application/test_ledger_outbox.py::test_writer_retries_until_success.
- AC-3: tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry + tests/application/test_ledger_outbox.py::test_writer_marks_conflict_blocked_and_does_not_retry.
- AC-4: tests/application/test_execution_ledger.py::test_duplicate_completed_when_reprocessing_terminal_item + test_duplicate_execution_id_results_in_zero_duplicate_in_ledger.
- AC-5: tests/application/test_execution_ledger.py::test_slot_transition_proceeds_when_ledger_is_down.
@@ -1,6 +1,6 @@
# req-017: LedgerPort 추상화 + portfolio-hub 어댑터 + stub 원장 # req-017: LedgerPort 추상화 + portfolio-hub 어댑터 + stub 원장
- Status: Approved - Status: Implemented
- Type: Integration - Type: Integration
- Priority: High - Priority: High
- Owner: project maintainers - Owner: project maintainers
@@ -113,3 +113,37 @@
#### Validation Plan #### Validation Plan
- 기존 Acceptance Criteria 로 batch delivery 에서 검증. - 기존 Acceptance Criteria 로 batch delivery 에서 검증.
### CHG-20260604-03
- Date: 2026-06-04
- Author: Justin Ko
#### Change Summary
- Status 변경: Approved -> Implemented. BAT-006 release-candidate verification evidence 기반.
#### Intent
- LedgerPort/portfolio-hub adapter/in-memory stub 구현과 검증 evidence 확보 후 구현 완료 상태로 동기화.
#### Acceptance Criteria Delta
- Added/Updated/Removed: 없음
#### Impacted Area Delta
- Docs/Modules/Tests: 없음 (변경은 상태 전이만)
#### Delivery Trace Delta
- Batches:
- BAT-006 (release-candidate)
- Releases:
- none (배포 미생성)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented
- Follow-up Action: release 단계는 별도 진행
#### Validation Plan
- AC-1: tests/adapters/ledger/test_in_memory_ledger.py::test_record_trade_preserves_fields_and_is_idempotent_on_execution_id.
- AC-2: tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_asset_id.
- AC-3: tests/adapters/ledger/test_portfolio_hub_ledger.py::test_409_surfaces_as_ledger_conflict_error + tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry.
- AC-4: tests/domain/test_domain_import_sweep.py::test_domain_has_no_forbidden_imports.
@@ -1,6 +1,6 @@
# req-018: 체결→원장 기록 (집행 루프 fill hook) # req-018: 체결→원장 기록 (집행 루프 fill hook)
- Status: Approved - Status: Implemented
- Type: Integration - Type: Integration
- Priority: High - Priority: High
- Owner: project maintainers - Owner: project maintainers
@@ -114,3 +114,38 @@
#### Validation Plan #### Validation Plan
- 기존 Acceptance Criteria 로 batch delivery 에서 검증. - 기존 Acceptance Criteria 로 batch delivery 에서 검증.
### CHG-20260604-03
- Date: 2026-06-04
- Author: Justin Ko
#### Change Summary
- Status 변경: Approved -> Implemented. BAT-006 release-candidate verification evidence 기반.
#### Intent
- 집행 루프 fill→ledger hook + FillStatus.execution_id/fee 확장 구현과 검증 evidence 확보 후 구현 완료 상태로 동기화.
#### Acceptance Criteria Delta
- Added/Updated/Removed: 없음
#### Impacted Area Delta
- Docs/Modules/Tests: 없음 (변경은 상태 전이만)
#### Delivery Trace Delta
- Batches:
- BAT-006 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented
- Follow-up Action: release 단계는 별도 진행
#### Validation Plan
- AC-1: tests/application/test_execution_ledger.py::test_buy_fill_enqueues_one_ledger_item_and_writer_records_it.
- AC-2: tests/application/test_execution_ledger.py::test_sell_fill_enqueues_sell_ledger_item.
- 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 지점).
+5 -5
View File
@@ -97,15 +97,15 @@
| REQ-014 | 주문 핸드오프 durability + reconciliation | Exception | Implemented | High | project maintainers | [req-014](./Exception/req-014_order-handoff-durability-reconciliation.md) | | REQ-014 | 주문 핸드오프 durability + reconciliation | Exception | Implemented | High | project maintainers | [req-014](./Exception/req-014_order-handoff-durability-reconciliation.md) |
| REQ-015 | 브로커/집행 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-015](./Documentation/req-015_broker-execution-baseline-alignment.md) | | REQ-015 | 브로커/집행 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-015](./Documentation/req-015_broker-execution-baseline-alignment.md) |
| REQ-016 | KIS 인증 토큰 Redis 캐시 | Integration | Implemented | Medium | project maintainers | [req-016](./Integration/req-016_kis-token-redis-cache.md) | | REQ-016 | KIS 인증 토큰 Redis 캐시 | Integration | Implemented | Medium | project maintainers | [req-016](./Integration/req-016_kis-token-redis-cache.md) |
| REQ-017 | LedgerPort 추상화 + portfolio-hub 어댑터 + stub | Integration | Approved | High | project maintainers | [req-017](./Integration/req-017_ledger-port-and-portfolio-hub-adapter.md) | | REQ-017 | LedgerPort 추상화 + portfolio-hub 어댑터 + stub | Integration | Implemented | High | project maintainers | [req-017](./Integration/req-017_ledger-port-and-portfolio-hub-adapter.md) |
| REQ-018 | 체결→원장 기록 (집행 루프 fill hook) | Integration | Approved | High | project maintainers | [req-018](./Integration/req-018_fill-to-ledger-recording.md) | | REQ-018 | 체결→원장 기록 (집행 루프 fill hook) | Integration | Implemented | High | project maintainers | [req-018](./Integration/req-018_fill-to-ledger-recording.md) |
| REQ-019 | ledger outbox durability + correlation_id 멱등 | Exception | Approved | High | project maintainers | [req-019](./Exception/req-019_ledger-outbox-durability-idempotency.md) | | REQ-019 | ledger outbox durability + correlation_id 멱등 | Exception | Implemented | High | project maintainers | [req-019](./Exception/req-019_ledger-outbox-durability-idempotency.md) |
| REQ-020 | ledger 레이어 baseline 문서 정합 | Documentation | Approved | Medium | project maintainers | [req-020](./Documentation/req-020_ledger-baseline-alignment.md) | | REQ-020 | ledger 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-020](./Documentation/req-020_ledger-baseline-alignment.md) |
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-06-04 :: REQ-017~020 Approved → Implemented — BAT-006 release-candidate verification evidence 기반. (confirm-req-implemented)
- 2026-06-04 :: REQ-017~020 Proposed -> Approved — 승인 게이트 통과. (confirm-req) - 2026-06-04 :: REQ-017~020 Proposed -> Approved — 승인 게이트 통과. (confirm-req)
- 2026-06-04 :: REQ-017~020 최초 생성(Proposed) — dcy-004 FR-1~FR-5 분해(NFR-1~3 흡수). (draft-req) - 2026-06-04 :: REQ-017~020 최초 생성(Proposed) — dcy-004 FR-1~FR-5 분해(NFR-1~3 흡수). (draft-req)
- 2026-06-04 :: REQ-016 생성 + Approved — KIS 토큰 Redis 캐시 (minor-change). (draft-req/confirm-req) - 2026-06-04 :: REQ-016 생성 + Approved — KIS 토큰 Redis 캐시 (minor-change). (draft-req/confirm-req)
- 2026-06-04 :: REQ-011~015 Approved → Implemented — BAT-004 verification evidence 기반. (confirm-req-implemented) - 2026-06-04 :: REQ-011~015 Approved → Implemented — BAT-004 verification evidence 기반. (confirm-req-implemented)
- 2026-06-04 :: REQ-011~015 Proposed → Approved — 승인 게이트 통과. (confirm-req)
@@ -40,7 +40,7 @@ def test_post_contract_includes_execution_id_and_path_uses_asset_id():
adapter.record_trade(_trade(asset="PF-77")) adapter.record_trade(_trade(asset="PF-77"))
assert captured["url"] == "https://portfolio-hub.test/v1/assets/PF-77/ledger/trades" assert captured["url"] == "https://portfolio-hub.test/v1/assets/PF-77/ledger/trades"
assert "execution_id" in captured["body"] assert "execution_id" in captured["body"]
assert "\"side\": \"buy\"" in captured["body"] assert "\"side\":\"buy\"" in captured["body"]
assert captured["auth"] == "Bearer tok" assert captured["auth"] == "Bearer tok"
@@ -11,7 +11,13 @@ import psycopg
from trex_core.adapters.persistence.migrate import apply_migrations, migration_files from trex_core.adapters.persistence.migrate import apply_migrations, migration_files
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS from trex_core.domain.slot import EXPECTED_SLOT_FIELDS
_EXPECTED_TABLES = {"daily_slot", "daily_symbol_entries", "slot_transition_log", "intent_log"} _EXPECTED_TABLES = {
"daily_slot",
"daily_symbol_entries",
"slot_transition_log",
"intent_log",
"ledger_outbox", # BAT-006 / REQ-019
}
def _columns(dsn: str, table: str) -> set[str]: def _columns(dsn: str, table: str) -> set[str]:
@@ -0,0 +1,72 @@
"""PostgreSQL ledger outbox integration tests (REQ-019).
Skipped without TREX_POSTGRESQL_TEST_DSN (same gate as other PG suites).
"""
from __future__ import annotations
from datetime import datetime, timezone
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.postgresql import PostgreSQLLedgerOutbox
from trex_core.application.ledger_outbox import (
LedgerWriter,
OutboxOutcome,
WorkItemStatus,
)
from trex_core.application.ledger_port import LedgerTrade
def _trade(execution_id="paper:1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
)
def test_enqueue_persists_and_is_idempotent(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
a = outbox.enqueue(_trade())
b = outbox.enqueue(_trade())
assert a.correlation_id == b.correlation_id
pending = outbox.list_pending()
assert len(pending) == 1
assert pending[0].status is WorkItemStatus.PENDING
def test_writer_completes_persisted_item(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
outbox.enqueue(_trade(execution_id="pg-1"))
stats = writer.run_once()
assert stats.completed == 1 and ledger.count() == 1
row = outbox.get("PF-1:ORD-1:pg-1")
assert row is not None and row.status is WorkItemStatus.COMPLETED
assert row.last_outcome is OutboxOutcome.COMPLETED
def test_writer_marks_conflict_blocked_on_pg(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
ledger = InMemoryLedger(conflict_executions={"pg-x"})
writer = LedgerWriter(outbox, ledger)
outbox.enqueue(_trade(execution_id="pg-x"))
writer.run_once()
row = outbox.get("PF-1:ORD-1:pg-x")
assert row.status is WorkItemStatus.CONFLICT_BLOCKED
assert row.last_outcome is OutboxOutcome.CONFLICT_BLOCKED
def test_writer_retries_on_pg(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
ledger = InMemoryLedger(fail_n_then_succeed=1)
writer = LedgerWriter(outbox, ledger)
outbox.enqueue(_trade(execution_id="pg-r"))
writer.run_once()
row1 = outbox.get("PF-1:ORD-1:pg-r")
assert row1.status is WorkItemStatus.PENDING
assert row1.last_outcome is OutboxOutcome.RETRYABLE_FAILURE
writer.run_once()
row2 = outbox.get("PF-1:ORD-1:pg-r")
assert row2.status is WorkItemStatus.COMPLETED
assert ledger.count() == 1
+13 -6
View File
@@ -89,19 +89,26 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
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 enqueued twice = 1 ledger entry.""" """REQ-019 AC-1: same execution_id processed twice = 1 ledger entry."""
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)
_buy(intents).slot_id _buy(intents)
execution.run_once() execution.run_once()
writer.run_once() writer.run_once()
assert ledger.count() == 1 assert ledger.count() == 1
# Re-enqueue the same trade explicitly (simulate accidental re-fill). # Same correlation_id arriving again (re-enqueue) is dedup'd at the outbox.
first = outbox.all_items()[0] first = outbox.all_items()[0]
outbox.enqueue(first.trade) # idempotent on correlation_id outbox.enqueue(first.trade)
stats = writer.run_once() # still terminal-completed; reports duplicate assert len(outbox.all_items()) == 1
assert stats.duplicates >= 1 # And even if the writer somehow re-processes (simulated by a fresh outbox
# row for the same execution_id, e.g. after a manual replay), the ledger
# adapter dedup'd it server-side and the outcome is duplicate_completed.
replay_outbox = InMemoryLedgerOutbox()
replay_writer = LedgerWriter(replay_outbox, ledger)
replay_outbox.enqueue(first.trade)
stats = replay_writer.run_once()
assert stats.duplicates == 1 and stats.completed == 0
assert ledger.count() == 1 # no duplicate in the ledger assert ledger.count() == 1 # no duplicate in the ledger
+7 -1
View File
@@ -9,7 +9,13 @@ import os
import pytest import pytest
_PG_TABLES = ("intent_log", "slot_transition_log", "daily_symbol_entries", "daily_slot") _PG_TABLES = (
"ledger_outbox",
"intent_log",
"slot_transition_log",
"daily_symbol_entries",
"daily_slot",
)
@pytest.fixture @pytest.fixture
+9 -2
View File
@@ -126,13 +126,14 @@ class LedgerWriter:
return stats return stats
def _process(self, item: LedgerWorkItem) -> OutboxOutcome: def _process(self, item: LedgerWorkItem) -> OutboxOutcome:
# Local dedup: another writer pass (or restart) already completed this. # Local dedup: another writer pass (or restart) already completed this
# outbox row. Skip the adapter call.
existing = self._outbox.get(item.correlation_id) existing = self._outbox.get(item.correlation_id)
if existing is not None and existing.status is WorkItemStatus.COMPLETED: if existing is not None and existing.status is WorkItemStatus.COMPLETED:
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED) self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED)
return OutboxOutcome.DUPLICATE_COMPLETED return OutboxOutcome.DUPLICATE_COMPLETED
try: try:
self._ledger.record_trade(item.trade) result = self._ledger.record_trade(item.trade)
except LedgerConflictError as exc: except LedgerConflictError as exc:
self._outbox.mark_outcome( self._outbox.mark_outcome(
item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc) item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc)
@@ -143,6 +144,12 @@ class LedgerWriter:
item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE, error=str(exc) item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE, error=str(exc)
) )
return OutboxOutcome.RETRYABLE_FAILURE return OutboxOutcome.RETRYABLE_FAILURE
# Adapter-level dedup (server says "already recorded"): outcome is
# duplicate_completed; the ledger has exactly one entry for this
# execution_id either way (REQ-019 AC-1, AC-4).
if result is LedgerWriteResult.DUPLICATE_OK:
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED)
return OutboxOutcome.DUPLICATE_COMPLETED
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.COMPLETED) self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.COMPLETED)
return OutboxOutcome.COMPLETED return OutboxOutcome.COMPLETED