diff --git a/docs/batches/bat-057_20260714_operator-broker-truth-hotfix/design.md b/docs/batches/bat-057_20260714_operator-broker-truth-hotfix/design.md index 6326a57..a5b0432 100644 --- a/docs/batches/bat-057_20260714_operator-broker-truth-hotfix/design.md +++ b/docs/batches/bat-057_20260714_operator-broker-truth-hotfix/design.md @@ -24,9 +24,8 @@ | 모듈 | 변경/소유권 | 구현 경계 | | --- | --- | --- | -| `trex_core/application/broker_provider.py` (신규) | `BrokerProvider` protocol, source metadata, credential resolution, broker reuse/invalidation policy의 application ownership | portfolio-hub read port와 KIS/paper construction을 조립하되 CLI 출력/DB write를 소유하지 않는다. | -| `trex_core/runtime/broker_factory.py` (신규) 또는 `runtime/daemon.py`의 명시적 factory | `DaemonConfig` → provider assembly. 기존 `_build_broker` 직접 사용자를 provider로 이관 | runtime composition만 담당하며 credential 값을 로그/JSON으로 만들지 않는다. 구현 시 순환 import가 생기면 factory를 별도 runtime module로 둔다. | -| `trex_core/runtime/daemon.py` | daemon은 process-lifetime provider에서 broker를 획득하고 scheduler에 같은 provider/reload callback을 주입 | daemon이 별도 env broker를 만들거나 scheduler와 다른 credential source를 해석하지 않는다. | +| `trex_core/runtime/broker_provider.py` | `BrokerProvider` protocol, source metadata, credential resolution, broker reuse/invalidation policy의 runtime composition ownership | portfolio-hub read port와 KIS/paper construction을 조립하되 CLI 출력/DB write를 소유하지 않는다. 현재 구현의 `ConfigBrokerProvider`가 daemon과 CLI의 공통 경계다. | +| `trex_core/runtime/daemon.py` | `DaemonConfig` → provider assembly 및 process-lifetime provider 주입 | runtime composition만 담당하며 credential 값을 로그/JSON으로 만들지 않는다. legacy `_build_broker`는 env-only compatibility helper로 남아 있으므로 새 broker-read 경로에서 직접 사용하지 않는다. | | `trex_core/application/snapshot_scheduler.py` | day-start fetched credential 성공/실패 및 broker reload를 provider contract로 호출 | scheduler의 entry gate 역할은 유지하되, fetched failure 뒤 env credential으로 trading/broker-read를 계속하는 현재 fallback은 제거한다. | | `trex_core/runtime/cli.py` | `--config`가 있으면 daemon과 동일한 `DaemonConfig.from_config(toml_path, env_file=...)`로 instance TOML을 로드하고, `_cmd_sync`/`_cmd_verify`가 그 config의 같은 provider를 호출 | broker read가 성공하기 전 slot/warning write, `broker_qty_zero`, verify success를 만들지 않으며 CLI가 TOML `credential_fetch_enabled`를 env-only default로 덮어쓰지 않는다. | | `trex_core/adapters/broker/kis.py` | typed sanitized `BrokerBalanceError`, strict `get_balance()`, token/error mapping | credential/token/header/raw account/payload를 error message, log, CLI JSON에 싣지 않는다. | @@ -275,3 +274,54 @@ REQ-119~121 implementation and independent QC evidence가 확정된 뒤에만 RE - [ ] 4-case reconciliation matrix가 existing A/B/C behavior와 additive divergence effect를 구분한다. - [ ] REQ-119~122 AC별 test/document evidence가 §9에 연결되었다. - [ ] live service, credentials, production DB, `sync --live`를 사용하지 않았고 implementation/baseline update authorization과 혼동하지 않았다. + +## 12. Evidence boundary and unresolved gates + +### Source evidence inspected + +The following repository evidence is the basis for this design (paths are relative to +this trex-core root): + +- `trex_core/runtime/broker_provider.py`: `ConfigBrokerProvider` owns one broker per + process/command, distinguishes `env`/`fetched`/`break_glass`, and converts fetched + credential read failures to an allow-listed `BrokerCredentialUnavailable` code. +- `trex_core/adapters/broker/kis.py` and + `trex_core/application/broker_port.py`: balance reads validate `rt_cd` and + `output1`, return `[]` only for a validated empty response, and raise the + allow-listed `BrokerBalanceError` codes for failure classes. +- `trex_core/domain/slot/repository.py`, + `trex_core/domain/slot/in_memory_repository.py`, and + `trex_core/adapters/persistence/postgresql.py`: the instance-and-symbol query is + read-only, deterministic, and implemented with the same ordering rule in both + adapters. +- `trex_core/application/reconciliation.py`: the query is called only for an + unmatched positive broker holding; the result selects the additive decision code + without a persistence mutation. +- `tests/runtime/test_broker_provider.py`, + `tests/runtime/test_cli_broker_failure.py`, and + `tests/adapters/broker/test_kis.py`: fetched fallback prevention, CLI failure + boundary, sanitized balance failure, and validated-empty fixtures exist. The + repository parity evidence remains fake-driver based, not a live PostgreSQL + engine run. + +### Unresolved blockers and explicit non-decisions + +1. **Cross-service contract gate — unresolved for implementation/release:** this + repository contains only the consumer-side `PortfolioHubReadPort`. A live + portfolio-hub contract/integration fixture proving the credential-version and + `trading_enabled` semantics is not available here. Implementation must not + change the portfolio-hub API; any required wire change stops at a lead-owned + cross-service approval gate. +2. **Security gate — unresolved for release:** source-level sanitization and + deny-list tests are present, but production log sink, tracing, and portfolio-hub + observability configuration are outside this repository and have not been + independently reviewed. No credential or raw account evidence may be collected + until that review is complete. +3. **Migration gate — currently no schema migration is designed:** the lookup is + read-only over existing `daily_slot` columns and does not authorize an index or + schema change. If production query-plan evidence later requires an index, that + is a separate migration-approved batch, not an implementation assumption here. +4. **Scope/approval gate — explicit hold:** baseline document edits, production + configuration changes, deployment, and `sync --live` remain outside this design + gate. The next authorized step is `run-batch-implementation`; it must not be + started from this task without the lead's gate transition. diff --git a/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/design.md b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/design.md new file mode 100644 index 0000000..1faae59 --- /dev/null +++ b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/design.md @@ -0,0 +1,40 @@ +# BAT-058 Design + +- Status: in-progress +- Batch: BAT-058 + +## 핵심 설계 결정 + +### 1) cost-basis 응답 파싱 규칙 +- `portfolio-hub` 응답에서 `cost_basis_per_unit`(canonical)을 최우선으로 사용 +- canonical key가 존재하면 `avg_cost/wac/cost_basis`로 fallback 금지 +- canonical이 없을 때만 legacy chain(`avg_cost`, `wac`, `cost_basis`)를 순차적으로 시도 +- 유효성: `None` 금지, 숫자 변환 가능, 0보다 큰 값만 허용 + +### 2) 진단값 분리 +- adapter 수준에서 기존 `get_cost_basis()`는 값만 반환 +- 새 진단 API `get_cost_basis_with_diagnostics()` 추가: + - `(value, source, reason)` + - public `cost_basis_source`는 `canonical` / `legacy` / `none`만 허용한다. adapter의 `avg_cost` / `wac` / `cost_basis` alias는 `legacy`로 정규화하며, 진단 port의 미승인/비문자 source는 `none`으로 정규화한다. + - public `cost_basis_reason`는 `missing`, `invalid`, `http_status_<3-digit>`, `transport`, `invalid_json`, `invalid_payload`, `not_configured`, `diagnostic_exception`, `diagnostic_source_invalid` 또는 유효한 값이 seed되었을 때의 `null`만 허용한다. `http_status_<3-digit>`만 동적 형식이며 provider field name, parser detail, 임의 오류 텍스트는 공개 값으로 허용하지 않는다. +- adapter parser의 field-level detail은 private implementation detail이다. sync 결과와 `sync --dry-run/--live` 출력에는 위 source/reason taxonomy만 노출한다. + +### 3) sync fail-closed 정책 +- `slot.status == INACTIVE`, `broker_qty > 0`, 동기화 결과 `avg_price` 미확보 시: + - `dry_run=True`: write 없음 preview 반환 (`after`는 before와 동일), `skipped=True`, `skip_reason=cost_basis_unavailable` + - `dry_run=False`: no-write fail-closed. `error='cost_basis_unavailable'`, `skipped=True`, `after=before` +- 기존 `HOLDING` 및 `PENDING_*`, `ERROR` 경로는 기존 규칙 유지 + +### 4) 로그/출력 +- sync 결과 객체에 아래 필드 추가: `seeded_avg_price`, `cost_basis_source`, `cost_basis_reason` +- CLI 출력에서 위 값 표시 +- 실패 시 reason을 노출해 운영자가 어떤 fallback/오류가 발생했는지 즉시 확인 가능 + +## 비(非)설계 대상 +- `portfolio-hub` API 스키마 변경/인증 변경 +- 실제 live sync 수행이나 운영 배포 실행 + +## 검증 전략 +- adapter 단위 테스트: canonical 우선/legacy fallback/invalid canonical non-fallback/이유 분리 +- sync application 테스트: inactive fail-closed와 dry-run preview, holding 기본 동기화 동작 회귀 보존 +- 최소 통합: 기존 테스트셋에서 관련 케이스가 모두 통과되는지 확인 diff --git a/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/implementation.md b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/implementation.md new file mode 100644 index 0000000..14e714f --- /dev/null +++ b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/implementation.md @@ -0,0 +1,33 @@ +# BAT-058 Implementation + +- Status: in-progress +- Batch: BAT-058 + +## 변경 대상 + +1. `trex_core/adapters/portfolio/hub_read.py` + - `get_cost_basis_with_diagnostics()` 새 메서드 추가 + - canonical(`cost_basis_per_unit`) 우선 파싱 + legacy fallback 체인 적용 + - invalid/parse 오류 reason 문자열 반환 + +2. `trex_core/application/reconciliation.py` + - `sync_slot_to_broker()`에서 cost-basis 진단 메서드 사용 + - inactive onboarding & no cost basis 시 dry-run/no-write + live fail-closed 구현 + - sync 결과에 `seeded_avg_price`, `cost_basis_source`, `cost_basis_reason` 추가 + +3. `trex_core/runtime/cli.py` + - sync 결과 출력에 `cost_basis.seeded`, `cost_basis.source`, `cost_basis.reason`, `error` 표시 + +4. `tests/adapters/portfolio/test_hub_read.py` + - get_cost_basis canonical/legacy/invalid/오류 reason 검증 추가 + +5. `tests/application/test_sync.py` + - inactive no-cost_basis live/dry-run 동작 + - hold/warning scenarios 회귀 보존 + +## 상태 + +- planning: completed +- design: completed +- implementation: completed +- draft-batch-verification: completed diff --git a/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/index.md b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/index.md new file mode 100644 index 0000000..3cb628b --- /dev/null +++ b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/index.md @@ -0,0 +1,29 @@ +# BAT-058: cost-basis canonical parse 및 안전 sync seed hotfix + +- Status: release-candidate (lead `confirm-batch-verification`, 2026-07-17; Release gate/approval 미실행) +- Profile: standard +- Source Discovery: dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding +- Source REQ: REQ-123 +- Included REQ: REQ-123 +- Excluded REQ: 없음 + +## Scope + +### In Scope + +- REQ-123(제안된 cost-basis canonical parse 및 WAC unavailable fail-closed seed) +- portfolio-hub 응답 `cost_basis_per_unit` canonical 우선 파싱 고정 +- legacy alias fallback/invalid 처리 규칙 구현 +- dry-run 진단 및 live no-write fail-closed semantics +- adapter/application regression test 확장 및 baseline 문서 정합 + +### Out of Scope + +- portfolio-hub API/schema/인증/토큰/설정 변경 +- DB migration / 데이터 삭제 / 실서버 직접 수정 +- `sync --live`, 배포/운영 재시작 실행은 별도 release 승인 단계 + +## Notes + +- 이 batch는 `suggest-batch-reqs` 다음 단계로 생성한 lead-승인 배치입니다. +- implementation 전환 직전까지는 설계 산출물을 대체하지 않고, 해당 scope만 구현/검증합니다. diff --git a/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/planning.md b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/planning.md new file mode 100644 index 0000000..faab5ac --- /dev/null +++ b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/planning.md @@ -0,0 +1,43 @@ +# BAT-058 Planning + +- Status: in-progress +- Batch: BAT-058 +- Profile: standard + +## Included REQ +- REQ-123 (Approved): cost-basis canonical 우선 파싱 + safe sync seeding + +## Delivery Plan +1. `trex_core.adapters.portfolio.hub_read`에서 `cost-basis` 응답 파싱 순서 정립 + - 우선순위: `cost_basis_per_unit`(canonical) -> `avg_cost` -> `wac` -> `cost_basis` + - canonical가 present이면 legacy fallback를 금지하고 이유를 기록 + - 유효성 검사(형변환/양수) 실패 시 source/reason 분리 +2. `sync_slot_to_broker`에 WAC 사용 경로를 cost-basis 진단 결과와 함께 통합 + - INACTIVE + broker_qty>0 + 가격 미확보 시 dry-run은 preview만 + - live에서는 no-write fail-closed + exit fail +3. `tests/adapters/portfolio/test_hub_read.py`와 `tests/application/test_sync.py`를 AC-기반으로 확장 + - canonical 우선 규칙, invalid canonical non-fallback, unavailable reason 보존 + - inactive onboarding no-write dry-run / live fail-closed +4. `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/operations.md` baseline 동기화 +5. `docs/batches/bat-058...` 산출물(implementation + verification) 작성 후 리드 검토 제출 + +## Dependencies +- modules: `trex_core/adapters/portfolio/hub_read.py`, `trex_core/application/reconciliation.py`, `trex_core/runtime/cli.py` +- tests: `tests/adapters/portfolio/test_hub_read.py`, `tests/application/test_sync.py` +- docs: `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/operations.md` + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 계약 precedence 및 fail-closed semantics이 배포 안전성에 직결되어, 미리 수용 기준(재기록/no-write/오류 로그)을 고정해야 함. + +## Milestones +1. planning 확정 (현재) +2. design 작성 +3. implementation 완료 +4. draft-batch-verification 작성 및 테스트 실행 +5. QC handoff + +## Risks +- R1 High: canonical 값 parse 실패 시 legacy fallback를 허용하면 REQ-123 위반 가능성(의도치 않은 source 사용) +- R2 High: inactive onboarding에서 가격 미확보일 때 live write가 진행될 경우 거래원장·slot 상태 오염 +- R3 Medium: 비용/시간 과다로 기존 `cost_basis` 회귀 테스트 간과 시 interface contract drift diff --git a/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/verification.md b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/verification.md new file mode 100644 index 0000000..8e00df5 --- /dev/null +++ b/docs/batches/bat-058_20260716_cost-basis-safe-sync-seed/verification.md @@ -0,0 +1,74 @@ +# BAT-058 Verification + +- Status: release-candidate (lead `confirm-batch-verification`, 2026-07-17) +- Batch: BAT-058 +- Verified By: delivery-runner (implementation evidence) + independent `dev-qc` QC-7 +- Confirmed By: trex-dev-lead (QC-7 `PASS-WITH-NOTES` 수용; 사용자 지시: “QC-7 PASS-WITH-NOTES로 batch verification을 진행해”) + +## Verification 범위 + +- Scope: REQ-123 및 `dcy-038` 기반 hotfix 범위 + - `trex_core/adapters/portfolio/hub_read.py` + - `trex_core/application/reconciliation.py` + - `trex_core/runtime/cli.py` + - `tests/adapters/portfolio/test_hub_read.py` + - `tests/application/test_sync.py` + +## AC 점검 결과 + +| AC | 내용 | 결과 | 근거 | +|---|---|---|---| +| AC-1 | canonical `cost_basis_per_unit` 우선 파싱 + 유효성 검증 | ✅ PASS | `test_get_cost_basis_with_diagnostics_prefers_canonical_when_valid`, `test_get_cost_basis_with_diagnostics_canonical_null_blocks_legacy`, `test_get_cost_basis_with_diagnostics_canonical_non_numeric_blocks_legacy`, `test_get_cost_basis_with_diagnostics_canonical_negative_blocks_legacy` | +| AC-2 | canonical invalid 시 legacy masking 금지, canonical 부재 시 legacy fallback 순차 적용 | ✅ PASS | `test_get_cost_basis_with_diagnostics_canonical_invalid_blocks_legacy`, `test_get_cost_basis_with_diagnostics_legacy_chain_fallback_order`, `test_get_cost_basis_with_diagnostics_legacy_skips_invalid_values` | +| AC-3 | 성공/실패 이유 분류(reason) | ✅ PASS | `test_get_cost_basis_with_diagnostics_canonical_null_blocks_legacy`, `test_get_cost_basis_with_diagnostics_canonical_non_numeric_blocks_legacy`, `test_get_cost_basis_with_diagnostics_canonical_negative_blocks_legacy`, `test_get_cost_basis_with_diagnostics_missing_field_returns_missing_reason`, `test_get_cost_basis_with_diagnostics_all_legacy_invalid_returns_last_reason` | +| AC-4 | non-2xx/transport 실패 사유 분류 | ✅ PASS | `test_get_cost_basis_with_diagnostics_non_2xx_or_transport_keeps_diagnostic` | +| AC-5 | dry-run no-write preview (seeded=true/false 반영) | ✅ PASS | `test_sync_dry_run_no_write`, `test_sync_dry_run_inactive_valid_canonical_seed_previews_without_writes` | +| AC-6 | dry-run `inactive + broker_qty>0 + avg_price unavailable` no-write preview | ✅ PASS | `test_sync_dry_run_no_write` | +| AC-7 | live fail-closed (status/qty/avg_price 모두 미반영, non-zero 종료) | ✅ PASS | independent QC-7이 `test_sync_fail_closed_skipped_error_exits_nonzero_without_writes` 및 legacy-only `0.0`/negative/NaN/+Inf no-write 회귀를 재실행·검토함; CLI rc=1, mutation SQL 0, warning clear 0 확인 | +| AC-8 | HOLDING 정상 동기화 회귀 및 seed 적용 유지 | ✅ PASS | `test_sync_holding_qty_mismatch_updated`, `test_sync_seeds_avg_price_from_cost_basis_port`, `test_sync_seeds_avg_price_holding_with_cost_basis`, `test_sync_degrades_gracefully_cost_basis_returns_none` | +| AC-9 | response-contract + regression 테스트 전체 통과 | ✅ PASS | `tests/runtime/test_cli_operator.py`, `tests/adapters/portfolio/test_hub_read.py`, `tests/application/test_sync.py` (independent QC-7 affected suite: 113 passed; full suite: 833 passed, 47 skipped) | +| AC-10 | public diagnostic source/reason contract normalization (raw field/detail non-leak) 및 release gate | ✅ PASS (release 실행은 별도 gate) | independent QC-7이 `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/operations.md`와 bounded diagnostic/no-leak 구현을 대조함. REQ가 요구하는 actual release artifact·approval·live evidence는 Release 단계에서만 생성/판정하며, 본 batch verification은 이를 승인하거나 대체하지 않음. | + +## 독립 QC-7 및 Lead `confirm-batch-verification` + +- QC-7 실행: 2026-07-17 00:30:22 UTC 시작, supervised child exit `0`. +- QC verdict: `PASS-WITH-NOTES`. +- QC-7 독립 재실행: legacy-invalid focused `8 passed, 19 deselected`; affected suite `113 passed`; full suite `833 passed, 47 skipped`; Ruff `All checks passed`; compileall exit `0`; `git diff --check` exit `0`. +- Lead 판정: 사용자 지시에 따라 QC-7 `PASS-WITH-NOTES`를 수용하여 BAT-058의 `confirm-batch-verification`을 완료하고 `release-candidate`로 승격한다. +- Note 해소: REQ-123 AC-10의 release artifact/explicit approval/dry-run evidence/live execution은 **Release 단계의 별도 gate**다. 이 batch confirmation은 release approval, `sync --live`, 배포, credential 접근을 승인하지 않는다. +- Ledger note: 이전 QC 반복 횟수 표기는 과거 handoff 사이에 불일치가 있어 이 verification의 판정 근거로 사용하지 않는다. 이번 confirmation의 근거는 QC-7의 독립 verdict와 위 재실행 증적이다. + +## 실행 커맨드 및 실제 재실행 결과 + +아래는 delivery worktree에서 실행한다. 공유 가상환경은 worktree의 상위 repository root에 있으므로 `git rev-parse`로 worktree root를 해석해 사용한다. + +```bash +WORKTREE="$(git rev-parse --show-toplevel)" +PYTHON="${TREX_CORE_PYTHON:-$(cd "$WORKTREE/../.." && pwd)/.venv312/bin/python}" +cd "$WORKTREE" + +"$PYTHON" -m pytest tests/runtime/test_cli_operator.py tests/adapters/portfolio/test_hub_read.py tests/application/test_sync.py -q +"$PYTHON" -m pytest -q +"$PYTHON" -m ruff check trex_core/runtime/cli.py trex_core/adapters/portfolio/hub_read.py trex_core/application/reconciliation.py tests/runtime/test_cli_operator.py tests/adapters/portfolio/test_hub_read.py tests/application/test_sync.py +"$PYTHON" -m compileall -q trex_core/runtime/cli.py trex_core/adapters/portfolio/hub_read.py trex_core/application/reconciliation.py tests/runtime/test_cli_operator.py tests/adapters/portfolio/test_hub_read.py tests/application/test_sync.py +``` + +- focused pytest (2026-07-17, QC-5 재실행): `105 passed in 0.28s` +- full pytest (2026-07-17, QC-5 재실행): `825 passed, 47 skipped in 1.71s` +- ruff (2026-07-17, QC-5 재실행): `All checks passed!` +- compileall (2026-07-17, QC-5 재실행): `compileall: PASS` (exit 0; quiet mode) +- `git diff --check` (2026-07-17, QC-5 재실행): `PASS` (exit 0; no output) +- QC-6 targeted pytest (2026-07-17): `tests/application/test_sync.py -q -k legacy_only_invalid_wac` → `8 passed, 19 deselected in 0.05s`. +- QC-6 focused pytest (2026-07-17): `tests/runtime/test_cli_operator.py tests/adapters/portfolio/test_hub_read.py tests/application/test_sync.py -q` → `113 passed in 0.29s`. +- QC-6 full pytest (2026-07-17): `pytest -q` → `833 passed, 47 skipped in 1.72s`. +- QC-6 ruff (2026-07-17): 대상 6개 source/test 파일 → `All checks passed!` (exit 0). +- QC-6 compileall (2026-07-17): 대상 6개 source/test 파일 → exit 0 (quiet mode; no output). + +## 미해결/미실행 항목 + +- 실운영 배포, `sync --live` 실행, DB 직접 수정은 실행하지 않음. +- 본 문서는 implementation self-approval가 아니다. 독립 QC-7 `PASS-WITH-NOTES`와 lead `confirm-batch-verification`까지 반영한 batch verification 기록이며, release approval은 포함하지 않는다. + +## 위험/차단 요인 + +- 없음. 단, Release 단계에서 REQ-123 AC-10의 explicit release approval, 단일 대상 live 실행 승인, rollback 기준, post-release monitoring plan을 별도 `draft-release` 산출물로 점검해야 한다. diff --git a/docs/batches/index.md b/docs/batches/index.md index 376bd07..51abdae 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -63,3 +63,4 @@ | BAT-053 | standard | release-candidate | REQ-112, REQ-113, REQ-114, REQ-115 | dcy-036 | [bat-053](./bat-053_20260628_daystart-credential-fetch-failsafe/) | | BAT-054 | standard | in-progress | REQ-113 (AC-2/AC-4 보강), REQ-115 (AC-1) | dcy-036 | [bat-054](./bat-054_20260628_daystart-credential-kis-inject/) | | BAT-057 | standard | release-candidate | REQ-119, REQ-120, REQ-121, REQ-122 | dcy-037 | [bat-057](./bat-057_20260714_operator-broker-truth-hotfix/) | +| BAT-058 | standard | release-candidate | REQ-123 | dcy-038 | [bat-058](./bat-058_20260716_cost-basis-safe-sync-seed/) | diff --git a/docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md b/docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md index 75c8ad7..36c60e2 100644 --- a/docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md +++ b/docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md @@ -6,7 +6,7 @@ - 작성 시각(KST): 2026-07-14 21:52:01 KST - 마지막 갱신 시각(KST): 2026-07-14 22:24:41 KST (draft-req) - 대체됨: 없음 -- 후속 Discovery 참조: 없음 +- 후속 Discovery 참조: `dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding` — portfolio-hub cost-basis canonical response parsing 및 안전한 sync avg_price seed P1 follow-up - 생성된 REQ 참조: REQ-119, REQ-120, REQ-121, REQ-122 # 1. 계획 상태 요약 diff --git a/docs/discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md b/docs/discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md new file mode 100644 index 0000000..6fc76cc --- /dev/null +++ b/docs/discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md @@ -0,0 +1,227 @@ +# 0. 문서 상태 + +- 상태: confirmed +- 문서 ID: dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding +- 이슈명: P1 — portfolio-hub cost-basis response contract 정합 및 안전한 sync 평균가 seed +- 작성 시각(KST): 2026-07-16 22:37:30 KST +- 마지막 갱신 시각(KST): 2026-07-16 22:44:20 KST (confirm-discovery) +- 대체됨: 없음 +- 선행 Discovery: dcy-034, dcy-037 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: `docs/srs/Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md` + +# 1. 계획 상태 요약 + +- 연계 문서 현황: + - 선행 `dcy-034`/REQ-105는 broker-held position을 inactive slot에 onboarding하는 `sync`와 portfolio-hub cost-basis seed를 도입했다. + - 선행 `dcy-037`/BAT-057/REL-011은 fetched credential broker truth 및 managed non-holding divergence를 정정했다. 운영 preflight는 해당 credential/config 가설을 정상으로 확인했다. + - portfolio-hub는 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis`에서 `HoldingsCostBasisResponse.cost_basis_per_unit`을 canonical WAC field로 반환한다. +- 해석: + - trex-core의 realized-PnL adapter는 이미 `cost_basis_per_unit`을 읽지만, sync용 `PortfolioHubReadAdapter.get_cost_basis()`는 legacy `avg_cost`, `wac`, `cost_basis`만 탐색한다. + - 따라서 정상 HTTP 200 response의 `cost_basis_per_unit=124580`도 `None`으로 해석된다. reconcile은 이를 graceful degrade로 취급해 qty/status만 seed하고 `avg_price`를 누락할 수 있다. + +# 2. 요구사항 판정 결과 + +- 판정: 신규 P1 hotfix Discovery +- 근거: + - REQ-105 FR-7은 cost-basis seed 목적을 정의했지만, portfolio-hub canonical response field를 adapter contract와 regression test로 고정하지 못했다. + - dcy-037의 credential source/broker truth 결함과 달리, 이번 결함은 정상 인증·정상 HTTP 200 response를 잘못 parse하는 consumer contract defect다. + - `avg_price`는 slot의 전략/손익 판단에 영향을 주는 값이므로, 양수 broker holding에 avg_price 없이 live sync하는 안전 정책을 별도 명시해야 한다. + +# 3. 문제점의 요약 + +## 현재 상태 + +- portfolio-hub schema: `HoldingsCostBasisResponse.cost_basis_per_unit: float | None`. +- portfolio-hub endpoint: 보유 수량이 양수면 WAC를 `cost_basis_per_unit`에 반환한다. +- trex-core `PortfolioHubReadAdapter.get_cost_basis()`: + ```python + val = body.get("avg_cost") or body.get("wac") or body.get("cost_basis") + ``` +- trex-core reconcile `sync_slot_to_broker()`: + - adapter가 `None`을 반환하면 avg_price seed를 건너뛰고, dry-run은 `avg_price: null`, `seeded_avg_price: false`를 반환한다. + - live path는 현재 inactive slot을 `holding`으로 전이하고 broker qty를 쓸 수 있다. +- 운영 재현(비밀 없는 response shape): + ```text + HTTP 200: {"current_quantity": 5.0, "cost_basis_per_unit": 124580.0} + current adapter result: None + sync --dry-run: holding / qty=5 / avg_price=null / seeded_avg_price=false + ``` + +## 기대 상태 + +- trex-core가 portfolio-hub의 canonical `cost_basis_per_unit`을 우선 읽고, 정상 양수 WAC를 slot `avg_price` seed에 사용한다. +- legacy field는 호환 필요성이 확인된 경우에만 lower-priority fallback으로 남긴다. +- cost-basis가 실제로 unavailable/malformed/invalid인 경우에는 정상 canonical 응답 parsing failure와 구분 가능한 operator reason을 낸다. +- 양수 broker holding을 inactive slot으로 live onboarding할 때 WAC가 없으면, 승인된 안전 정책에 따라 불완전한 slot write를 방지한다. + +## 이해관계자 및 제약 + +- 사장님: P1 범위 및 live sync fail-closed 정책의 승인자. +- 팀장: Discovery/REQ/Batch/release gate 관리. +- trex-core-dev: adapter, reconcile, CLI/operator output, tests 및 baseline 문서 delivery 담당. +- qc-reviewer: canonical response contract, no-write barrier, regression의 독립 검증 담당. +- portfolio-hub maintainers: existing response contract의 source owner; 이 Discovery에서는 API/model 변경을 수행하지 않는다. +- 제약: + - `sync --live`, DB 직접 수정, token/config 변경은 본 Discovery/REQ/Batch/verification 완료 및 별도 release approval 전 금지한다. + - raw credential, account number, token을 fixtures/logs/문서에 기록하지 않는다. + - portfolio-hub endpoint/schema 및 service-token authorization policy를 변경하지 않는다. + +## 주요 사용자 시나리오 + +1. 운영자가 inactive slot과 broker qty>0을 대상으로 `sync --dry-run`을 실행한다. canonical WAC가 있으면 결과는 `avg_price=`, `seeded_avg_price=true`로 표시되며 DB write는 없다. +2. portfolio-hub가 WAC를 `null`로 반환하거나 response가 malformed이면 dry-run은 왜 seed가 불가능한지 안전하게 표시한다. live sync는 확정된 no-write policy를 위반하지 않는다. +3. QC는 portfolio-hub canonical payload와 legacy fallback payload를 각각 주입해 adapter→reconcile 경로를 검증한다. 정상 canonical payload가 `None`으로 degrade하지 않음을 증명한다. + +# 4. 이번에 정의할 변경 + +- 변경 1 — canonical response parsing: `get_cost_basis()`가 `cost_basis_per_unit`을 canonical source로 읽고 수치 검증을 수행한다. +- 변경 2 — compatibility policy: legacy `avg_cost`/`wac`/`cost_basis`의 유지 필요성·우선순위·허용 값(0/negative/null)을 REQ에서 명시한다. canonical field가 존재하지만 invalid한 경우 legacy fallback으로 조용히 마스킹하지 않는다. +- 변경 3 — safe sync behavior: broker qty>0 + avg_price unavailable의 dry-run diagnostics와 live write barrier를 명시한다. 기존 `cost_basis_port` 미연결/네트워크 실패의 generic graceful degrade와 canonical parsing defect를 구분한다. +- 변경 4 — evidence and baseline alignment: HTTP response contract, sync output semantics, `avg_price` source/absence policy를 relevant interface/runtime/operations baseline 문서와 동기화한다. + +## 영향 범위 + +- 코드 영향: + - `trex_core/adapters/portfolio/hub_read.py`: canonical parser 및 typed/result error semantics. + - `trex_core/application/reconciliation.py`: avg_price unavailable 조건의 dry-run/live behavior 및 output. + - `tests/adapters/portfolio/test_hub_read.py`, `tests/application/test_sync.py`: response-contract 및 no-write regression. +- 문서 영향: + - `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/operations.md`, 신규 REQ/Batch/verification/release 문서. +- 서비스 영향: + - portfolio-hub는 existing GET response contract의 producer로 참조만 한다. API, schema, DB migration, auth policy 변경은 없다. + +# 5. 요구사항 목록 + +## 기능 요구사항 + +- FR-1: trex-core cost-basis adapter는 successful response에서 `cost_basis_per_unit`을 canonical WAC field로 parse한다. +- FR-2: canonical response가 valid numeric WAC를 제공하면 `sync --dry-run`과 approved live sync candidate는 동일한 avg_price seed value를 산출한다. +- FR-3: canonical field missing/null/non-numeric/invalid, non-2xx, response-shape failure, transport failure는 서로 구분 가능한 sanitized reason으로 operator surface에 나타난다. +- FR-4: broker qty>0의 inactive slot onboarding에서 avg_price unavailable일 때 dry-run은 no-write preview와 reason을 제공하고, live path는 확정된 fail-closed/explicit-confirmation policy를 적용한다. +- FR-5: legacy aliases가 유지되면 canonical field보다 낮은 우선순위로만 사용하며, canonical invalid value를 legacy value로 은폐하지 않는다. +- FR-6: response contract와 reconcile behavior는 deterministic test fixtures로 고정하고 independent QC가 adapter, application, CLI/output 경로를 재실행한다. + +## 비기능 요구사항 + +- NFR-1 (안전성): cost-basis unavailable 상태에서 broker qty/status만 write해 avg_price 없는 holding slot을 만드는 경로는 승인된 정책 없이 허용되지 않는다. +- NFR-2 (회귀): 정상 `cost_basis_per_unit`, legacy fallback(채택 시), no cost-basis port, port transport failure, current holding update, dry-run no-write가 기존 의도에 맞게 분리 검증된다. +- NFR-3 (관측성): sync output/log는 `seeded_avg_price`, source/absence reason, dry-run/live 여부를 비밀 없이 판별 가능하게 한다. +- NFR-4 (계약 안정성): portfolio-hub HTTP response producer를 변경하지 않으며, trex-core AMQP wire contract와 broker credential source behavior를 변경하지 않는다. + +## 범위 경계 + +- In Scope: + - trex-core consumer의 canonical `cost_basis_per_unit` parsing, legacy compatibility policy, safe sync behavior, regression tests, baseline/runbook 정합. +- Out of Scope: + - portfolio-hub endpoint/schema/authorization/ledger WAC calculation 변경. + - fetched credential/token lifecycle 수정, broker balance/reconcile classification 수정(dcy-037 범위). + - live sync 실행, database direct edit, bulk onboarding, automatic trading/automatic remediation. + - avg_price 재계산 알고리즘 또는 broker WAC를 새 authoritative source로 도입하는 설계 변경. + +# 6. 리스크/가정 목록 + +## 리스크 + +- R-1 (High): canonical parser만 고치고 WAC absence live-write policy를 두지 않으면, 다른 unavailable failure에서 avg_price 없는 holding write가 계속 가능하다. + - 완화: OQ-1을 Discovery confirmation 전에 결정하고, live no-write assertion을 tests/QC/release gate에 둔다. +- R-2 (Medium): `or`-chain parsing은 `0`/negative/invalid values 및 canonical-invalid+legacy-present response의 의미를 흐릴 수 있다. + - 완화: explicit presence/type/range validation 및 deterministic precedence tests를 REQ에 명시한다. +- R-3 (Medium): producer schema 변경 없이 consumer만 수정하므로 실제 cross-service drift가 재발할 수 있다. + - 완화: canonical fixture는 portfolio-hub schema/endpoint implementation에 근거하고, producer-owned contract reference와 consumer regression을 함께 기록한다. +- R-4 (High): P1 수정 전 live sync를 실행하면 avg_price null slot이 실제 DB에 기록될 수 있다. + - 완화: release 문서에 dry-run → QC → explicit release approval → single-target live execution 순서를 강제한다. + +## 가정 + +- A-1: portfolio-hub response의 `cost_basis_per_unit`은 `HoldingsCostBasisResponse` 및 endpoint implementation의 현재 canonical public field다. +- A-2: broker qty>0 onboarding에서 avg_price는 downstream 전략/손익 판단에 필요한 safety-relevant slot data다. +- A-3: current `sync --dry-run`은 DB write를 하지 않으며, actual live execution은 별도 release gate에서만 허용된다. + +## 오픈 질문 + +- OQ-1: `broker_qty > 0`이고 avg_price/WAC가 unavailable일 때 live sync 정책은 무엇인가? + - 상태: Answered (2026-07-16, Justin Ko) + - 결정: **(a) fail-closed** — status/qty/avg_price를 모두 write하지 않고 non-zero failure로 중단한다. dry-run은 reason과 no-write preview를 제공하되, 평균가 없는 inactive→holding 전이는 차단한다. + - 종료 근거: Justin Ko가 팀장 권고인 fail-closed 정책을 명시 승인했다. +- OQ-2: legacy `avg_cost`/`wac`/`cost_basis` aliases를 지원할 실제 producer가 현재 존재하는가? + - 상태: Answered (2026-07-16, in REQ-123) + - 결정: producer/fixture의 canonical은 `cost_basis_per_unit` 기반이며, 호환성 유지를 위해 `avg_cost` → `wac` → `cost_basis` fallback은 유지한다. 단, canonical key가 유효하지 않으면 legacy fallback을 허용하지 않는다. +- OQ-3: response unavailability reason을 API adapter result type으로 확장할지, existing Optional port를 유지하고 reconcile-local diagnostics를 추가할지? + - 상태: Answered (2026-07-16, in REQ-123) + - 결정: `Optional[float]` port를 유지하고, unavailable는 reconcile-local diagnostics(`seeded_avg_price`, source, reason)로 분리 표시한다. + +# 7. 초기 성공 기준 + +- S-1: canonical fixture `{current_quantity: 5.0, cost_basis_per_unit: 124580.0}`가 adapter에서 `124580.0`으로 parse된다. +- S-2: inactive slot + broker qty=5 + canonical WAC=124580 dry-run이 `after.status=holding`, `after.qty=5`, `after.avg_price=124580`, `seeded_avg_price=true`를 반환하며 slot/warning write가 없다. +- S-3: unavailable/malformed WAC fixture가 source reason을 남기고, OQ-1에서 확정한 live no-write/override behavior를 만족한다. +- S-4: canonical field가 invalid일 때 legacy alias가 있더라도 silent masking하지 않는 precedence regression이 통과한다. +- S-5: existing no-port/transport-failure graceful-degrade behavior, holding update, dry-run no-write and relevant core suite pass evidence가 있다. + +## 측정 방식 및 데이터 출처 + +- S-1: `tests/adapters/portfolio/test_hub_read.py`에서 HTTP response fixture → adapter return assertion. +- S-2/S-3: `tests/application/test_sync.py`에서 in-memory slot/warning store, broker qty, fake cost-basis port를 이용한 dry-run/live no-write assertions. +- S-4: adapter contract test에서 canonical/legacy presence matrix를 명시한다. +- S-5: targeted adapter/application suite 및 affected CLI/output tests, independent QC rerun. + +# 8. REQ로 넘기기 전 확인 체크 + +- [x] producer canonical field 및 consumer mismatch의 source evidence 확인 + - 근거: portfolio-hub `HoldingsCostBasisResponse.cost_basis_per_unit`; trex-core `get_cost_basis()` legacy-only parser; synthetic canonical response 재현. +- [x] P1 scope 및 non-goals 초안화 + - 근거: producer API 변경, credential/token scope, live execution, DB direct edit를 명시적으로 제외했다. +- [x] 성공 기준의 test seam과 evidence source 정의 + - 근거: adapter response fixture, sync application fixture, independent QC rerun을 S-1~S-5에 연결했다. +- [x] OQ-1 live sync WAC-unavailable policy 확정 + - 근거: Justin Ko가 2026-07-16 fail-closed 정책을 승인했다. dry-run은 no-write preview, live는 non-zero failure 및 status/qty/avg_price 무기록이다. +- [x] OQ-2/OQ-3: producer canonical 우선순위와 compatibility strategy, unavailability diagnostics는 REQ-123에서 확정했다. +- [x] Freeze 확정 담당자 지정 + - 근거: 승인자 Justin Ko가 2026-07-16 Discovery scope와 OQ-1 fail-closed 정책을 confirm했다. + +# 9. 파일 처리 결과 + +- 처리 결과: 생성 +- 생성 경로: `docs/discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md` +- 참조 문서: + - `docs/discovery/dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart.md` + - `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` + - `trex_core/adapters/portfolio/hub_read.py:get_cost_basis` + - `trex_core/application/reconciliation.py:sync_slot_to_broker` + - `tests/adapters/portfolio/test_hub_read.py`, `tests/application/test_sync.py` + - portfolio-hub `app/schemas/v1.py:HoldingsCostBasisResponse`, `app/api/v1_portfolios.py:get_holdings_cost_basis` + +# 10. 사용자 결정 필요 항목 요약 + +## DECIDE + +- OQ-1: WAC가 unavailable인 양수 broker holding의 live sync는 **fail-closed**로 차단한다. + - 승인자: **Justin Ko**, 2026-07-16 KST. + +## CONFIRM + +- 본 P1을 trex-core consumer hotfix로 채택한다. portfolio-hub producer API는 변경하지 않는다. +- 수정·independent QC·dry-run 성공 증적·별도 release approval 전까지 live sync는 실행하지 않는다. + +## DATA + +- 실제 운영 대상의 portfolio/slot/account/credential 값은 Discovery/REQ/test fixture에 기록하지 않는다. release gate에서 필요한 대상은 별도 안전한 운영 증적으로만 취급한다. + +# 11. Discovery Freeze + +## 섹션 참조 + +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 + +- Handoff Decision: 승인 — REQ 인계 +- Handoff Rationale: canonical field mismatch와 P1 scope를 evidence로 확정했고, Justin Ko가 avg_price unavailable 상태의 live sync를 fail-closed로 차단하는 안전 정책을 승인했다. 이 승인은 REQ drafting을 허용하지만 live sync 실행·배포·DB 변경 승인을 의미하지 않는다. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-07-16 22:44:20 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 138e8c7..2d6c41d 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -42,3 +42,4 @@ | dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart | 20260625 | 슬롯↔실계좌 차단형 reconcile + sync/verify 명령 — 중장기 cold-start(기존 보유 onboarding). B/C 불일치→슬롯 차단(durable warning) + pause∨warning 게이트 + 운영자 sync/verify (M3) | [Discovery](./dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart.md) | confirmed | | dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load | 20260628 | 그날-시작 KIS 인증정보 fetch(portfolio-hub) + fail-safe 거래 로드 — 진실원천 부재 시 신규 거래 미개시, 장중 무중단 (교차 발원 portfolio-hub dcy-010) | [Discovery](./dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load.md) | confirmed | | dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix | 20260714 | HOTFIX — fetched credential 운영자 broker truth 정합 및 managed non-holding divergence 분리 (#39, #40, #41) | [Discovery](./dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md) | confirmed | +| dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding | 20260716 | P1 — portfolio-hub cost-basis response contract 정합 및 안전한 sync 평균가 seed | [Discovery](./dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md) | confirmed | diff --git a/docs/interface-contract.md b/docs/interface-contract.md index bc7038b..735463b 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -620,3 +620,17 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기 - 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062) - 2026-07-04 :: BAT-055 (dcy-001/002 v3 FR-8, REQ-116) — `trex.slots.req` additive 확장: 요청에 선택적 `strategy: str` 필터 추가(exact-match, `filter` AND 조합, `available_slots` 불변 — REQ-073 AC-6); 응답 슬롯 행에 `strategy_name`(null)/`strategy_params`({})/`markets`([]) 3필드 추가(PortfolioItem/hub 정본, safe default). 기존 소비자(orb·tsl) backward compatible(unknown 필드 무시). REQ-074 AC-5 additive 재도입(dcy-025 OQ-4 후속 확정). PortfolioItem에 `strategy_name`/`markets` 필드 추가 + hub_read.py 파싱 확장. - 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — `OrderRequest.venue: str = "KRX"` additive 추가(기존 위치 인수 순서 불변 — AMQP wire 계약 영향 없음, 브로커 어댑터 계층 DTO까지만). KIS 어댑터: submit_order/cancel_order body에 `EXCG_ID_DVSN_CD` 항상 명시 전송(기존 미전송 → KRX 명시화 견고성 향상), cancel_order/get_current_price 시그니처 venue/market 파라미터 추가(기본값 KRX/"J" — 기존 호출자 무영향). poll_fill TR 유지(NXT 체결 포함 여부 미확인 open item). paper broker 시그니처 정합(venue/market 무시). 실측 근거: 2026-07-06 NXT 주문/취소 rt_cd=0 확인. + + +## REQ-123 Contract Addendum: cost-basis canonical parsing & sync diagnostics + +`trex-core` REQ-123 적용 시, portfolio-hub cost-basis contract는 다음을 준수한다. + +- `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 응답의 `cost_basis_per_unit`(canonical)을 먼저 확인한다. +- canonical 값이 유효한 양수 수치면 이를 `avg_price` seed 후보로 사용한다. +- canonical key가 존재하지만 null / non-numeric / non-positive / 범위 외이면 legacy fallback(`avg_cost` → `wac` → `cost_basis`)은 시도하지 않는다. +- canonical이 부재한 경우에만 approved legacy 순서로 fallback을 검증한다. +- canonical/legacy parsing 실패는 실패 원인을 reason 코드(`cost_basis_reason`)로 분리해 운영 진단으로 노출한다. +- `sync`/`sync --dry-run` 결과에는 `seeded_avg_price`(bool), `cost_basis_source`(`canonical`/`legacy`/`none`), `cost_basis_reason` 필드가 들어간다. `cost_basis_reason`의 공개 값은 `missing`, `invalid`, `http_status_<3-digit>`, `transport`, `invalid_json`, `invalid_payload`, `not_configured`, `diagnostic_exception`, `diagnostic_source_invalid` 또는 유효한 값이 seed되었을 때의 `null`로 한정한다. +- `http_status_<3-digit>`만 동적 형식이다. provider field name, parser detail, 임의 오류 텍스트는 공개 `cost_basis_reason` 계약 값이 아니다. +- 비밀(credential/token/계좌정보)은 로그/출력에 노출하지 않는다. diff --git a/docs/operations.md b/docs/operations.md index f0af40e..f7022e1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -383,3 +383,17 @@ pause 후 동작: - `docs/interface-contract.md` — RabbitMQ 토폴로지 + 메시지 payload + reason/actor 어휘 - `docs/runtime-flows.md` — 흐름 다이어그램(스냅샷/intent RPC/이벤트/집행 루프/로깅) - `.claude/skills/run-trex-core-daemon/SKILL.md` — AI 에이전트용 실행 레시피(이 런북의 사실 자료 출처) + + +### `sync` 출력 진단 포맷 (REQ-123) + +`trex-core sync [--dry-run|--live]` 실행 시 `--json` 또는 일반 출력에서 다음 진단 필드로 no-write/fail-closed를 확인한다. + +- `seeded_avg_price`: true면 seed된 avg_price가 반영 대상인지 표시 +- `cost_basis_source`: `canonical` / `legacy` / `none` +- `cost_basis_reason`: `missing`, `invalid`, `http_status_<3-digit>`, `transport`, `invalid_json`, `invalid_payload`, `not_configured`, `diagnostic_exception`, `diagnostic_source_invalid` 또는 유효한 값이 seed되었을 때의 `null`. `http_status_<3-digit>`만 동적 형식이며 provider field name, parser detail, 임의 오류 텍스트는 출력 계약 값이 아니다. +- `skip_reason`: `cost_basis_unavailable`, `broker_qty_zero`, `status_in_flight:*` 등 +- `error`: live fail-closed (`cost_basis_unavailable`) 등 write 중단 사유 +- `before`/`after`는 write/미리보기 비교에 사용하며, dry-run은 `warning_cleared=False`로 고정 + +`sync --dry-run`은 실제 DB 쓰기·warning clear를 하지 않으므로 운영자가 미리보기를 확인한 뒤 직접 승인된 live 실행으로 전환한다. diff --git a/docs/releases/index.md b/docs/releases/index.md index c336011..63f9efa 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -19,3 +19,4 @@ | REL-009 | app-service | feedback-captured | BAT-047, BAT-048, BAT-049 | dcy-031/OPS-005 (이벤트 발행 신뢰성 — idle keepalive·재시도·DLQ 경보) + dcy-032 (체결가 0/None 백필 가드·WARN 로깅) + dcy-033 (알람 emitter + trex.alerts exchange 1단계) + dcy-017 후속(무데이터/죽은 종목 위생 런북 docs-only) — M2 통합 릴리즈. 환류: OPS-005 해소 확인, slot_filled NO_ROUTE/DLQ 신규 발견(M3 Discovery 입력) | [rel-009](rel-009_20260625_event-reliability-fill-guard-alarm-runbook.md) | | REL-010 | app-service | released | BAT-050, BAT-051, BAT-052, BAT-053, BAT-054 | dcy-034 (차단형 reconcile + sync/verify cold-start) + dcy-035 (WAC 실현손익 운영리포트) + dcy-036 (KIS 인증정보 휴면 배포 — credential_fetch_enabled=OFF) — M3 통합 릴리즈. 배포 revision `314047b`(2026-06-30). verify ImportError 핫픽스 `51cc3cc` 포함. | [rel-010](rel-010_20260629_reconcile-coldstart-wac-report-credential-dormant.md) | | REL-011 | app-service | draft | BAT-057 | dcy-037 (operator broker truth / managed non-holding divergence hotfix) — **M5 trex-core P0 release**. Source pin `6d90f89`; production daemon restart와 read/dry-run smoke만 대상이며 `sync --live`, config activation, DB migration은 별도 gate. | [rel-011](rel-011_20260716_m5_operator-broker-truth-hotfix.md) | +| REL-012 | app-service | draft / release-pending | BAT-058 | dcy-038 / REQ-123 (cost-basis canonical parse + unavailable WAC safe sync seed) — **긴급 consumer hotfix**. Immutable source pin은 commit/push 후 확정하며, daemon restart와 read/dry-run smoke만 release scope; `sync --live`, config activation, DB migration은 별도 gate. | [rel-012](rel-012_20260717_cost-basis-safe-sync-seed-hotfix.md) | diff --git a/docs/releases/rel-012_20260717_cost-basis-safe-sync-seed-hotfix.md b/docs/releases/rel-012_20260717_cost-basis-safe-sync-seed-hotfix.md new file mode 100644 index 0000000..a8418fa --- /dev/null +++ b/docs/releases/rel-012_20260717_cost-basis-safe-sync-seed-hotfix.md @@ -0,0 +1,73 @@ +# REL-012: cost-basis canonical parse 및 안전 sync seed 긴급 핫픽스 + +- Status: **Draft / Release-Pending** +- Profile: `app-service` +- Priority: **urgent** +- Owner: 팀장 / 운영팀 +- Included batch: **BAT-058** (`REQ-123`) +- Source discovery: [`dcy-038`](../discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md) +- Batch verification: [`BAT-058 verification`](../batches/bat-058_20260716_cost-basis-safe-sync-seed/verification.md) — lead `confirm-batch-verification`, QC-7 `PASS-WITH-NOTES` +- Operations handoff: [`REL-012 deployment request`](../runbooks/rel-012-cost-basis-safe-sync-seed-ops-handoff.md) + +## Release intent + +portfolio-hub의 기존 cost-basis 응답에서 canonical `cost_basis_per_unit`을 우선 해석하고, broker quantity가 양수인 inactive-slot onboarding에서 WAC/avg_price를 얻지 못하면 live sync가 slot status/qty/avg_price/warning을 쓰지 않은 채 non-zero로 fail-closed하도록 한다. dry-run은 sanitized source/reason과 no-write preview를 제공한다. + +## Source publication boundary + +| Item | Current evidence | Release constraint | +| --- | --- | --- | +| Remote `origin/main` | `18bd8d9f0b7bb76b08303bbf8743e6ddf916de89` (2026-07-17 fetch) | BAT-058 source is **not included** in this remote revision. | +| BAT-058 candidate | delivery worktree `sp/trex-bat-058-lead-kickoff-delivery`, currently uncommitted | A local worktree is not a deployable source pin. | +| Immutable deploy target | **PENDING_COMMIT_AND_PUSH** | Git-ops must publish an exact reviewed commit SHA and verify that the remote tree contains this REL/runbook before ops execution. | + +This document is a release draft and a preflight request only. It does **not** authorize deployment, daemon restart, broker/portfolio access, `sync --live`, database writes, configuration activation, or credential access. + +## Included scope + +1. `cost_basis_per_unit` canonical priority parsing and approved legacy compatibility chain (`avg_cost` → `wac` → `cost_basis`) only when canonical is absent. +2. Canonical invalid/null/non-numeric/zero/negative values are not silently masked by a legacy value; diagnostic reasons remain sanitized. +3. Inactive slot + positive broker quantity + unavailable WAC: dry-run no-write preview; live non-zero fail-closed with no slot/warning mutation. +4. Existing HOLDING reconciliation remains compatible; sanitized CLI/operator diagnostics preserve `seeded_avg_price`, source, and absence reason. + +## Explicit exclusions / no-go + +- portfolio-hub API/schema/auth changes, token/config changes, dependency upgrade, DB migration, direct DB edit/delete, AMQP wire/topology change. +- Any `sync --live`, `--yes` live write, automatic remediation, order action (`enter`/`exit`/`cancel`), or target portfolio selection. +- Changing `credential_fetch_enabled`, break-glass mode, instance configuration, endpoint, or secrets. +- Strategy daemon deployment. + +A single-target `sync --live --yes` remains a separately approved post-release action even after this source rollout. It must not be used as a deployment smoke shortcut. + +## Draft verification evidence + +| Check | Result | Evidence / limitation | +| --- | --- | --- | +| Independent QC-7 | **PASS-WITH-NOTES** | Legacy-invalid focused `8 passed, 19 deselected`; affected suite `113 passed`; full suite `833 passed, 47 skipped`; Ruff/compileall/diff hygiene pass. | +| Release-prep full regression | **PASS** | 2026-07-17 delivery worktree: `833 passed, 47 skipped in 1.88s`. | +| Focused Ruff | **PASS** | Changed adapter/reconcile/CLI and relevant tests: `All checks passed!`. | +| Focused compile check | **PASS** | Six changed source/test paths, `compileall -q`, exit `0`. | +| Live broker, live PostgreSQL, production deploy | **NOT EXECUTED** | Outside batch verification and this draft; no claim is made. | +| Source publication | **PENDING** | No immutable remote candidate SHA exists yet. | + +## Release approval gates + +All items must be satisfied before operations executes the runbook: + +- [ ] Git-ops publishes the reviewed source and these release documents; lead records the exact remote immutable target SHA. +- [ ] Operations verifies that exact target SHA contains REL-012 and its runbook, and captures the actual deployed revision as `ROLLBACK_REVISION`. +- [ ] User/approver explicitly authorizes production execution and the approved off-market window is recorded. +- [ ] Operations confirms the source checkout is clean; existing runtime configuration and credential/break-glass modes will not change. +- [ ] Operations confirms no dependency or migration delta exists between `ROLLBACK_REVISION` and the exact target SHA. +- [ ] Operations records the approved supervisor method, instance key (redacted as necessary), and post-deploy observation owner. + +## Rollout and rollback policy + +1. Use the pinned immutable source only; use the host’s existing approved supervisor to restart the same daemon with unchanged approved configuration. +2. After startup acceptance, run only the read/dry-run commands in the REL-012 runbook. +3. Stop and roll back on startup failure, traceback/error regression, unexpected credential/source-mode behavior, unexpected mutation indication, or smoke output that violates the no-write/fail-closed contract. +4. Roll back by restoring the actual captured `ROLLBACK_REVISION` and restarting with unchanged configuration. Do not run database rollback commands, direct DB edits, or live sync. + +## Evidence required for `confirm-release` + +Operations returns a secret-free record containing: approver/window/operator; `ROLLBACK_REVISION` and target SHA; clean-checkout/preflight result; restart/startup result; dry-run smoke and `verify` exit/status summaries; observed no-write/fail-closed classifications; and rollback disposition. The lead then reviews that evidence before changing REL-012 from Draft / Release-Pending. diff --git a/docs/runbooks/rel-012-cost-basis-safe-sync-seed-deployment.md b/docs/runbooks/rel-012-cost-basis-safe-sync-seed-deployment.md new file mode 100644 index 0000000..29f47e4 --- /dev/null +++ b/docs/runbooks/rel-012-cost-basis-safe-sync-seed-deployment.md @@ -0,0 +1,115 @@ +# REL-012 cost-basis safe sync seed hotfix — deployment runbook + +- Release: `REL-012` +- Target source: **PENDING_COMMIT_AND_PUSH** (replace only with the approved immutable remote SHA) +- Execution owner: 운영팀 +- Approval required: team lead review + user production execution approval +- Safety classification: daemon restart; no DB migration; read/dry-run smoke only + +> Do not record credentials, tokens, account numbers, DSNs, `.env` content, raw broker/portfolio responses, or command lines containing secrets. Use `[REDACTED]`. + +## 1. Hard boundaries + +Allowed only after every REL-012 approval gate is checked: + +- Check out the approved immutable source and restart the existing trex-core daemon through the host’s approved supervisor. +- Inspect startup status/logs and execute `sync --all --dry-run --json` plus `verify --json` using the unchanged approved instance configuration. + +Not allowed: + +- `sync --live`, `--yes` with a live write, automatic remediation, `enter`, `exit`, `cancel`, target portfolio selection, or any DB write. +- Changes to credential/break-glass mode, instance config, endpoints, secrets, dependency versions, DB schema, AMQP topology, or strategy daemons. + +If a prohibited action appears necessary, stop and return to the approver. + +## 2. Preflight + +1. Confirm the exact approved target SHA, approval, and off-market execution window. If the target remains `PENDING_COMMIT_AND_PUSH`, **stop**. +2. Capture only redacted operating-checkout metadata: + + ```bash + pwd + git status --short + git rev-parse HEAD + git rev-parse --abbrev-ref HEAD + ``` + + Record the current SHA as `ROLLBACK_REVISION`. A dirty checkout is a stop condition. +3. Identify the active instance configuration and existing supervisor method without exposing configuration values. Confirm credential/break-glass state will remain unchanged. +4. From a clean source checkout, verify the candidate and migration/dependency delta: + + ```bash + git fetch origin + git show -s --format='%H %s' + git diff --name-only "$ROLLBACK_REVISION" -- pyproject.toml trex_core/adapters/persistence/migrations + ``` + + Any output on a dependency/migration path or any uncertainty about the target SHA is a stop condition requiring lead review. + +## 3. Source deployment and restart + +Use the approved host supervisor; do not infer a service-manager command from this document. + +```bash +git fetch origin +git checkout --detach +# Stop the existing daemon through the approved host supervisor. +# Start exactly one daemon with the unchanged approved instance configuration. +.venv/bin/trex-core-daemon --config +``` + +Record redacted proof of target SHA, old-process stop, new-process supervisor/PID status, selected instance key, and startup log interval. + +## 4. Startup acceptance and read-only smoke + +Before CLI smoke, confirm startup completed, RabbitMQ topology declaration completed, expected workers are running, and the post-restart window has no `ERROR`, `CRITICAL`, or traceback. + +Run only: + +```bash +.venv/bin/trex-core sync --all --dry-run --config --json +.venv/bin/trex-core verify --config --json +``` + +Acceptance: + +- Output is sanitized; no credential/account/raw payload is collected. +- For available WAC, dry-run may show `seeded_avg_price=true` with the preview only; no slot/warning write occurs. +- For unavailable WAC on a positive-broker inactive slot, dry-run must show `seeded_avg_price=false`, sanitized reason, and no-write preview. +- No invocation adds `--live` or `--yes`; no remediation occurs. +- Any unexpected write indication, unsafe success/empty-balance interpretation, or error inconsistent with fail-closed behavior is a rollback trigger. + +## 5. Rollback + +For any stop condition, stop the new daemon through the approved supervisor, restore the actual captured `ROLLBACK_REVISION`, and restart with the unchanged configuration: + +```bash +# Stop the new daemon through the approved host supervisor. +git checkout --detach "$ROLLBACK_REVISION" +# Restart with the same approved configuration through the supervisor. +``` + +Do not run DB rollback, direct DB changes, or `sync --live` as a diagnostic shortcut. + +## 6. Evidence template + +```text +Release: REL-012 +Approved by / timestamp: [REDACTED AS NEEDED] +Window: [REDACTED] +Host / instance key: [REDACTED] +Pre-deploy revision (ROLLBACK_REVISION): +Target revision: +Configuration/credential mode unchanged: yes/no (values not recorded) +Checkout clean: pass/fail +Dependency/migration path delta: none / stop +Daemon stop/start evidence: +Startup acceptance: pass/fail +sync --dry-run result: pass/fail + sanitized exit/status summary +verify result: pass/fail + sanitized exit/status summary +Unexpected write indication: none / details and stop action +Rollback performed: no / yes (reason and restored SHA) +Operator: [REDACTED] +``` + +REL-012 can move to `released` only after this record is returned and the lead completes `confirm-release`. `sync --live` remains a separate explicit approval gate. diff --git a/docs/runbooks/rel-012-cost-basis-safe-sync-seed-ops-handoff.md b/docs/runbooks/rel-012-cost-basis-safe-sync-seed-ops-handoff.md new file mode 100644 index 0000000..a5c703f --- /dev/null +++ b/docs/runbooks/rel-012-cost-basis-safe-sync-seed-ops-handoff.md @@ -0,0 +1,50 @@ +# TREX REL-012 운영팀 배포 요청 근거 + +- 대상: 운영팀 (`trex-op`) +- 작성일: 2026-07-17 +- 상태: **긴급 배포 사전 근거 및 실행 요청 준비 — source publication / production approval 대기** + +> 이 문서는 운영팀용 요약이다. 실제 실행은 immutable remote source에 존재하는 REL-012 및 deployment runbook만 따른다. 이 문서는 deployment approval 또는 execution completion이 아니다. + +## 1. 결론 + +BAT-058은 independent QC-7 `PASS-WITH-NOTES`를 수용해 batch verification을 완료했고, REL-012 긴급 릴리즈 초안을 준비했다. 코드가 현재 delivery worktree에만 존재하여 **배포 가능한 immutable target SHA는 아직 없다**. 따라서 운영팀은 지금 배포를 시작하지 말고, 아래 source-publication gate가 해소된 뒤에만 preflight를 시작한다. + +## 2. 배포 전 근거와 원본 + +| 순서 | Release | 배포 대상 source/artifact | 비운영 근거 | 실행 전 남은 gate | immutable 원본 링크 | +| --- | --- | --- | --- | --- | --- | +| 1 | REL-012 / BAT-058 | `PENDING_COMMIT_AND_PUSH` | QC-7: focused `113 passed`, full `833 passed, 47 skipped`; release-prep full regression `833 passed, 47 skipped`; focused Ruff/compileall PASS | immutable SHA publish·remote tree 확인·release execution approval·window·host preflight | **PENDING** — git-ops 검증 후 commit-URL만 삽입 | + +원본 문서(현재 release candidate worktree): + +- REL draft: [`REL-012`](../releases/rel-012_20260717_cost-basis-safe-sync-seed-hotfix.md) +- 운영 runbook: [`REL-012 deployment runbook`](rel-012-cost-basis-safe-sync-seed-deployment.md) +- batch verification: [`BAT-058 verification`](../batches/bat-058_20260716_cost-basis-safe-sync-seed/verification.md) + +## 3. 승인 후 실행 순서 + +1. **Git-ops / lead** — reviewed source와 REL/runbook을 commit·push하고 exact remote SHA 및 immutable commit URLs를 검증·전달한다. +2. **운영팀** — 승인된 장외 window에 `ROLLBACK_REVISION`, clean checkout, unchanged config/credential mode, dependency/migration delta를 preflight에 기록한다. +3. **운영팀** — approved supervisor로 동일 config의 trex-core daemon을 target SHA로 재기동한다. +4. **운영팀** — startup acceptance 후 `sync --all --dry-run --json` 및 `verify --json`만 수행하고 runbook evidence template을 반환한다. +5. **팀장** — evidence를 검토해 `confirm-release` 여부를 판정한다. + +어느 단계에서든 stop condition이면 즉시 rollout을 멈추고 captured `ROLLBACK_REVISION`으로 되돌린다. `sync --live`는 이 배포 요청의 범위가 아니다. + +## 4. 핵심 주의사항 / 중단 조건 + +- 허용 범위: daemon restart, startup/log inspection, `sync --dry-run`, `verify`. +- 금지 범위: `sync --live`, `--yes` live write, 자동 remediation, 주문 명령, DB/스키마 변경, config/credential/break-glass 변경, AMQP topology 변경. +- target SHA가 정확하지 않거나 dirty checkout, dependency/migration delta, startup error/traceback, unexpected write indication, fail-closed 위반, unsafe empty-balance 해석이 있으면 **중단 및 rollback**. +- token, secret, account number, DSN, raw broker/portfolio payload를 전달서 또는 증적에 기록하지 않는다. + +## 5. 운영팀 반환 증적 + +runbook의 evidence template을 작성해 아래만 전달한다. + +- approver/window/operator 및 redacted host/instance identifier +- target SHA와 `ROLLBACK_REVISION` +- clean-checkout·dependency/migration preflight 결과 +- daemon restart/startup·dry-run·verify의 secret-free 결과 +- no-write/fail-closed 관찰 결과, rollback 여부와 사유/복구 SHA diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 789dfdb..70edc9b 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -452,3 +452,17 @@ trex-notifier (단일 소비자) → 텔레그램 등 전달 | 소비자 | 전략 모듈(strategy_id 필터) | trex-notifier 단독 | | 발행 재시도 | 재연결 후 1회 retry | **없음** (best-effort) | - 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — 브로커 집행 venue 전달 경로 명시: `OrderRequest.venue`(기본 KRX) → KIS 어댑터 `EXCG_ID_DVSN_CD` 항상 명시 전송(submit_order/cancel_order). `get_current_price` market 파라미터 추가(기본 J). poll_fill TR 유지(NXT 체결 open item). 기존 orb/tsl 경로 무영향. + + +## Flow: `operator sync` (REQ-105/REQ-123, dcy-034 / BAT-051) + +- Operator CLI `trex-core sync`는 브로커 잔고를 1회 조회해 슬롯 병합을 수행한다. +- 대상 슬롯은 상태별로 다음 규칙을 따른다: + - `PENDING_*`: 쓰기 스킵(수정 없음, warning clear 없음) + - `broker_qty == 0`: 스킵(안전 가드) + - `HOLDING`: qty(및 avg_price) 갱신 + - `INACTIVE`: qty onboarding 시 `cost_basis`를 `seeded_avg_price`로 seed 시도 +- `INACTIVE` 상태에서 `broker_qty > 0`이나 seedable avg_price가 없으면: + - `--dry-run`은 `skipped=True`, `seeded_avg_price=False`, `cost_basis_reason=`만 노출하고 status/qty/avg_price 변경을 수행하지 않는다. + - live는 `cost_basis_unavailable` 에러로 fail-closed 하여 status/qty/avg_price 쓰기를 모두 거부한다. +- 결과 객체에는 `before/after`, `warning_cleared`, `seeded_avg_price`, `cost_basis_source`, `cost_basis_reason`, `error`가 포함되어 운영자가 no-write/fail-closed 여부를 판별할 수 있다. diff --git a/docs/srs/Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md b/docs/srs/Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md new file mode 100644 index 0000000..3d23c28 --- /dev/null +++ b/docs/srs/Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md @@ -0,0 +1,149 @@ +# req-123: cost-basis canonical parse 및 WAC unavailable 안전 sync seed + +- Status: Approved +- Type: Integration +- Priority: High +- Owner: trex-core-dev + +## Intent +- portfolio-hub의 기존 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 응답에서 canonical field인 `cost_basis_per_unit`을 trex-core `PortfolioHubReadAdapter.get_cost_basis()`가 우선 해석하도록 하여 정상 HTTP 200 응답이 `None`으로 degrade되는 consumer contract defect를 수정한다. +- legacy alias는 실제 호환 필요성이 확인되는 경우에만 낮은 우선순위 fallback으로 유지하고, canonical field의 invalid 값을 legacy 값으로 조용히 은폐하지 않는다. +- broker 수량이 양수인데 WAC/avg_price가 unavailable한 inactive-slot onboarding에서 불완전한 live write를 방지한다. Discovery 승인 결정에 따라 live path는 fail-closed이며, dry-run은 진단과 no-write preview를 제공한다. +- 본 REQ는 dcy-038의 consumer hotfix 요구사항만 다루며 portfolio-hub producer API 변경, 인증·토큰·설정 변경, DB migration/direct edit, `sync --live` 실행, 배포를 포함하지 않는다. + +## Requirement +1. `PortfolioHubReadAdapter.get_cost_basis(portfolio_id)`는 successful JSON object response에서 `cost_basis_per_unit`을 canonical WAC source로 먼저 검사하고, 유효한 양의 수치이면 `float` 값으로 반환해야 한다. +2. canonical key가 없을 때에만 승인된 legacy alias를 정해진 순서로 검사할 수 있다. 현재 지원 alias는 `avg_cost` → `wac` → `cost_basis`이며 순차 적용한다. canonical key가 response에 존재하지만 null, non-numeric, 0, negative 또는 허용 범위 밖이면 legacy 값으로 fallback masking하지 않는다. +3. cost-basis 결과는 최소한 다음 상태를 구분해 reconcile/operator surface가 sanitized reason을 표시할 수 있어야 한다: valid canonical/legacy value, field missing/null, malformed/non-numeric/invalid value, non-2xx response, transport/timeout failure, unexpected response shape. 기존 호출부 호환성이 필요한 경우 `Optional[float]` port를 유지하되, unavailable reason은 reconcile-local result/diagnostic으로 보존한다. +4. canonical WAC가 유효하면 `sync --dry-run`의 `after.avg_price`와 `seeded_avg_price=true`가 해당 값과 일치해야 하며, 실제 DB slot/warning write는 발생하지 않아야 한다. 승인된 live candidate도 동일한 source value를 사용해야 한다. +5. `broker_qty > 0`인 inactive slot onboarding에서 WAC/avg_price가 unavailable이면 dry-run은 `after.avg_price=null`, `seeded_avg_price=false`, sanitized source/absence reason 및 no-write preview를 제공해야 한다. 이 경우 inactive→holding 전이와 qty/status/avg_price write를 dry-run에서 수행하지 않아야 한다. +6. 동일 조건의 live sync path는 Discovery에서 승인된 fail-closed 정책을 적용해야 한다. 즉 status, qty, avg_price를 모두 기록하지 않고 non-zero failure로 중단하며, 평균가 없는 inactive→holding 전이를 허용하지 않는다. 기존 double-ack/live gate와 별도로 이 no-write barrier를 유지한다. +7. cost-basis port가 연결되지 않았거나 transport failure인 기존 graceful-degrade 경로와, canonical response parsing/validation failure 경로를 operator reason 및 테스트에서 구분해야 한다. unavailable을 정상적인 0 또는 빈 잔고로 해석하지 않아야 한다. +8. sync diagnostics/logging은 비밀 없이 `portfolio_id`/`slot_id`/`symbol`의 허용된 식별자, `dry_run` 또는 `live` mode, `seeded_avg_price`, cost-basis source(`canonical`/legacy/none), sanitized absence reason, no-write/fail-closed 결과를 판별 가능하게 해야 한다. token, credential payload, account number 등은 출력·로그·fixture에 포함하지 않는다. +9. response-contract 및 regression test는 adapter→reconcile→CLI/output 경로를 고정해야 한다. canonical payload, legacy payload(채택 시), canonical-invalid+legacy-present, missing/null, malformed, non-2xx/transport failure, current holding update, positive broker qty unavailable WAC의 dry-run no-write와 live no-write를 각각 검증해야 한다. +10. 관련 baseline 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/operations.md`)와 REQ/Batch/verification/release 문서는 portfolio-hub existing response contract, WAC absence semantics, dry-run diagnostics, fail-closed barrier, observability, rollback 및 release gate를 구현 결과와 일치하게 기술해야 한다. + +## Acceptance Criteria +1. Given `{\"current_quantity\": 5.0, \"cost_basis_per_unit\": 124580.0}`인 HTTP 200 response, When `get_cost_basis()`를 호출하면, Then `124580.0`을 반환하고 요청 경로가 `/v1/portfolios/{portfolio_id}/holdings/cost-basis`임을 contract test가 고정한다. +2. Given canonical field가 없고 승인된 legacy fixture가 있는 response, When adapter가 parsing하면, Then 명시된 legacy 우선순위에 따라 값이 반환된다. Given canonical field가 invalid하고 legacy 값이 함께 있으면, When parsing하면, Then legacy로 masking하지 않고 invalid/unavailable reason을 반환한다. +3. Given canonical field가 null/missing/non-numeric/0/negative 또는 response shape가 object가 아닌 경우, When adapter/reconcile가 처리하면, Then 정상 WAC seed로 취급하지 않고 케이스별 sanitized reason을 기록·반환한다. +4. Given cost-basis endpoint가 non-2xx 또는 transport/timeout failure를 반환하는 경우, When sync 경로가 처리하면, Then parsing failure와 구분되는 unavailable reason을 내고 credential/token/raw response 비밀을 노출하지 않는다. +5. Given inactive slot과 `broker_qty=5`, canonical WAC `124580.0`인 경우, When `sync --dry-run`을 실행하면, Then preview의 `after.status=holding`, `after.qty=5`, `after.avg_price=124580.0`, `seeded_avg_price=true`를 표시하되 slot/warning write count는 0이다. +6. Given inactive slot과 `broker_qty>0`, WAC가 unavailable인 경우, When `sync --dry-run`을 실행하면, Then inactive→holding 전이 및 qty/status/avg_price write를 하지 않고 `avg_price=null`, `seeded_avg_price=false`, reason 및 no-write preview를 표시한다. +7. Given 동일한 positive broker holding/WAC unavailable 조건과 live path, When reconcile/sync가 실행되면, Then non-zero failure로 종료하고 status, qty, avg_price, warning clear를 포함한 write가 모두 0이며 inactive→holding 전이가 일어나지 않는다. +8. Given 이미 HOLDING인 slot의 broker 수량 정합 업데이트, When sync/reconcile가 처리하면, Then 기존 의도된 current-holding update와 warning semantics가 유지되고 canonical WAC가 있으면 avg_price seed 값이 일관되게 적용된다. +9. Given adapter/application/CLI regression suite를 실행하면, Then canonical contract, alias precedence, unavailable reason, dry-run no-write, live fail-closed, current holding regression, output/log redaction 및 `seeded_avg_price` 관측성 검증이 모두 통과한다. +10. Given release candidate를 운영에 반영하려는 경우, When release gate를 점검하면, Then targeted regression 및 independent QC PASS, dry-run evidence, explicit release approval, 단일 대상 live 실행 승인, rollback 명령/기준과 post-release monitoring plan이 없이는 배포 또는 `sync --live`를 시작하지 않는다. + +## Impacted Area +- Docs: + - `docs/interface-contract.md` — portfolio-hub existing cost-basis response의 canonical field와 legacy compatibility 경계 + - `docs/runtime-flows.md` — sync/reconcile WAC seed 및 unavailable fail-closed/no-write 흐름 + - `docs/operations.md` — dry-run diagnostics, operator reason, observability, rollback/release gate + - `docs/discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md` +- Modules: + - `trex_core/adapters/portfolio/hub_read.py` — `get_cost_basis()` canonical/legacy parse와 unavailable 분류 + - `trex_core/application/reconciliation.py` — WAC unavailable no-write barrier, dry-run result 및 failure semantics + - `trex_core/runtime/cli.py` 또는 현재 sync output 경계 — sanitized diagnostics와 non-zero failure + - 기존 warning/observability 호출부 — fail-closed 시 warning clear/write 차단 및 식별 가능한 로그 +- Tests: + - `tests/adapters/portfolio/test_hub_read.py` — HTTP response contract와 precedence/validation regression + - `tests/application/test_sync.py` 및 관련 reconcile/CLI test — dry-run no-write, live fail-closed, diagnostics/output regression + +## Notes +- Source Discovery: `docs/discovery/dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md`. +- Producer source of truth: portfolio-hub existing `HoldingsCostBasisResponse.cost_basis_per_unit`; 이 REQ에서는 producer endpoint/schema/auth를 수정하지 않는다. +- Discovery confirmation: Justin Ko, 2026-07-16 22:44:20 KST. 승인 내용은 REQ drafting을 허용하며 `sync --live`, DB 변경, token/config 변경 또는 배포 승인을 의미하지 않는다. +- OQ-2 Answered(2026-07-16): Producer 확인 결과, 현재 producer/fixture는 canonical `HoldingsCostBasisResponse.cost_basis_per_unit`을 기준으로 하고, 호환성 사유로 `avg_cost` → `wac` → `cost_basis` fallback을 유지한다. 단, canonical key가 유효하지 않으면 legacy fallback을 허용하지 않는다. +- OQ-3 Answered(2026-07-16): 기존 `Optional[float]` port를 유지한다. unavailable reason은 reconcile-local 진단 구조에서 `seeded_avg_price`, source(`canonical`/`legacy`/`none`), reason을 분리해 노출한다. +- Rollback은 consumer hotfix의 코드/문서 revert와 release 이전 candidate 철회를 의미한다. 이미 운영에 기록된 slot을 자동으로 직접 수정하거나 DB migration으로 복구하는 범위는 포함하지 않으며, 별도 승인된 운영 절차로만 다룬다. +- Dependencies: existing REQ-105 sync contract, dcy-034의 WAC seed 범위, dcy-037의 broker truth/credential 변경은 재설계하지 않고 현재 baseline과 회귀 확인만 한다. +- No live action: 구현·QC·release approval 전 `sync --live`, production sync, deployment, credential/token/config access는 금지한다. + +## Change Log + +### CHG-20260716-01 +- Date: 2026-07-16 +- Author: delivery-runner + +#### Change Summary +- dcy-038 confirmed Discovery의 consumer hotfix 범위를 단일 Integration REQ 초안으로 분해했다. +- canonical `cost_basis_per_unit` 우선 parsing, legacy alias 정책, WAC unavailable fail-closed sync, dry-run diagnostics, response-contract/regression test, observability 및 rollback/release gate를 요구사항과 AC로 고정했다. + +#### Acceptance Criteria Delta +- Added: AC-1~10 전부 신규. +- Updated: 없음. +- Removed: 없음. + +#### Impacted Area Delta +- `hub_read.py`, `reconciliation.py`, sync CLI/output, 관련 adapter/application tests 및 runtime/interface/operations baseline 문서를 영향 범위로 명시했다. +- portfolio-hub API/schema/auth, token/config, DB migration/direct edit, live sync, deployment는 명시적으로 제외했다. + +#### Delivery Trace Delta +- Batches: none (draft only; suggest-batch-reqs 이후 편성). +- Releases: none (별도 release approval gate 대상). + +#### Revalidation Impact +- Reverification Needed: yes — confirm-req 전 legacy producer 증거, diagnostic result seam, source-level no-write 경계를 재검토하고 구현/QC에서 AC별 재검증한다. +- Existing Implementation Invalidated: no — 현재 구현의 canonical-field parsing gap과 WAC unavailable live-write risk를 후속 hotfix requirement로 명시한다. +- Status Recommendation: Proposed +- Follow-up Action: OQ-2/OQ-3이 이 문서에서 Answered로 전환되었으나, implementation에서 reason enum/출력 형식 고정은 설계 단계에서 재확인한다. + +#### Validation Plan +- adapter HTTP fake/fixture로 canonical field와 alias precedence를 검증하고, canonical-invalid에서 legacy fallback 금지(차단) 및 canonical-absent에서 legacy fallback 동작을 함께 검증한다. +- application/CLI recording double로 dry-run/live write count, fail-closed no-write, `seeded_avg_price`, source, reason 분리 표시를 검증한다. +- targeted suite와 independent QC rerun 결과를 Batch verification에 AC별로 기록한다. + +### CHG-20260716-02 +- Date: 2026-07-16 +- Author: delivery-runner + +#### Change Summary +- OQ-2/OQ-3을 본 REQ에서 답변 상태로 확정한다. +- OQ-2: canonical-absent fallback alias 순서 및 canonical-invalid/no-valid canonical 값 처리 우선순위를 `Requirement`/`AC`/`Notes`에 고정한다. +- OQ-3: `Optional[float]` 시그니처 유지 + unavailable reason을 reconcile-local 진단으로 분리 표기( `seeded_avg_price`, source, reason )하도록 변경한다. + +#### Acceptance Criteria Delta +- Added: AC-2/AC-3의 canonical-invalid/legacy precedence 경로 재정의(유효성 규칙 및 canonical masking 금지). +- Updated: 없음. +- Removed: 없음. + +#### Impacted Area Delta +- `trex_core/adapters/portfolio/hub_read.py`, `trex_core/application/reconciliation.py`, `tests/adapters/portfolio/test_hub_read.py`, `tests/application/test_sync.py`, `docs/interface-contract.md` 문안에 OQ 결정 반영. + +#### Delivery Trace Delta +- Batches: none (draft/confirm 단계). +- Releases: none (별도 release approval gate 대상). + +#### Revalidation Impact +- Reverification Needed: yes — batch design에서 reason enum/표시 고정값, AC-별 regression test matrix 보강 및 independent QC rerun을 통해 재검증한다. +- Existing Implementation Invalidated: no. +- Status Recommendation: Proposed. +- Follow-up Action: 확인된 OQ 반영 후 confirm-req 재평가에서 pass 되면 `suggest-batch-reqs` 진행. + +### CHG-20260716-03 +- Date: 2026-07-16 +- Author: trex-dev-lead + +#### Change Summary +- confirm-req 게이트에서 dcy-038 승인 근거(제한범위/구조/결정사항)를 기준으로 `REQ-123`을 `Approved`로 전환하고, 다음 배치 인계만 선행한다. +- 구현/검증/배포는 이 상태 전이 이후 `trex-core-dev`가 `suggest-batch-reqs`에서 생성되는 `BAT-058`로 진행한다. + +#### Acceptance Criteria Delta +- Added: StagePilot 상태 transition 근거(확정 OQ 0개, OQ-1/BLA no-write 정책 승인, scope non-goals 고정)을 confirm 증빙으로 고정. +- Updated: none. +- Removed: none. + +#### Impacted Area Delta +- StagePilot state: docs/srs/index REQ-123 status 및 문서 연계 준비. +- Implementation trace: batch planning 경로로 이동. + +#### Delivery Trace Delta +- Next: `BAT-058` (dcy-038, dcy-038-based hotfix) 생성 후 implementation 단계로 이관. +- Scope: implementation은 REQ-123 AC-1~10 + no-write 리스크 관리 포함. + +#### Revalidation Impact +- Reverification Needed: yes (batch implementation/verification 단계). +- Existing Implementation Invalidated: no. +- Status Recommendation: Approved. +- Follow-up Action: suggest-batch-reqs 완료 후 implementation/handoff. diff --git a/docs/srs/index.md b/docs/srs/index.md index fb6ff07..6c67c52 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-123` +- Current: `REQ-124` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -203,9 +203,12 @@ | REQ-120 | KIS balance 인증 실패 fail-loud 및 broker/token lifecycle 정책 | Exception | Implemented | High | trex-core-dev | [req-120](./Exception/req-120_kis-balance-auth-failure-fail-loud-lifecycle-policy.md) | | REQ-121 | managed non-holding divergence 분류와 slot 존재 조회 계약 | Data | Implemented | High | trex-core-dev | [req-121](./Data/req-121_managed-non-holding-divergence-classification-query.md) | | REQ-122 | operator broker truth hotfix baseline 문서 정합 | Documentation | Implemented | High | trex-core-dev | [req-122](./Documentation/req-122_operator-broker-truth-hotfix-baseline-alignment.md) | +| REQ-123 | cost-basis canonical parse 및 WAC unavailable 안전 sync seed | Integration | Approved | High | trex-core-dev | [req-123](./Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md) | ## Recent Change Log Summary +- 2026-07-16 :: REQ-123 **confirm-req 승인(Approved, lead confirm)**: dcy-038 OQ-1~3 완료 기준으로 제약(scope 제외 항목: API/schema 변경/credential/config 변경/live sync/배포), OQ-2/OQ-3 결정 반영, fail-closed WAC unavail no-write 정책, canonical parsing 우선순위/legacy fallback 규칙 및 dry-run diagnostics가 일치함을 확인해 `REQ-123`을 Approved 전환. +- 2026-07-16 :: REQ-123 **신규 생성(Proposed, draft-req, dcy-038)**: portfolio-hub canonical `cost_basis_per_unit` consumer parse, legacy alias precedence, WAC unavailable fail-closed sync/no-write, dry-run diagnostics, response-contract/regression tests, observability 및 rollback/release gate. portfolio-hub API/schema/auth·token/config·DB migration/direct edit·live sync·배포 제외. - 2026-07-15 :: REQ-119~122 **`Approved` → `Implemented`** (`confirm-req-implemented`, dcy-037/BAT-057): PR #42 merged to `main` (94e1489). Independent QC PASS-WITH-NOTES (acceptance blocker 0); 806 passed/47 skipped/1 deselected. `sync --live`, deployment, and production remediation remain separate release-approval work. - 2026-07-14 :: REQ-119~122 **승인(Approved, confirm-req, dcy-037)**: lead 검토로 각 REQ의 owner·priority·scope·검증 가능 AC와 Notes의 non-blocking 설계 제약을 확인했다. #39/#40/#41은 다음 `suggest-batch-reqs`에서 단일 emergency batch 후보이며, `sync --live`는 여전히 별도 release approval gate다. - 2026-07-14 :: REQ-119~122 **신규 생성(Proposed, draft-req, dcy-037)**: #39 fetched credential daemon/operator broker truth parity, #40 token/auth fail-loud empty-balance 분리, #41 managed non-holding divergence classification, runtime/interface/operations baseline 정합. #39/#40/#41 단일 emergency batch 및 `sync --live` 별도 release approval gate를 유지한다. diff --git a/tests/adapters/portfolio/test_hub_read.py b/tests/adapters/portfolio/test_hub_read.py index 569ed2b..c719501 100644 --- a/tests/adapters/portfolio/test_hub_read.py +++ b/tests/adapters/portfolio/test_hub_read.py @@ -113,6 +113,232 @@ def test_get_portfolios_network_error_raises_unavailable(): adapter.get_portfolios("d-7") +# --------------------------------------------------------------------------- +# REQ-123: cost-basis parsing (canonical + diagnostics) +# --------------------------------------------------------------------------- + +def test_get_cost_basis_with_diagnostics_prefers_canonical_when_valid(): + """REQ-123 AC-1: cost_basis_per_unit가 있으면 우선 사용.""" + + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.path.endswith("/holdings/cost-basis") + return httpx.Response(200, json={ + "cost_basis_per_unit": "125000.5", + "avg_cost": 1000, + "wac": 1200, + "cost_basis": 1300, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value == 125000.5 + assert source == "canonical" + assert reason is None + + +def test_get_cost_basis_with_diagnostics_canonical_invalid_blocks_legacy(): + """REQ-123 AC-1: canonical 존재 시 invalid면 legacy fallback 없이 실패.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "cost_basis_per_unit": 0, + "avg_cost": 1000, + "wac": 900, + "cost_basis": 1300, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "invalid" + + +def test_get_cost_basis_with_diagnostics_canonical_null_blocks_legacy(): + """REQ-123 QC-5: canonical null은 legacy 값이 있어도 fallback하지 않는다.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "cost_basis_per_unit": None, + "avg_cost": 1000, + "wac": 900, + "cost_basis": 1300, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "missing" + + +def test_get_cost_basis_with_diagnostics_canonical_non_numeric_blocks_legacy(): + """REQ-123 QC-5: canonical 비숫자 문자열은 legacy 값이 있어도 fallback하지 않는다.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "cost_basis_per_unit": "not-a-number", + "avg_cost": 1000, + "wac": 900, + "cost_basis": 1300, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "invalid" + + +def test_get_cost_basis_with_diagnostics_canonical_negative_blocks_legacy(): + """REQ-123 QC-5: canonical 음수는 legacy 값이 있어도 fallback하지 않는다.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "cost_basis_per_unit": -1, + "avg_cost": 1000, + "wac": 900, + "cost_basis": 1300, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "invalid" + + +def test_get_cost_basis_with_diagnostics_legacy_chain_fallback_order(): + """REQ-123 AC-2: canonical 미존재 시 avg_cost → wac → cost_basis.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "wac": 7000, + "avg_cost": "8200", + "cost_basis": 9000, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value == 8200.0 + assert source == "legacy" + assert reason is None + + +def test_get_cost_basis_with_diagnostics_legacy_skips_invalid_values(): + """REQ-123 AC-2: legacy 중 invalid면 다음 legacy 키로 이동.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "avg_cost": "-1", # invalid + "wac": "not-a-number", # invalid + "cost_basis": 3000, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value == 3000.0 + assert source == "legacy" + assert reason is None + + +def test_get_cost_basis_with_diagnostics_all_legacy_invalid_returns_last_reason(): + """REQ-123 AC-3: legacy 체인에서 모두 invalid/비정상이면 reason 반환.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "avg_cost": 0, + "wac": "-100", + "cost_basis": None, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "missing" + + +def test_get_cost_basis_with_diagnostics_legacy_invalid_is_sanitized(): + """REQ-123 QC-2: legacy parser detail never leaks through diagnostics.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"avg_cost": 0}) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "invalid" + + +def test_get_cost_basis_with_diagnostics_missing_field_returns_missing_reason(): + """REQ-123 AC-4: cost-basis 필드가 모두 없으면 missing reason.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"portfolio_id": "pf-1"}) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "missing" + + +def test_get_cost_basis_with_diagnostics_non_2xx_or_transport_keeps_diagnostic(): + """REQ-123 AC-4: non-2xx/transport 오류는 reason 코드로 반영.""" + + def transport_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(503, text="down") + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(transport_handler)) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert value is None + assert source == "none" + assert reason == "http_status_503" + + def error_handler(req: httpx.Request): + raise httpx.ConnectError("network down") + + adapter2 = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(error_handler)) + value2, source2, reason2 = adapter2.get_cost_basis_with_diagnostics("pf-1") + assert value2 is None + assert source2 == "none" + assert reason2 == "transport" + + +def test_get_cost_basis_with_diagnostics_invalid_json_and_payload_are_sanitized(): + """REQ-123 QC-2: malformed responses expose only stable reason codes.""" + + def invalid_json_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"not-json", headers={"content-type": "application/json"}) + + adapter = PortfolioHubReadAdapter( + "https://pf.test", token="tok", client=_client(invalid_json_handler) + ) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert (value, source, reason) == (None, "none", "invalid_json") + + def invalid_payload_handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=["not", "an", "object"]) + + adapter = PortfolioHubReadAdapter( + "https://pf.test", token="tok", client=_client(invalid_payload_handler) + ) + value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1") + assert (value, source, reason) == (None, "none", "invalid_payload") + + +def test_get_cost_basis_falls_back_to_legacy_alias(): + """REQ-123 AC-4: 기존 get_cost_basis는 diagnostics에서 value만 반환.""" + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"avg_cost": "7500"}) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) + assert adapter.get_cost_basis("pf-1") == 7500.0 + + # --------------------------------------------------------------------------- # REQ-109: get_realized_pnl # --------------------------------------------------------------------------- diff --git a/tests/application/test_sync.py b/tests/application/test_sync.py index ec431ef..c09d08f 100644 --- a/tests/application/test_sync.py +++ b/tests/application/test_sync.py @@ -17,7 +17,6 @@ TDD coverage: from __future__ import annotations import logging -import uuid from uuid import uuid4 import pytest @@ -69,15 +68,41 @@ class FakeCostBasisPort: def __init__(self, values: dict[str, float | None]): self._v = values + def get_cost_basis_with_diagnostics(self, portfolio_id: str): + value = self._v.get(portfolio_id) + if value is None: + return None, "none", "missing" + return value, "legacy", None + + # Backward compatibility for non-diagnostic callers def get_cost_basis(self, portfolio_id: str) -> float | None: - return self._v.get(portfolio_id) + value = self._v.get(portfolio_id) + return value class RaisingCostBasisPort: - def get_cost_basis(self, portfolio_id: str) -> float | None: + def get_cost_basis_with_diagnostics(self, portfolio_id: str): raise RuntimeError("simulated network error") +class DiagnosticCostBasisPort: + def __init__(self, result): + self._result = result + + def get_cost_basis_with_diagnostics(self, portfolio_id: str): + return self._result + + +class LegacyOnlyCostBasisPort: + """Compatibility seam with no diagnostic method.""" + + def __init__(self, value): + self._value = value + + def get_cost_basis(self, portfolio_id: str): + return self._value + + def _setup(slot: Slot, broker_balances: dict[str, int], cost_basis_port=None, dry_run=False, warn_store=None): @@ -104,19 +129,23 @@ def _setup(slot: Slot, broker_balances: dict[str, int], # --------------------------------------------------------------------------- def test_sync_inactive_to_holding_on_cold_start(): - """AC-1: INACTIVE slot with broker qty → INACTIVE→HOLDING, qty set, warning cleared.""" + """AC-1: INACTIVE slot with broker qty + seeded cost-basis transitions to HOLDING.""" slot = _make_slot(SlotState.INACTIVE, qty=None) ws = InMemoryReconcileWarningStore() ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="B") + cb_port = FakeCostBasisPort({"pf-005930": 50000.0}) - result, repo, ws_out = _setup(slot, {"005930": 10}, warn_store=ws) + result, repo, ws_out = _setup(slot, {"005930": 10}, cost_basis_port=cb_port, warn_store=ws) refreshed = repo.fetch_slot(slot.slot_id) assert refreshed.status is SlotState.HOLDING assert refreshed.qty == 10.0 + assert refreshed.avg_price == 50000.0 assert result["warning_cleared"] is True assert not ws_out.is_warned(str(slot.slot_id)) assert result["skipped"] is False + assert result["seeded_avg_price"] is True + assert result["cost_basis_source"] == "legacy" assert result["before"]["status"] == "inactive" assert result["after"]["status"] == "holding" assert result["after"]["qty"] == 10.0 @@ -130,6 +159,7 @@ def test_sync_inactive_to_holding_sets_transition_log(): svc = SlotService(repo, INSTANCE) ws = InMemoryReconcileWarningStore() broker = FakeBroker({"005930": 5}) + cb_port = FakeCostBasisPort({"pf-005930": 50000.0}) sync_slot_to_broker( slot_id=str(slot.slot_id), @@ -138,6 +168,7 @@ def test_sync_inactive_to_holding_sets_transition_log(): broker=broker, instance_key=INSTANCE, warn_store=ws, + cost_basis_port=cb_port, ) transitions = repo._transitions @@ -199,20 +230,25 @@ def test_sync_seeds_avg_price_holding_with_cost_basis(): # AC-5: graceful degrade — no cost_basis_port or port fails # --------------------------------------------------------------------------- -def test_sync_degrades_gracefully_no_cost_basis_port(): - """AC-5: When cost_basis_port is None, avg_price is not seeded (just qty updated).""" +def test_sync_inactive_requires_cost_basis_live(): + """AC-5: INACTIVE live sync requires avg_price seed; missing cost_basis_port fails closed.""" slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930") result, repo, _ = _setup(slot, {"005930": 10}, cost_basis_port=None) refreshed = repo.fetch_slot(slot.slot_id) - assert refreshed.qty == 10.0 - assert refreshed.avg_price is None # no seed - assert result.get("seeded_avg_price") is not True + assert refreshed.status is SlotState.INACTIVE + assert refreshed.qty is None + assert refreshed.avg_price is None + assert result["skipped"] is True + assert result["error"] == "cost_basis_unavailable" + assert result["dry_run"] is False + assert result["skip_reason"] == "cost_basis_unavailable" + assert result.get("seeded_avg_price") is False -def test_sync_degrades_gracefully_cost_basis_port_raises(caplog): - """AC-5: When cost_basis_port raises, qty is updated and WARN is logged.""" +def test_sync_inactive_cost_basis_port_raises_fail_closed(caplog): + """AC-5: INACTIVE live sync fails closed if cost_basis_port diagnostics raises.""" slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930") cb_port = RaisingCostBasisPort() @@ -220,7 +256,11 @@ def test_sync_degrades_gracefully_cost_basis_port_raises(caplog): result, repo, _ = _setup(slot, {"005930": 10}, cost_basis_port=cb_port) refreshed = repo.fetch_slot(slot.slot_id) - assert refreshed.qty == 10.0 # qty still synced + assert refreshed.status is SlotState.INACTIVE + assert refreshed.qty is None + assert result["skipped"] is True + assert result["error"] == "cost_basis_unavailable" + assert "cost_basis_unavailable" in result.get("skip_reason", "") assert "graceful degrade" in caplog.text or "cost_basis_port" in caplog.text @@ -236,10 +276,132 @@ def test_sync_degrades_gracefully_cost_basis_returns_none(): assert refreshed.avg_price == 60000.0 # unchanged +@pytest.mark.parametrize("invalid_wac", (0.0, -1.0, float("nan"), float("inf"))) +@pytest.mark.parametrize("dry_run", (True, False)) +def test_sync_legacy_only_invalid_wac_fails_closed_without_writes(invalid_wac, dry_run): + """QC-6: legacy-only invalid WAC never bypasses inactive fail-closed barrier.""" + slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930") + ws = InMemoryReconcileWarningStore() + ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="B") + + result, repo, ws_out = _setup( + slot, + {"005930": 10}, + cost_basis_port=LegacyOnlyCostBasisPort(invalid_wac), + dry_run=dry_run, + warn_store=ws, + ) + + refreshed = repo.fetch_slot(slot.slot_id) + assert refreshed is not None + assert refreshed.status is SlotState.INACTIVE + assert refreshed.qty is None + assert refreshed.avg_price is None + assert ws_out.is_warned(str(slot.slot_id)) + assert result["after"] == result["before"] + assert result["warning_cleared"] is False + assert result["seeded_avg_price"] is False + assert result["cost_basis_source"] == "none" + assert result["cost_basis_reason"] == "invalid" + assert result["skipped"] is True + assert result["skip_reason"] == "cost_basis_unavailable" + if dry_run: + assert result["dry_run"] is True + assert "error" not in result + else: + assert result["dry_run"] is False + assert result["error"] == "cost_basis_unavailable" + + +def test_sync_normalizes_diagnostic_source_aliases_and_rejects_unknown_sources(): + """REQ-123: diagnostic seam never exposes raw provider field names.""" + for raw_source in ("avg_cost", "wac", "cost_basis"): + slot = _make_slot(SlotState.INACTIVE, qty=None) + result, repo, _ = _setup( + slot, + {"005930": 10}, + cost_basis_port=DiagnosticCostBasisPort((50000.0, raw_source, None)), + ) + refreshed = repo.fetch_slot(slot.slot_id) + assert refreshed is not None + assert refreshed.status is SlotState.HOLDING + assert result["cost_basis_source"] == "legacy" + assert result["cost_basis_reason"] is None + + for raw_source in ("provider_private", 7, None): + slot = _make_slot(SlotState.INACTIVE, qty=None) + result, repo, _ = _setup( + slot, + {"005930": 10}, + cost_basis_port=DiagnosticCostBasisPort((50000.0, raw_source, "raw.detail")), + ) + refreshed = repo.fetch_slot(slot.slot_id) + assert refreshed is not None + assert refreshed.status is SlotState.INACTIVE + assert result["cost_basis_source"] == "none" + assert result["cost_basis_reason"] == "diagnostic_source_invalid" + + +def test_sync_preserves_only_sanitized_adapter_failure_reasons(): + """REQ-123: public result reasons are stable and do not expose parser detail.""" + cases = ( + (None, "canonical", "invalid", "invalid"), + (None, "legacy", "missing", "missing"), + (None, "none", "transport", "transport"), + (None, "none", "http_status_503", "http_status_503"), + (None, "none", "invalid_json", "invalid_json"), + (None, "none", "invalid_payload", "invalid_payload"), + ) + for value, source, reason, expected_reason in cases: + slot = _make_slot(SlotState.INACTIVE, qty=None) + result, repo, _ = _setup( + slot, + {"005930": 10}, + cost_basis_port=DiagnosticCostBasisPort((value, source, reason)), + dry_run=True, + ) + refreshed = repo.fetch_slot(slot.slot_id) + assert refreshed is not None + assert refreshed.status is SlotState.INACTIVE + assert result["cost_basis_source"] in {"canonical", "legacy", "none"} + assert result["cost_basis_reason"] == expected_reason + + # --------------------------------------------------------------------------- # AC-6: dry-run — no writes, shows before/after # --------------------------------------------------------------------------- +def test_sync_dry_run_inactive_valid_canonical_seed_previews_without_writes(): + """QC-5: canonical seed dry-run previews holding state without mutations.""" + slot = _make_slot(SlotState.INACTIVE, qty=None) + ws = InMemoryReconcileWarningStore() + ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="B") + cb_port = DiagnosticCostBasisPort((75000.0, "canonical", None)) + + result, repo, ws_out = _setup( + slot, + {"005930": 5}, + cost_basis_port=cb_port, + dry_run=True, + warn_store=ws, + ) + + refreshed = repo.fetch_slot(slot.slot_id) + assert refreshed is not None + assert refreshed.status is SlotState.INACTIVE + assert refreshed.qty is None + assert refreshed.avg_price is None + assert ws_out.is_warned(str(slot.slot_id)) + assert result["dry_run"] is True + assert result["warning_cleared"] is False + assert result["after"]["status"] == "holding" + assert result["after"]["qty"] == 5.0 + assert result["after"]["avg_price"] == 75000.0 + assert result["seeded_avg_price"] is True + assert result["cost_basis_source"] == "canonical" + assert result["cost_basis_reason"] is None + + def test_sync_dry_run_no_write(): """AC-6: dry-run does NOT modify the slot, returns before/after preview.""" slot = _make_slot(SlotState.INACTIVE, qty=None) @@ -254,11 +416,12 @@ def test_sync_dry_run_no_write(): assert refreshed.qty is None # warning must NOT be cleared assert ws_out.is_warned(str(slot.slot_id)) - # result has dry_run flag and after preview + # result has dry_run flag and no-write preview assert result["dry_run"] is True - assert result["after"]["status"] == "holding" - assert result["after"]["qty"] == 10.0 + assert result["after"]["status"] == "inactive" + assert result["after"]["qty"] is None assert result["warning_cleared"] is False + assert result["skip_reason"] == "cost_basis_unavailable" # --------------------------------------------------------------------------- @@ -359,20 +522,18 @@ def test_sync_missing_slot_returns_not_found(): # --------------------------------------------------------------------------- def test_sync_unwarned_slot_still_syncs_qty(): - """sync_slot_to_broker works on slots without an active warning. - - warning_cleared=True means clear_warned() was called and succeeded - (idempotent: even if there was no warning, clear is a no-op so True is fine). - The important thing is that qty was updated. - """ - slot = _make_slot(SlotState.INACTIVE, qty=None) + """sync_slot_to_broker works on slots without an active warning, + but INACTIVE still requires avg_price for live write.""" + slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930") ws = InMemoryReconcileWarningStore() # no warning set + cb_port = FakeCostBasisPort({"pf-005930": 50000.0}) - result, repo, _ = _setup(slot, {"005930": 7}, warn_store=ws) + result, repo, _ = _setup(slot, {"005930": 7}, cost_basis_port=cb_port, warn_store=ws) refreshed = repo.fetch_slot(slot.slot_id) assert refreshed.status is SlotState.HOLDING assert refreshed.qty == 7.0 + assert refreshed.avg_price == 50000.0 # warning_cleared is True because clear_warned() was called successfully # (idempotent no-op when there was no warning) assert result["warning_cleared"] is True diff --git a/tests/runtime/test_cli_operator.py b/tests/runtime/test_cli_operator.py index a922f13..f110464 100644 --- a/tests/runtime/test_cli_operator.py +++ b/tests/runtime/test_cli_operator.py @@ -2,6 +2,9 @@ from __future__ import annotations import json +import sys +import types +from types import SimpleNamespace import pytest @@ -405,6 +408,76 @@ def test_sync_live_requires_live_flag(capsys, monkeypatch): assert rc == 2 +@pytest.mark.parametrize("as_json", [False, True]) +def test_sync_fail_closed_skipped_error_exits_nonzero_without_writes( + monkeypatch, capsys, as_json, +): + """REQ-123: a fail-closed skipped live sync is a CLI failure.""" + class _Provider: + def get_broker_for_read(self): + return SimpleNamespace(get_balance=lambda: []), None + + class _WarningStore: + def clear_warned(self, *_args): + raise AssertionError("warning clear must not be called") + + class _Connection: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def cursor(self): + return self + + def execute(self, *_args): + return None + + def fetchone(self): + return ("slot-1",) + + sync_calls = [] + + def _fail_closed_sync(**kwargs): + sync_calls.append(kwargs) + return { + "slot_id": "slot-1", "symbol": "005930", "portfolio_id": "PF-1", + "before": {"status": "inactive", "qty": None, "avg_price": None}, + "after": {"status": "inactive", "qty": None, "avg_price": None}, + "warning_cleared": False, "dry_run": False, "skipped": True, + "skip_reason": "cost_basis_unavailable", "seeded_avg_price": False, + "cost_basis_source": "none", "cost_basis_reason": "transport", + "error": "cost_basis_unavailable", + } + + psycopg = types.ModuleType("psycopg") + setattr(psycopg, "connect", lambda *_args, **_kwargs: _Connection()) + monkeypatch.setitem(sys.modules, "psycopg", psycopg) + monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda _cfg: _Provider()) + monkeypatch.setattr("trex_core.adapters.portfolio.reconcile_warning_store.PostgreSQLReconcileWarningStore", lambda *_args: _WarningStore()) + monkeypatch.setattr("trex_core.adapters.persistence.postgresql.PostgreSQLSlotRepository", lambda *_args: object()) + monkeypatch.setattr("trex_core.domain.slot.service.SlotService", lambda *_args: object()) + monkeypatch.setattr("trex_core.application.reconciliation.sync_slot_to_broker", _fail_closed_sync) + + args = SimpleNamespace(portfolio_id="PF-1", all=False, json=as_json, dry_run=False, + yes=True, live=False, instance=None, config=None, config_path=None) + rc = cli._cmd_sync(args, SimpleNamespace(dsn="dsn", broker="paper", kis=None, portfolio_hub=None), lambda _: True) + + assert rc == 1 + assert len(sync_calls) == 1 + assert sync_calls[0]["dry_run"] is False + captured = capsys.readouterr() + if as_json: + result = json.loads(captured.out)["results"][0] + assert result["error"] == "cost_basis_unavailable" + assert result["skipped"] is True + else: + assert "SKIP" in captured.out + assert "error=cost_basis_unavailable" in captured.out + assert "CLEARED" not in captured.out + + # --------------------------------------------------------------------------- # verify parser tests (REQ-106 / BAT-051) — parser-level only # --------------------------------------------------------------------------- @@ -455,7 +528,6 @@ def test_verify_cmd_broker_import_does_not_raise(monkeypatch): Paper broker config is used (no real network needed). """ from trex_core.runtime.config import DaemonConfig - from trex_core.adapters.broker.paper import PaperBroker cfg = DaemonConfig( dsn="postgresql://x/y", diff --git a/trex_core/adapters/portfolio/hub_read.py b/trex_core/adapters/portfolio/hub_read.py index 8dacde0..3828ef9 100644 --- a/trex_core/adapters/portfolio/hub_read.py +++ b/trex_core/adapters/portfolio/hub_read.py @@ -15,7 +15,8 @@ signal (REQ-028 AC-2) without callers seeing raw httpx exceptions. """ from __future__ import annotations -from typing import Optional +import math +from typing import Any, Optional import httpx @@ -250,28 +251,88 @@ class PortfolioHubReadAdapter: total_realized_pnl=total, ) + def _parse_cost_basis_value(self, raw: Any) -> tuple[Optional[float], str]: + """Parse and validate one cost-basis field. + + Returns (value, reason). `reason` is "ok" on success. + """ + if raw is None: + return None, "missing" + if isinstance(raw, bool): + return None, "invalid" + try: + value = float(raw) + except (TypeError, ValueError): + return None, "invalid" + if not math.isfinite(value) or value <= 0: + return None, "invalid" + return value, "ok" + + def get_cost_basis_with_diagnostics( + self, + portfolio_id: str, + ) -> tuple[Optional[float], str, Optional[str]]: + """Read cost-basis from portfolio-hub with normalized diagnostics. + + Source priority: + 1) cost_basis_per_unit (canonical) + 2) avg_cost + 3) wac + 4) cost_basis + + ``source`` is always one of ``canonical``, ``legacy``, or ``none``. + If the canonical field is present but invalid, legacy fields are not used. + """ + url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis" + try: + resp = self._get(url) + except Exception: + return None, "none", "transport" + + if not (200 <= resp.status_code < 300): + return None, "none", f"http_status_{resp.status_code}" + + try: + body = resp.json() + except ValueError: + return None, "none", "invalid_json" + + if not isinstance(body, dict): + return None, "none", "invalid_payload" + + has_any = any( + key in body for key in ("cost_basis_per_unit", "avg_cost", "wac", "cost_basis") + ) + if not has_any: + return None, "none", "missing" + + if "cost_basis_per_unit" in body: + value, reason = self._parse_cost_basis_value( + body.get("cost_basis_per_unit") + ) + if reason == "ok": + return value, "canonical", None + return None, "none", reason + + # Legacy fallback chain only when canonical is absent. + legacy_reasons: list[str] = [] + for source in ("avg_cost", "wac", "cost_basis"): + if source not in body: + continue + value, reason = self._parse_cost_basis_value(body.get(source)) + if reason == "ok": + return value, "legacy", None + legacy_reasons.append(reason) + + if legacy_reasons: + return None, "none", legacy_reasons[-1] + return None, "none", "missing" + def get_cost_basis(self, portfolio_id: str) -> "Optional[float]": """REQ-105 FR-7: GET /v1/portfolios/{id}/holdings/cost-basis -> avg_cost (WAC). Returns None on any error (graceful degrade — caller logs WARN and skips avg_price seed). portfolio-hub REQ-043 must be live for this to succeed. """ - url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis" - try: - resp = self._get(url) - except Exception: # noqa: BLE001 — network/timeout — degrade - return None - if not (200 <= resp.status_code < 300): - return None - try: - body = resp.json() - except ValueError: - return None - if isinstance(body, dict): - val = body.get("avg_cost") or body.get("wac") or body.get("cost_basis") - if val is not None: - try: - return float(val) - except (TypeError, ValueError): - return None - return None + value, _source, _reason = self.get_cost_basis_with_diagnostics(portfolio_id) + return value diff --git a/trex_core/application/reconciliation.py b/trex_core/application/reconciliation.py index 4aa470c..8c67a4a 100644 --- a/trex_core/application/reconciliation.py +++ b/trex_core/application/reconciliation.py @@ -23,7 +23,9 @@ BAT-051 / REQ-106: verify_slot_reconcile — operator CLI verify path (C-only, r from __future__ import annotations import logging -from typing import Optional +import math +import re +from typing import Optional, cast from trex_core.application.alarm_emitter import AlarmEmitter from trex_core.application.broker_port import BrokerPort @@ -32,6 +34,68 @@ from trex_core.domain.slot.state import SlotState _log = logging.getLogger(__name__) +_COST_BASIS_REASON_CODES = frozenset({ + "missing", + "invalid", + "transport", + "invalid_json", + "invalid_payload", + "not_configured", + "diagnostic_exception", + "diagnostic_source_invalid", +}) +_COST_BASIS_LEGACY_SOURCE_ALIASES = frozenset({"avg_cost", "wac", "cost_basis"}) + + +def _sanitize_cost_basis_reason(reason: object) -> Optional[str]: + """Keep public diagnostics in the bounded REQ-123 reason taxonomy.""" + if not isinstance(reason, str): + return None + if reason in _COST_BASIS_REASON_CODES: + return reason + if re.fullmatch(r"http_status_\d{3}", reason): + return reason + return None + + +def _normalize_cost_basis_diagnostics( + value: object, + source: object, + reason: object, +) -> tuple[Optional[float], str, Optional[str]]: + """Normalize diagnostic-port output before it reaches reconciliation results.""" + safe_reason = _sanitize_cost_basis_reason(reason) + if source == "canonical": + normalized_source = "canonical" + elif source == "legacy" or source in _COST_BASIS_LEGACY_SOURCE_ALIASES: + normalized_source = "legacy" + elif source == "none": + normalized_source = "none" + else: + return None, "none", safe_reason or "diagnostic_source_invalid" + + if value is None: + return None, normalized_source, safe_reason + if isinstance(value, bool): + return None, "none", safe_reason or "invalid_payload" + try: + normalized_value = float(cast(str | int | float, value)) + except (TypeError, ValueError): + return None, "none", safe_reason or "invalid_payload" + if not math.isfinite(normalized_value) or normalized_value <= 0: + return None, "none", safe_reason or "invalid_payload" + if normalized_source == "none": + return None, "none", safe_reason or "invalid_payload" + return normalized_value, normalized_source, safe_reason + + +def _normalize_legacy_cost_basis(value: object) -> tuple[Optional[float], str, Optional[str]]: + """Make the diagnostic-free compatibility seam obey safe WAC semantics.""" + normalized_value, _, _ = _normalize_cost_basis_diagnostics(value, "legacy", "invalid") + if normalized_value is None: + return None, "none", "invalid" + return normalized_value, "legacy", None + def reconcile_core_holdings( *, @@ -308,25 +372,114 @@ def sync_slot_to_broker( # Try to seed avg_price from portfolio-hub cost-basis port (FR-7, graceful degrade). avg_price = slot.avg_price seeded_avg_price = False + cost_basis_source = "none" + cost_basis_reason: Optional[str] = None + + def _set_cost_basis_unavailable_message() -> tuple[str, Optional[str]]: + if pid: + return "cost_basis_unavailable", f"inactive slot {slot_id} portfolio_id={pid}" + return "cost_basis_unavailable", "inactive slot without portfolio_id" + if cost_basis_port is not None and pid: - try: - wac = cost_basis_port.get_cost_basis(pid) - if wac is not None: - avg_price = wac - seeded_avg_price = True - except Exception: # noqa: BLE001 — graceful degrade - _log.warning( - "sync: cost_basis_port.get_cost_basis failed for portfolio_id=%s slot=%s " - "— skipping avg_price seed (graceful degrade)", - pid, slot_id, - ) - elif cost_basis_port is None and pid: + diagnostics = getattr(cost_basis_port, "get_cost_basis_with_diagnostics", None) + if callable(diagnostics): + try: + result = diagnostics(pid) + + if isinstance(result, dict): + raw_value = result.get("value") + raw_source = result.get("source") + raw_reason = result.get("reason") + elif isinstance(result, tuple) and len(result) >= 1: + raw_value = result[0] + raw_source = result[1] if len(result) >= 2 else None + raw_reason = result[2] if len(result) >= 3 else None + else: + raise TypeError("invalid_cost_basis_diagnostic_format") + + cost_basis_value, cost_basis_source, cost_basis_reason = ( + _normalize_cost_basis_diagnostics(raw_value, raw_source, raw_reason) + ) + if cost_basis_value is not None: + avg_price = cost_basis_value + seeded_avg_price = True + cost_basis_reason = None + except Exception: # noqa: BLE001 — graceful degrade + cost_basis_source = "none" + cost_basis_reason = "diagnostic_exception" + _log.warning( + "sync: cost_basis_port.get_cost_basis_with_diagnostics failed for " + "portfolio_id=%s slot=%s — skipping avg_price seed (graceful degrade)", + pid, slot_id, + ) + else: + # Backward compatibility path if diagnostic method is not available. + try: + legacy_value, legacy_source, legacy_reason = _normalize_legacy_cost_basis( + cost_basis_port.get_cost_basis(pid) + ) + cost_basis_source = legacy_source + cost_basis_reason = legacy_reason + if legacy_value is not None: + avg_price = legacy_value + seeded_avg_price = True + except Exception: # noqa: BLE001 — graceful degrade + cost_basis_source = "none" + cost_basis_reason = "diagnostic_exception" + _log.warning( + "sync: cost_basis_port.get_cost_basis failed for portfolio_id=%s slot=%s " + "— skipping avg_price seed (graceful degrade)", + pid, slot_id, + ) + elif pid: + cost_basis_reason = "not_configured" _log.warning( "sync: no cost_basis_port configured for slot=%s portfolio_id=%s " "— skipping avg_price seed (graceful degrade)", slot_id, pid, ) + inactive_no_cost_basis = slot.status is SlotState.INACTIVE and avg_price is None and broker_qty > 0 + fail_closed_cost_basis = inactive_no_cost_basis and not dry_run + if inactive_no_cost_basis and dry_run: + return { + "slot_id": slot_id, + "symbol": sym, + "portfolio_id": pid, + "before": before, + "after": before, + "warning_cleared": False, + "dry_run": True, + "skipped": True, + "skip_reason": _set_cost_basis_unavailable_message()[0], + "seeded_avg_price": False, + "cost_basis_source": cost_basis_source, + "cost_basis_reason": cost_basis_reason, + } + + if fail_closed_cost_basis: + _log.error( + "sync fail-closed: INACTIVE onboarding requires avg_price seed (portfolio_id=%s slot=%s) " + "— refuse live write", + pid, + slot_id, + ) + return { + "slot_id": slot_id, + "symbol": sym, + "portfolio_id": pid, + "before": before, + "after": before, + "warning_cleared": False, + "dry_run": False, + "skipped": True, + "skip_reason": _set_cost_basis_unavailable_message()[0], + "seeded_avg_price": False, + "cost_basis_source": cost_basis_source, + "cost_basis_reason": cost_basis_reason, + "error": "cost_basis_unavailable", + } + if dry_run: # Compute what *would* happen without writing anything. new_status = SlotState.HOLDING if slot.status is SlotState.INACTIVE else slot.status @@ -343,6 +496,8 @@ def sync_slot_to_broker( "dry_run": True, "skipped": False, "seeded_avg_price": seeded_avg_price, + "cost_basis_source": cost_basis_source, + "cost_basis_reason": cost_basis_reason, } # --- Live write --- @@ -374,11 +529,15 @@ def sync_slot_to_broker( _log.exception("sync_slot_to_broker: write failed for slot=%s", slot_id) return { "slot_id": slot_id, "symbol": sym, "portfolio_id": pid, - "before": before, "after": before, + "before": before, + "after": before, "warning_cleared": False, "dry_run": False, "skipped": False, "error": "write_failed", + "seeded_avg_price": seeded_avg_price, + "cost_basis_source": cost_basis_source, + "cost_basis_reason": cost_basis_reason, } # Refresh to confirm @@ -409,9 +568,10 @@ def sync_slot_to_broker( "dry_run": False, "skipped": False, "seeded_avg_price": seeded_avg_price, + "cost_basis_source": cost_basis_source, + "cost_basis_reason": cost_basis_reason, } - # --------------------------------------------------------------------------- # BAT-051 / REQ-106: operator verify command — C-case re-check (read-centric) # --------------------------------------------------------------------------- diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index bd54670..344f57f 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -568,7 +568,9 @@ def _cmd_sync(args, cfg, confirm) -> int: dry_run=dry_run, ) results.append(res) - if res.get("error") and not res.get("skipped"): + # A skipped result can still be a fail-closed live error (REQ-123). + # Do not let the no-write marker hide a failed sync from automation. + if res.get("error"): exit_code = 1 if args.json: @@ -584,6 +586,9 @@ def _cmd_sync(args, cfg, confirm) -> int: skipped = res.get("skipped", False) skip_reason = res.get("skip_reason", "") cleared = res.get("warning_cleared", False) + seeded_avg_price = res.get("seeded_avg_price", False) + cost_basis_source = res.get("cost_basis_source") + cost_basis_reason = res.get("cost_basis_reason") prefix = "[dry-run] " if dry_run else "" if skipped: print(f" {prefix}SKIP slot={sid} symbol={sym} pid={pid} reason={skip_reason}") @@ -595,6 +600,13 @@ def _cmd_sync(args, cfg, confirm) -> int: f"qty={after.get('qty')} avg_price={after.get('avg_price')}") if cleared: print(" reconcile-warning CLEARED") + if seeded_avg_price is not None: + print(f" cost_basis.seeded={seeded_avg_price}") + if cost_basis_source or cost_basis_reason: + print(f" cost_basis.source={cost_basis_source} reason={cost_basis_reason}") + + if res.get("error"): + print(f" error={res.get('error')}") return exit_code