feat(reset): cutover 도구 폐기 → trex-core-reset(DB 재초기화) 대체 (REQ-064 CHG-20260610-03)
실 DB 점검 중 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 데이터(같은 자산이 closed+재진입 holding 으로 portfolio_id 중복)에 적용 불가 → cutover· 데몬 부팅 모두 차단되는 결함 확인. 데이터 보존 cutover 를 폐기하고 DB wipe + 자산 중심 재초기화로 전환(사용자 결정). 빈 테이블에는 009 인덱스가 정상 생성되고 자산 중심에서 (instance_key, portfolio_id)는 설계상 유일. - trex_core/runtime/reset.py 신규(trex-core-reset): slot-core 7테이블 wipe + apply_migrations 재적용 → 자산 중심 스키마 착지 검증. dry-run 기본/--yes 적용, 파괴적·DB 전역. 보유 carry-over 없음(재초기화 후 reconcile_core_holdings 재편입). - runtime/cutover.py + trex-core-cutover console script + test_cutover_rehearsal.py 삭제. - pyproject: trex-core-reset 등록. - tests/runtime/test_reset.py 신규 5건(PG-gated): wipe+착지·dry-run 무변경·멱등· wipe 후 admission·테이블 커버리지. - change-req REQ-064 CHG-20260610-03(cutover→재초기화 재정의) + srs index 갱신. - baseline 4문서(data-model/runtime-flows/project-structure/interface-contract) + BAT-030 design/planning/index/implementation/verification 도구 서술 reset 으로 교체. 검증: ruff clean, 단위 367 passed, PG+RabbitMQ 통합 411 passed/1 skip(KIS sandbox). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
> **체크포인트 결정(2026-06-10)**: D1=**전체 rebuild**(보유는 브로커 잔고 기준 carry-over) / D2=**전부 완성**(literal inactive + 별칭 제거 + entry_count·trading_date drop) / D3=**집행 매도 qty 브로커 정본**(미러 fallback 없음, 0이면 skip+divergence) / D4=**실 cutover 는 운영자(REL 단계, 리허설 후)**. 아래 §1 을 전체 rebuild 로 읽는다.
|
> **체크포인트 결정(2026-06-10)**: D1=**전체 rebuild**(보유는 브로커 잔고 기준 carry-over) / D2=**전부 완성**(literal inactive + 별칭 제거 + entry_count·trading_date drop) / D3=**집행 매도 qty 브로커 정본**(미러 fallback 없음, 0이면 skip+divergence) / D4=**실 cutover 는 운영자(REL 단계, 리허설 후)**. 아래 §1 을 전체 rebuild 로 읽는다.
|
||||||
|
|
||||||
|
|
||||||
|
> **2026-06-10 갱신(CHG-20260610-03)**: D1(데이터 보존 cutover) 폐기 — 실 DB 점검에서 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 중복 데이터에 적용 불가한 결함 확인. **DB wipe + 자산 중심 재초기화**(`trex-core-reset`)로 대체. §1 의 cutover(전체 rebuild·보유 carry-over·promote-unique)는 미적용; 보유는 재초기화 후 universe reconcile + reconcile_core_holdings(REQ-062)로 운영자 재편입. D2/D3 는 유효.
|
||||||
|
|
||||||
## 설계 목표
|
## 설계 목표
|
||||||
- 자산 중심 모델을 완성하고 라이브로 안전 전환. 보유 포지션 무손실·집행 정확·비가역 변경 통제.
|
- 자산 중심 모델을 완성하고 라이브로 안전 전환. 보유 포지션 무손실·집행 정확·비가역 변경 통제.
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
## 구현 요약
|
## 구현 요약
|
||||||
|
|
||||||
design.md 확정 결정(D1=전체 rebuild / D2=전부 완성 / D3=매도 qty 브로커 정본 / D4=실 cutover 운영자) 전부 구현 완료. M1~M5 전 마일스톤 수행.
|
design.md 확정 결정(D2=전부 완성 / D3=매도 qty 브로커 정본) 구현 완료. M1~M5 수행. **단, D1(데이터 보존 cutover)은 폐기** — 실 DB 점검 중 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 데이터(같은 자산이 closed+재진입 holding 으로 portfolio_id 중복)와 충돌해 cutover/데몬 부팅이 모두 막히는 결함 발견. 사용자 결정으로 데이터 보존 마이그레이션을 버리고 **DB wipe + 자산 중심 재초기화**(`trex-core-reset`)로 대체(REQ-064 CHG-20260610-03). 빈 테이블에는 009 인덱스가 정상 생성되고 자산 중심에서 (instance_key,portfolio_id)는 설계상 유일.
|
||||||
|
|
||||||
- **M1 — literal inactive 전면화 (REQ-057 잔여)**: `SlotState` 5-state(inactive/pending_buy/holding/pending_sell/error) — `IDLE`/`CLOSED` 별칭·`normalize_state` 완전 제거, 전이 12종(6 base + 4 to-error + 2 resolve). 마이그레이션 **010**: 기존 idle/closed 행 → inactive UPDATE + status CHECK 교체(drop-and-add 멱등). `execution.py` 매도 체결 `PENDING_SELL→INACTIVE`, 매수 롤백 `PENDING_BUY→INACTIVE`. `cli.py` buyable 필터 `inactive`+`universe_detached` 제외. `ops_checks.py` ledger-gap 검출을 `slot_transition_log`(reason=`sell_filled`/`sell_partial_cancel`) 기반으로 재작성(`closed` 상태 소멸 대응).
|
- **M1 — literal inactive 전면화 (REQ-057 잔여)**: `SlotState` 5-state(inactive/pending_buy/holding/pending_sell/error) — `IDLE`/`CLOSED` 별칭·`normalize_state` 완전 제거, 전이 12종(6 base + 4 to-error + 2 resolve). 마이그레이션 **010**: 기존 idle/closed 행 → inactive UPDATE + status CHECK 교체(drop-and-add 멱등). `execution.py` 매도 체결 `PENDING_SELL→INACTIVE`, 매수 롤백 `PENDING_BUY→INACTIVE`. `cli.py` buyable 필터 `inactive`+`universe_detached` 제외. `ops_checks.py` ledger-gap 검출을 `slot_transition_log`(reason=`sell_filled`/`sell_partial_cancel`) 기반으로 재작성(`closed` 상태 소멸 대응).
|
||||||
- **M2 — 집행 매도 수량 브로커 정본 (REQ-062 M2(b), D3)**: dispatch 매도 qty = `broker.get_balance()` 의 해당 symbol `hldg_qty`(tick 당 1회 lazy fetch). 미러 `slot.qty` fallback 없음. 브로커 0 → 주문 미제출 + WARN + `ExecutionResult.sell_skipped_no_balance` 카운터(divergence 는 reconcile/운영자 경로로 표면화). skip 매도가 큐 head 를 막지 않도록 단건 claim(`find_pending_for_dispatch`) → **리스트 기반**(`list_undispatched_pending(instance_key, limit)`) dispatch 로 전환.
|
- **M2 — 집행 매도 수량 브로커 정본 (REQ-062 M2(b), D3)**: dispatch 매도 qty = `broker.get_balance()` 의 해당 symbol `hldg_qty`(tick 당 1회 lazy fetch). 미러 `slot.qty` fallback 없음. 브로커 0 → 주문 미제출 + WARN + `ExecutionResult.sell_skipped_no_balance` 카운터(divergence 는 reconcile/운영자 경로로 표면화). skip 매도가 큐 head 를 막지 않도록 단건 claim(`find_pending_for_dispatch`) → **리스트 기반**(`list_undispatched_pending(instance_key, limit)`) dispatch 로 전환.
|
||||||
- **M3 — 컬럼 drop + legacy pool 제거 (REQ-056 잔여)**: 마이그레이션 **011**: `daily_slot.entry_count`·`trading_date` DROP(IF EXISTS 멱등). 글롭 재적용 정합: 001 의 `(trading_date,status)` 인덱스 → `(status)` 교체, 008(entry_count ADD)은 no-op 화(매 boot 컬럼 부활 방지). `Slot` 모델/`EXPECTED_SLOT_FIELDS`/`_SLOT_COLUMNS`/`_row_to_slot` 정합(19 컬럼). repo 계약 개편: `pre_allocate_slots`/`find_idle_slot`/`find_allocatable_slot`/`increment_symbol_entry`/`get_symbol_entry_count` 제거, `list_slots(instance_key)`/`count_slots_by_status(instance_key)`/`reconcile_universe(instance_key, universe)` 로 전환, 집행 큐(`list_undispatched_pending`/`list_dispatched_pending`)도 **instance 스코프**(공유 DB 의 두 데몬이 서로의 주문을 집행하지 않음 — REQ-056). `preallocation_scheduler.py` 삭제 + 데몬 7 스레드. admission 은 자산 중심 단일 경로(slot_id 정본/portfolio_id dual-accept; `max_active_slots` 미설정 = 무캡 — design §5: 운영 TOML 로 M 부여). `BuyIntent.trading_date`·intent/이벤트/슬롯 뷰 payload 의 `trading_date` 키 제거. capacity 를 `SlotCapacity(inactive/holding/pending/active/available/total/scarcity)` 자산 중심 재정의(available = max(0, M−active)). `daily_symbol_entries` 코드 경로 제거(테이블 잔존, baseline 에 폐기 명시). config 에서 `total_slots`/`daily_preallocation_time` 제거. SnapshotScheduler reconcile 은 slot_repo 주입 시 항상 on.
|
- **M3 — 컬럼 drop + legacy pool 제거 (REQ-056 잔여)**: 마이그레이션 **011**: `daily_slot.entry_count`·`trading_date` DROP(IF EXISTS 멱등). 글롭 재적용 정합: 001 의 `(trading_date,status)` 인덱스 → `(status)` 교체, 008(entry_count ADD)은 no-op 화(매 boot 컬럼 부활 방지). `Slot` 모델/`EXPECTED_SLOT_FIELDS`/`_SLOT_COLUMNS`/`_row_to_slot` 정합(19 컬럼). repo 계약 개편: `pre_allocate_slots`/`find_idle_slot`/`find_allocatable_slot`/`increment_symbol_entry`/`get_symbol_entry_count` 제거, `list_slots(instance_key)`/`count_slots_by_status(instance_key)`/`reconcile_universe(instance_key, universe)` 로 전환, 집행 큐(`list_undispatched_pending`/`list_dispatched_pending`)도 **instance 스코프**(공유 DB 의 두 데몬이 서로의 주문을 집행하지 않음 — REQ-056). `preallocation_scheduler.py` 삭제 + 데몬 7 스레드. admission 은 자산 중심 단일 경로(slot_id 정본/portfolio_id dual-accept; `max_active_slots` 미설정 = 무캡 — design §5: 운영 TOML 로 M 부여). `BuyIntent.trading_date`·intent/이벤트/슬롯 뷰 payload 의 `trading_date` 키 제거. capacity 를 `SlotCapacity(inactive/holding/pending/active/available/total/scarcity)` 자산 중심 재정의(available = max(0, M−active)). `daily_symbol_entries` 코드 경로 제거(테이블 잔존, baseline 에 폐기 명시). config 에서 `total_slots`/`daily_preallocation_time` 제거. SnapshotScheduler reconcile 은 slot_repo 주입 시 항상 on.
|
||||||
- **M4 — cutover 도구 (REQ-064, D1·D4)**: `trex_core/runtime/cutover.py` + console script `trex-core-cutover`(기본 dry-run, `--yes` 적용). 절차: preflight(pending_* in-flight 0, 위반 시 CutoverAbort) → instance 행 전체 DELETE → universe 자산별 영구 슬롯(inactive) 생성 → 브로커 잔고 (symbol,qty) → universe 매핑 → holding 설정(qty=브로커 정본, avg_price=NULL+WARN — 잔고 API 에 평단 없음) → `slot_transition_log` 감사(`cutover_rebuild`) → 옵션 `--promote-unique`(portfolio_id NOT NULL; 공유 DB 전 인스턴스 cutover 후). 매핑 실패 기본 중단(`--allow-unmapped` 로 WARN+skip). 단일 트랜잭션(중단/실패 시 무변경, dry-run 은 rollback).
|
- **M4 — DB 재초기화 도구 (REQ-064, CHG-20260610-03)**: `trex_core/runtime/reset.py` + console script `trex-core-reset`(기본 dry-run, `--yes` 적용). 절차: 대상 DB 확인 → slot-core 7개 테이블 중 존재하는 것 행수 집계(dry-run 은 여기서 보고만) → `--yes` 면 `TRUNCATE ... RESTART IDENTITY CASCADE` → `apply_migrations`(빈 테이블이라 009 유니크 인덱스 정상 생성·010 inactive CHECK·011 컬럼 drop) → `daily_slot` 컬럼 == EXPECTED_SLOT_FIELDS 검증. 파괴적·**DB 전역**(모든 instance). 데이터 보존(보유 carry-over)은 하지 않음 — 재초기화 후 universe reconcile 이 자산 슬롯 생성, 보유는 `reconcile_core_holdings`(REQ-062)로 운영자 재편입. (구 `cutover.py`·`trex-core-cutover`·`test_cutover_rehearsal.py` 삭제.)
|
||||||
- **M5 — baseline 마감 (REQ-065)**: data-model/runtime-flows/project-structure/interface-contract 4문서 자산 중심 최종 갱신(daily_symbol_entries·dcy-014 preallocation·풀 admission 폐기 확정, slot_id 계약·5-state·M 캡·브로커 정본 반영). `config/instance.example.toml` 에 `max_active_slots` 안내. `.claude/skills/run-trex-core-daemon`(SKILL.md+smoke.py) 자산 중심으로 갱신.
|
- **M5 — baseline 마감 (REQ-065)**: data-model/runtime-flows/project-structure/interface-contract 4문서 자산 중심 최종 갱신(daily_symbol_entries·dcy-014 preallocation·풀 admission 폐기 확정, slot_id 계약·5-state·M 캡·브로커 정본 반영). `config/instance.example.toml` 에 `max_active_slots` 안내. `.claude/skills/run-trex-core-daemon`(SKILL.md+smoke.py) 자산 중심으로 갱신.
|
||||||
|
|
||||||
## 변경 내역 (Changed Files)
|
## 변경 내역 (Changed Files)
|
||||||
@@ -19,18 +19,18 @@ design.md 확정 결정(D1=전체 rebuild / D2=전부 완성 / D3=매도 qty 브
|
|||||||
- trex_core/domain/slot/: state.py(5-state) · models.py(컬럼 drop+SlotCapacity 재정의) · service.py(preallocate/카운터 제거, INACTIVE 리셋=점유 초기화+자산 정체성 보존, capacity 자산 중심) · repository.py · in_memory_repository.py(+add_slot 테스트 헬퍼) · __init__.py
|
- trex_core/domain/slot/: state.py(5-state) · models.py(컬럼 drop+SlotCapacity 재정의) · service.py(preallocate/카운터 제거, INACTIVE 리셋=점유 초기화+자산 정체성 보존, capacity 자산 중심) · repository.py · in_memory_repository.py(+add_slot 테스트 헬퍼) · __init__.py
|
||||||
- trex_core/adapters/persistence/: postgresql.py · ops_checks.py(재작성) · sql/001(인덱스 교체)·008(no-op 화)·**010_literal_inactive_status.sql**(신규)·**011_drop_pool_model_columns.sql**(신규)
|
- trex_core/adapters/persistence/: postgresql.py · ops_checks.py(재작성) · sql/001(인덱스 교체)·008(no-op 화)·**010_literal_inactive_status.sql**(신규)·**011_drop_pool_model_columns.sql**(신규)
|
||||||
- trex_core/application/: intents.py(단일 자산 중심 admission) · execution.py(M2+inactive 전이+instance 스코프) · messaging_handlers.py · portfolio.py · safety_sell.py · killswitch.py · snapshot_scheduler.py · **preallocation_scheduler.py 삭제**
|
- trex_core/application/: intents.py(단일 자산 중심 admission) · execution.py(M2+inactive 전이+instance 스코프) · messaging_handlers.py · portfolio.py · safety_sell.py · killswitch.py · snapshot_scheduler.py · **preallocation_scheduler.py 삭제**
|
||||||
- trex_core/runtime/: daemon.py(7 스레드) · config.py · cli.py · **cutover.py(신규)**
|
- trex_core/runtime/: daemon.py(7 스레드) · config.py · cli.py · **reset.py(신규)** · ~~cutover.py(삭제)~~
|
||||||
- pyproject.toml: `trex-core-cutover` console script
|
- pyproject.toml: `trex-core-reset` console script (구 `trex-core-cutover` 제거)
|
||||||
- docs: data-model.md · runtime-flows.md · project-structure.md · interface-contract.md (자산 중심 마감)
|
- docs: data-model.md · runtime-flows.md · project-structure.md · interface-contract.md (자산 중심 마감)
|
||||||
- config/instance.example.toml · .claude/skills/run-trex-core-daemon/{SKILL.md,smoke.py}
|
- config/instance.example.toml · .claude/skills/run-trex-core-daemon/{SKILL.md,smoke.py}
|
||||||
- tests: 도메인/애플리케이션/어댑터/런타임 전면 정합(test_state·test_service 재작성, test_slot_entry_count·test_buy_admission_reallocation·test_preallocation_scheduler 삭제, test_execution 에 M2 3건 신설, **tests/runtime/test_cutover_rehearsal.py 신규 8건(PG-gated)**, test_postgresql_repository 자산 중심 재작성+dispatch 인스턴스 격리, test_ops_checks 재작성)
|
- tests: 도메인/애플리케이션/어댑터/런타임 전면 정합(test_state·test_service 재작성, test_slot_entry_count·test_buy_admission_reallocation·test_preallocation_scheduler 삭제, test_execution 에 M2 3건 신설, **tests/runtime/test_reset.py 신규 5건(PG-gated)**, test_postgresql_repository 자산 중심 재작성+dispatch 인스턴스 격리, test_ops_checks 재작성)
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
- 단위 전체: **367 passed** (PG/RabbitMQ/KIS unset 시 40 skipped), ruff clean
|
- 단위 전체: **367 passed** (PG/RabbitMQ/KIS unset 시 40 skipped), ruff clean
|
||||||
- PG-gated(trex_core_test): **29 passed** — 마이그레이션 010/011 round-trip + 멱등 재적용, daily_slot 컬럼 == EXPECTED_SLOT_FIELDS 드리프트 가드(컬럼 drop 후 정합), reconcile ON CONFLICT, dispatch 인스턴스 격리, ops_checks 재작성, **cutover 리허설 8건**(전체 rebuild·브로커 carry-over(미러 7 vs 브로커 12 → 12)·in-flight 중단·unmapped 중단/allow-skip·dry-run rollback·인스턴스 격리·promote-unique·감사 로그)
|
- PG-gated(trex_core_test): **26 passed** — 마이그레이션 010/011 round-trip + 멱등 재적용, daily_slot 컬럼 == EXPECTED_SLOT_FIELDS 드리프트 가드(컬럼 drop 후 정합), reconcile ON CONFLICT, dispatch 인스턴스 격리, ops_checks 재작성, **reset 5건**(wipe+자산 중심 스키마 착지·dry-run 무변경·멱등·wipe 후 즉시 admission 가능·slot-core 테이블 커버리지)
|
||||||
- RabbitMQ-gated: 21 passed (실 브로커)
|
- RabbitMQ-gated: 21 passed (실 브로커)
|
||||||
- 통합(전부 enabled): **405 passed, 10 skipped**(KIS sandbox — 기존 환경 이슈, 본 배치 무관)
|
- 통합(PG+RabbitMQ enabled): **411 passed, 1 skipped**(KIS sandbox — 기존 환경 이슈, 본 배치 무관)
|
||||||
|
|
||||||
## 검증 실행 (run-the-app, 자산 중심 e2e)
|
## 검증 실행 (run-the-app, 자산 중심 e2e)
|
||||||
|
|
||||||
@@ -45,7 +45,8 @@ test DB(trex_core_test) + 실 RabbitMQ + paper broker + stub universe(2 자산)
|
|||||||
|
|
||||||
## Remaining Risks / Blockers
|
## Remaining Risks / Blockers
|
||||||
|
|
||||||
- **실 cutover 미실행(의도)** — D4: 운영자가 REL 단계에서 리허설 후 `trex-core-cutover --config <instance> --yes` 실행. 사전 백업 + 거래 세션 외 + in-flight 0 필수. 롤백 = 백업 복원 + 직전 리비전.
|
- **실 DB 재초기화 미실행(의도)** — 운영자가 `trex-core-reset --config <instance> --yes` 실행(파괴적·DB 전역). 사전 백업 필수. 재초기화라 롤백 = 백업 복원.
|
||||||
- cutover carry-over 의 `avg_price=NULL` — 잔고 API 미제공. SL/TP 는 절대가 기준이라 안전 매도 무영향이나, 운영자/전략이 필요 시 후속 갱신.
|
- 데이터 보존 cutover 폐기로 **기존 보유 포지션은 재초기화 시 소실** — 재초기화 후 브로커 보유는 universe reconcile + `reconcile_core_holdings`(REQ-062)에서 unmanaged 로 표면화되어 운영자가 재편입. 실거래 보유가 있는 DB 라면 재편입 절차를 release 노트에 포함.
|
||||||
|
- 마이그레이션 009 부분 유니크 인덱스는 legacy 풀 중복 데이터에 적용 불가(이 결함이 cutover→reset 전환의 직접 원인). 자산 중심 DB(신규/재초기화 후)에서는 정상.
|
||||||
- 전략 모듈(trex-orb) 계약 전환: buy `slot_id` 정본(snapshot slots[] round-trip), `trading_date` 키 소멸, 이벤트 payload 변경 — portfolio_id dual-accept 로 무중단이나 전략 측 전환 권고(REL 노트 대상).
|
- 전략 모듈(trex-orb) 계약 전환: buy `slot_id` 정본(snapshot slots[] round-trip), `trading_date` 키 소멸, 이벤트 payload 변경 — portfolio_id dual-accept 로 무중단이나 전략 측 전환 권고(REL 노트 대상).
|
||||||
- `max_active_slots` 미설정 = 무캡 — 운영 TOML 에 반드시 설정(배포 체크리스트, design §5).
|
- `max_active_slots` 미설정 = 무캡 — 운영 TOML 에 반드시 설정(배포 체크리스트, design §5).
|
||||||
|
|||||||
@@ -30,3 +30,4 @@
|
|||||||
- Source discovery 단일(dcy-020). BAT-027/028/029 후속, dcy-020 최종.
|
- Source discovery 단일(dcy-020). BAT-027/028/029 후속, dcy-020 최종.
|
||||||
- **최고 위험**: 라이브 데이터·집행 전환. **실 cutover 는 운영자 실행**(본 배치는 마이그레이션·코드·리허설 제공). design 체크포인트에서 cutover 절차·롤백·운영자 입회·전환 순서 확정 후 구현.
|
- **최고 위험**: 라이브 데이터·집행 전환. **실 cutover 는 운영자 실행**(본 배치는 마이그레이션·코드·리허설 제공). design 체크포인트에서 cutover 절차·롤백·운영자 입회·전환 순서 확정 후 구현.
|
||||||
- 본 배치 완료 후 REQ-056~065 confirm-req-implemented → REL.
|
- 본 배치 완료 후 REQ-056~065 confirm-req-implemented → REL.
|
||||||
|
- 2026-06-10 (CHG-20260610-03): 데이터 보존 cutover → DB 재초기화(`trex-core-reset`)로 전환 (마이그레이션 009 legacy 충돌 결함). 폴더명의 'cutover' 는 역사적 표기로 유지.
|
||||||
|
|||||||
@@ -42,3 +42,6 @@
|
|||||||
## Notes
|
## Notes
|
||||||
- 실 cutover 는 운영자 실행(본 배치는 마이그레이션·코드·리허설 제공). confirm-release 전 cutover 리허설 + run-the-app evidence 필수.
|
- 실 cutover 는 운영자 실행(본 배치는 마이그레이션·코드·리허설 제공). confirm-release 전 cutover 리허설 + run-the-app evidence 필수.
|
||||||
- green 기준: 단위 전체 회귀(literal inactive·drop 무파손) + cutover 리허설(보유 보존·롤백) + 자산 중심 e2e.
|
- green 기준: 단위 전체 회귀(literal inactive·drop 무파손) + cutover 리허설(보유 보존·롤백) + 자산 중심 e2e.
|
||||||
|
|
||||||
|
## 갱신 (2026-06-10, CHG-20260610-03)
|
||||||
|
- M4 의 데이터 보존 cutover 도구(`trex-core-cutover`)는 폐기되고 **DB 재초기화 도구**(`trex-core-reset`: slot-core wipe + 마이그레이션 재적용)로 대체. 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 중복 데이터에 적용 불가한 결함이 직접 원인. 보유 carry-over 불요(재초기화 후 reconcile 재편입).
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
### REQ-056 (Data — 잔여: trading_date/entry_count drop, 스키마 마감)
|
### REQ-056 (Data — 잔여: trading_date/entry_count drop, 스키마 마감)
|
||||||
| AC | 판정 | Evidence |
|
| AC | 판정 | Evidence |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| AC-1 스키마: portfolio_id NOT NULL·instance_key·UNIQUE(instance_key,portfolio_id)·retired_at·trading_date/entry_count 부재 | **pass** (NOT NULL 은 promote 후) | 마이그레이션 009/010/011 + `test_promote_unique_sets_not_null`(cutover `--promote-unique` 로 NOT NULL 승격 — 공유 DB 전 인스턴스 cutover 후 적용하는 설계, BAT-027 결정); `test_daily_slot_columns_match_expected_contract`(drop 후 컬럼셋 일치) |
|
| AC-1 스키마: portfolio_id·instance_key·부분 UNIQUE(instance_key,portfolio_id)·retired_at·trading_date/entry_count 부재 | pass | 마이그레이션 009/010/011 + `test_daily_slot_columns_match_expected_contract`(drop 후 컬럼셋 == EXPECTED_SLOT_FIELDS) + `test_reset_wipes_and_lands_asset_centric`(유니크 인덱스·inactive CHECK 존재 확인). ※ portfolio_id NOT NULL 승격은 데이터 보존 cutover 와 함께 폐기 — 자산 중심에서 universe reconcile 이 항상 portfolio_id 를 채우므로 부분 유니크로 충분(CHG-20260610-03) |
|
||||||
| AC-2 EXPECTED_SLOT_FIELDS ↔ information_schema 일치 | pass | `tests/adapters/persistence/test_migration_and_drift.py` 3건 PG 통과 |
|
| AC-2 EXPECTED_SLOT_FIELDS ↔ information_schema 일치 | pass | `tests/adapters/persistence/test_migration_and_drift.py` 3건 PG 통과 |
|
||||||
| AC-3 (instance_key, portfolio_id) 중복 거부 | pass | partial UNIQUE(009) + `test_universe_reconcile_pg`(ON CONFLICT 멱등, created=0) |
|
| AC-3 (instance_key, portfolio_id) 중복 거부 | pass | partial UNIQUE(009) + `test_universe_reconcile_pg`(ON CONFLICT 멱등, created=0) |
|
||||||
| AC-4 인스턴스 간 행 미혼합 | pass | `test_dispatch_queue_instance_scoped`(집행 큐 격리) + `test_instance_isolation`(reconcile) + cutover `test_instance_isolation_other_instance_untouched` |
|
| AC-4 인스턴스 간 행 미혼합 | pass | `test_dispatch_queue_instance_scoped`(집행 큐 격리) + `test_instance_isolation`(reconcile) |
|
||||||
|
|
||||||
### REQ-057 (Interface — 잔여: literal inactive + 별칭 제거)
|
### REQ-057 (Interface — 잔여: literal inactive + 별칭 제거)
|
||||||
| AC | 판정 | Evidence |
|
| AC | 판정 | Evidence |
|
||||||
@@ -31,13 +31,15 @@
|
|||||||
| AC-3 core flat/브로커 보유 → WARN·운영자 결정 | pass (BAT-029 기구현; 슬롯 부재로 '격리' 대신 unmanaged WARN — issue #15 무자동조치 결정) | `test_B_broker_holds_unmanaged_warns` |
|
| AC-3 core flat/브로커 보유 → WARN·운영자 결정 | pass (BAT-029 기구현; 슬롯 부재로 '격리' 대신 unmanaged WARN — issue #15 무자동조치 결정) | `test_B_broker_holds_unmanaged_warns` |
|
||||||
| AC-4 수량 불일치 → 상태 전이 없음·warning 노출 | pass (BAT-029 기구현; 미러 자동 갱신 없이 WARN — 보수 구현) | `test_C_qty_mismatch_warns_no_state_change` |
|
| AC-4 수량 불일치 → 상태 전이 없음·warning 노출 | pass (BAT-029 기구현; 미러 자동 갱신 없이 WARN — 보수 구현) | `test_C_qty_mismatch_warns_no_state_change` |
|
||||||
|
|
||||||
### REQ-064 (Migration — cutover + 롤백)
|
### REQ-064 (Migration → **DB 재초기화로 재정의**, CHG-20260610-03)
|
||||||
| AC | 판정 | Evidence |
|
원 AC(데이터 보존 cutover)는 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 중복 데이터에 적용 불가한 결함으로 폐기되고, **wipe + 자산 중심 재초기화**(`trex-core-reset`)로 재정의됨. 재정의된 AC 로 매핑:
|
||||||
|
| AC(재정의) | 판정 | Evidence |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| AC-1 보유 N개 → 영구 슬롯 holding 보존(수량 무유실) | **pass (리허설)** | `tests/runtime/test_cutover_rehearsal.py::test_full_rebuild_with_broker_carry_over` — 브로커 qty=12 정본 carry-over(미러 7 아님), 자산 바인딩 보존. ※ avg_price 는 잔고 API 미제공으로 NULL carry(+WARN) — implementation.md 잔여 리스크 |
|
| AC-1' 재초기화 시 slot-core 테이블 wipe + 자산 중심 스키마 착지 | pass | `test_reset_wipes_and_lands_asset_centric`(2행 → 0행, 컬럼==EXPECTED, 유니크 인덱스+inactive CHECK 존재) |
|
||||||
| AC-2 cutover 후 데몬 회귀 없음(reconcile·admission·safety-sell) | pass | run-the-app e2e(아래) + 단위 367/PG 29 green |
|
| AC-2' 재초기화 후 데몬 회귀 없음(reconcile·admission) | pass | `test_reset_usable_for_admission_after_wipe`(reset 후 reconcile+활성 동작) + run-the-app e2e(아래) |
|
||||||
| AC-3 실패 시 롤백 가능 | pass (계획+부분 검증) | 단일 트랜잭션(중단 시 무변경: `test_aborts_on_in_flight_pending`/`test_aborts_on_unmapped_broker_holding` DB 불변 단언) + `test_dry_run_rolls_back`; 전체 롤백 = 백업 복원 + 직전 리비전(design §롤백, 운영 절차) |
|
| AC-3' 안전성: dry-run 무변경 + 멱등 | pass | `test_dry_run_reports_without_wiping`(행 보존) + `test_reset_is_idempotent`(2회차 0행·schema_ok) |
|
||||||
| AC-4 instance_key 태깅·스코프 쿼리 정확 | pass | cutover 가 instance_key 로 생성 + `test_instance_isolation_other_instance_untouched` |
|
| AC-4' 데이터 보존(보유 carry-over) 폐기 — 보유는 reconcile 로 재편입 | pass(의도된 범위 축소) | CHG-20260610-03; 재초기화 후 브로커 보유는 `reconcile_core_holdings`(REQ-062) unmanaged 경로 |
|
||||||
|
| (참고) 백업/복원 | 운영 절차 | reset 은 재초기화라 롤백 = 백업 복원. 실행 전 백업 필수(release 게이트) |
|
||||||
|
|
||||||
### REQ-065 (Documentation — baseline 마감)
|
### REQ-065 (Documentation — baseline 마감)
|
||||||
| AC | 판정 | Evidence |
|
| AC | 판정 | Evidence |
|
||||||
@@ -49,16 +51,16 @@
|
|||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
1. **단위 회귀**: `pytest tests` → **367 passed**, ruff `All checks passed` (commit 7d72782 시점).
|
1. **단위 회귀**: `pytest tests` → **367 passed**, ruff `All checks passed` (commit 7d72782 시점).
|
||||||
2. **PG-gated**(실 PostgreSQL `trex_core_test`): **29 passed** — 마이그레이션 001~011 전체 재적용 멱등(`test_apply_migrations_is_idempotent`; 010 CHECK drop-and-add·011 DROP IF EXISTS·008 no-op 가 매 boot 재실행에 안전함을 모든 PG 테스트의 pg_clean 반복 적용으로 입증), 컬럼 드리프트 가드, reconcile/admission/lifecycle, dispatch 인스턴스 격리, ops_checks(transition_log 기반) 3건, **cutover 리허설 8건**.
|
2. **PG-gated**(실 PostgreSQL `trex_core_test`): **26 passed** — 마이그레이션 001~011 전체 재적용 멱등(`test_apply_migrations_is_idempotent`; 010 CHECK drop-and-add·011 DROP IF EXISTS·008 no-op 가 매 boot 재실행에 안전함을 모든 PG 테스트의 pg_clean 반복 적용으로 입증), 컬럼 드리프트 가드, reconcile/admission/lifecycle, dispatch 인스턴스 격리, ops_checks(transition_log 기반) 3건, **reset 5건**(wipe+자산 중심 착지·dry-run 무변경·멱등·wipe 후 admission·테이블 커버리지).
|
||||||
3. **RabbitMQ-gated**(실 브로커): 21 passed — slot_id 계약 포함 intent RPC e2e dedup.
|
3. **RabbitMQ-gated**(실 브로커): 21 passed — slot_id 계약 포함 intent RPC e2e dedup.
|
||||||
4. **run-the-app e2e**(test DB + 실 RabbitMQ + paper broker, instance=e2etest, M=1): boot reconcile created=2 → snapshot(slots=2 inactive, available=1) → buy by slot_id admitted → 중복 intent identical → 2번째 자산 capacity_full → fill→holding(available=0) → 동일 슬롯 already_active → sell→fill→**inactive**(qty=None·portfolio_id 보존·available=1) → 동일 영구 슬롯 재매수 admitted. DB 최종행·transition_log·ledger_outbox(completed×3) 일치, 데몬 로그 ERROR/Traceback 0, SIGTERM Normal shutdown. (전 과정 implementation.md 검증 실행 절 기록)
|
4. **run-the-app e2e**(test DB + 실 RabbitMQ + paper broker, instance=e2etest, M=1): boot reconcile created=2 → snapshot(slots=2 inactive, available=1) → buy by slot_id admitted → 중복 intent identical → 2번째 자산 capacity_full → fill→holding(available=0) → 동일 슬롯 already_active → sell→fill→**inactive**(qty=None·portfolio_id 보존·available=1) → 동일 영구 슬롯 재매수 admitted. DB 최종행·transition_log·ledger_outbox(completed×3) 일치, 데몬 로그 ERROR/Traceback 0, SIGTERM Normal shutdown. (전 과정 implementation.md 검증 실행 절 기록)
|
||||||
5. **baseline 동기화**(Architecture Impact 有): data-model/runtime-flows/project-structure/interface-contract 4문서 + run-trex-core-daemon 스킬 + instance.example.toml 갱신 — commit 7d72782 포함.
|
5. **baseline 동기화**(Architecture Impact 有): data-model/runtime-flows/project-structure/interface-contract 4문서 + run-trex-core-daemon 스킬 + instance.example.toml 갱신 — commit 7d72782 포함.
|
||||||
|
|
||||||
## Blocking Issues
|
## Blocking Issues
|
||||||
|
|
||||||
- 없음 (코드/리허설 범위). 단, **실 운영 DB cutover 는 본 배치 산출물 범위 밖**(design D4) — release(REL) 단계에서 운영자가 백업→리허설→`trex-core-cutover --yes`→운영 TOML `max_active_slots` 설정 순으로 수행해야 하며, 그 운영 증거는 release 검증에 귀속된다.
|
- 없음 (코드/테스트 범위). 단, **실 운영 DB 재초기화는 본 배치 산출물 범위 밖** — release(REL) 단계에서 운영자가 백업→`trex-core-reset --config <instance> --yes`→운영 TOML `max_active_slots` 설정 순으로 수행하고, 그 운영 증거는 release 검증에 귀속된다. 데이터 보존 cutover 폐기로 기존 보유는 재초기화 후 운영자가 재편입한다(REQ-062 경로).
|
||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- 포함 REQ AC 전 항목 evidence 연결 완료(REQ-062 AC-3/4 는 issue #15 무자동조치 결정에 따른 보수 구현 — 의도된 편차로 기록).
|
- 포함 REQ AC 전 항목 evidence 연결 완료. REQ-064 는 데이터 보존 cutover → **DB 재초기화로 재정의**(CHG-20260610-03, 마이그레이션 009 결함 + dev/페이퍼 컨텍스트)되어 재정의 AC 로 검증. REQ-062 AC-3/4 는 issue #15 무자동조치 결정에 따른 보수 구현(의도된 편차).
|
||||||
- **Ready for Release: true** (배치 산출물 기준 — 코드·마이그레이션·cutover 도구·리허설·baseline. 실 cutover 실행은 REL 단계 운영자 작업으로 release 게이트에 위임)
|
- **Ready for Release: true** (배치 산출물 기준 — 코드·마이그레이션·reset 도구·테스트·baseline. 실 DB 재초기화 실행은 REL 단계 운영자 작업으로 release 게이트에 위임)
|
||||||
|
|||||||
+4
-4
@@ -11,7 +11,7 @@
|
|||||||
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
|
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
|
||||||
|
|
||||||
## Primary Domain
|
## Primary Domain
|
||||||
- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — portfolio-hub universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 cutover 로 폐기)
|
- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — portfolio-hub universe 자산 1:1 영구 슬롯; 일일 풀 모델 폐기, DB 는 BAT-030 `trex-core-reset` 로 재초기화)
|
||||||
|
|
||||||
## Primary Persistence Backend
|
## Primary Persistence Backend
|
||||||
- PostgreSQL (BAT-002, REQ-006; BAT-006 확장) — 코어 데몬의 durable 단일 writer. 도메인은 여전히 storage-agnostic(SlotRepository Protocol); in-memory 어댑터는 테스트용으로 잔존. 슬라이스 1 테이블: daily_slot / daily_symbol_entries / slot_transition_log / intent_log / ledger_outbox (daily_slot_archive·prune 은 범위 밖).
|
- PostgreSQL (BAT-002, REQ-006; BAT-006 확장) — 코어 데몬의 durable 단일 writer. 도메인은 여전히 storage-agnostic(SlotRepository Protocol); in-memory 어댑터는 테스트용으로 잔존. 슬라이스 1 테이블: daily_slot / daily_symbol_entries / slot_transition_log / intent_log / ledger_outbox (daily_slot_archive·prune 은 범위 밖).
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
### Key Fields
|
### Key Fields
|
||||||
|
|
||||||
- slot_id :: UUID :: 슬롯 식별자(PK) — 외부 intent 계약의 정본 키(REQ-061)
|
- slot_id :: UUID :: 슬롯 식별자(PK) — 외부 intent 계약의 정본 키(REQ-061)
|
||||||
- instance_key :: TEXT NOT NULL DEFAULT 'default' :: 공유 DB 에서 데몬 인스턴스 스코프(REQ-056). UNIQUE(instance_key, portfolio_id) (partial — portfolio_id NOT NULL; cutover `--promote-unique` 후 NOT NULL 승격)
|
- instance_key :: TEXT NOT NULL DEFAULT 'default' :: 공유 DB 에서 데몬 인스턴스 스코프(REQ-056). 부분 UNIQUE(instance_key, portfolio_id) WHERE portfolio_id IS NOT NULL (마이그레이션 009; 빈 DB 에 생성되며 자산 중심에서 (instance_key,portfolio_id)는 설계상 유일)
|
||||||
- retired_at :: TIMESTAMPTZ? :: universe 이탈 자산의 soft-retire 마커(REQ-058; 운영 뷰 `universe_detached`)
|
- retired_at :: TIMESTAMPTZ? :: universe 이탈 자산의 soft-retire 마커(REQ-058; 운영 뷰 `universe_detached`)
|
||||||
- status :: SlotState :: 5 상태(inactive/pending_buy/holding/pending_sell/error) 중 현재 상태 — CHECK 제약(마이그레이션 010)
|
- status :: SlotState :: 5 상태(inactive/pending_buy/holding/pending_sell/error) 중 현재 상태 — CHECK 제약(마이그레이션 010)
|
||||||
- symbol :: str? :: 점유 종목
|
- symbol :: str? :: 점유 종목
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
- `daily_symbol_entries` 테이블은 폐기 상태로 잔존(코드 경로 없음) — 후속 정리 시 DROP 가능.
|
- `daily_symbol_entries` 테이블은 폐기 상태로 잔존(코드 경로 없음) — 후속 정리 시 DROP 가능.
|
||||||
- daily_slot_archive(EOD 아카이브)와 retention prune 은 범위 밖 유지.
|
- daily_slot_archive(EOD 아카이브)와 retention prune 은 범위 밖 유지.
|
||||||
- 포지션 사이징(issue #1 잔여, dcy-020 OQ-4 분리)은 후속 Discovery.
|
- 포지션 사이징(issue #1 잔여, dcy-020 OQ-4 분리)은 후속 Discovery.
|
||||||
- 실 cutover(REQ-064)는 운영자 실행: `trex-core-cutover --config <instance> --yes` (전체 rebuild, 보유 carry-over=브로커 잔고; avg_price 는 NULL 로 carry — 잔고 API 미제공).
|
- DB 재초기화(REQ-064)는 운영자 실행: `trex-core-reset --config <instance> --yes` — slot-core 테이블 wipe + 마이그레이션 재적용으로 자산 중심 스키마 확정(데이터 보존 cutover 폐기, CHG-20260610-03). 보유 포지션은 재초기화 후 universe reconcile + `reconcile_core_holdings`(REQ-062)로 운영자가 재편입. 파괴적·DB 전역 — 백업 선행.
|
||||||
|
|
||||||
## Update Triggers
|
## Update Triggers
|
||||||
|
|
||||||
@@ -193,4 +193,4 @@
|
|||||||
- 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009).
|
- 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009).
|
||||||
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
- 2026-06-04 :: BAT-006 — ledger 통합 데이터 모델 반영(LedgerTrade DTO + `ledger_outbox` 테이블/`correlation_id` 멱등/outcome 어휘, FillStatus.execution_id/fee/tax 운반) (REQ-017~020).
|
- 2026-06-04 :: BAT-006 — ledger 통합 데이터 모델 반영(LedgerTrade DTO + `ledger_outbox` 테이블/`correlation_id` 멱등/outcome 어휘, FillStatus.execution_id/fee/tax 운반) (REQ-017~020).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 영구 슬롯 모델 확정: 5-state(inactive), idle/closed 폐합(마이그레이션 010), trading_date/entry_count DROP(011), instance_key/retired_at 정식화, slot_id intent 계약, M 캡 admission, 매도 수량 브로커 정본, daily_symbol_entries·일일 preallocation(dcy-014)·풀 admission(dcy-001/019) 폐기, cutover 도구(trex-core-cutover) (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 영구 슬롯 모델 확정: 5-state(inactive), idle/closed 폐합(마이그레이션 010), trading_date/entry_count DROP(011), instance_key/retired_at 정식화, slot_id intent 계약, M 캡 admission, 매도 수량 브로커 정본, daily_symbol_entries·일일 preallocation(dcy-014)·풀 admission(dcy-001/019) 폐기, DB 재초기화 도구(trex-core-reset — 데이터 보존 cutover 대체, REQ-064 CHG-20260610-03) (REQ-056~065).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
|
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
|
||||||
|
|
||||||
## Primary Domain
|
## Primary Domain
|
||||||
- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 cutover 로 폐기)
|
- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 에서 폐기, DB 는 `trex-core-reset` 로 재초기화)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- Python >=3.12
|
- Python >=3.12
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- Entry Points:
|
- Entry Points:
|
||||||
- `trex-core-daemon --config <path>.toml` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — `--config` 필수(per-instance TOML; `max_active_slots`=M 캡). migrate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / control RPC consumer / snapshot scheduler[universe 슬롯 reconcile 포함] / execution / ledger / safety-sell)를 `instance_key` 큐로 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011 + BAT-012 + BAT-013 + BAT-028/030)
|
- `trex-core-daemon --config <path>.toml` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — `--config` 필수(per-instance TOML; `max_active_slots`=M 캡). migrate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / control RPC consumer / snapshot scheduler[universe 슬롯 reconcile 포함] / execution / ledger / safety-sell)를 `instance_key` 큐로 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011 + BAT-012 + BAT-013 + BAT-028/030)
|
||||||
- `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause`/`resume`/`paused` + `universe`/`slots`/`enter`/`exit`) (BAT-008/020)
|
- `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause`/`resume`/`paused` + `universe`/`slots`/`enter`/`exit`) (BAT-008/020)
|
||||||
- `trex-core-cutover` (console_script → `trex_core.runtime.cutover:main`) :: 풀→자산 중심 cutover 도구 (REQ-064, BAT-030 — 운영자 실행, dry-run 기본)
|
- `trex-core-reset` (console_script → `trex_core.runtime.reset:main`) :: DB 를 자산 중심 스키마로 wipe + 재초기화 (REQ-064, BAT-030 — 데이터 보존 cutover 대체; 운영자 실행, dry-run 기본, 파괴적·DB 전역)
|
||||||
- RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.pause.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md)
|
- RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.pause.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md)
|
||||||
- SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용)
|
- SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용)
|
||||||
- MessagingTransport (Protocol) :: transport 추상화 (RabbitMQ / InMemory stub)
|
- MessagingTransport (Protocol) :: transport 추상화 (RabbitMQ / InMemory stub)
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
│ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
│ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
||||||
│ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030)
|
│ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030)
|
||||||
│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown
|
│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown
|
||||||
│ ├── cutover.py # trex-core-cutover: 풀→자산 중심 전체 rebuild cutover (REQ-064, BAT-030)
|
│ ├── reset.py # trex-core-reset: slot-core 테이블 wipe + 자산 중심 재초기화 (REQ-064, BAT-030)
|
||||||
│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit (BAT-008/020 / REQ-032)
|
│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit (BAT-008/020 / REQ-032)
|
||||||
└── tests/
|
└── tests/
|
||||||
├── domain/slot/ # test_state / test_service / test_contract
|
├── domain/slot/ # test_state / test_service / test_contract
|
||||||
@@ -152,4 +152,4 @@
|
|||||||
- 2026-06-07 :: BAT-011 — safety-sell 트리거 레이어(`application/safety_sell.py` SafetySellService 신설 — forced_close/stop-loss·take-profit/장 마감, `BrokerPort.get_current_price()` + KIS `inquire-price`(`FHKST01010100`, CHG-04 정정)·paper stub, `SlotService.update_thresholds()` + intent RPC `update_thresholds` 액션, runtime/daemon 을 7 스레드로 확장(`trex-safety-sell`) + WatchdogStore 배선) (REQ-037~040, 042).
|
- 2026-06-07 :: BAT-011 — safety-sell 트리거 레이어(`application/safety_sell.py` SafetySellService 신설 — forced_close/stop-loss·take-profit/장 마감, `BrokerPort.get_current_price()` + KIS `inquire-price`(`FHKST01010100`, CHG-04 정정)·paper stub, `SlotService.update_thresholds()` + intent RPC `update_thresholds` 액션, runtime/daemon 을 7 스레드로 확장(`trex-safety-sell`) + WatchdogStore 배선) (REQ-037~040, 042).
|
||||||
- 2026-06-07 :: BAT-012 — 비상 정지/kill-switch(`trex.pause.req`→`trex.control.req` 제어 채널 + action dispatch, `application/killswitch.py` KillSwitchService + `adapters/portfolio/killswitch_store.py` + `005_create_killswitch_exclusion.sql` day-scoped 제외, snapshot masking 에 killswitch_today 추가, `make_control_rpc_handler` + kill_switch/resume_kill_switch, daemon control consumer 전환) (REQ-043~047).
|
- 2026-06-07 :: BAT-012 — 비상 정지/kill-switch(`trex.pause.req`→`trex.control.req` 제어 채널 + action dispatch, `application/killswitch.py` KillSwitchService + `adapters/portfolio/killswitch_store.py` + `005_create_killswitch_exclusion.sql` day-scoped 제외, snapshot masking 에 killswitch_today 추가, `make_control_rpc_handler` + kill_switch/resume_kill_switch, daemon control consumer 전환) (REQ-043~047).
|
||||||
- 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051).
|
- 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환: `runtime/cutover.py` + `trex-core-cutover` 콘솔 스크립트 신설, `application/preallocation_scheduler.py` 삭제(일일 preallocation 폐기), 마이그레이션 010(inactive CHECK)/011(trading_date·entry_count drop), repo 계약 instance 스코프 개편, `ops_checks.py` transition_log 기반 재작성, config 에서 `total_slots`/`daily_preallocation_time` 제거 (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환: `runtime/reset.py` + `trex-core-reset` 콘솔 스크립트(데이터 보존 cutover 폐기 — wipe+재초기화로 대체, REQ-064 CHG-20260610-03), `application/preallocation_scheduler.py` 삭제(일일 preallocation 폐기), 마이그레이션 010(inactive CHECK)/011(trading_date·entry_count drop), repo 계약 instance 스코프 개편, `ops_checks.py` transition_log 기반 재작성, config 에서 `total_slots`/`daily_preallocation_time` 제거 (REQ-056~065).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
## Covered Entry Points
|
## Covered Entry Points
|
||||||
|
|
||||||
- `trex-core-daemon` :: 슬롯 코어 데몬 (lifecycle + RabbitMQ 통신, BAT-002/BAT-007)
|
- `trex-core-daemon` :: 슬롯 코어 데몬 (lifecycle + RabbitMQ 통신, BAT-002/BAT-007)
|
||||||
- `trex-core-cutover` :: 풀→자산 중심 cutover 도구 (REQ-064, 운영자 실행 — preflight/전체 rebuild/브로커 잔고 carry-over/`--promote-unique`)
|
- `trex-core-reset` :: DB 를 자산 중심 스키마로 wipe + 재초기화 (REQ-064, 운영자 실행 — 데이터 보존 cutover 대체; 파괴적·DB 전역, dry-run 기본)
|
||||||
- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007)
|
- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007)
|
||||||
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
|
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
|
||||||
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
|
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
|
||||||
@@ -301,4 +301,4 @@
|
|||||||
- 2026-06-04 :: BAT-006 — ledger 통합 반영(체결→ledger outbox enqueue, LedgerWriter retry/dedup/409 흐름, FillStatus.execution_id/fee 운반, slot 전이와 ledger write 분리) (REQ-017~020).
|
- 2026-06-04 :: BAT-006 — ledger 통합 반영(체결→ledger outbox enqueue, LedgerWriter retry/dedup/409 흐름, FillStatus.execution_id/fee 운반, slot 전이와 ledger write 분리) (REQ-017~020).
|
||||||
- 2026-06-05 :: BAT-007 — transport 전환(HTTP `/v1` API 제거, RabbitMQ 메시징 도입: 스냅샷/intent RPC + 이벤트 topic, sync+thread daemon, intent_log 멱등 ack-after-commit, publisher confirms + DLQ, per-strategy durable queue + TTL/max-length, broker degrade 분기) (REQ-021~026).
|
- 2026-06-05 :: BAT-007 — transport 전환(HTTP `/v1` API 제거, RabbitMQ 메시징 도입: 스냅샷/intent RPC + 이벤트 topic, sync+thread daemon, intent_log 멱등 ack-after-commit, publisher confirms + DLQ, per-strategy durable queue + TTL/max-length, broker degrade 분기) (REQ-021~026).
|
||||||
- 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`PortfolioHubReadPort` + hub_read/stub_read 어댑터, `SnapshotStore` in-memory + `snapshot_pause` Postgres, `SnapshotScheduler` 일일 refresh, `reconcile_positions` 정합 검증 + `BrokerPort.get_balance()` 확장, `trex.snapshot.req` envelope 응답 + degraded 신호, `trex.pause.req` 제어 RPC, `trex-core` CLI) (REQ-027~033; 옵션 D AC-2/AC-3 은 portfolio-hub#8 배포 후 별도 구현).
|
- 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`PortfolioHubReadPort` + hub_read/stub_read 어댑터, `SnapshotStore` in-memory + `snapshot_pause` Postgres, `SnapshotScheduler` 일일 refresh, `reconcile_positions` 정합 검증 + `BrokerPort.get_balance()` 확장, `trex.snapshot.req` envelope 응답 + degraded 신호, `trex.pause.req` 제어 RPC, `trex-core` CLI) (REQ-027~033; 옵션 D AC-2/AC-3 은 portfolio-hub#8 배포 후 별도 구현).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, cutover 도구(`trex-core-cutover`) (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, DB 재초기화 도구(`trex-core-reset`, 데이터 보존 cutover 대체 — REQ-064 CHG-20260610-03) (REQ-056~065).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# req-064: 풀 모델 → 영구 자산 슬롯 cutover 마이그레이션
|
# req-064: 풀 모델 → 자산 중심 DB 재초기화 (구 cutover 마이그레이션)
|
||||||
|
|
||||||
- Status: Implemented
|
- Status: Implemented
|
||||||
- Type: Migration
|
- Type: Migration
|
||||||
@@ -6,25 +6,26 @@
|
|||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
|
|
||||||
## Intent
|
## Intent
|
||||||
- 출시·운영 중인 익명 풀 모델(일일 daily_slot 행) 데이터를 자산 영구 슬롯 모델로 안전하게 이행한다. 라이브 트레이딩 데이터(보유 포지션)를 잃지 않고 cutover 한다. (dcy-020 NFR-2/R-1)
|
- 익명 풀 모델(일일 daily_slot 행) 데이터베이스를 자산 중심 영구 슬롯 substrate 로 전환한다. **데이터 보존 cutover 는 폐기**(CHG-20260610-03): 마이그레이션 009 의 부분 유니크 인덱스가 legacy 풀 데이터(같은 자산이 closed+재진입 holding 으로 portfolio_id 중복)에 적용 불가한 결함 + dev/페이퍼 컨텍스트로, **DB wipe + 자산 중심 재초기화**로 전환한다. (dcy-020 NFR-2/R-1; 결함 출처는 실 DB 점검 2026-06-10)
|
||||||
|
|
||||||
## Requirement
|
## Requirement
|
||||||
- 기존 일일 `daily_slot` 행을 **자산당 영구 1행**으로 전환하고, 현재 보유(holding) 포지션을 새 영구 슬롯으로 **carry-over** 한다. 기존 slot-scoped 행(slot_transition_log/intent_log/ledger_outbox 등)에 `instance_key` 를 태깅한다. cutover 는 거래 세션 외 시간에 수행하고, 롤백 계획(역마이그레이션 또는 스냅샷 복원)을 갖춘다. 단계적 전환(읽기 정합 → 모델 전환)을 우선 검토하고, cutover 전후 보유 포지션·safety-sell 보호가 끊기지 않음을 검증한다.
|
- `trex-core-reset` 도구로 slot-core 테이블(daily_slot/daily_symbol_entries/slot_transition_log/intent_log/ledger_outbox/snapshot_pause/killswitch_exclusion)을 **wipe** 한 뒤 마이그레이션을 재적용해 DB 가 깨끗한 자산 중심 스키마(009 부분 유니크 인덱스·010 inactive CHECK·011 컬럼 drop)에 착지하게 한다. 빈 테이블에는 인덱스가 정상 생성되고, 자산 중심에서 (instance_key, portfolio_id)는 universe reconcile 가 항상 채우므로 설계상 유일하다. 파괴적·DB 전역이며 dry-run 기본 + `--yes` 적용, 실행 전 백업을 전제로 한다. 보유 포지션 carry-over 는 하지 않으며, 재초기화 후 universe reconcile(REQ-058) + `reconcile_core_holdings`(REQ-062)로 운영자가 재편입한다.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
1. Given 기존 풀 모델 데이터(보유 N개 포함), When cutover 적용, Then 각 보유 자산이 영구 슬롯 1행(holding)으로 보존되고 수량/임계값 등 보호 컨텍스트가 유실되지 않는다.
|
1. Given 기존 풀 모델(또는 임의 상태) DB, When `trex-core-reset --yes`, Then slot-core 테이블이 wipe 되고 `daily_slot` 컬럼셋이 `EXPECTED_SLOT_FIELDS`(자산 중심)와 일치하며 부분 유니크 인덱스·inactive CHECK 가 존재한다.
|
||||||
2. Given cutover 후, When 데몬 기동, Then universe reconcile(REQ-058)·admission(REQ-059)·safety-sell 이 회귀 없이 동작한다.
|
2. Given 재초기화 후, When 데몬 기동, Then universe reconcile(REQ-058)·admission(REQ-059)·safety-sell 이 회귀 없이 동작한다.
|
||||||
3. Given cutover 실패, When 롤백, Then 직전 released 리비전/스키마로 복원 가능하다(롤백 계획 검증).
|
3. Given 안전성, When dry-run 실행, Then 어떤 행도 변경되지 않고 wipe 대상 행수만 보고된다. 재초기화는 멱등이다(2회차도 0행·schema_ok).
|
||||||
4. Given slot-scoped 기존 행, When cutover, Then `instance_key` 태깅이 적용되어 인스턴스 스코프 쿼리가 정확하다.
|
4. Given 보유 포지션, When 재초기화(데이터 보존 폐기), Then 보유는 소실되며 재초기화 후 브로커 보유가 `reconcile_core_holdings`(REQ-062)의 unmanaged 경로로 표면화되어 운영자가 재편입한다. 롤백 = 사전 백업 복원.
|
||||||
|
|
||||||
## Impacted Area
|
## Impacted Area
|
||||||
- Docs: docs/runtime-flows.md(cutover 절차), 릴리즈 Rollback Plan
|
- Docs: docs/runtime-flows.md(재초기화 절차), 릴리즈 Rollback Plan(백업 복원)
|
||||||
- Modules: trex_core/adapters/persistence/sql/(cutover 마이그레이션), 이행 스크립트
|
- Modules: trex_core/runtime/reset.py, trex_core/adapters/persistence/sql/(009~011)
|
||||||
- Tests: tests/adapters/persistence/(cutover round-trip), 회귀 스위트
|
- Tests: tests/runtime/test_reset.py(PG-gated), 회귀 스위트
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- 라이브 데이터 영향 — 배포 게이트·운영자 입회 권장. 실행은 별도 배치(BAT-δ)로 분리하고 confirm-release 전 run-the-app/cutover 리허설 evidence 필요.
|
- 라이브 데이터 영향 — 파괴적·DB 전역. 배포 게이트·운영자 입회·**사전 백업 필수**. 실행은 release(REL) 단계 운영자 작업으로 confirm-release 전 백업 evidence 필요.
|
||||||
- 출처: dcy-020 NFR-2, R-1.
|
- 구 cutover 도구(`trex-core-cutover`/`runtime/cutover.py`)는 폐기·삭제(CHG-20260610-03).
|
||||||
|
- 출처: dcy-020 NFR-2, R-1 + 마이그레이션 009 legacy 충돌 결함.
|
||||||
|
|
||||||
## Change Log
|
## Change Log
|
||||||
|
|
||||||
@@ -86,8 +87,40 @@
|
|||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- 승인 후 cutover 배치 planning 에서 본 REQ AC 를 검증 기준으로 사용.
|
- 승인 후 cutover 배치 planning 에서 본 REQ AC 를 검증 기준으로 사용.
|
||||||
|
|
||||||
|
### CHG-20260610-03
|
||||||
|
- Date: 2026-06-10
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- **데이터 보존 cutover → DB 재초기화로 재정의.** BAT-030 구현 중 실 DB 점검에서 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 중복 데이터(같은 portfolio_id 가 closed+holding 2행)에 적용 불가 → cutover·데몬 부팅 모두 차단되는 결함 확인. 사용자 결정으로 데이터 보존 마이그레이션을 폐기하고 `trex-core-reset`(wipe+재초기화)로 대체. 구 `trex-core-cutover` 도구·테스트 삭제.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 빈 테이블에 자산 중심 스키마를 깨끗이 착지시켜 009 결함을 코드 수정 없이 우회하고, 전환 절차를 단순화(데이터 보존 불요 — dev/페이퍼·신규 인스턴스 컨텍스트).
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Updated: AC-1~4 를 cutover(보유 carry-over/롤백 역마이그레이션) → 재초기화(wipe+스키마 착지/기동 회귀/dry-run·멱등/보유 재편입+백업 복원) 기준으로 재정의
|
||||||
|
- Removed: 보유 무손실 carry-over, instance_key 태깅(전역 wipe 라 불필요), 역마이그레이션 롤백
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Modules: trex_core/runtime/reset.py(신규), runtime/cutover.py(삭제)
|
||||||
|
- Tests: tests/runtime/test_reset.py(신규), test_cutover_rehearsal.py(삭제)
|
||||||
|
- Docs: data-model/runtime-flows/project-structure(도구 서술 교체)
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: BAT-030
|
||||||
|
- Releases: none (실 재초기화는 REL 단계)
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: yes (reset 도구 PG 테스트로 충족)
|
||||||
|
- Existing Implementation Invalidated: yes (cutover 도구 폐기)
|
||||||
|
- Status Recommendation: keep-current (Implemented — reset evidence 로 대체 충족)
|
||||||
|
- Follow-up Action: 실 DB 재초기화는 REL 단계 운영자(백업 선행).
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- tests/runtime/test_reset.py(wipe+스키마 착지·dry-run·멱등·재편입 가능) + run-the-app e2e.
|
||||||
|
|
||||||
### CHG-20260610-IMPL
|
### CHG-20260610-IMPL
|
||||||
- Date: 2026-06-10
|
- Date: 2026-06-10
|
||||||
- Author: Justin Ko
|
- Author: Justin Ko
|
||||||
- Type: status
|
- Type: status
|
||||||
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-030(cutover 도구+PG 리허설 8건; 실 cutover 는 REL 운영자) — 모든 AC 가 release-candidate 배치 verification 의 evidence 와 연결됨(단위 367/PG 29/MQ 21 passed + run-the-app 자산 중심 e2e). Blocking Issues 없음.
|
- Summary: Approved → Implemented (confirm-req-implemented), 이후 CHG-20260610-03 으로 재정의 반영. 근거: BAT-030(`trex-core-reset` 도구 + PG reset 테스트 5건; 실 재초기화는 REL 운영자) — 재정의 AC 가 release-candidate 배치 verification 의 evidence 와 연결됨(단위 367/PG 26/MQ 21 passed + run-the-app 자산 중심 e2e). Blocking Issues 없음.
|
||||||
|
|||||||
+2
-1
@@ -144,7 +144,7 @@
|
|||||||
| REQ-061 | intent 계약 자산(portfolio_id) 키 통일 + 중복/무효 규칙 | Interface | Implemented | High | project maintainers | [req-061](./Interface/req-061_asset-keyed-intent-contract.md) |
|
| REQ-061 | intent 계약 자산(portfolio_id) 키 통일 + 중복/무효 규칙 | Interface | Implemented | High | project maintainers | [req-061](./Interface/req-061_asset-keyed-intent-contract.md) |
|
||||||
| REQ-062 | 수량 hub 정본+로컬 미러 + holding↔broker/hub 불일치 해소 | Integration | Implemented | High | project maintainers | [req-062](./Integration/req-062_quantity-hub-authority-and-divergence.md) |
|
| REQ-062 | 수량 hub 정본+로컬 미러 + holding↔broker/hub 불일치 해소 | Integration | Implemented | High | project maintainers | [req-062](./Integration/req-062_quantity-hub-authority-and-divergence.md) |
|
||||||
| REQ-063 | 운영자 표면 단일화 — slots = 자산 포지션 단일 뷰 | Interface | Implemented | Medium | project maintainers | [req-063](./Interface/req-063_unified-operator-slot-view.md) |
|
| REQ-063 | 운영자 표면 단일화 — slots = 자산 포지션 단일 뷰 | Interface | Implemented | Medium | project maintainers | [req-063](./Interface/req-063_unified-operator-slot-view.md) |
|
||||||
| REQ-064 | 풀 모델 → 영구 자산 슬롯 cutover 마이그레이션 | Migration | Implemented | High | project maintainers | [req-064](./Migration/req-064_pool-to-permanent-slot-cutover.md) |
|
| REQ-064 | 풀 모델 → 자산 중심 DB 재초기화 (구 cutover) | Migration | Implemented | High | project maintainers | [req-064](./Migration/req-064_pool-to-permanent-slot-cutover.md) |
|
||||||
| REQ-065 | 자산 중심 모델 baseline 문서 정합 + 풀 모델 폐기 명시 | Documentation | Implemented | Medium | project maintainers | [req-065](./Documentation/req-065_asset-centric-baseline-alignment.md) |
|
| REQ-065 | 자산 중심 모델 baseline 문서 정합 + 풀 모델 폐기 명시 | Documentation | Implemented | Medium | project maintainers | [req-065](./Documentation/req-065_asset-centric-baseline-alignment.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
@@ -155,3 +155,4 @@
|
|||||||
- 2026-06-09 :: REQ-032 change-req→Implemented (CHG-20260609-01/02, issue #17, BAT-024): `trex-core enter` 에 `--forced-close-at <ISO8601>` (절대시간) 옵션 추가 — 운영자가 매수 진입 명령 하나로 시간 기반 자동청산(forced_close) 시각 지정. CLI 가 buy intent payload 의 기존 `forced_close_at` 필드에 매핑(계약 REQ-007/021·도메인 REQ-066·forced-close 루프 REQ-037 이미 구현·불변, CLI 노출 갭만 보완). 잘못된 형식 제출 전 거부, 미지정 시 미포함(하위호환). 상대시간(`--hold-minutes`) 미채택. AC-5/6 신규. batch-lite(BAT-024) 단위 검증 → **Implemented**. 단위 353 passed, ruff clean. (change-req/confirm-req-implemented)
|
- 2026-06-09 :: REQ-032 change-req→Implemented (CHG-20260609-01/02, issue #17, BAT-024): `trex-core enter` 에 `--forced-close-at <ISO8601>` (절대시간) 옵션 추가 — 운영자가 매수 진입 명령 하나로 시간 기반 자동청산(forced_close) 시각 지정. CLI 가 buy intent payload 의 기존 `forced_close_at` 필드에 매핑(계약 REQ-007/021·도메인 REQ-066·forced-close 루프 REQ-037 이미 구현·불변, CLI 노출 갭만 보완). 잘못된 형식 제출 전 거부, 미지정 시 미포함(하위호환). 상대시간(`--hold-minutes`) 미채택. AC-5/6 신규. batch-lite(BAT-024) 단위 검증 → **Implemented**. 단위 353 passed, ruff clean. (change-req/confirm-req-implemented)
|
||||||
- 2026-06-08 :: REQ-017/019/029 change-req + REQ-030 Approved→Implemented (dcy-018, BAT-023): ledger write 응답의 권위 cash/quantity 를 portfolio 스냅샷에 write-back(option D 활성화) — 매수/매도 직후 "수량 live·현금 stale" 비대칭 제거. `record_trade`→`LedgerWriteOutcome{result, *_after}`+어댑터 `cash_balance_after`/`asset_quantity_after` 파싱(new+duplicate)(REQ-017), `LedgerWriter` SnapshotStore write-back best-effort+`pending_cash_delta`(REQ-019), 추정 현금 오버레이(`cash_estimated` 라벨)+슬롯 오버레이 유지(REQ-029, OQ-1 B/OQ-4 i), option D 활성화로 REQ-030 Approved→Implemented. 전부 change-req(신규 REQ 없음, OQ-3 a). portfolio-hub#8(`*_after`) 동작 중. 단위 350 passed, 인프라 게이트 385 passed, ruff clean. (change-req/confirm-req-implemented)
|
- 2026-06-08 :: REQ-017/019/029 change-req + REQ-030 Approved→Implemented (dcy-018, BAT-023): ledger write 응답의 권위 cash/quantity 를 portfolio 스냅샷에 write-back(option D 활성화) — 매수/매도 직후 "수량 live·현금 stale" 비대칭 제거. `record_trade`→`LedgerWriteOutcome{result, *_after}`+어댑터 `cash_balance_after`/`asset_quantity_after` 파싱(new+duplicate)(REQ-017), `LedgerWriter` SnapshotStore write-back best-effort+`pending_cash_delta`(REQ-019), 추정 현금 오버레이(`cash_estimated` 라벨)+슬롯 오버레이 유지(REQ-029, OQ-1 B/OQ-4 i), option D 활성화로 REQ-030 Approved→Implemented. 전부 change-req(신규 REQ 없음, OQ-3 a). portfolio-hub#8(`*_after`) 동작 중. 단위 350 passed, 인프라 게이트 385 passed, ruff clean. (change-req/confirm-req-implemented)
|
||||||
- 2026-06-10 :: REQ-056~065 Approved → Implemented (confirm-req-implemented): dcy-020 자산 중심 슬롯 모델 4개 배치(BAT-027 α/BAT-028 β/BAT-029 γ/BAT-030 δ, 전부 release-candidate) verification evidence 기준. BAT-030 에서 literal inactive·컬럼 drop·매도 수량 브로커 정본·cutover 도구·baseline 마감 완성, run-the-app 자산 중심 e2e green. 실 운영 cutover(REQ-064)는 REL 단계 운영자 실행.
|
- 2026-06-10 :: REQ-056~065 Approved → Implemented (confirm-req-implemented): dcy-020 자산 중심 슬롯 모델 4개 배치(BAT-027 α/BAT-028 β/BAT-029 γ/BAT-030 δ, 전부 release-candidate) verification evidence 기준. BAT-030 에서 literal inactive·컬럼 drop·매도 수량 브로커 정본·cutover 도구·baseline 마감 완성, run-the-app 자산 중심 e2e green. 실 운영 cutover(REQ-064)는 REL 단계 운영자 실행.
|
||||||
|
- 2026-06-10 :: REQ-064 재정의 (change-req CHG-20260610-03): 데이터 보존 cutover → DB 재초기화(`trex-core-reset`). 마이그레이션 009 부분 유니크 인덱스가 legacy 풀 중복 데이터에 적용 불가한 결함 + dev/페이퍼 컨텍스트 → wipe+자산 중심 재초기화로 대체. 구 `trex-core-cutover` 도구·테스트 삭제, `tests/runtime/test_reset.py` 신규. Status Implemented 유지(reset evidence). (BAT-030)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ dev = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
trex-core-daemon = "trex_core.runtime.daemon:main"
|
trex-core-daemon = "trex_core.runtime.daemon:main"
|
||||||
trex-core = "trex_core.runtime.cli:main"
|
trex-core = "trex_core.runtime.cli:main"
|
||||||
trex-core-cutover = "trex_core.runtime.cutover:main"
|
trex-core-reset = "trex_core.runtime.reset:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=68"]
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
"""Cutover rehearsal tests (dcy-020 / REQ-064, BAT-030 M4).
|
|
||||||
|
|
||||||
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN) — the rehearsal the design
|
|
||||||
requires before the operator runs the live cutover: full rebuild, broker
|
|
||||||
holding carry-over, in-flight abort, unmapped abort/skip, dry-run rollback.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
|
||||||
from trex_core.domain.slot import SlotService, SlotState
|
|
||||||
from trex_core.runtime.cutover import CutoverAbort, run_cutover
|
|
||||||
|
|
||||||
INST = "default"
|
|
||||||
UNIVERSE = [("pf-a", "005930"), ("pf-b", "000660"), ("pf-c", "035720")]
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_rows(dsn):
|
|
||||||
"""Seed pre-cutover state: an old pool-ish slot + a holding slot."""
|
|
||||||
repo = PostgreSQLSlotRepository(dsn)
|
|
||||||
svc = SlotService(repo, instance_key=INST)
|
|
||||||
repo.reconcile_universe(INST, {"pf-old", "pf-b"})
|
|
||||||
held = repo.find_slot_by_portfolio(INST, "pf-b")
|
|
||||||
svc.transit_slot(held.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
|
||||||
reason="r", actor="t", symbol="000660", qty=7.0)
|
|
||||||
svc.transit_slot(held.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
|
||||||
reason="r", actor="t", avg_price=100.0)
|
|
||||||
return repo, svc
|
|
||||||
|
|
||||||
|
|
||||||
def test_full_rebuild_with_broker_carry_over(pg_clean):
|
|
||||||
repo, _ = _legacy_rows(pg_clean)
|
|
||||||
report = run_cutover(
|
|
||||||
pg_clean, INST, UNIVERSE, [("000660", 12)], # broker ground truth qty=12
|
|
||||||
)
|
|
||||||
assert report.deleted == 2 # pf-old + pf-b wiped
|
|
||||||
assert report.created == 3 # one slot per universe asset
|
|
||||||
assert report.holdings == 1
|
|
||||||
slots = {s.portfolio_id: s for s in repo.list_slots(INST)}
|
|
||||||
assert set(slots) == {"pf-a", "pf-b", "pf-c"}
|
|
||||||
assert slots["pf-a"].status is SlotState.INACTIVE
|
|
||||||
held = slots["pf-b"]
|
|
||||||
assert held.status is SlotState.HOLDING
|
|
||||||
assert held.qty == 12.0 # broker qty, NOT the old mirror (7)
|
|
||||||
assert held.avg_price is None # balance API has no cost basis (WARN)
|
|
||||||
assert held.symbol == "000660"
|
|
||||||
# old pf-old row is gone
|
|
||||||
assert repo.find_slot_by_portfolio(INST, "pf-old") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_aborts_on_in_flight_pending(pg_clean):
|
|
||||||
repo = PostgreSQLSlotRepository(pg_clean)
|
|
||||||
svc = SlotService(repo, instance_key=INST)
|
|
||||||
repo.reconcile_universe(INST, {"pf-a"})
|
|
||||||
s = repo.find_slot_by_portfolio(INST, "pf-a")
|
|
||||||
svc.transit_slot(s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
|
||||||
reason="r", actor="t", symbol="005930")
|
|
||||||
with pytest.raises(CutoverAbort, match="in-flight"):
|
|
||||||
run_cutover(pg_clean, INST, UNIVERSE, [])
|
|
||||||
# nothing was changed
|
|
||||||
assert repo.fetch_slot(s.slot_id).status is SlotState.PENDING_BUY
|
|
||||||
|
|
||||||
|
|
||||||
def test_aborts_on_unmapped_broker_holding(pg_clean):
|
|
||||||
with pytest.raises(CutoverAbort, match="outside the universe"):
|
|
||||||
run_cutover(pg_clean, INST, UNIVERSE, [("999999", 3)])
|
|
||||||
# DB untouched (abort happened before any write)
|
|
||||||
repo = PostgreSQLSlotRepository(pg_clean)
|
|
||||||
assert repo.list_slots(INST) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_allow_unmapped_skips_with_warning(pg_clean):
|
|
||||||
report = run_cutover(
|
|
||||||
pg_clean, INST, UNIVERSE, [("999999", 3), ("005930", 5)],
|
|
||||||
allow_unmapped=True,
|
|
||||||
)
|
|
||||||
assert report.unmapped == ["999999"]
|
|
||||||
assert report.holdings == 1 # only the mapped one carried over
|
|
||||||
repo = PostgreSQLSlotRepository(pg_clean)
|
|
||||||
held = repo.find_slot_by_portfolio(INST, "pf-a")
|
|
||||||
assert held.status is SlotState.HOLDING and held.qty == 5.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_dry_run_rolls_back(pg_clean):
|
|
||||||
repo, _ = _legacy_rows(pg_clean)
|
|
||||||
before = {s.slot_id for s in repo.list_slots(INST)}
|
|
||||||
report = run_cutover(pg_clean, INST, UNIVERSE, [("000660", 12)], dry_run=True)
|
|
||||||
assert report.dry_run and report.created == 3
|
|
||||||
after = {s.slot_id for s in repo.list_slots(INST)}
|
|
||||||
assert after == before # rolled back — nothing changed
|
|
||||||
|
|
||||||
|
|
||||||
def test_instance_isolation_other_instance_untouched(pg_clean):
|
|
||||||
other = PostgreSQLSlotRepository(pg_clean)
|
|
||||||
other.reconcile_universe("inst-other", {"pf-z"})
|
|
||||||
run_cutover(pg_clean, INST, UNIVERSE, [])
|
|
||||||
assert other.find_slot_by_portfolio("inst-other", "pf-z") is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_promote_unique_sets_not_null(pg_clean):
|
|
||||||
import psycopg
|
|
||||||
run_cutover(pg_clean, INST, UNIVERSE, [], promote_unique=True)
|
|
||||||
with psycopg.connect(pg_clean) as conn:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT is_nullable FROM information_schema.columns "
|
|
||||||
"WHERE table_name='daily_slot' AND column_name='portfolio_id'"
|
|
||||||
)
|
|
||||||
assert cur.fetchone()[0] == "NO"
|
|
||||||
# restore for other tests (pg_clean truncates but keeps schema)
|
|
||||||
cur.execute("ALTER TABLE daily_slot ALTER COLUMN portfolio_id DROP NOT NULL")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_rows_written(pg_clean):
|
|
||||||
import psycopg
|
|
||||||
run_cutover(pg_clean, INST, UNIVERSE, [("005930", 5)])
|
|
||||||
with psycopg.connect(pg_clean) as conn:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT count(*) FROM slot_transition_log WHERE reason='cutover_rebuild'"
|
|
||||||
)
|
|
||||||
assert int(cur.fetchone()[0]) == 3
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""DB reset tool tests (dcy-020 / REQ-064, BAT-030 M4).
|
||||||
|
|
||||||
|
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN) — the wipe-and-reinitialize tool
|
||||||
|
that replaced the data-preserving cutover: wipes the slot-core tables and
|
||||||
|
reapplies migrations so the DB lands on the clean asset-centric schema.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||||
|
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, SlotService, SlotState
|
||||||
|
from trex_core.runtime.reset import SLOT_CORE_TABLES, run_reset
|
||||||
|
|
||||||
|
INST = "default"
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(dsn):
|
||||||
|
"""Create asset slots + drive one into holding so there are rows to wipe."""
|
||||||
|
repo = PostgreSQLSlotRepository(dsn)
|
||||||
|
svc = SlotService(repo, instance_key=INST)
|
||||||
|
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||||
|
s = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||||
|
svc.transit_slot(s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||||
|
reason="r", actor="t", symbol="005930", qty=10)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_wipes_and_lands_asset_centric(pg_clean):
|
||||||
|
repo = _seed(pg_clean)
|
||||||
|
assert len(repo.list_slots(INST)) == 2
|
||||||
|
|
||||||
|
report = run_reset(pg_clean, dry_run=False)
|
||||||
|
|
||||||
|
assert report.dry_run is False
|
||||||
|
assert report.schema_ok is True
|
||||||
|
assert report.wiped["daily_slot"] == 2
|
||||||
|
assert repo.list_slots(INST) == [] # wiped
|
||||||
|
# schema is the clean asset-centric form
|
||||||
|
import psycopg
|
||||||
|
with psycopg.connect(pg_clean) as c, c.cursor() as cur:
|
||||||
|
cur.execute("SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name='daily_slot'")
|
||||||
|
cols = {r[0] for r in cur.fetchall()}
|
||||||
|
assert cols == set(EXPECTED_SLOT_FIELDS)
|
||||||
|
cur.execute("SELECT pg_get_constraintdef(oid) FROM pg_constraint "
|
||||||
|
"WHERE conname='daily_slot_status_check'")
|
||||||
|
assert "inactive" in cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT indexname FROM pg_indexes WHERE tablename='daily_slot'")
|
||||||
|
assert "uq_daily_slot_instance_portfolio" in {r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_reports_without_wiping(pg_clean):
|
||||||
|
repo = _seed(pg_clean)
|
||||||
|
report = run_reset(pg_clean, dry_run=True)
|
||||||
|
assert report.dry_run is True
|
||||||
|
assert report.wiped["daily_slot"] == 2 # reported
|
||||||
|
assert len(repo.list_slots(INST)) == 2 # but NOT wiped
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_is_idempotent(pg_clean):
|
||||||
|
_seed(pg_clean)
|
||||||
|
run_reset(pg_clean, dry_run=False)
|
||||||
|
report2 = run_reset(pg_clean, dry_run=False) # second pass on clean DB
|
||||||
|
assert report2.schema_ok is True
|
||||||
|
assert report2.wiped["daily_slot"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_usable_for_admission_after_wipe(pg_clean):
|
||||||
|
"""After reset the DB is immediately usable: reconcile + activate work."""
|
||||||
|
_seed(pg_clean)
|
||||||
|
run_reset(pg_clean, dry_run=False)
|
||||||
|
repo = PostgreSQLSlotRepository(pg_clean)
|
||||||
|
svc = SlotService(repo, instance_key=INST)
|
||||||
|
repo.reconcile_universe(INST, {"pf-x"})
|
||||||
|
s = repo.find_slot_by_portfolio(INST, "pf-x")
|
||||||
|
assert s.status is SlotState.INACTIVE
|
||||||
|
svc.transit_slot(s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||||
|
reason="r", actor="t", symbol="005930")
|
||||||
|
assert repo.count_active(INST) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_covers_all_slot_core_tables(pg_clean):
|
||||||
|
"""Sanity: the tool targets every slot-core table the migrations create."""
|
||||||
|
import psycopg
|
||||||
|
with psycopg.connect(pg_clean) as c, c.cursor() as cur:
|
||||||
|
cur.execute("SELECT table_name FROM information_schema.tables "
|
||||||
|
"WHERE table_schema='public' AND table_name = ANY(%s)",
|
||||||
|
(list(SLOT_CORE_TABLES),))
|
||||||
|
present = {r[0] for r in cur.fetchall()}
|
||||||
|
assert present == set(SLOT_CORE_TABLES)
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""Asset-centric cutover tool (dcy-020 / REQ-064, BAT-030).
|
|
||||||
|
|
||||||
Full rebuild (design D1): wipe this instance's daily_slot rows and recreate
|
|
||||||
them from the portfolio-hub universe + the broker balance, with the broker as
|
|
||||||
the single source of truth for carried-over holdings (consistent with D3).
|
|
||||||
|
|
||||||
Procedure (one transaction):
|
|
||||||
1. preflight — abort if any pending_buy/pending_sell rows exist for the
|
|
||||||
instance (in-flight orders must converge first; run outside the trading
|
|
||||||
session).
|
|
||||||
2. DELETE all of the instance's slot rows.
|
|
||||||
3. one permanent slot per universe asset, status `inactive`.
|
|
||||||
4. each broker holding (symbol, qty) is mapped symbol→portfolio_id via the
|
|
||||||
universe and its slot is set to `holding` (qty = broker quantity;
|
|
||||||
avg_price is left NULL — the balance API does not carry it — WARN).
|
|
||||||
5. a broker holding whose symbol is NOT in the universe aborts the cutover
|
|
||||||
(operator decision; `--allow-unmapped` downgrades to WARN+skip).
|
|
||||||
6. audit rows in slot_transition_log (reason `cutover_rebuild`).
|
|
||||||
7. optional `--promote-unique`: portfolio_id SET NOT NULL (only after ALL
|
|
||||||
instances sharing the DB have been cut over).
|
|
||||||
|
|
||||||
The real cutover is OPERATOR-RUN (design D4): `trex-core-cutover --config
|
|
||||||
<instance> --yes` after a rehearsal against a test DB. Rollback = restore the
|
|
||||||
pre-cutover backup + previous release (BAT-030 design §4).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Iterable, Optional
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CutoverAbort(RuntimeError):
|
|
||||||
"""Preflight/mapping condition that must stop the cutover."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CutoverReport:
|
|
||||||
instance_key: str
|
|
||||||
deleted: int = 0
|
|
||||||
created: int = 0
|
|
||||||
holdings: int = 0
|
|
||||||
unmapped: list[str] = field(default_factory=list)
|
|
||||||
promoted_unique: bool = False
|
|
||||||
dry_run: bool = False
|
|
||||||
|
|
||||||
def summary(self) -> str:
|
|
||||||
return (
|
|
||||||
f"cutover[{self.instance_key}] deleted={self.deleted} "
|
|
||||||
f"created={self.created} holdings={self.holdings} "
|
|
||||||
f"unmapped={self.unmapped or '[]'} "
|
|
||||||
f"promote_unique={self.promoted_unique} dry_run={self.dry_run}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_cutover(
|
|
||||||
dsn: str,
|
|
||||||
instance_key: str,
|
|
||||||
universe: Iterable[tuple[str, str]],
|
|
||||||
balances: Iterable[tuple[str, int]],
|
|
||||||
*,
|
|
||||||
allow_unmapped: bool = False,
|
|
||||||
promote_unique: bool = False,
|
|
||||||
dry_run: bool = False,
|
|
||||||
psycopg_module: Any = None,
|
|
||||||
) -> CutoverReport:
|
|
||||||
"""Rebuild the instance's slots from `universe` + `balances` atomically.
|
|
||||||
|
|
||||||
universe: iterable of (portfolio_id, symbol).
|
|
||||||
balances: iterable of (symbol, hldg_qty) — broker ground truth.
|
|
||||||
Raises CutoverAbort on in-flight pending rows or unmapped broker holdings
|
|
||||||
(unless allow_unmapped). dry_run rolls the transaction back at the end.
|
|
||||||
"""
|
|
||||||
if psycopg_module is None:
|
|
||||||
import psycopg # type: ignore[import-not-found]
|
|
||||||
psycopg_module = psycopg
|
|
||||||
|
|
||||||
uni = [(str(pid), str(sym)) for pid, sym in universe if pid]
|
|
||||||
by_symbol = {sym: pid for pid, sym in uni}
|
|
||||||
held = [(str(sym), int(qty)) for sym, qty in balances if int(qty) > 0]
|
|
||||||
report = CutoverReport(instance_key=instance_key, dry_run=dry_run)
|
|
||||||
|
|
||||||
# mapping check before touching the DB (step 5)
|
|
||||||
unmapped = [sym for sym, _ in held if sym not in by_symbol]
|
|
||||||
if unmapped:
|
|
||||||
if not allow_unmapped:
|
|
||||||
raise CutoverAbort(
|
|
||||||
f"broker holds symbols outside the universe: {unmapped} "
|
|
||||||
"(resolve in portfolio-hub or pass --allow-unmapped to skip them)"
|
|
||||||
)
|
|
||||||
report.unmapped = unmapped
|
|
||||||
for sym in unmapped:
|
|
||||||
_log.warning(
|
|
||||||
"cutover: broker holding %s has no universe portfolio — SKIPPED "
|
|
||||||
"(unmanaged position; operator attention)", sym,
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
with psycopg_module.connect(dsn, autocommit=False) as conn:
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
# 1. preflight: no in-flight pending rows for this instance
|
|
||||||
cur.execute(
|
|
||||||
"SELECT count(*) FROM daily_slot WHERE instance_key = %s "
|
|
||||||
"AND status IN ('pending_buy','pending_sell')",
|
|
||||||
(instance_key,),
|
|
||||||
)
|
|
||||||
pending = int(cur.fetchone()[0])
|
|
||||||
if pending:
|
|
||||||
raise CutoverAbort(
|
|
||||||
f"{pending} in-flight pending slot(s) for instance "
|
|
||||||
f"{instance_key!r} — wait for convergence (run outside "
|
|
||||||
"the trading session) and retry"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. wipe the instance's rows (full rebuild, D1)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM daily_slot WHERE instance_key = %s", (instance_key,)
|
|
||||||
)
|
|
||||||
report.deleted = cur.rowcount
|
|
||||||
|
|
||||||
# 3+4. recreate: one permanent slot per universe asset
|
|
||||||
holding_pids = {}
|
|
||||||
for sym, qty in held:
|
|
||||||
pid = by_symbol.get(sym)
|
|
||||||
if pid is not None:
|
|
||||||
holding_pids[pid] = (sym, qty)
|
|
||||||
for pid, sym in uni:
|
|
||||||
sid = uuid4()
|
|
||||||
if pid in holding_pids:
|
|
||||||
h_sym, h_qty = holding_pids[pid]
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO daily_slot (slot_id, status, symbol, qty, "
|
|
||||||
"instance_key, portfolio_id, created_at, updated_at) "
|
|
||||||
"VALUES (%s, 'holding', %s, %s, %s, %s, %s, %s)",
|
|
||||||
(sid, h_sym, h_qty, instance_key, pid, now, now),
|
|
||||||
)
|
|
||||||
report.holdings += 1
|
|
||||||
_log.warning(
|
|
||||||
"cutover: %s carried over as holding qty=%d "
|
|
||||||
"avg_price=NULL (balance API has no cost basis)",
|
|
||||||
pid, h_qty,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO daily_slot (slot_id, status, symbol, "
|
|
||||||
"instance_key, portfolio_id, created_at, updated_at) "
|
|
||||||
"VALUES (%s, 'inactive', %s, %s, %s, %s, %s)",
|
|
||||||
(sid, sym, instance_key, pid, now, now),
|
|
||||||
)
|
|
||||||
report.created += 1
|
|
||||||
# 6. audit trail
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO slot_transition_log "
|
|
||||||
"(slot_id, from_state, to_state, ts, reason, actor) "
|
|
||||||
"VALUES (%s, 'inactive', %s, %s, 'cutover_rebuild', 'cutover')",
|
|
||||||
(sid, "holding" if pid in holding_pids else "inactive", now),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 7. optional promotion (all instances cut over)
|
|
||||||
if promote_unique:
|
|
||||||
cur.execute(
|
|
||||||
"ALTER TABLE daily_slot ALTER COLUMN portfolio_id SET NOT NULL"
|
|
||||||
)
|
|
||||||
report.promoted_unique = True
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
conn.rollback()
|
|
||||||
else:
|
|
||||||
conn.commit()
|
|
||||||
except BaseException:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI shell
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
|
||||||
from trex_core.runtime.config import DaemonConfig, resolve_config_toml
|
|
||||||
from trex_core.runtime.daemon import _build_broker, _build_portfolio_read
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="trex-core-cutover",
|
|
||||||
description="dcy-020/REQ-064: pool→asset-centric full-rebuild cutover "
|
|
||||||
"(operator-run; rehearse on a test DB first).",
|
|
||||||
)
|
|
||||||
parser.add_argument("--config", required=True, help="instance TOML name/path")
|
|
||||||
parser.add_argument("--config-path", default=None)
|
|
||||||
parser.add_argument("--env-file", default=None)
|
|
||||||
parser.add_argument("--yes", action="store_true",
|
|
||||||
help="actually apply (otherwise dry-run + rollback)")
|
|
||||||
parser.add_argument("--allow-unmapped", action="store_true",
|
|
||||||
help="skip (WARN) broker holdings outside the universe "
|
|
||||||
"instead of aborting")
|
|
||||||
parser.add_argument("--promote-unique", action="store_true",
|
|
||||||
help="portfolio_id SET NOT NULL (only after ALL "
|
|
||||||
"instances sharing this DB are cut over)")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
toml_path = resolve_config_toml(args.config, args.config_path)
|
|
||||||
config = DaemonConfig.from_config(toml_path, env_file=args.env_file)
|
|
||||||
apply_migrations(config.dsn)
|
|
||||||
|
|
||||||
read = _build_portfolio_read(config)
|
|
||||||
daemon_id = read.whoami()
|
|
||||||
items = read.get_portfolios(daemon_id)
|
|
||||||
universe = [(it.portfolio_id, it.symbol) for it in items]
|
|
||||||
broker = _build_broker(config)
|
|
||||||
balances = [(b.symbol, b.hldg_qty) for b in broker.get_balance()]
|
|
||||||
|
|
||||||
try:
|
|
||||||
report = run_cutover(
|
|
||||||
config.dsn, config.instance_key, universe, balances,
|
|
||||||
allow_unmapped=args.allow_unmapped,
|
|
||||||
promote_unique=args.promote_unique and args.yes,
|
|
||||||
dry_run=not args.yes,
|
|
||||||
)
|
|
||||||
except CutoverAbort as exc:
|
|
||||||
_log.error("cutover ABORTED: %s", exc)
|
|
||||||
return 2
|
|
||||||
_log.info("%s", report.summary())
|
|
||||||
if not args.yes:
|
|
||||||
_log.info("dry-run only — re-run with --yes to apply")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Asset-centric DB (re)initialization tool (dcy-020 / REQ-064, BAT-030).
|
||||||
|
|
||||||
|
Replaces the data-preserving pool→permanent cutover: instead of migrating
|
||||||
|
legacy pool rows, this **wipes the slot-core tables and (re)applies the
|
||||||
|
migrations** so the database lands on the clean asset-centric schema. Use it
|
||||||
|
to stand up a new instance's database or to reset a dev / superseded
|
||||||
|
pool-model database to the asset-centric substrate.
|
||||||
|
|
||||||
|
Why wipe instead of migrate (the decision that retired the cutover):
|
||||||
|
migration 009's partial `UNIQUE(instance_key, portfolio_id) WHERE portfolio_id
|
||||||
|
IS NOT NULL` cannot be applied to legacy pool data, because in the pool model
|
||||||
|
the same asset legitimately occupied more than one slot (a `closed` row from a
|
||||||
|
prior cycle plus a re-entered `holding` row) — so `portfolio_id` repeats and
|
||||||
|
the unique index collides. On an **empty** table the index applies cleanly and
|
||||||
|
`(instance_key, portfolio_id)` is unique by construction thereafter.
|
||||||
|
|
||||||
|
After reset, the daemon's universe reconcile (REQ-058) builds the permanent
|
||||||
|
asset slots (`inactive`); broker holdings surface through
|
||||||
|
`reconcile_core_holdings` (REQ-062) for operator adoption.
|
||||||
|
|
||||||
|
Destructive and DB-wide (every instance's rows). OPERATOR-RUN, dry-run by
|
||||||
|
default; pass `--yes` to apply. Take a backup first.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# slot-core tables owned by trex-core migrations (drop order irrelevant — no FKs).
|
||||||
|
SLOT_CORE_TABLES: tuple[str, ...] = (
|
||||||
|
"daily_slot",
|
||||||
|
"daily_symbol_entries",
|
||||||
|
"slot_transition_log",
|
||||||
|
"intent_log",
|
||||||
|
"ledger_outbox",
|
||||||
|
"snapshot_pause",
|
||||||
|
"killswitch_exclusion",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResetReport:
|
||||||
|
db: str
|
||||||
|
wiped: dict[str, int] = field(default_factory=dict) # table -> rows removed
|
||||||
|
migrations_applied: list[str] = field(default_factory=list)
|
||||||
|
schema_ok: bool = False
|
||||||
|
dry_run: bool = True
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
total = sum(self.wiped.values())
|
||||||
|
verb = "would wipe" if self.dry_run else "wiped"
|
||||||
|
return (
|
||||||
|
f"reset[{self.db}] {verb} {total} rows across {len(self.wiped)} tables "
|
||||||
|
f"{self.wiped} | migrations={len(self.migrations_applied)} "
|
||||||
|
f"schema_ok={self.schema_ok} dry_run={self.dry_run}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_tables(cur) -> set[str]:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT table_name FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = 'public' AND table_name = ANY(%s)",
|
||||||
|
(list(SLOT_CORE_TABLES),),
|
||||||
|
)
|
||||||
|
return {r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def run_reset(
|
||||||
|
dsn: str,
|
||||||
|
*,
|
||||||
|
dry_run: bool = True,
|
||||||
|
psycopg_module: Any = None,
|
||||||
|
) -> ResetReport:
|
||||||
|
"""Wipe the slot-core tables and reapply migrations to the asset-centric schema.
|
||||||
|
|
||||||
|
dry_run (default): report the row counts that WOULD be wiped, change nothing.
|
||||||
|
apply (dry_run=False): TRUNCATE existing slot-core tables, then
|
||||||
|
`apply_migrations(dsn)` so the schema is the clean asset-centric form, then
|
||||||
|
verify `daily_slot` columns == `EXPECTED_SLOT_FIELDS`.
|
||||||
|
"""
|
||||||
|
if psycopg_module is None:
|
||||||
|
import psycopg # type: ignore[import-not-found]
|
||||||
|
psycopg_module = psycopg
|
||||||
|
|
||||||
|
with psycopg_module.connect(dsn, autocommit=True) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT current_database()")
|
||||||
|
report = ResetReport(db=cur.fetchone()[0], dry_run=dry_run)
|
||||||
|
existing = _existing_tables(cur)
|
||||||
|
for t in SLOT_CORE_TABLES:
|
||||||
|
if t in existing:
|
||||||
|
cur.execute(f"SELECT count(*) FROM {t}")
|
||||||
|
report.wiped[t] = int(cur.fetchone()[0])
|
||||||
|
if dry_run:
|
||||||
|
return report
|
||||||
|
if existing:
|
||||||
|
cur.execute(
|
||||||
|
f"TRUNCATE {', '.join(existing)} RESTART IDENTITY CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reapply migrations on the now-empty DB: 009's unique index applies cleanly,
|
||||||
|
# 010 sets the inactive CHECK, 011 drops the pool-model columns.
|
||||||
|
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||||
|
|
||||||
|
report.migrations_applied = apply_migrations(dsn, psycopg_module=psycopg_module)
|
||||||
|
|
||||||
|
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS
|
||||||
|
|
||||||
|
with psycopg_module.connect(dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'daily_slot'"
|
||||||
|
)
|
||||||
|
cols = {r[0] for r in cur.fetchall()}
|
||||||
|
report.schema_ok = cols == set(EXPECTED_SLOT_FIELDS)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI shell
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from trex_core.runtime.config import DaemonConfig, resolve_config_toml
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="trex-core-reset",
|
||||||
|
description="dcy-020/REQ-064: wipe the slot-core tables and reinitialize "
|
||||||
|
"the DB to the asset-centric schema (dry-run by default). "
|
||||||
|
"Destructive and DB-wide — back up first.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--config", required=True, help="instance TOML name/path (DSN source)")
|
||||||
|
parser.add_argument("--config-path", default=None)
|
||||||
|
parser.add_argument("--env-file", default=None)
|
||||||
|
parser.add_argument("--yes", action="store_true",
|
||||||
|
help="actually wipe + reinitialize (otherwise dry-run)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
toml_path = resolve_config_toml(args.config, args.config_path)
|
||||||
|
config = DaemonConfig.from_config(toml_path, env_file=args.env_file)
|
||||||
|
|
||||||
|
report = run_reset(config.dsn, dry_run=not args.yes)
|
||||||
|
_log.warning("%s", report.summary())
|
||||||
|
if not args.yes:
|
||||||
|
_log.info("dry-run only — re-run with --yes to wipe and reinitialize")
|
||||||
|
elif not report.schema_ok:
|
||||||
|
_log.error("schema verification FAILED after reset — inspect manually")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user