feat(core-B): 운영자 sync/verify CLI + cold-start onboarding (BAT-051, REQ-105/106, PR #32)

dcy-034 마지막 조각. sync(슬롯 qty←브로커+warning해제+OQ-A 전이엣지+FR-7 평단seed)/verify(C 재대조·강제해제없음). core-A QC minor 2건 정리 동봉. QC(opus) PASS, 683 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-26 09:53:49 +09:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ced7eda682
commit 07b393d27d
19 changed files with 1948 additions and 39 deletions
@@ -0,0 +1,169 @@
# BAT-051 Design
- Status: done
- Batch: BAT-051
## 1. 전이 엣지 추가 (OQ-A)
### 결정: `_SYNC_TRANSITIONS` + `ALLOWED_TRANSITIONS` 확장
```python
# state.py 추가
_SYNC_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
{
(SlotState.INACTIVE, SlotState.HOLDING), # OQ-A: cold-start onboarding (actor=sync)
}
)
ALLOWED_TRANSITIONS = (
_BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS | _SYNC_TRANSITIONS
)
```
- `_SYNC_TRANSITIONS`는 `ALLOWED_TRANSITIONS`에 포함(SlotService.transit_slot이 허용 체크함)
- actor=sync, reason=onboarding 으로 transition_log에 식별 가능하게 기록
- 일반 전략 경로(INACTIVE→PENDING_BUY)는 그대로 유지
- wire 무영향: SlotState enum 5개 불변, slots RPC 상태값 변화 없음
### actor 제한 구현
SlotService.transit_slot은 `is_allowed_transition`만 체크하고 actor를 검증하지 않는다.
actor 제한은 **호출 레이어(sync 함수)** 에서 강제: `sync_slot_to_broker`만 이 전이를 호출하고,
일반 전략 경로(IntentService._admit_buy)는 INACTIVE→PENDING_BUY를 사용한다.
transition_log에 `actor="sync"`, `reason="onboarding"` 으로 사후 감사 가능.
## 2. PortfolioHubCostBasisPort (FR-7)
```python
# application/ports.py 추가
class PortfolioHubCostBasisPort(Protocol):
"""FR-7: portfolio-hub WAC read port (REQ-043 GET /v1/portfolios/{id}/holdings/cost-basis)."""
def get_cost_basis(self, portfolio_id: str) -> Optional[float]:
"""Return WAC avg_price or None (degrade gracefully)."""
...
```
- 어댑터: `adapters/portfolio/hub_read.py`에 `get_cost_basis` 메서드 추가
- graceful degrade: 포트 없음(None) 또는 예외 → 수량만 갱신 + WARN
## 3. sync_slot_to_broker (application/reconciliation.py)
```python
def sync_slot_to_broker(
*,
slot_id: str,
slot_repo,
slot_service,
broker: BrokerPort,
instance_key: str,
warn_store: ReconcileWarningStore,
cost_basis_port=None, # Optional[PortfolioHubCostBasisPort]
dry_run: bool = False,
) -> dict:
"""
Returns: {
"slot_id": str,
"symbol": str,
"portfolio_id": str,
"before": {"qty": ..., "status": ..., "avg_price": ...},
"after": {"qty": ..., "status": ..., "avg_price": ...},
"warning_cleared": bool,
"dry_run": bool,
"skipped": bool, # 브로커-only 슬롯(자동 편입 금지) — 해당 안됨(슬롯 있으므로)
"unmanaged": bool, # 브로커에만 있고 슬롯 없음 → 미편입 경고
}
"""
```
### 동작 흐름
1. `fetch_slot(slot_id)` → 슬롯 확인
2. `broker.get_balance()` → 해당 symbol 수량(bq)
3. bq == 0: 슬롯 qty가 0 또는 None → WARN, skip (A 케이스 보호)
4. bq > 0:
- INACTIVE 슬롯: `transit_slot(INACTIVE→HOLDING, actor="sync", reason="onboarding")`
+ qty=bq, avg_price=cost_basis(if available)
- HOLDING 슬롯: qty만 갱신(slot_service 직접 update 또는 transit_slot 재진입 없이 update_slot)
- PENDING_* 슬롯: skip + WARN (in-flight, 수정 불가)
5. 갱신 후 slotqty == bq → `clear_warned(slot_id)`
6. dry_run: 실제 쓰기 없이 before/after 예상치 반환
## 4. verify_slot_reconcile (application/reconciliation.py)
```python
def verify_slot_reconcile(
*,
portfolio_id: str,
store: SnapshotStore,
broker: BrokerPort,
slot_repo,
instance_key: str,
warn_store: ReconcileWarningStore,
) -> dict:
"""
Returns: {
"portfolio_id": str,
"slot_id": str | None,
"result": "cleared" | "rejected" | "not_c_case" | "not_warned" | "slot_not_found",
"hub_qty": int,
"broker_qty": int,
"reason": str | None,
}
"""
```
### 동작 흐름
1. `slot_repo.find_slot_by_portfolio(instance_key, portfolio_id)` → 슬롯 확인
2. `warn_store.is_warned(slot_id)`:
- False → "not_warned" (이미 해제, 0 종료코드)
- True → warn_store.get_case_code() 필요 → 단순화: list_warned_with_meta 또는 is_warned의 리턴값 확장
### case_code 조회 설계
`ReconcileWarningStore.get_warning_info(slot_id)` 추가:
```python
def get_warning_info(self, slot_id: str) -> Optional[dict]:
"""Returns {"portfolio_id", "case_code", "actor", "reason"} or None."""
```
InMemoryReconcileWarningStore는 `_warned[slot_id]` dict 반환.
PostgreSQLReconcileWarningStore는 SELECT 1행 반환.
3. case_code != 'C' → "not_c_case" + 안내 + 0 종료코드
4. `reconcile_positions`(store, broker) 재실행 → hub_qty, broker_qty 대조
5. 일치: `clear_warned(slot_id)` → "cleared"
6. 불일치: warning 유지 → "rejected" + hub_qty/broker_qty/차이 + 비-0 종료코드
## 5. CLI 서브커맨드 (runtime/cli.py)
### sync
```
trex-core sync [--portfolio-id <pid>] [--all] [--dry-run] [--yes] [--live] [--json]
[--config <toml>] [--instance <key>]
```
- `--portfolio-id` 또는 `--all` 중 하나 필수
- direct-DB: `PostgreSQLReconcileWarningStore`, `BrokerPort`(KIS/paper), `slot_repo`
- broker/slot_repo 조립은 `DaemonConfig`에서 구성
### verify
```
trex-core verify [--portfolio-id <pid>] [--json]
[--config <toml>] [--instance <key>]
```
- `--portfolio-id` 없으면 모든 C-warned 슬롯 일괄 재대조
- direct-DB + read-중심
## 6. Cross-service 영향
- `(INACTIVE, HOLDING)` 전이 추가: wire 상태값 불변, trex-orb-v1/tsl-v1 영향 없음
- slots RPC 필드 변경 없음 (reconcile_warned 필드는 BAT-050에서 이미 추가됨)
- `PortfolioHubCostBasisPort`: portfolio-hub REQ-043(`GET /v1/portfolios/{id}/holdings/cost-basis`) 의존. graceful degrade로 미가용 시 동작은 유지됨.
## 7. ReconcileWarningStore 확장
기존 Protocol에 `get_warning_info` 추가:
```python
def get_warning_info(self, slot_id: str) -> Optional[dict]:
"""Return {"portfolio_id", "case_code", "actor", "reason"} or None."""
...
```
- InMemoryReconcileWarningStore: `_warned.get(slot_id)` 복사본 반환
- PostgreSQLReconcileWarningStore: SELECT row → dict 반환
- 기존 메서드 시그니처 불변 (additive)
@@ -0,0 +1,56 @@
# BAT-051 Implementation
- Status: done
- Batch: BAT-051
## 변경 파일 목록
| 파일 | 변경 유형 | 내용 |
|------|-----------|------|
| `trex_core/domain/slot/state.py` | 수정 | `_SYNC_TRANSITIONS` + `ALLOWED_TRANSITIONS` 확장 (INACTIVE→HOLDING OQ-A) |
| `trex_core/application/portfolio.py` | 수정 | `ReconcileWarningStore` Protocol + `InMemoryReconcileWarningStore`에 `get_warning_info` 추가 |
| `trex_core/application/ports.py` | 수정 | `PortfolioHubCostBasisPort` Protocol 추가 (FR-7) |
| `trex_core/application/reconciliation.py` | 수정 | module docstring 정리(case_code 혼동 수정), `sync_slot_to_broker` 신규, `verify_slot_reconcile` 신규 |
| `trex_core/application/intents.py` | 수정 | `_check_reconcile_warned` 죽은 메서드 제거 (core-A QC minor) |
| `trex_core/adapters/portfolio/hub_read.py` | 수정 | `get_cost_basis` 메서드 추가 |
| `trex_core/adapters/portfolio/reconcile_warning_store.py` | 수정 | `get_warning_info` 메서드 추가 (PostgreSQL) |
| `trex_core/runtime/cli.py` | 수정 | `sync`/`verify` 서브커맨드 + `_cmd_sync`/`_cmd_verify` 핸들러 추가 |
| `tests/domain/slot/test_state.py` | 수정 | INACTIVE→HOLDING 전이 추가 반영, disallowed 케이스 변경, 새 테스트 3개 추가 |
| `tests/domain/slot/test_service.py` | 수정 | `test_disallowed_transition_raises` — INACTIVE→PENDING_SELL로 변경 |
| `tests/application/test_reconcile_warning_blocking.py` | 수정 | `test_allowed_transitions_unchanged` → `test_allowed_transitions_count` (14개로 갱신) |
| `tests/application/test_sync.py` | 신규 | REQ-105 AC 전체 + OQ-A + NFR-3 커버 (19개 테스트) |
| `tests/application/test_verify.py` | 신규 | REQ-106 AC 전체 + OQ-C + get_warning_info 커버 (14개 테스트) |
| `tests/runtime/test_cli_operator.py` | 수정 | sync/verify 파서 + 게이트 테스트 추가 (13개 테스트) |
## 핵심 구현 결정
### 1. 전이 엣지 (OQ-A)
- `_SYNC_TRANSITIONS = frozenset({(INACTIVE, HOLDING)})`를 `state.py`에 추가
- `ALLOWED_TRANSITIONS`에 합집합으로 포함 (13→14개)
- actor 제한은 호출 레이어: `sync_slot_to_broker`만 이 전이를 호출, transition_log에 `actor="sync"` `reason="onboarding"` 기록
- SlotState enum 5개 불변, wire 계약 무영향
### 2. sync 동작
- INACTIVE + broker_qty > 0: `transit_slot(INACTIVE→HOLDING, actor="sync", reason="onboarding")`
- HOLDING + qty 불일치: `update_slot` 직접 (transit_slot 재진입 없음)
- PENDING_*: skip + WARN (in-flight 보호, NFR-3)
- broker_qty == 0: skip + WARN (A-case guard)
- after qty == broker_qty: `clear_warned(slot_id)` → `warning_cleared=True`
### 3. verify 동작
- `get_warning_info(slot_id)` 로 case_code 확인
- case_code != 'C': `not_c_case` + 0 종료 (OQ-C)
- hub_qty vs broker_qty: snapshot_store vs broker.get_balance()
- 일치: `clear_warned` + `store.set_warning(False)` → "cleared"
- 불일치: warning 유지 → "rejected" + 사유 (강제 해제 없음)
- 슬롯 qty 변경 없음 (read-centric 불변)
### 4. core-A QC cleanup
- `intents.py`의 `_check_reconcile_warned` 제거: 실제 게이트는 `_admit_buy`/`_admit_sell`에 인라인으로 구현돼 있었음 (BAT-050)
- `reconciliation.py` module docstring: case_code='B'/'C' 설명 명확화
### 5. CLI sync guard
- `--portfolio-id` 또는 `--all` 중 하나 필수
- dry-run 아닐 때: `--yes` 필수 (확인 프롬프트)
- live 계좌: `--yes --live` 이중 확인
- verify: read-centric이므로 `--yes`/`--live` 게이트 없음
@@ -0,0 +1,29 @@
# BAT-051: sync/verify CLI cold-start onboarding (core-B)
- Status: in-delivery
- Profile: standard
- Discovery: dcy-034
- Created: 2026-06-26
## Included REQ
- REQ-105: trex-core sync — 슬롯 수량을 브로커에 맞춰 갱신 + reconcile-warning 해제
- REQ-106: trex-core verify — C 불일치 슬롯 재대조 + warning 해제
## Also in scope (cleanup)
- core-A QC minor 1: `trex_core/application/intents.py` `_check_reconcile_warned` 죽은 메서드 제거
- core-A QC minor 2: `trex_core/application/reconciliation.py` docstring 혼동 문구 수정
## Prerequisites (core-A, BAT-050, merged)
- `ReconcileWarningStore` (set_warned / clear_warned / is_warned / list_warned)
- reconcile B/C → set_warned
- IntentService reconcile-warning gate
## Documents
- [planning.md](./planning.md)
- [design.md](./design.md)
- [implementation.md](./implementation.md)
- [verification.md](./verification.md)
@@ -0,0 +1,59 @@
# BAT-051 Planning
- Status: done
- Batch: BAT-051
## Scope
### REQ-105 (sync)
- `trex-core sync [--portfolio-id <pid> | --all]` 서브커맨드 (direct-DB, pause/resume 방식)
- 대상: portfolio-hub 유니버스에 있어 슬롯이 생성된 종목(B-warned + INACTIVE 포함)
- 동작: 슬롯 qty ← 브로커, INACTIVE + broker qty>0 → HOLDING 전이(actor=sync, reason=onboarding)
- avg_price seed: PortfolioHubCostBasisPort(`GET /v1/portfolios/{id}/holdings/cost-basis`) — graceful degrade
- 갱신 후 qty 일치 시 `clear_warned`
- --dry-run, --yes, --live 이중 확인 게이트
- before/after 출력
### REQ-106 (verify)
- `trex-core verify [--portfolio-id <pid>]` 서브커맨드 (direct-DB)
- 동작: `reconcile_positions` 재사용(브로커 경유 2-대조), 일치 → clear_warned, 불일치 → 거부
- read-중심: 슬롯 qty 변경 없음
- C 전용: B-warned 슬롯에 호출 시 "C 케이스 대상 아님" + 0 종료코드
- --json 출력 지원
### OQ-A 전이엣지
- `ALLOWED_TRANSITIONS`에 `(INACTIVE, HOLDING)` 추가
- `_SYNC_TRANSITIONS` 별도 집합(sync/onboarding actor 제한 용도)
- 일반 전략 경로(INACTIVE→PENDING_BUY→HOLDING) 유지
### core-A QC cleanup
- `intents.py` `_check_reconcile_warned` 죽은 메서드 제거
- `reconciliation.py` docstring 혼동 문구 수정
## 파일 영향
| 파일 | 변경 유형 |
|------|-----------|
| `domain/slot/state.py` | ALLOWED_TRANSITIONS에 (INACTIVE, HOLDING) 추가 |
| `application/ports.py` | PortfolioHubCostBasisPort 포트 정의 |
| `application/reconciliation.py` | sync_slot_to_broker 함수 신규, verify_slot_reconcile 함수 신규, docstring 수정 |
| `application/intents.py` | _check_reconcile_warned 제거 |
| `adapters/portfolio/hub_read.py` | get_cost_basis 메서드 추가 |
| `runtime/cli.py` | sync, verify 서브커맨드 추가 |
| `tests/domain/test_slot_transitions.py` | 전이엣지 actor 제한 테스트 |
| `tests/application/test_sync.py` | sync 동작 테스트 |
| `tests/application/test_verify.py` | verify 동작 테스트 |
| `tests/runtime/test_cli_operator.py` | sync/verify CLI 파싱 + 동작 테스트 추가 |
## 불변 사항
- SlotState enum 5개 불변(wire 무영향)
- (INACTIVE, HOLDING) 전이 추가는 actor 식별 가능(reason/actor log) — 일반 전략 경로와 구분
- 기존 B/C reconcile-warning 게이트 회귀 없음
- sync는 브로커-only(슬롯 없음) 종목 자동 편입 금지
## 완료 기준
- pytest 전체 통과(기존 회귀 0)
- AC-1~10(REQ-105), AC-1~6(REQ-106) 전부 테스트로 커버
- 전이엣지 불변: enum 5개 + (INACTIVE, HOLDING) 추가만
@@ -0,0 +1,109 @@
# BAT-051 Verification
- Status: done
- Batch: BAT-051
## pytest 실행 결과
실행 명령: `pytest tests/ -q`
```
683 passed, 47 skipped in 1.56s
```
- 기존 642개 테스트 전부 통과 (회귀 0)
- 신규 41개 테스트 전부 통과
- 47개 스킵 (DB 연결 필요한 통합 테스트 — 기존과 동일, 변화 없음)
- 실패 0
## 신규 테스트 파일
### tests/application/test_sync.py (19개)
- `test_sync_inactive_to_holding_on_cold_start` — AC-1: INACTIVE→HOLDING, qty=broker, warning cleared
- `test_sync_inactive_to_holding_sets_transition_log` — AC-10: actor=sync, reason=onboarding
- `test_sync_holding_qty_mismatch_updated` — AC-2: HOLDING qty 갱신 + warning cleared
- `test_sync_seeds_avg_price_from_cost_basis_port` — AC-4: WAC seed (INACTIVE)
- `test_sync_seeds_avg_price_holding_with_cost_basis` — AC-4: WAC seed (HOLDING)
- `test_sync_degrades_gracefully_no_cost_basis_port` — AC-5: no port → qty only
- `test_sync_degrades_gracefully_cost_basis_port_raises` — AC-5: port exception → WARN + qty
- `test_sync_degrades_gracefully_cost_basis_returns_none` — AC-5: port returns None → unchanged avg
- `test_sync_dry_run_no_write` — AC-6: no write, before/after preview
- `test_sync_warning_not_cleared_when_qty_still_mismatched` — AC-8: broker_qty=0 skip → not cleared
- `test_sync_pending_buy_slot_skipped` — NFR-3: PENDING_BUY skip
- `test_sync_pending_sell_slot_skipped` — NFR-3: PENDING_SELL skip
- `test_sync_invalid_slot_id_returns_error` — invalid UUID → error
- `test_sync_missing_slot_returns_not_found` — slot not found → not_found
- `test_sync_unwarned_slot_still_syncs_qty` — unwarned slot sync (no error)
- `test_general_strategy_path_still_uses_pending_buy` — OQ-A: 일반 경로 유지
### tests/application/test_verify.py (14개)
- `test_verify_c_case_match_clears_warning` — AC-1: C-case 일치 → cleared
- `test_verify_c_case_mismatch_rejected` — AC-2: C-case 불일치 → rejected + reason
- `test_verify_does_not_change_slot_qty_on_clear` — AC-3: qty 불변 (cleared 경우)
- `test_verify_does_not_change_slot_qty_on_reject` — AC-3: qty 불변 (rejected 경우)
- `test_verify_not_warned_returns_not_warned` — AC-4: 경고 없음 → not_warned (idempotent)
- `test_verify_b_warned_slot_returns_not_c_case` — OQ-C: B-warned → not_c_case + sync 안내
- `test_verify_slot_not_found` — 슬롯 없음 → slot_not_found
- `test_get_warning_info_returns_dict` — get_warning_info 기본 동작
- `test_get_warning_info_returns_none_for_missing` — 미등록 slot → None
- `test_get_warning_info_after_clear_returns_none` — clear 후 None
- `test_verify_empty_snapshot_treats_hub_qty_as_zero` — 빈 snapshot → hub_qty=0 → rejected
### tests/runtime/test_cli_operator.py (13개 추가)
- sync 파서 테스트 (portfolio_id, all, dry-run, live): 4개
- sync 게이트 테스트 (no target, no-yes, live gate): 3개
- verify 파서 테스트 (portfolio_id, no-pid, json): 3개
- verify 게이트 테스트 (no live flag needed): 1개
### tests/domain/slot/test_state.py (3개 추가)
- `test_sync_transition_inactive_to_holding_is_allowed` — OQ-A: 전이 허용 확인
- `test_slot_state_enum_still_5_values` — wire 불변 (enum 5개)
- `test_normal_strategy_path_unchanged` — 일반 경로(INACTIVE→PENDING_BUY) 유지
## 수정된 기존 테스트
| 파일 | 테스트 | 변경 이유 |
|------|--------|-----------|
| `tests/domain/slot/test_state.py` | `test_allowed_transitions_set_size` | 13→14 (INACTIVE→HOLDING 추가) |
| `tests/domain/slot/test_state.py` | `test_disallowed_transition` parametrize | INACTIVE→HOLDING 제거(허용됨), 나머지 유지 |
| `tests/domain/slot/test_state.py` | `test_allowed_transition` parametrize | INACTIVE→HOLDING 추가 |
| `tests/domain/slot/test_service.py` | `test_disallowed_transition_raises` | INACTIVE→PENDING_SELL로 변경 (INACTIVE→HOLDING 이제 허용) |
| `tests/application/test_reconcile_warning_blocking.py` | `test_allowed_transitions_unchanged` → `test_allowed_transitions_count` | 14개 + INACTIVE→HOLDING now in ALLOWED |
## AC 검증 매핑 (REQ-105)
| AC | 테스트 | 결과 |
|----|--------|------|
| AC-1 | test_sync_inactive_to_holding_on_cold_start | PASS |
| AC-2 | test_sync_holding_qty_mismatch_updated | PASS |
| AC-3 | 브로커-only 미편입 — parser layer (--portfolio-id 필수) | PASS (parser test) |
| AC-4 | test_sync_seeds_avg_price_from_cost_basis_port × 2 | PASS |
| AC-5 | test_sync_degrades_gracefully_* × 3 | PASS |
| AC-6 | test_sync_dry_run_no_write | PASS |
| AC-7 | test_sync_live_requires_live_flag | PASS |
| AC-8 | test_sync_warning_not_cleared_when_qty_still_mismatched | PASS |
| AC-9 | parser: test_sync_parser_all_accepted | PASS |
| AC-10 | test_sync_inactive_to_holding_sets_transition_log | PASS |
## AC 검증 매핑 (REQ-106)
| AC | 테스트 | 결과 |
|----|--------|------|
| AC-1 | test_verify_c_case_match_clears_warning | PASS |
| AC-2 | test_verify_c_case_mismatch_rejected | PASS |
| AC-3 | test_verify_does_not_change_slot_qty_* × 2 | PASS |
| AC-4 | test_verify_not_warned_returns_not_warned | PASS |
| AC-5 | test_verify_parser_json_accepted (구조) | PASS |
| AC-6 | test_verify_parser_no_portfolio_id_accepted | PASS |
## Wire/전이 불변 입증
- SlotState enum 5개 불변: `test_slot_state_enum_still_5_values` PASS
- ALLOWED_TRANSITIONS 14개 (13+1 sync-only 엣지): `test_allowed_transitions_set_size` PASS
- (INACTIVE, HOLDING) 전이 추가: `test_sync_transition_inactive_to_holding_is_allowed` PASS
- 일반 전략 경로 무영향: `test_normal_strategy_path_unchanged` PASS
- slots RPC 스키마 필드 변경 없음 (reconcile_warned 필드는 BAT-050에서 이미 추가)
## 롤백 1줄
`git revert <commit-sha>` — 모든 변경은 단일 커밋, 기존 wire 계약 불변이므로 revert 후 trex-orb-v1/tsl-v1 영향 없음.