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:
2026-06-10 16:08:05 +09:00
co-authored by Claude Opus 4.8
parent 5330f32247
commit c04fd276b3
15 changed files with 335 additions and 405 deletions
@@ -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 갱신(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` 상태 소멸 대응).
- **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.
- **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) 자산 중심으로 갱신.
## 변경 내역 (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/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/runtime/: daemon.py(7 스레드) · config.py · cli.py · **cutover.py(신규)**
- pyproject.toml: `trex-core-cutover` console script
- trex_core/runtime/: daemon.py(7 스레드) · config.py · cli.py · **reset.py(신규)** · ~~cutover.py(삭제)~~
- pyproject.toml: `trex-core-reset` console script (구 `trex-core-cutover` 제거)
- 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}
- 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
- 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 (실 브로커)
- 통합(전부 enabled): **405 passed, 10 skipped**(KIS sandbox — 기존 환경 이슈, 본 배치 무관)
- 통합(PG+RabbitMQ enabled): **411 passed, 1 skipped**(KIS sandbox — 기존 환경 이슈, 본 배치 무관)
## 검증 실행 (run-the-app, 자산 중심 e2e)
@@ -45,7 +45,8 @@ test DB(trex_core_test) + 실 RabbitMQ + paper broker + stub universe(2 자산)
## Remaining Risks / Blockers
- **실 cutover 미실행(의도)** — D4: 운영자가 REL 단계에서 리허설 후 `trex-core-cutover --config <instance> --yes` 실행. 사전 백업 + 거래 세션 외 + in-flight 0 필수. 롤백 = 백업 복원 + 직전 리비전.
- cutover carry-over 의 `avg_price=NULL` — 잔고 API 미제공. SL/TP 는 절대가 기준이라 안전 매도 무영향이나, 운영자/전략이 필요 시 후속 갱신.
- **실 DB 재초기화 미실행(의도)** — 운영자가 `trex-core-reset --config <instance> --yes` 실행(파괴적·DB 전역). 사전 백업 필수. 재초기화라 롤백 = 백업 복원.
- 데이터 보존 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 노트 대상).
- `max_active_slots` 미설정 = 무캡 — 운영 TOML 에 반드시 설정(배포 체크리스트, design §5).
@@ -30,3 +30,4 @@
- Source discovery 단일(dcy-020). BAT-027/028/029 후속, dcy-020 최종.
- **최고 위험**: 라이브 데이터·집행 전환. **실 cutover 는 운영자 실행**(본 배치는 마이그레이션·코드·리허설 제공). design 체크포인트에서 cutover 절차·롤백·운영자 입회·전환 순서 확정 후 구현.
- 본 배치 완료 후 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
- 실 cutover 는 운영자 실행(본 배치는 마이그레이션·코드·리허설 제공). confirm-release 전 cutover 리허설 + run-the-app evidence 필수.
- 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, 스키마 마감)
| 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-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 + 별칭 제거)
| 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-4 수량 불일치 → 상태 전이 없음·warning 노출 | pass (BAT-029 기구현; 미러 자동 갱신 없이 WARN — 보수 구현) | `test_C_qty_mismatch_warns_no_state_change` |
### REQ-064 (Migration — cutover + 롤백)
| AC | 판정 | Evidence |
### REQ-064 (Migration → **DB 재초기화로 재정의**, CHG-20260610-03)
원 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-2 cutover 후 데몬 회귀 없음(reconcile·admission·safety-sell) | pass | run-the-app e2e(아래) + 단위 367/PG 29 green |
| AC-3 실패 시 롤백 가능 | pass (계획+부분 검증) | 단일 트랜잭션(중단 시 무변경: `test_aborts_on_in_flight_pending`/`test_aborts_on_unmapped_broker_holding` DB 불변 단언) + `test_dry_run_rolls_back`; 전체 롤백 = 백업 복원 + 직전 리비전(design §롤백, 운영 절차) |
| AC-4 instance_key 태깅·스코프 쿼리 정확 | pass | cutover 가 instance_key 로 생성 + `test_instance_isolation_other_instance_untouched` |
| AC-1' 재초기화 시 slot-core 테이블 wipe + 자산 중심 스키마 착지 | pass | `test_reset_wipes_and_lands_asset_centric`(2행 → 0행, 컬럼==EXPECTED, 유니크 인덱스+inactive CHECK 존재) |
| AC-2' 재초기화 후 데몬 회귀 없음(reconcile·admission) | pass | `test_reset_usable_for_admission_after_wipe`(reset 후 reconcile+활성 동작) + run-the-app e2e(아래) |
| AC-3' 안전성: dry-run 무변경 + 멱등 | pass | `test_dry_run_reports_without_wiping`(행 보존) + `test_reset_is_idempotent`(2회차 0행·schema_ok) |
| AC-4' 데이터 보존(보유 carry-over) 폐기 — 보유는 reconcile 로 재편입 | pass(의도된 범위 축소) | CHG-20260610-03; 재초기화 후 브로커 보유는 `reconcile_core_holdings`(REQ-062) unmanaged 경로 |
| (참고) 백업/복원 | 운영 절차 | reset 은 재초기화라 롤백 = 백업 복원. 실행 전 백업 필수(release 게이트) |
### REQ-065 (Documentation — baseline 마감)
| AC | 판정 | Evidence |
@@ -49,16 +51,16 @@
## Evidence
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.
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 포함.
## 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
- 포함 REQ AC 전 항목 evidence 연결 완료(REQ-062 AC-3/4 는 issue #15 무자동조치 결정에 따른 보수 구현 — 의도된 편차로 기록).
- **Ready for Release: true** (배치 산출물 기준 — 코드·마이그레이션·cutover 도구·리허설·baseline. 실 cutover 실행은 REL 단계 운영자 작업으로 release 게이트에 위임)
- 포함 REQ AC 전 항목 evidence 연결 완료. REQ-064 는 데이터 보존 cutover → **DB 재초기화로 재정의**(CHG-20260610-03, 마이그레이션 009 결함 + dev/페이퍼 컨텍스트)되어 재정의 AC 로 검증. REQ-062 AC-3/4 는 issue #15 무자동조치 결정에 따른 보수 구현(의도된 편차).
- **Ready for Release: true** (배치 산출물 기준 — 코드·마이그레이션·reset 도구·테스트·baseline. 실 DB 재초기화 실행은 REL 단계 운영자 작업으로 release 게이트에 위임)
+4 -4
View File
@@ -11,7 +11,7 @@
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
## 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
- 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
- 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`)
- status :: SlotState :: 5 상태(inactive/pending_buy/holding/pending_sell/error) 중 현재 상태 — CHECK 제약(마이그레이션 010)
- symbol :: str? :: 점유 종목
@@ -179,7 +179,7 @@
- `daily_symbol_entries` 테이블은 폐기 상태로 잔존(코드 경로 없음) — 후속 정리 시 DROP 가능.
- daily_slot_archive(EOD 아카이브)와 retention prune 은 범위 밖 유지.
- 포지션 사이징(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
@@ -193,4 +193,4 @@
- 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-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).
+4 -4
View File
@@ -11,7 +11,7 @@
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
## Primary Domain
- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 cutover 로 폐기)
- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 에서 폐기, DB 는 `trex-core-reset` 로 재초기화)
## Tech Stack
- Python >=3.12
@@ -25,7 +25,7 @@
- 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` (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)
- SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용)
- MessagingTransport (Protocol) :: transport 추상화 (RabbitMQ / InMemory stub)
@@ -90,7 +90,7 @@
│ └── 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)
│ ├── 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)
└── tests/
├── 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-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-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).
+2 -2
View File
@@ -22,7 +22,7 @@
## Covered Entry Points
- `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.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, 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-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-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
- Type: Migration
@@ -6,25 +6,26 @@
- Owner: project maintainers
## 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
- 기존 일일 `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
1. Given 기존 풀 모델 데이터(보유 N개 포함), When cutover 적용, Then 각 보유 자산이 영구 슬롯 1행(holding)으로 보존되고 수량/임계값 등 보호 컨텍스트가 유실되지 않는다.
2. Given cutover 후, When 데몬 기동, Then universe reconcile(REQ-058)·admission(REQ-059)·safety-sell 이 회귀 없이 동작한다.
3. Given cutover 실패, When 롤백, Then 직전 released 리비전/스키마로 복원 가능하다(롤백 계획 검증).
4. Given slot-scoped 기존 행, When cutover, Then `instance_key` 태깅이 적용되어 인스턴스 스코프 쿼리가 정확하다.
1. Given 기존 풀 모델(또는 임의 상태) DB, When `trex-core-reset --yes`, Then slot-core 테이블이 wipe 되고 `daily_slot` 컬럼셋이 `EXPECTED_SLOT_FIELDS`(자산 중심)와 일치하며 부분 유니크 인덱스·inactive CHECK 가 존재한다.
2. Given 재초기화 후, When 데몬 기동, Then universe reconcile(REQ-058)·admission(REQ-059)·safety-sell 이 회귀 없이 동작한다.
3. Given 안전성, When dry-run 실행, Then 어떤 행도 변경되지 않고 wipe 대상 행수만 보고된다. 재초기화는 멱등이다(2회차도 0행·schema_ok).
4. Given 보유 포지션, When 재초기화(데이터 보존 폐기), Then 보유는 소실되며 재초기화 후 브로커 보유가 `reconcile_core_holdings`(REQ-062)의 unmanaged 경로로 표면화되어 운영자가 재편입한다. 롤백 = 사전 백업 복원.
## Impacted Area
- Docs: docs/runtime-flows.md(cutover 절차), 릴리즈 Rollback Plan
- Modules: trex_core/adapters/persistence/sql/(cutover 마이그레이션), 이행 스크립트
- Tests: tests/adapters/persistence/(cutover round-trip), 회귀 스위트
- Docs: docs/runtime-flows.md(재초기화 절차), 릴리즈 Rollback Plan(백업 복원)
- Modules: trex_core/runtime/reset.py, trex_core/adapters/persistence/sql/(009~011)
- Tests: tests/runtime/test_reset.py(PG-gated), 회귀 스위트
## Notes
- 라이브 데이터 영향 — 배포 게이트·운영자 입회 권장. 실행은 별도 배치(BAT-δ)로 분리하고 confirm-release 전 run-the-app/cutover 리허설 evidence 필요.
- 출처: dcy-020 NFR-2, R-1.
- 라이브 데이터 영향 — 파괴적·DB 전역. 배포 게이트·운영자 입회·**사전 백업 필수**. 실행은 release(REL) 단계 운영자 작업으로 confirm-release 전 백업 evidence 필요.
- 구 cutover 도구(`trex-core-cutover`/`runtime/cutover.py`)는 폐기·삭제(CHG-20260610-03).
- 출처: dcy-020 NFR-2, R-1 + 마이그레이션 009 legacy 충돌 결함.
## Change Log
@@ -86,8 +87,40 @@
#### Validation Plan
- 승인 후 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
- Date: 2026-06-10
- Author: Justin Ko
- 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
View File
@@ -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-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-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) |
## 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-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-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
View File
@@ -20,7 +20,7 @@ dev = [
[project.scripts]
trex-core-daemon = "trex_core.runtime.daemon: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]
requires = ["setuptools>=68"]
-124
View File
@@ -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
+89
View File
@@ -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)
-235
View File
@@ -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())
+156
View File
@@ -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())