From f0ad37747b4e9d43dc798bfeda5b543cab048890 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 11 Jun 2026 14:45:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(rel-007):=20dcy-025/024=20+=20BAT-035/036?= =?UTF-8?q?=20=E2=80=94=20slots.req=20=EC=A6=89=EC=8B=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20+=20safety=5Fsell=5Fenabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BAT-035 (dcy-025): trex.snapshot.req → trex.slots.req 즉시 교체 - topology: SNAPSHOT_REQ_QUEUE 제거, SLOTS_REQ_QUEUE = "trex.slots.req" 추가 - portfolio.py: View/build_snapshot_envelope/SnapshotEnvelope/_asset_to_dict 제거; slot_to_dict 시그니처 확장(snapshot_store/watchdog_store/pause_store/watchdog_timeout_sec) — asset_quantity(hub)/cash_balance/cash_estimated/warned/watchdog{}/paused 병합 (REQ-074) - messaging_handlers.py: make_snapshot_rpc_handler 제거, make_slots_rpc_handler 추가 — filter(all/inactive/pending/holding/error) + spy(bool) 처리; strategy 필드 제외 (REQ-073/074) - daemon.py: _snapshot_rpc_loop → _slots_rpc_loop, make_slots_rpc_handler 연결 - cli.py: universe/slots → slots_queue + spy=True + server-side filter; buyable→inactive 매핑 (REQ-063) - rabbitmq.py: snapshot_queue → slots_queue - docs: interface-contract.md + runtime-flows.md slots.req 계약으로 업데이트 (REQ-075) - REQ-028: Implemented → Deprecated (코드 삭제 완료) - 테스트: test_snapshot_handler/selection/rpc_watchdog/messaging_snapshot_consumer 삭제; test_slots_handler.py(신규, 13 cases), test_killswitch_masking_resume 재작성, test_ledger_option_d._apply_cash_estimate → slots handler 경유, test_topology/cli_operator 갱신 BAT-036 (dcy-024): per-instance safety_sell_enabled 플래그 - config.py: DaemonInstanceConfig + DaemonConfig 에 safety_sell_enabled: bool = True 추가; from_toml 파싱, from_config 전파 - daemon.py: start() workers 조건부 — safety_sell_enabled=false 시 trex-safety-sell 미기동 + WARNING - tests: test_instance_config.py 에 safety_sell_enabled 파싱 3개 케이스 추가 REQ: 073/074/075/021/034/063/048/005 Approved → Implemented Co-Authored-By: Claude Opus 4.8 --- .../design.md | 55 +++++ .../implementation.md | 35 +++ .../index.md | 25 ++ .../planning.md | 61 +++++ .../verification.md | 72 ++++++ .../design.md | 42 ++++ .../implementation.md | 23 ++ .../index.md | 25 ++ .../planning.md | 49 ++++ .../verification.md | 44 ++++ docs/batches/index.md | 2 + ...y-024_20260611_safety-sell-disable-mode.md | 194 +++++++++++++++ ...0260611_strategy-read-surface-slots-req.md | 228 ++++++++++++++++++ docs/discovery/index.md | 2 + docs/interface-contract.md | 184 +++++--------- docs/runtime-flows.md | 47 ++-- .../req-048_per-instance-toml-config.md | 44 +++- .../Data/req-074_merged-slot-row-schema.md | 71 ++++++ ...5_slots-read-surface-baseline-alignment.md | 61 +++++ .../req-005_slot-core-daemon-runtime.md | 50 +++- ...messaging-topology-and-contract-mapping.md | 43 ++++ ...shot-read-message-contract-and-envelope.md | 41 +++- .../Interface/req-034_per-slot-watchdog.md | 43 ++++ .../req-063_unified-operator-slot-view.md | 43 ++++ .../req-073_slots-req-rpc-contract.md | 70 ++++++ docs/srs/index.md | 13 +- .../messaging/test_instance_topology.py | 6 +- tests/adapters/messaging/test_topology.py | 3 +- .../test_killswitch_masking_resume.py | 106 +++----- tests/application/test_ledger_option_d.py | 24 +- .../test_messaging_snapshot_consumer.py | 134 ---------- tests/application/test_slots_handler.py | 212 ++++++++++++++++ tests/application/test_snapshot_handler.py | 95 -------- .../application/test_snapshot_rpc_watchdog.py | 97 -------- tests/application/test_snapshot_selection.py | 182 -------------- tests/runtime/test_cli_operator.py | 141 ++++++----- tests/runtime/test_instance_config.py | 27 +++ trex_core/adapters/messaging/rabbitmq.py | 6 +- trex_core/adapters/messaging/topology.py | 12 +- trex_core/application/messaging_handlers.py | 196 +++++---------- trex_core/application/portfolio.py | 223 ++++------------- trex_core/runtime/cli.py | 89 +++---- trex_core/runtime/config.py | 8 + trex_core/runtime/daemon.py | 24 +- 44 files changed, 1963 insertions(+), 1189 deletions(-) create mode 100644 docs/batches/bat-035_20260611_strategy-read-surface-slots-req/design.md create mode 100644 docs/batches/bat-035_20260611_strategy-read-surface-slots-req/implementation.md create mode 100644 docs/batches/bat-035_20260611_strategy-read-surface-slots-req/index.md create mode 100644 docs/batches/bat-035_20260611_strategy-read-surface-slots-req/planning.md create mode 100644 docs/batches/bat-035_20260611_strategy-read-surface-slots-req/verification.md create mode 100644 docs/batches/bat-036_20260611_safety-sell-disable-mode/design.md create mode 100644 docs/batches/bat-036_20260611_safety-sell-disable-mode/implementation.md create mode 100644 docs/batches/bat-036_20260611_safety-sell-disable-mode/index.md create mode 100644 docs/batches/bat-036_20260611_safety-sell-disable-mode/planning.md create mode 100644 docs/batches/bat-036_20260611_safety-sell-disable-mode/verification.md create mode 100644 docs/discovery/dcy-024_20260611_safety-sell-disable-mode.md create mode 100644 docs/discovery/dcy-025_20260611_strategy-read-surface-slots-req.md create mode 100644 docs/srs/Data/req-074_merged-slot-row-schema.md create mode 100644 docs/srs/Documentation/req-075_slots-read-surface-baseline-alignment.md create mode 100644 docs/srs/Interface/req-073_slots-req-rpc-contract.md delete mode 100644 tests/application/test_messaging_snapshot_consumer.py create mode 100644 tests/application/test_slots_handler.py delete mode 100644 tests/application/test_snapshot_handler.py delete mode 100644 tests/application/test_snapshot_rpc_watchdog.py delete mode 100644 tests/application/test_snapshot_selection.py diff --git a/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/design.md b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/design.md new file mode 100644 index 0000000..6f58905 --- /dev/null +++ b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/design.md @@ -0,0 +1,55 @@ +# BAT-035 Design + +- Status: confirmed +- Batch: BAT-035 + +## Architecture Summary +- `trex.snapshot.req` RPC 파이프라인(큐 선언·핸들러·View enum·assets[]/available_slots/watchdog[] 구조)을 제거하고 `trex.slots.req` 단일 RPC로 즉시 교체한다. +- 슬롯 행(slot_to_dict)이 자산·워치독·overlay를 흡수해 소비자 측 배열 조인을 없앤다. +- 변경은 topology → messaging_handlers → portfolio → CLI 레이어 순으로 안쪽부터 바깥으로 진행한다. + +## Changed Areas +- Docs: docs/interface-contract.md (snapshot→slots 계약 교체 + 필드별 정본 출처 표), docs/runtime-flows.md (read 흐름 교체) +- Modules: + - `trex_core/adapters/messaging/topology.py` — SNAPSHOT_REQ_QUEUE → SLOTS_REQ_QUEUE, snapshot_queue() → slots_queue() + - `trex_core/application/messaging_handlers.py` — make_slots_rpc_handler 신설, make_snapshot_rpc_handler 제거, _attach_watchdog 제거 + - `trex_core/application/portfolio.py` — slot_to_dict 확장, View enum 제거, build_snapshot_envelope 제거 + - CLI 모듈 — slots.req 사용, spy=true, filter 매핑 +- Tests: + - tests/adapters/messaging/ — topology 단언 + - tests/application/ — slots 핸들러·slot_to_dict·divergence·overlay + +## Key Decisions + +1. **즉시 교체(lockstep)**: snapshot.req 큐·핸들러·legacy stub 경로를 같은 커밋에서 제거한다. 전략 모듈(trex-orb-v1)은 배포 전 lockstep 대응 완료 상태여야 한다(REQ-073, dcy-025 OQ-1). + +2. **slot_to_dict 서명 확장**: `slot_to_dict(s, *, snapshot_store=None, watchdog_store=None, pause_store=None) → dict`. snapshot_store 없으면 asset_quantity=None, cash_balance=None, cash_estimated=False; watchdog_store 없으면 watchdog=None; pause_store 없으면 paused=False. None 필드는 응답에 그대로 포함(소비자가 구분 가능). + +3. **warned 계산**: `warned = (slot.qty != asset_quantity)`. asset_quantity가 None(snapshot 미연결)이면 warned=False(불확정, 조용히 처리). slot.qty는 슬롯 미러 값. + +4. **watchdog 행 흡수**: `watchdog: {expired: bool, last_seen: str|null}`. watchdog_store.get(slot_id) → WatchdogEntry(last_seen_at, timeout_sec). expired = (now - last_seen_at) > timeout_sec (또는 last_seen_at is None). WatchdogStore None 이면 watchdog 필드 전체 None. + +5. **filter 분기**: `filter` → `{all: 전체, inactive: status==inactive, pending: status in (pending_buy, pending_sell), holding: status==holding, error: status==error}`. 미지정/null/"all" = 전체. 미지정 값은 all로 처리(조용히). + +6. **spy 분기**: `spy=true` → holding 슬롯 워치독 미터치. `spy=false` or 미지정 → holding 슬롯 watchdog.update(). REQ-034 CHG-01 시맨틱. + +7. **View enum 제거**: `portfolio.py:65`의 `class View(str, Enum)` 전체 제거. 호출 지점(build_snapshot_envelope, CLI) 정리. + +8. **build_snapshot_envelope 제거**: `portfolio.py:340`의 `build_snapshot_envelope` 함수 전체 제거. messaging_handlers.py 의 snapshot 경로(221~244) 같이 제거. + +9. **REQ-028 Deprecated 전환**: 이번 배치에서 snapshot.req 코드가 제거되면 REQ-028 상태를 Implemented→Deprecated로 전환한다. + +## Edge Cases +- asset_quantity(hub) = 0 이고 qty(slot) = 0 → warned=False (정상 일치) +- asset_quantity(hub) = 0 이고 qty(slot) = 1 → warned=True (divergence) +- snapshot_store 미연결(degraded) → asset_quantity=None, cash_balance=None, cash_estimated=False, degraded=true +- spy=true 요청인데 holding 슬롯 없음 → watchdog 터치 없음, 정상 반환 +- invalid filter 값("buyable" 등) → all 처리 (하위호환, CLI 매핑은 상위 계층에서 처리) + +## Architecture Impact +- runtime-flows: yes (snapshot → slots 흐름 교체) +- project-structure: no (파일 추가 없음, 기존 파일 수정) + +## Reference Doc Update Plan +- docs/interface-contract.md: snapshot 섹션 제거 → slots.req 계약 + 필드별 정본 출처 표 추가 (REQ-075 AC-1) +- docs/runtime-flows.md: read 흐름 snapshot→slots 교체 (REQ-075 AC-2) diff --git a/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/implementation.md b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/implementation.md new file mode 100644 index 0000000..2c441bd --- /dev/null +++ b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/implementation.md @@ -0,0 +1,35 @@ +# BAT-035 Implementation + +- Status: draft +- Batch: BAT-035 + +## Plan Summary +- topology.py: SLOTS_REQ_QUEUE 신설, SNAPSHOT_REQ_QUEUE 제거, snapshot_queue→slots_queue +- messaging_handlers.py: make_slots_rpc_handler 신설, make_snapshot_rpc_handler + _attach_watchdog 제거 +- portfolio.py: View enum 제거, build_snapshot_envelope 제거, slot_to_dict 확장 +- CLI: slots.req 사용 + spy=true + filter 매핑 +- docs: interface-contract.md, runtime-flows.md 교체 +- tests: topology + slots 핸들러 + slot_to_dict + +## Changed Files +- `trex_core/adapters/messaging/topology.py` +- `trex_core/application/messaging_handlers.py` +- `trex_core/application/portfolio.py` +- CLI 모듈 (slots.req 호출 경로) +- `docs/interface-contract.md` +- `docs/runtime-flows.md` +- `tests/adapters/messaging/test_topology.py` (또는 해당 파일) +- `tests/application/test_messaging_handlers.py` (또는 해당 파일) +- `tests/application/test_portfolio.py` (또는 해당 파일) +- `docs/srs/Interface/req-028_snapshot-read-message-contract-and-envelope.md` (Deprecated 전환) +- `docs/srs/index.md` (REQ-028 Deprecated) + +## Execution Log + +(구현 진행 시 기록) + +## Validation +(구현 후 기록) + +## Remaining Risks +- trex-orb-v1 lockstep 대응 확인 전 배포 불가 diff --git a/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/index.md b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/index.md new file mode 100644 index 0000000..7bcff12 --- /dev/null +++ b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/index.md @@ -0,0 +1,25 @@ +# BAT-035: 전략 read 표면 재설계 — trex.slots.req + 슬롯 단일 행 스키마 (dcy-025) + +- Status: confirmed +- Profile: standard +- Source Discovery: dcy-025 +- Included REQ: REQ-073, REQ-074, REQ-075, REQ-021 (CHG-20260611-01), REQ-034 (CHG-20260611-01), REQ-063 (CHG-20260611-01) +- Excluded REQ: 없음 + +## Summary +- `trex.snapshot.req` 파이프라인(view/assets[]/available_slots/watchdog[] 구조)을 `trex.slots.req` 단일 RPC로 즉시 교체(과도기 없음)한다. 슬롯 행 하나가 슬롯 상태·자산(hub 정본 + 슬롯 미러)·워치독·overlay 플래그를 모두 운반해 소비자 측 배열 조인을 없앤다. 필터는 status 단일 값, spy=true 시 워치독 무터치. strategy 계열 필드는 전면 제외. + +## Scope +- In: topology.py 큐명 교체(SNAPSHOT_REQ_QUEUE → SLOTS_REQ_QUEUE) + snapshot.req 선언 제거, messaging_handlers.py slots 핸들러 신설 + snapshot 핸들러 제거 + `_attach_watchdog` top-level 제거, portfolio.py `View` enum 폐기 + `slot_to_dict` 확장(asset_quantity/qty/warned/cash_balance/cash_estimated/watchdog nested/paused) + `build_snapshot_envelope` 제거, CLI slots.req 사용 + spy=true + --status→filter 매핑, docs(interface-contract.md / runtime-flows.md) snapshot→slots 계약 교체, 테스트(topology/slots 핸들러/filter/spy/응답 키 집합) +- Out: strategy 계열 필드 slots 행 도입(후속 discovery), trex-orb-v1 소비 측 코드 수정(운영자 별도 lockstep 확인), 라이브 브로커 잔고 행 포함, overlay 서버측 필터링 + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- 즉시 교체(과도기 없음): 배포 시점에 trex-orb-v1 의 `trex.slots.req` 대응 준비 완료 여부를 사전 확인해야 한다(dcy-025 R-1, REQ-073 Notes). +- REQ-028(snapshot 계약 REQ): keep-current(Implemented) 유지, 이번 배치에서 코드 제거 완료 시 Deprecated 전환. +- REQ-062(hub 정본 + 슬롯 미러 + 브로커 ground truth 정합)는 이번 배치 영향 없이 유지 — slot_to_dict 가 그 계약을 행에 표현한다. diff --git a/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/planning.md b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/planning.md new file mode 100644 index 0000000..7363221 --- /dev/null +++ b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/planning.md @@ -0,0 +1,61 @@ +# BAT-035 Planning + +- Status: done +- Batch: BAT-035 +- Profile: standard + +## Included REQ +- REQ-073: trex.slots.req RPC 계약 (Interface/High) — 큐 교체, filter/spy, 응답 slots+health only +- REQ-074: 슬롯 단일 행 스키마 (Data/High) — asset_quantity+qty+warned, cash_balance+cash_estimated, watchdog nested, paused +- REQ-075: baseline 문서 정합 (Documentation/Medium) — interface-contract/runtime-flows +- REQ-021 CHG-20260611-01: 토폴로지 큐명 snapshot→slots 교체 +- REQ-034 CHG-20260611-01: watchdog 트리거=slots.req 기본/spy=무터치, 행 흡수 +- REQ-063 CHG-20260611-01: CLI slots.req+spy=true, --status→filter 매핑 + +## Delivery Plan + +### M1: 토폴로지 + 인프라 계약 교체 +- `trex_core/adapters/messaging/topology.py`: SNAPSHOT_REQ_QUEUE → SLOTS_REQ_QUEUE (`"trex.slots.req"`), `snapshot_queue()` → `slots_queue()` +- `docs/interface-contract.md`: trex.snapshot.req 섹션 제거, trex.slots.req 계약(요청 {filter, spy}, 응답 {slots[], degraded/reason/message}, 필드별 정본 출처 표) 추가 +- `docs/runtime-flows.md`: read 흐름 snapshot→slots 교체 + +### M2: messaging_handlers — slots 핸들러 신설 + snapshot 핸들러 제거 +- `make_snapshot_rpc_handler` 전체 제거 +- `make_slots_rpc_handler` 신설: filter(str) / spy(bool) 파싱, filter 분기 슬롯 조회, spy=false → holding watchdog 터치, 응답 {slots, degraded, reason, message} only +- `_attach_watchdog` top-level 함수 제거 (watchdog 처리가 핸들러 내부로 흡수) + +### M3: portfolio — slot_to_dict 확장 + View 폐기 + snapshot envelope 제거 +- `portfolio.py`: `View` enum 제거, `build_snapshot_envelope` 제거 +- `slot_to_dict(s, snapshot_store=None, watchdog_store=None, pause_store=None)` 확장: + - 슬롯/코어 정본 필드(기존 유지) + - `asset_quantity` (snapshot_store → hub 정본) + `qty` (슬롯 미러 s.qty) + `warned` (qty≠asset_quantity) + - `cash_balance` + `cash_estimated` (snapshot_store) + - `watchdog: {expired: bool, last_seen: str|null}` nested (watchdog_store) + - `paused: bool` (pause_store) + +### M4: CLI — slots.req 사용 + spy=true + filter 매핑 +- `trex_core/interfaces/cli.py` (또는 해당 모듈): 슬롯 조회 시 slots.req + spy=true, `--status` 인자→filter 매핑 (all/inactive/pending/holding/error, 기존 "buyable" → "inactive") + +### M5: 테스트 + 회귀 +- tests/adapters/messaging/: topology 단언 (SLOTS_REQ_QUEUE 존재, SNAPSHOT_REQ_QUEUE 부재) +- tests/application/: slots 핸들러 filter·spy·응답 키 집합, watchdog 터치/무터치, degraded 신호, portfolio slot_to_dict 확장 필드 + divergence + warned + cash_estimated + paused holding 직교, strategy 필드 부재 +- 전체 suite green + +## Dependencies +- Internal: REQ-062(hub 정본 + 슬롯 미러 정합 — 이번 배치가 그 계약을 표면에 구현), PauseStore, WatchdogStore 기존 API 재사용 +- External: trex-orb-v1 lockstep 대응(배포 전 확인) + +## Design Gate +- Design Required Before Implementation: **yes** +- Reason: topology.py 큐 선언 변경(REQ-021), messaging_handlers.py 핸들러 신설/제거(REQ-073), portfolio.py slot_to_dict 서명 변경 + View 폐기(REQ-074)가 상호 연결된 인터페이스 변경. 제거 지점 전수와 새 핸들러 설계 계약을 design.md에 먼저 고정해 구현 drift를 방지. + +## 위험 요소 +- R-1 (lockstep): trex-orb-v1 이 snapshot.req 를 아직 쓰고 있으면 배포 즉시 read 실패 → 배포 전 trex-orb-v1 의 slots.req 대응 완료 여부 확인 필수 +- R-2 (slot_to_dict 서명 변경): 호출 지점이 다수(messaging_handlers + CLI + 테스트) → grep 으로 호출 지점 전수 확인 후 한 번에 교체 +- R-3 (build_snapshot_envelope 제거): snapshot_store wiring path 삭제 시 CLI / daemon wiring 코드에서 잔존 참조 compile 오류 → M5 회귀로 검출 + +## 검증 계획 (요약) +- topology: SLOTS_REQ_QUEUE 존재, SNAPSHOT_REQ_QUEUE 부재 assert +- slots 핸들러: filter 분기(all/inactive/pending/holding/error), spy 터치/무터치, 응답 키 집합 {slots/degraded/reason/message} only, strategy 필드 부재 +- slot_to_dict: asset_quantity(hub) + qty(mirror) + warned(divergence) + cash_balance + cash_estimated + watchdog:{expired,last_seen} + paused 포함 +- 회귀: 전체 suite green diff --git a/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/verification.md b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/verification.md new file mode 100644 index 0000000..9f0f4b7 --- /dev/null +++ b/docs/batches/bat-035_20260611_strategy-read-surface-slots-req/verification.md @@ -0,0 +1,72 @@ +# BAT-035 Verification + +- Status: confirmed +- Batch: BAT-035 + +## Acceptance Mapping + +- REQ-073 AC-1: topology 선언 — `trex.slots.req` 큐 존재, `trex.snapshot.req` 큐·핸들러 부재 +- REQ-073 AC-2: filter 분기 — inactive/pending(buy∪sell)/holding/error/all/invalid 처리 +- REQ-073 AC-3: 응답 키 집합 — {slots/degraded/reason/message} only, assets/available_slots/watchdog(top) 키 부재 +- REQ-073 AC-4: spy=true → last_seen_at 미갱신; spy=false(default) → holding watchdog 갱신 +- REQ-073 AC-5: degraded=true + reason 전파, credentials sanitized +- REQ-074 AC-1: 행 필드 — slot_id/status/avg_price/entry_price/forced_close_at/symbol/portfolio_id/cash_balance/asset_quantity/qty/watchdog:{expired,last_seen}/paused/warned 포함 +- REQ-074 AC-2: qty≠asset_quantity → warned=true, 두 값 모두 노출 +- REQ-074 AC-3: cash_estimated=true 구간 + 수렴 시 false +- REQ-074 AC-4: paused holding → filter=holding 반환, paused=true (직교) +- REQ-074 AC-5: strategy 계열 키 부재 (strategy_id/sell_strategy_id/strategy_params/active_strategy_id) +- REQ-075 AC-1: interface-contract.md 에 slots.req 계약 + 필드 소스 테이블 추가, snapshot.req 현행 계약 섹션 제거 +- REQ-075 AC-2: runtime-flows.md read 흐름 slots.req 기준으로 업데이트 +- REQ-075 AC-3: `trex.snapshot.req` 현행 계약 서술 제거됨 (change log 이력 제외) +- REQ-021 CHG-01 AC: 토폴로지에 slots.req 선언, snapshot.req 없음 (SNAPSHOT_REQ_QUEUE 상수 제거) +- REQ-034 CHG-01 AC: spy=true → watchdog 미갱신; spy=false → holding watchdog 갱신 +- REQ-063 CHG-01 AC: CLI spy=true 전송, --status 인자 server-side filter 매핑 (buyable→inactive) + +## Evidence + +### Tests (420 passed, 45 skipped — 2026-06-11) + +- **REQ-073 AC-1**: `tests/adapters/messaging/test_topology.py::test_topology_constants` + - `topology.SLOTS_REQ_QUEUE == "trex.slots.req"` ✓ + - `not hasattr(topology, "SNAPSHOT_REQ_QUEUE")` ✓ +- **REQ-073 AC-2**: `tests/application/test_slots_handler.py` + - `test_filter_all_returns_all_slots` ✓ + - `test_filter_inactive_returns_inactive_slots` ✓ + - `test_filter_holding_returns_holding_slots` ✓ + - `test_filter_pending_returns_pending_buy_and_sell` ✓ + - `test_invalid_filter_treated_as_all` ✓ +- **REQ-073 AC-3**: `test_response_keys_only_slots_health` ✓ +- **REQ-073 AC-4**: `test_spy_true_no_watchdog_update`, `test_spy_false_updates_watchdog` ✓ +- **REQ-073 AC-5**: `test_degraded_signal_propagated` ✓ +- **REQ-074 AC-1**: `test_slot_row_contains_merged_fields` ✓ +- **REQ-074 AC-2**: `test_warned_true_when_qty_diverges` ✓ +- **REQ-074 AC-3**: `tests/application/test_ledger_option_d.py::test_apply_cash_estimate_overlays_pending_delta` ✓ +- **REQ-074 AC-4**: `test_paused_holding_orthogonal_to_filter` ✓ +- **REQ-074 AC-5**: `test_strategy_fields_absent` ✓ +- **REQ-021 CHG-01**: `test_topology_constants` + `tests/adapters/messaging/test_instance_topology.py::test_named_instance_suffix` ✓ +- **REQ-034 CHG-01**: spy 워치독 억제 tests (위) ✓ +- **REQ-063 CHG-01**: `tests/runtime/test_cli_operator.py` + - `test_universe_uses_slots_queue_with_spy` ✓ + - `test_slots_status_sends_server_side_filter` ✓ + - `test_slots_buyable_maps_to_inactive_filter` ✓ + +### Manual + +- `tests/application/test_killswitch_masking_resume.py`: View/build_snapshot_envelope 의존 제거 후 재작성 완료 +- `tests/application/test_ledger_option_d.py`: _apply_cash_estimate 직접 테스트 → slots handler 경유 재작성 완료 + +### Code Review + +- `trex_core/adapters/messaging/topology.py`: SNAPSHOT_REQ_QUEUE 제거, SLOTS_REQ_QUEUE 추가 ✓ +- `trex_core/application/portfolio.py`: View, build_snapshot_envelope, _asset_to_dict, SnapshotEnvelope 제거 ✓ +- `trex_core/application/messaging_handlers.py`: make_snapshot_rpc_handler 제거, make_slots_rpc_handler 추가 ✓ +- `trex_core/runtime/daemon.py`: _snapshot_rpc_loop → _slots_rpc_loop, make_slots_rpc_handler 연결 ✓ +- `trex_core/runtime/cli.py`: slots_queue + spy=True + server-side filter ✓ +- `docs/interface-contract.md`: slots.req 계약 추가, snapshot.req envelope 섹션 제거 ✓ +- `docs/runtime-flows.md`: 슬롯 RPC flow 추가, snapshot RPC flow 교체 ✓ +- `docs/srs/index.md`: REQ-028 → Deprecated ✓ + +## Result + +- Ready for Release: true +- Blocking Issues: 없음 diff --git a/docs/batches/bat-036_20260611_safety-sell-disable-mode/design.md b/docs/batches/bat-036_20260611_safety-sell-disable-mode/design.md new file mode 100644 index 0000000..3a35dae --- /dev/null +++ b/docs/batches/bat-036_20260611_safety-sell-disable-mode/design.md @@ -0,0 +1,42 @@ +# BAT-036 Design + +- Status: confirmed +- Batch: BAT-036 + +## Architecture Summary +- `DaemonInstanceConfig`에 `safety_sell_enabled: bool = True` 필드를 추가하고 per-instance TOML 파싱 경로(`from_toml`)와 `DaemonConfig.from_config()` 전파 경로를 업데이트한다. +- `Daemon.start()` 내부 workers 리스트 구성 시 `safety_sell_enabled` 값을 체크해 False이면 `("trex-safety-sell", self._safety_sell_loop)` 항목을 포함하지 않는다. + +## Changed Areas +- Docs: 없음 (docs 변경 없음) +- Modules: + - `trex_core/runtime/config.py` — DaemonInstanceConfig 필드 + from_toml + from_config 전파 + - `trex_core/runtime/daemon.py` — start() workers 분기 +- Tests: + - config 파싱 단위 테스트 + - daemon workers 구성 테스트 + +## Key Decisions + +1. **absent=true 기본값**: `from_toml()` 내 `data.get("safety_sell_enabled", True)`. TOML 키 부재 시 True → 기존 배포 동작 보존. + +2. **DaemonConfig 전파 경로**: `DaemonConfig.from_config()` → `DaemonInstanceConfig.from_toml()` → `DaemonInstanceConfig.safety_sell_enabled` → `DaemonConfig` 생성자 또는 속성으로 전달. `Daemon.__init__` 에서 `self._config.safety_sell_enabled` 로 참조. + +3. **workers 조건 분기 위치**: `daemon.py:328` workers 리스트 구성 블록 내, `("trex-safety-sell", self._safety_sell_loop)` 항목을 조건부로 append. 리스트 구성 후 순회 루프(`for name, target in workers:`)는 변경 없음. + +4. **로그 메시지**: `logger.warning("safety_sell disabled — trex-safety-sell worker not started. Autonomous safety-sell will NOT run.")`. RUNNING 로그(기존)로 전체 워커 시작 상태를 가시화. + +5. **AC-6 보존**: `_safety_sell_loop` 제거가 아닌 workers 리스트 미포함. `Daemon._safety_sell_loop` 메서드 자체는 삭제하지 않는다(CLI exit/cancel/kill-switch 독립 경로는 이 메서드를 직접 호출하지 않으므로 무관). + +## Edge Cases +- safety_sell_enabled=True (기본): workers에 포함, 기존 동작과 동일 +- safety_sell_enabled=False: workers에 미포함, 경고 로그, 나머지 워커 영향 없음 +- TOML 키 오타(예: "safety_sell_enable"): get() 기본값 True 처리 → 활성화(안전한 방향) + +## Architecture Impact +- runtime-flows: no (daemon 기동 흐름만 경미하게 변경, 문서 갱신 불필요) +- project-structure: no + +## Reference Doc Update Plan +- docs/interface-contract.md: 없음 +- docs/runtime-flows.md: 없음 diff --git a/docs/batches/bat-036_20260611_safety-sell-disable-mode/implementation.md b/docs/batches/bat-036_20260611_safety-sell-disable-mode/implementation.md new file mode 100644 index 0000000..17bef9e --- /dev/null +++ b/docs/batches/bat-036_20260611_safety-sell-disable-mode/implementation.md @@ -0,0 +1,23 @@ +# BAT-036 Implementation + +- Status: draft +- Batch: BAT-036 + +## Plan Summary +- config.py: DaemonInstanceConfig.safety_sell_enabled 필드 + from_toml 파싱 + from_config 전파 +- daemon.py: start() workers 조건 분기 + +## Changed Files +- `trex_core/runtime/config.py` +- `trex_core/runtime/daemon.py` +- `tests/` (config 파싱 + daemon workers 테스트) + +## Execution Log + +(구현 진행 시 기록) + +## Validation +(구현 후 기록) + +## Remaining Risks +- 없음 diff --git a/docs/batches/bat-036_20260611_safety-sell-disable-mode/index.md b/docs/batches/bat-036_20260611_safety-sell-disable-mode/index.md new file mode 100644 index 0000000..70c3327 --- /dev/null +++ b/docs/batches/bat-036_20260611_safety-sell-disable-mode/index.md @@ -0,0 +1,25 @@ +# BAT-036: safety_sell_enabled TOML 설정 + 조건부 데몬 워커 기동 (dcy-024) + +- Status: confirmed +- Profile: standard +- Source Discovery: dcy-024 +- Included REQ: REQ-048 (CHG-20260611-01), REQ-005 (CHG-20260611-01) +- Excluded REQ: 없음 + +## Summary +- per-instance TOML 에 `safety_sell_enabled: bool = true` 필드를 추가하고, `false` 시 daemon.start() 가 `trex-safety-sell` 워커를 workers 리스트에서 제외해 스레드를 기동하지 않는다. 기동 로그로 상태를 가시화하며, 나머지 실행 경로(ExecutionService/CLI exit/cancel/kill-switch/strategy sell intent)는 이 값과 무관하게 동작한다. + +## Scope +- In: `trex_core/runtime/config.py` — DaemonInstanceConfig에 `safety_sell_enabled: bool = True` 필드 추가 + from_toml/from_config 파싱 + DaemonConfig로 전파, `trex_core/runtime/daemon.py` — start() workers 리스트 조건 분기 (safety_sell_enabled=False → trex-safety-sell 제외 + RUNNING 로그 + 경고 로그), 관련 테스트 +- Out: env 변수 TREX_SAFETY_SELL_ENABLED, CLI 노출, slots/snapshot 응답 노출, 다른 워커 제어 + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- absent=true 기본값: TOML에 키 없으면 True(기존 동작 보존). +- 선례: max_active_slots, default_order_timeout_sec 와 동일한 per-instance TOML 경로. +- REQ-005 AC-6: ExecutionService/CLI exit/cancel/kill-switch/strategy sell intent 는 safety_sell_enabled 값과 무관하게 동작 — 이번 배치 구현에서 그 경로를 건드리지 않는 것이 검증 기준. diff --git a/docs/batches/bat-036_20260611_safety-sell-disable-mode/planning.md b/docs/batches/bat-036_20260611_safety-sell-disable-mode/planning.md new file mode 100644 index 0000000..79ce55c --- /dev/null +++ b/docs/batches/bat-036_20260611_safety-sell-disable-mode/planning.md @@ -0,0 +1,49 @@ +# BAT-036 Planning + +- Status: done +- Batch: BAT-036 +- Profile: standard + +## Included REQ +- REQ-048 CHG-20260611-01: DaemonInstanceConfig.safety_sell_enabled: bool=True, from_toml/from_config 파싱, DaemonConfig 전파 (AC-10) +- REQ-005 CHG-20260611-01: daemon.start() 조건부 workers — safety_sell_enabled=False 시 trex-safety-sell 제외 + RUNNING 로그 + 경고 로그 (AC-5/6) + +## Delivery Plan + +### M1: config.py — DaemonInstanceConfig 필드 추가 +- `trex_core/runtime/config.py:DaemonInstanceConfig` 에 `safety_sell_enabled: bool = True` dataclass 필드 추가 +- `from_toml()`: `data.get("safety_sell_enabled", True)` 파싱 +- `DaemonConfig.from_config()`: DaemonConfig 생성 시 inst.safety_sell_enabled 전파 + +### M2: daemon.py — start() 조건부 workers +- `trex_core/runtime/daemon.py:328~337` workers 리스트 구성 부분에서 조건 분기: + ```python + if self._config.safety_sell_enabled: + workers.append(("trex-safety-sell", self._safety_sell_loop)) + else: + logger.warning("safety_sell disabled — trex-safety-sell worker not started") + ``` +- RUNNING 로그(기존 구조 안에서)로 비활성 상태 가시화 + +### M3: 테스트 +- config 파싱: TOML `safety_sell_enabled=false` → False; 키 부재 → True +- daemon start: safety_sell_enabled=False → trex-safety-sell 스레드 미기동 단언 +- AC-6: ExecutionService/exit/cancel/kill-switch 경로는 이번 M1~M2 변경으로 건드리지 않음(회귀 테스트로 확인) + +## Dependencies +- Internal: DaemonConfig 내 DaemonInstanceConfig 전파 경로(from_config → DaemonInstanceConfig.from_toml) +- External: 없음 + +## Design Gate +- Design Required Before Implementation: **yes** +- Reason: DaemonConfig ↔ DaemonInstanceConfig 전파 경로와 daemon.start() workers 리스트 수정 지점을 design.md 에 먼저 확정해 구현 오류를 방지. + +## 위험 요소 +- R-1 (absent=true 누락): from_toml 에서 get 기본값을 False 로 잘못 설정하면 기존 인스턴스 동작 파괴 → 단위 테스트로 absent→True 확인 +- R-2 (DaemonConfig 전파): DaemonConfig 에 safety_sell_enabled 를 전달하지 않으면 daemon.start() 에서 참조 실패 → M1 에서 전파 경로를 먼저 확인 + +## 검증 계획 (요약) +- TOML 파싱: present=false→False, present=true→True, absent→True +- daemon.start() workers: False → trex-safety-sell 제외, True → 포함 +- 다른 워커(trex-execution 등) 영향 없음 +- 전체 suite green diff --git a/docs/batches/bat-036_20260611_safety-sell-disable-mode/verification.md b/docs/batches/bat-036_20260611_safety-sell-disable-mode/verification.md new file mode 100644 index 0000000..5908259 --- /dev/null +++ b/docs/batches/bat-036_20260611_safety-sell-disable-mode/verification.md @@ -0,0 +1,44 @@ +# BAT-036 Verification + +- Status: confirmed +- Batch: BAT-036 + +## Acceptance Mapping + +- REQ-048 CHG-20260611-01 AC-10: Given per-instance TOML safety_sell_enabled(absent=true), When from_toml/from_config, Then 값이 파싱되어 DaemonConfig로 전파된다 + - present=false → DaemonInstanceConfig.safety_sell_enabled=False → DaemonConfig.safety_sell_enabled=False + - present=true → True + - absent → True (기본값) +- REQ-005 CHG-20260611-01 AC-5: Given safety_sell_enabled=False, When daemon.start(), Then trex-safety-sell 스레드 미기동 + WARNING 로그 +- REQ-005 CHG-20260611-01 AC-6: ExecutionService/CLI exit/cancel/kill-switch/strategy sell intent — safety_sell_enabled 값과 무관 동작 (회귀: 해당 경로 테스트 green) + +## Evidence + +### Tests (420 passed, 45 skipped — 2026-06-11) + +- **REQ-048 CHG-20260611-01 AC-10**: `tests/runtime/test_instance_config.py` + - `test_safety_sell_enabled_default_true` ✓ + - `test_safety_sell_enabled_false_from_toml` ✓ + - `test_safety_sell_enabled_true_explicit_from_toml` ✓ +- **REQ-005 CHG-20260611-01 AC-6** (회귀): 전체 테스트 스위트 420 passed — execution/exit/cancel/kill-switch 경로 모두 green ✓ + +### Code Review + +- `trex_core/runtime/config.py`: + - `DaemonInstanceConfig.safety_sell_enabled: bool = True` ✓ + - `from_toml()`: `safety_sell_enabled=bool(data.get("safety_sell_enabled", True))` ✓ + - `DaemonConfig.safety_sell_enabled: bool = True` ✓ + - `from_config()` replace: `safety_sell_enabled=inst.safety_sell_enabled` ✓ +- `trex_core/runtime/daemon.py`: + - `start()` workers 조건부: `if self.config.safety_sell_enabled: workers.append(...)` ✓ + - `else: _log.warning("safety_sell_enabled=false — trex-safety-sell worker not started...")` ✓ + +### Manual + +- `daemon.start()` 스레드 조건부 기동: 단위 테스트에서 실제 스레드 기동 없이 코드 경로 검증 (config 파싱 tests + code review) +- safety_sell 비기동 시 execution/exit/cancel/kill-switch 경로 무관함을 회귀 테스트로 확인 + +## Result + +- Ready for Release: true +- Blocking Issues: 없음 diff --git a/docs/batches/index.md b/docs/batches/index.md index 9c634c3..518fb20 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -42,3 +42,5 @@ | BAT-032 | standard | release-candidate | REQ-068, REQ-069, REQ-070, REQ-071, REQ-072, REQ-061(CHG-05), REQ-013(CHG-03) | dcy-022 | [bat-032](./bat-032_20260610_execution-policy/) | | BAT-033 | batch-lite | release-candidate | REQ-032 (CHG-20260610-06) | REQ-032 change-req (issue #23) | [bat-033](./bat-033_20260610_enter-qty-validation/) | | BAT-034 | standard | release-candidate | REQ-002 (CHG-20260610-02), REQ-006 (CHG-20260610-02), REQ-007 (CHG-20260610-02), REQ-028 (CHG-20260610-01), REQ-032 (CHG-20260610-07) | dcy-023 (issue #22) | [bat-034](./bat-034_20260610_drop-allocated-capital/) | +| BAT-035 | standard | confirmed | REQ-073, REQ-074, REQ-075, REQ-021 (CHG-20260611-01), REQ-034 (CHG-20260611-01), REQ-063 (CHG-20260611-01) | dcy-025 | [bat-035](./bat-035_20260611_strategy-read-surface-slots-req/) | +| BAT-036 | standard | confirmed | REQ-048 (CHG-20260611-01), REQ-005 (CHG-20260611-01) | dcy-024 | [bat-036](./bat-036_20260611_safety-sell-disable-mode/) | diff --git a/docs/discovery/dcy-024_20260611_safety-sell-disable-mode.md b/docs/discovery/dcy-024_20260611_safety-sell-disable-mode.md new file mode 100644 index 0000000..81ff906 --- /dev/null +++ b/docs/discovery/dcy-024_20260611_safety-sell-disable-mode.md @@ -0,0 +1,194 @@ +# 0. 문서 상태 + +- 상태: confirmed +- 문서 ID: dcy-024_20260611_safety-sell-disable-mode +- 이슈명: safety-sell 자율 청산 비활성화 모드 (issue #15) +- 작성 시각(KST): 2026-06-11 +- 마지막 갱신 시각(KST): 2026-06-11 13:50 KST (change-req 진행: REQ-048 CHG-20260611-01(safety_sell_enabled TOML) + REQ-005 CHG-20260611-01(조건부 워커·기동 로그·runtime-flows), 둘 다 Implemented→Approved. 신규 REQ 없음(OQ-1).) +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: 없음 (change-req only, dcy-024 OQ-1). Delivery: REQ-048(CHG-20260611-01)·REQ-005(CHG-20260611-01) change-req → 차기 batch. + +# 1. 계획 상태 요약 + +- 연계 문서 현황: + - 관련 점수 파일: 없음 + - 관련 TODO/메모: GitHub issue #15 (자율 청산 비활성화 모드 설계 전문 포함) +- 해석: + - REL-006 피드백 환류 Discovery 후보로 식별됨. 자율 청산(forced_close / SL·TP / market_close) 워커를 완전히 끄고 전략·운영자 지시 매도만 허용하는 운영 모드 요구. + - REQ-048(per-instance TOML) + REQ-005(daemon bootstrap) change-req 2건으로 닫는다 — OQ-1·OQ-2 확정. SafetySellConfig(env) 변경 없음. + +# 2. 요구사항 판정 결과 + +- 판정: 신규 +- 근거 + - dcy-007(core-autonomous-safety-sell)은 자율 매도 기능을 구현한 Discovery로 REL-003에서 released됨. 비활성화 모드는 그 기능의 확장이 아닌 운영 정책 플래그 추가 — 독립 후속 주기. + - 현재 SafetySellConfig에 `enabled` 플래그가 없고, `daemon.start()`의 workers 리스트에 `trex-safety-sell` 스레드가 조건 없이 포함됨. 코드 갭이 실존하는 신규 요구. + +# 3. 문제점의 요약 + +- 현재 상태 + - `trex_core/runtime/daemon.py:335`: `("trex-safety-sell", self._safety_sell_loop)` 이 workers 리스트에 항상 포함되어 데몬 기동 시 매번 자율 청산 워커가 시작됨. + - `SafetySellConfig`(config.py:113)에는 `enabled` 플래그가 없고, per-trigger 비활성화 수단도 없음. 관찰 전용 인스턴스·신규 전략 검증 환경에서 자율 청산을 막을 방법이 없음. +- 기대 상태 + - `DaemonInstanceConfig`(per-instance TOML)에 `safety_sell_enabled: bool = True` 추가(OQ-2 — env 제외, `max_active_slots`/`default_order_timeout_sec` 선례)로, `false` 시 `trex-safety-sell` 스레드를 데몬 기동부에서 아예 시작하지 않음. + - 기동 로그에 `safety_sell=disabled` 표시로 운영자가 안전망 없는 인스턴스임을 즉시 인지 가능(스냅샷 응답 미포함 — OQ-3). +- 이해관계자 + - 운영자/시스템 관리자 (Justin Ko): 관찰 전용 인스턴스·신규 전략 검증 시 자율 청산 OFF 필요 + - 전략 모듈(trex-orb-v1): 전략 지시 매도는 항상 유지되어야 함 + - 리뷰어/승인자: 안전망 없음을 명시한 운영 가이드 확인 +- 제약사항 + - `ExecutionService`(집행 루프), 운영자 CLI, kill-switch는 이 플래그에 영향받지 않아야 함 — 자율 청산(safety-sell 워커)만 OFF. + - Redis·RabbitMQ·PostgreSQL 의존성 변경 없음. config.py + daemon.py 한정 수정. + +## 주요 사용자 시나리오 + +- 관찰 전용 인스턴스: per-instance TOML `safety_sell_enabled = false` 설정 후 데몬 기동 → 기동 로그에 `safety_sell=disabled` 확인 → 포지션 유지 중 자동 청산 미발생, 전략 sell intent는 정상 집행. +- 신규 전략 검증(paper 브로커): safety-sell 없이 전략 모듈 매도 타이밍만 테스트 — 의도치 않은 장마감 강제청산 없이 전략 e2e 검증. +- 기본 운영(enabled=true): 기존 동작과 완전 동일. 하위 호환 변경 없음. + +# 4. 이번에 정의할 변경 + +- `DaemonInstanceConfig`(per-instance TOML)에 `safety_sell_enabled: bool = True` 필드 추가(OQ-2 — env 제외, SafetySellConfig 변경 없음). +- `daemon.start()` workers 리스트 구성부에서 `safety_sell_enabled=false` 시 `trex-safety-sell` 스레드 제외 — run_once 내부 if-가드보다 스레드 미기동이 물리적으로 자율 청산을 차단. +- 기동 로그(`RUNNING`)에 `safety_sell_enabled` 상태 노출(스냅샷 응답 미포함 — OQ-3) — 운영자가 인지 없이 안전망 없는 인스턴스를 운영하는 사고 방지. + +## 영향 범위 + +- 코드 영향: + - `trex_core/runtime/config.py`: `DaemonInstanceConfig`에 `safety_sell_enabled: bool = True` 필드 추가 + `from_toml()` 파싱 (SafetySellConfig 변경 없음) + - `trex_core/runtime/daemon.py`: `start()` workers 조건부 구성 — `config.instance.safety_sell_enabled` 참조 + 기동 로그 `safety_sell=disabled/enabled` +- 문서 영향: + - REQ-048(per-instance TOML 설정) change-req: `safety_sell_enabled` 필드 추가 + - REQ-005(daemon bootstrap) change-req: 워커 조건부 기동 명시 + - `docs/runtime-flows.md`: safety-sell 비활성 모드 플로우 추가 +- 운영 영향: + - 기본값 `true` — 기존 운영 인스턴스 동작 무변. 기동 전 `.env` 또는 TOML에 `TREX_SAFETY_SELL_ENABLED=false` 추가 시에만 비활성 모드로 전환. + - 비활성 모드 인스턴스는 손절/강제만기/장마감 안전망 없음 — 운영 가이드·기동 로그 명시 필수. + +# 5. 요구사항 목록 + +## 기능 요구사항 + +- FR-1: `DaemonInstanceConfig`(per-instance TOML)에 `safety_sell_enabled: bool = True` 필드를 추가하고, TOML `safety_sell_enabled = false`로 인스턴스별 비활성화를 제어한다. 기본값 `true`(미지정 시 기존 동작 유지). env var 경로 미노출 — 인스턴스별 운영 모드 차이(`max_active_slots` 선례). +- FR-2: `daemon.start()` 시 `DaemonInstanceConfig.safety_sell_enabled == False` 이면 `trex-safety-sell` 워커 스레드를 workers 리스트(`daemon.py:335`)에 포함하지 않는다. `True`(기본)이면 기존과 동일하게 기동한다. +- FR-3: 기동 시 RUNNING 로그에 `safety_sell_enabled=` 필드를 포함한다. 비활성 모드(`false`)이면 추가로 `no autonomous liquidation` 경고를 로그에 남긴다. +- FR-4: `safety_sell_enabled` 모드는 **기동 로그(FR-3)로만** 노출하고 **스냅샷/slots 응답에는 포함하지 않는다**(OQ-3). 근거: 인스턴스 config 이지 per-slot 데이터가 아니며, dcy-025 가 read 표면을 "응답=상태(slots[])+health"로 최소화하므로 config 를 응답에 싣지 않는 패턴과 정합한다. 런타임 노출이 필요해지면 per-slot 이 아니라 dcy-025 envelope-level(또는 별도 instance-status 표면)에서 조율한다. + +## 비기능 요구사항 + +- NFR-1: `enabled=false` 시 `ExecutionService`(집행 루프)·운영자 CLI(`enter`/`exit`/`cancel`)·kill-switch·messaging handler에는 영향 없음. 자율 청산(safety-sell 워커)만 미기동. +- NFR-2: 기본값 `true` — 기존 운영 인스턴스 재배포 시 config 변경 없이 기존 동작 보존(완전 하위 호환). +- NFR-3: 비활성 모드는 테스트에서 `config.safety_sell.enabled=False`를 주입하는 것만으로 재현 가능. 추가 mocking 불필요. + +## 범위 경계 + +- In Scope + - SafetySellConfig `enabled` 필드 + env + TOML 파싱 + - daemon.start() workers 조건부 구성 + - 기동 로그에 모드 노출(스냅샷 응답 미포함 — OQ-3) + - REQ-048(per-instance TOML) change-req + REQ-005(daemon bootstrap) change-req + runtime-flows 갱신 +- Out of Scope + - per-trigger 개별 플래그(forced_close / SL·TP / market_close 각각 ON/OFF) — 마스터 플래그 이후 별도 후속 이슈로 분리 + - kill-switch 포함 여부 변경 — 운영자 지시 경로이므로 유지(혼용 금지) + - 비활성 모드용 별도 watchdog 가시화 — 워커 미기동 시 로깅 없어도 무방(과설계 회피) + - trex-orb-v1 전략 모듈 변경 — 전략 sell intent는 messaging handler 경로이며 영향 없음 + +# 6. 리스크/가정 목록 + +## 리스크 + +- R-1 (Medium): 비활성 모드 인스턴스에서 전략 모듈이 죽어 폴링이 끊겨도 안전망 없이 포지션이 계속 유지됨 — 의도적 모드이지만 운영자 인지 없이 설정하면 무제한 손실 리스크. + - 완화 방안: 기동 로그 `no autonomous liquidation` 경고로 운영자가 즉시 인지(스냅샷 응답 미포함 — OQ-3). 운영 가이드에 위험 명시. +- R-2 (Low): TOML `safety_sell_enabled` 오타/잘못된 타입이 의도와 다르게 해석될 경우 기대와 반대로 자율 청산이 활성화됨. + - 완화 방안: TOML bool 파싱 규약을 따르되(미지정/비-false → `true`), 기동 로그에 파싱된 값을 항상 출력해 운영자가 확인 가능하게 함. +- R-3 (Low): 기존 SafetySellConfig 단위 테스트가 `enabled` 필드 추가 후 drift할 수 있음. + - 완화 방안: `EXPECTED_SLOT_FIELDS` drift test 선례처럼 config 계약 가드 테스트 추가 또는 기존 테스트 보완. + +## 가정 + +- A-1: 단일 마스터 플래그(`enabled`) 구현으로 시작하고, per-trigger 세분화는 후속 이슈로 분리한다. +- A-2: `enabled=false` 시 kill-switch, 운영자 CLI exit, 전략 sell intent는 그대로 동작한다. +- A-3: 신규 REQ 없이 REQ-048(per-instance TOML) + REQ-005(daemon bootstrap) change-req 2건으로 닫는다 — OQ-1·OQ-2 Justin Ko 확정. SafetySellConfig(env) 변경 없음. + +## 오픈 질문 + +- OQ-1: 신규 REQ 없이 change-req 경로(REQ-041·005)만으로 진행할지, 별도 Configuration REQ를 신규 발행할지? + - 상태: Answered + - 처리 방안: Justin Ko 확정 — **change-req 우선(신규 REQ 미발행)**. REQ-048(DaemonInstanceConfig TOML) + REQ-005(daemon bootstrap) change-req 2건으로 닫는다. + - 종료 조건: 완료 +- OQ-2: per-instance TOML `[safety_sell] enabled = false` 지원 포함 여부(env만 지원할지 TOML도 포함할지)? + - 상태: Answered + - 처리 방안: Justin Ko 확정 — **TOML 포함(env 제외)**. 당일 매매 인스턴스는 enabled=true, 중장기 보유 인스턴스는 enabled=false — 인스턴스마다 다른 운영 모드이므로 `DaemonInstanceConfig`(TOML)에 배치. `max_active_slots`·`default_order_timeout_sec` 선례 동일. REQ-048 change-req 추가. + - 종료 조건: 완료 +- OQ-3: 스냅샷 응답(`slots[]` envelope)에 `safety_sell_enabled` 필드 추가 포함 여부(FR-4)? + - 상태: Answered (2026-06-11, 사용자 결정) + - 결정: **기동 로그만, 스냅샷/slots 응답 미포함.** 근거: `safety_sell_enabled`는 인스턴스 config(per-slot 데이터 아님)이고, dcy-025 가 read 표면을 "응답=상태(slots[])+health"로 최소화하므로 config 를 응답에 싣지 않는 패턴과 정합. **per-slot 행에 넣지 않는다.** 런타임 노출이 필요해지면 per-slot 이 아니라 dcy-025 envelope-level(또는 별도 instance-status 표면)에서 조율(후속). 반영: FR-4. + +# 7. 초기 성공 기준 + +- S-1: per-instance TOML `safety_sell_enabled = false`로 데몬 기동 시 `trex-safety-sell` 스레드가 시작되지 않는다. 기동 로그에 `safety_sell_enabled=false`와 `no autonomous liquidation` 경고가 포함된다. +- S-2: 비활성 모드에서 forced_close_at 만기·SL·TP·장마감 시각이 지나도 슬롯이 자동 청산되지 않는다. 전략 모듈 sell intent + 운영자 CLI exit는 정상 집행된다. +- S-3: `enabled=true`(기본값) 시 기존 동작과 동일하며 기존 단위·통합 테스트 회귀가 없다. + +## 측정 방식 및 데이터 출처 + +- S-1 측정 방식: 단위 테스트 — `enabled=False` config 주입 + `daemon.start()` 후 thread 이름 목록에 `trex-safety-sell` 부재 확인 / 데이터 출처: `tests/runtime/test_daemon.py` 또는 신규 테스트 +- S-2 측정 방식: run-the-app 스모크 — 비활성 모드 데몬 기동 후 holding 슬롯 유지 중 장마감 시각 경과 → 슬롯 상태 `holding` 유지 + 전략 sell intent 처리 확인 / 데이터 출처: 데몬 기동 로그(`safety_sell_enabled=false`) + 슬롯 상태(`trex-core slots`로 holding 유지 확인 — slots 출력 자체엔 safety_sell_enabled 미포함, OQ-3) +- S-3 측정 방식: `pytest` 전체 실행 회귀 없음, `ruff check` clean / 데이터 출처: CI 또는 로컬 테스트 결과 + +# 8. REQ로 넘기기 전 확인 체크 + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: per-trigger 플래그 Out of Scope + TOML 지원(OQ-2 TOML·env 제외) 확정 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1 단위(스레드 부재)·S-2 run-the-app 스모크·S-3 회귀로 정의됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(비활성 모드 손실)은 기동 로그 경고 + 운영 가이드로 해소, 가이드 작성 담당 = 구현 batch/Justin Ko(아래) +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1/2/3 전부 Answered +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: Justin Ko 승인 후 `confirm-discovery` 수행 +- [ ] (delivery 인계) 비활성 모드 운영 가이드(손실 리스크 경고) 작성 — 구현 batch 단계 산출물, 담당 project maintainers/Justin Ko (Discovery confirm 비차단) + +# 9. 파일 처리 결과 + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-024_20260611_safety-sell-disable-mode.md +- 참조 문서: + - GitHub issue #15 — 자율 청산 비활성화 모드 설계 전문 (영향 범위·구현 방안·OQ 상세) + - docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md — 자율 매도 구현 Discovery (선행 참조) + - trex_core/runtime/config.py:113 — SafetySellConfig (변경 대상) + - trex_core/runtime/daemon.py:335 — workers 리스트 (`trex-safety-sell` 항목, 변경 대상) + +# 10. 사용자 결정 필요 항목 요약 + +## DECIDE + +- 없음 (OQ-1·OQ-2 확정 완료) + +## CONFIRM + +- 없음. (1) 스냅샷 응답 노출 = OQ-3 Answered(기동 로그만). (2) kill-switch·운영자 CLI exit가 `safety_sell_enabled=false`와 무관하게 유지됨은 설계로 확정(A-2/NFR-1) — 저장소상 `KillSwitchService`(daemon.py:166)·운영자 CLI exit 는 `trex-safety-sell` 워커와 **독립 경로**라 워커 미기동이 그 둘에 영향 없음(확인 완료). + +## DATA + +- 없음. (운영 가이드(손실 리스크 경고) 작성은 Discovery 정책 결정이 아니라 **delivery 단계 산출물**이므로 §8 인계 체크로 이동 — 담당은 구현 batch/project maintainers.) + +# 11. Discovery Freeze + +## 섹션 참조 + +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 + +- Handoff Decision: 승인 +- Handoff Rationale: OQ-1/2/3 전부 Answered(change-req 경로·TOML·기동 로그만). C형 플레이스홀더 0(CONFIRM/DATA 해소). 저장소 사실 재확인 일치. 운영 가이드 작성만 delivery 단계 비차단 인계. REQ Drafting 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-11 13:29 KST diff --git a/docs/discovery/dcy-025_20260611_strategy-read-surface-slots-req.md b/docs/discovery/dcy-025_20260611_strategy-read-surface-slots-req.md new file mode 100644 index 0000000..9c6e989 --- /dev/null +++ b/docs/discovery/dcy-025_20260611_strategy-read-surface-slots-req.md @@ -0,0 +1,228 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-025_20260611_strategy-read-surface-slots-req +- 이슈명: 전략 read 표면 재설계 — `trex.snapshot.req`→`trex.slots.req` + 슬롯 상태 필터 + 슬롯 단일 행(assets[]/available_slots/watchdog[] 흡수·제거) +- 작성 시각(KST): 2026-06-11 12:38 KST +- 마지막 갱신 시각(KST): 2026-06-11 14:05 KST (draft-req 완료: REQ-073/074/075 신규 + REQ-021/034/063 change-req(Approved 강등) + REQ-028 폐기 예정 forward-link) +- 대체됨: 없음 +- 후속 Discovery 참조: 전략↔자산 할당 모델(portfolio-hub 주도 다중 전략 설정) — 미생성, 후속 예정(본 표면에 strategy 필드 additive 추가 시점) +- 생성된 REQ 참조: REQ-073(slots.req RPC 계약), REQ-074(슬롯 단일 행 스키마), REQ-075(baseline 정합). 변경 REQ: REQ-021/034/063(각 CHG-20260611-01, Implemented→Approved), REQ-028(CHG-20260611-01, keep-current — delivery 배치에서 Deprecated 전환) + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: dcy-020(자산 중심 영구 슬롯·REQ-063 slots 단일 뷰 — 직접 토대), BAT-022(universe↔slots 이중 표면 분리 선례), dcy-005(스냅샷 universe read 기원), REQ-028(snapshot 메시지 계약), REQ-021(messaging 토폴로지). +- 해석: + - dcy-020 이 "슬롯=자산 1:1 영구 슬롯"으로 모델을 바꾸면서, 전략이 universe 를 받던 스냅샷 표면(`trex.snapshot.req`, view=all/buyable/held + `assets[]`/`slots[]`/`available_slots`/`watchdog[]`)이 과도하게 분리·중복된 상태가 됐다. 자산 중심 모델에선 **슬롯 테이블 하나**가 곧 universe 이므로 `assets[]`·`watchdog[]` 같은 병렬 배열을 슬롯 행에 흡수할 수 있다. + - 본 Discovery 는 전략 read 표면을 **`trex.slots.req`(슬롯 단일 테이블 + 상태 필터)**로 재설계한다 — `assets[]`/`available_slots`/`watchdog[]` 제거, watchdog·자산 데이터(symbol/portfolio_id/cash_balance/asset_quantity)를 슬롯 행에 흡수, `spy`(워치독 무터치) 옵션, `degraded/reason/message` 유지. BAT-022·dcy-020(REQ-063)가 향하던 통합의 종착점이다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 큐 표면 교체(`trex.snapshot.req`→`trex.slots.req`)·응답 구조 재설계(assets/available_slots/watchdog 제거 + 슬롯 행 흡수)는 REQ-028(snapshot 계약)·REQ-021(토폴로지)·REQ-063(slots 뷰)을 동시에 재정의하는 인터페이스 변경이라 단일 REQ 미세 조정으로 흡수되지 않는다. + - dcy-020 이 자산 중심 모델을 confirmed·delivery(BAT-027~031)했으나 전략 read 표면의 통합은 별도 주제로 남겨졌다. 본 주기는 그 read 계약을 독립적으로 재설계한다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - **요청 표면 `trex.snapshot.req`**: payload `{view: all|buyable|held, strategy_key?, trading_date?, actor?/touch_watchdog?}`(`messaging_handlers.py` snapshot 핸들러). view 는 파생 집합(`View` enum, `portfolio.py:65`): ALL=universe∪holding, BUYABLE=universe−occupied−masked, HELD=holding. + - **응답 envelope**(`build_snapshot_envelope`): `assets[]`(view 필터된 자산: symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params) + `slots[]`(인스턴스 전체 슬롯 테이블, view 무관 — BAT-022) + `available_slots`(M−active capacity) + `degraded`/`reason`/`message` + `watchdog[]`(별도 배열, `_attach_watchdog`). + - **이중 표면·병렬 배열**: `assets[]`(universe view)와 `slots[]`(슬롯 테이블)가 공존하고, `watchdog[]`는 슬롯과 조인해야 하는 별도 배열이다. 자산 중심 모델(슬롯=자산 1:1)에선 `assets[]`가 사실상 `slots[]`의 부분집합이라 중복이다. + - **워치독 터치 시맨틱**: 전략 폴링은 holding 슬롯 워치독을 갱신(생존 신호, REQ-034)하고, 운영자 조회는 `actor="operator"`/`touch_watchdog=false`로 억제(REQ-021 R-4) — 두 갈래가 ad-hoc 플래그로 표현됨. + - **strategy 필터**: `strategy_key`로 소유 자산 필터, `assets[]`에 `active_strategy_id`/`strategy_params` 노출. + - legacy stub 경로(`{view, strategy_id, slots[], capacity{}}`)도 잔존(구버전·테스트 호환). + - baseline(`docs/interface-contract.md` snapshot 섹션)이 이 표면을 기술 — 재설계 시 갱신 대상. +- 기대 상태 + - 전략이 **`trex.slots.req`** 하나로 슬롯 테이블을 받는다. 응답 = `slots[]` + `degraded`/`reason`/`message` 만. + - `slots[]` 각 행이 슬롯 상태(slot_id/status/qty/avg_price/limit_price/forced_close_at…)와 자산 데이터(symbol/portfolio_id/cash_balance/asset_quantity)와 **워치독 상태(expired/last_seen)**를 함께 운반한다 — 병렬 배열·조인 제거. + - `filter`로 슬롯 상태별(all/inactive/holding/pending/paused…) 조회. `spy=true`면 워치독 무터치(생존 신호로 오인 안 함). + - `assets[]`·`available_slots`·`watchdog[]` 제거. 매수 가능 목록 = `filter=inactive` 결과, capacity 는 admission(`capacity_full`)이 게이트. +- 이해관계자 + - 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물 + - 리뷰어/승인자 — read 계약·토폴로지 재설계 승인 + - 전략 모듈(orb-v1 등) — 요청 큐·응답 구조 변경(외부 repo 소비 측) + - 운영 담당자 — `spy` 조회·슬롯 단일 뷰 관측 +- 제약사항 + - 큐 교체는 토폴로지(REQ-021/050 동적 큐명·per-instance)·전략 모듈 소비 측을 함께 바꿔야 한다 — cutover/과도기 필요. + - 슬롯 행에 브로커 정본(qty/avg)과 hub 정본(cash_balance/asset_quantity)이 공존하며 divergence(REQ-031/062) 가능 — 필드별 source 명시 필요. + - 단일 writer·워치독(REQ-034)·degraded 신호(REQ-028)의 의미를 새 표면에서 보존. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 전략이 `trex.slots.req {filter: "inactive"}` 로 매수 가능(잔고 0·미보유) 슬롯만 받아 신호를 낸다 — `assets[]` 없이 `slots[]` 한 배열로. +- 전략이 `{filter: "holding"}` 로 보유 슬롯의 수량·avg·워치독 상태를 한 행에서 본다(별도 watchdog[] 조인 불필요). +- 운영자가 `{filter: "all", spy: true}` 로 전 슬롯을 조회하되 워치독을 건드리지 않아 죽은 전략의 safety-sell 을 억제하지 않는다. +- 전략이 `{filter: "pending"}` 로 진행 중 주문 슬롯을 관측한다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- **요청 표면 교체**: `trex.snapshot.req` 파이프라인 제거, `trex.slots.req` 추가(토폴로지·핸들러·전략 소비 측 cutover, OQ-1). +- **슬롯 상태 필터**: `filter` = status 단일 값 `{all, inactive, pending(buy∪sell), holding, error}`(상호배타, OQ-2). `inactive` 판정=슬롯 status(잔고 아님). overlay(`paused`/`warned`)는 행 플래그(직교 축), 서버측 overlay 필터는 후속. 파생 view(ALL/BUYABLE/HELD) 폐기. +- **`spy` 옵션**: `spy=true` 면 워치독 무터치(현 `actor=operator`/`touch_watchdog=false` 일반화). 기본 false(터치=생존 신호). +- **슬롯 단일 행 흡수**(OQ-3 (a)): `slots[]` 각 행에 자산 데이터(symbol/portfolio_id/cash_balance/asset_quantity) + 워치독(`watchdog:{expired,last_seen}` nested) + overlay(`paused`/`warned`) 포함. 수량은 `asset_quantity`(hub 정본)+`qty`(미러) **둘 다** 노출(REQ-062), 어긋나면 `warned`. 현금은 `cash_balance`+`cash_estimated`. 필드별 정본 라벨 명시. +- **응답 정리**: `assets[]`·`available_slots`·`watchdog[]` **제거**, `degraded`/`reason`/`message` **유지**. +- **strategy 필드 제외(이번 범위)**: `strategy_id`/`strategy_key`/`strategy_params`/`active_strategy_id` 제외(OQ-4 — 인스턴스=전략 스코프 전제). +- baseline(`docs/interface-contract.md`/`docs/runtime-flows.md`) snapshot→slots 표면 갱신. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `trex_core/adapters/messaging/topology.py` 등 — `trex.snapshot.req`→`trex.slots.req` 큐명/바인딩(per-instance, REQ-021/050) + - `trex_core/application/messaging_handlers.py` — snapshot 핸들러 → slots 핸들러(filter/spy 파싱, 응답 구조), `_attach_watchdog` 제거(슬롯 행 흡수), legacy stub 경로 정리 + - `trex_core/application/portfolio.py` — `View` enum·`build_snapshot_envelope`(assets/available_slots/watchdog 제거, 슬롯 행에 자산·워치독 흡수), `slot_to_dict` 확장 + - `trex_core/runtime/cli.py` — 운영자 `slots`/`universe` 명령이 새 표면 사용(`spy`) + - (전략 모듈 orb-v1 — 외부 repo 소비 측 cutover) +- 문서 영향: + - `docs/interface-contract.md`(snapshot→slots 계약), `docs/runtime-flows.md`(read 표면) +- 운영 영향: + - 전략 모듈 동시 cutover 필요(큐 교체). 운영자 조회는 `spy`로 일원화. + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: `trex.snapshot.req` 를 제거하고 `trex.slots.req`(per-instance 큐명) RPC 를 추가한다 — 요청 `{filter?, spy?}`, 응답 `{slots[], degraded, reason, message}`. **즉시 교체**(과도기 없음, OQ-1): 두 큐를 한 배포에 교체하고 전략 모듈을 lockstep 전환한다. +- FR-2: `filter` 는 **status 단일 값**(상호배타) `{all, inactive, pending(=pending_buy∪pending_sell), holding, error}` 로 조회한다(OQ-2). 미지정 시 `all`. `inactive` 판정 키는 슬롯 **status**(hub `asset_quantity` 아님 — 정합 정본은 상태머신). overlay(`paused`/`warned`)는 필터 값이 아니라 각 슬롯 행의 플래그로 노출하며(FR-3), 서버측 overlay 필터는 후속(MVP는 행 플래그 + client 필터). +- FR-3: `slots[]` 각 행은 아래를 **필드별 정본 라벨**과 함께 운반한다(OQ-3 (a)): + - 슬롯/코어 정본: `slot_id`, `status`, `portfolio_id`, `symbol`, `avg_price`, `limit_price`, `forced_close_at` + - 수량(REQ-062 정합, **둘 다 노출**): `asset_quantity`(**hub 정본** — 전략 reasoning primary) + `qty`(**슬롯 미러** — 코어 confirmed local). 정상 시 일치, 어긋나면 `warned=true`. 라이브 브로커 잔고는 행에 미포함(청산/reconcile 시점 정본). + - 현금: `cash_balance`(hub 정본) + `cash_estimated`(bool, dcy-018 추정 현금 오버레이) + - 워치독: `watchdog: {expired: bool, last_seen: ISO}` (nested object) + - overlay 플래그(직교 축, OQ-2): `paused`, `warned` + - 계약 문서(interface-contract)에 각 필드 정본 출처(hub/슬롯·코어/브로커/watchdog/overlay)를 명시한다. +- FR-4: `spy=true` 면 워치독을 갱신하지 않는다(생존 신호 미발생). 기본 false(holding 슬롯 워치독 터치, REQ-034 계승). +- FR-5: 응답에서 `assets[]`·`available_slots`·`watchdog[]` 를 제거하고 `degraded`/`reason`/`message` 는 유지한다. 매수 가능 목록은 `filter=inactive`, capacity 는 admission(`capacity_full`) 게이트로 대체한다. +- FR-6: `strategy_id`/`strategy_key`/`strategy_params`/`active_strategy_id` 는 본 표면에서 제외한다(OQ-4). +- FR-7: `docs/interface-contract.md`·`docs/runtime-flows.md` 를 새 read 표면으로 갱신한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: 워치독 일관성 — 전략 폴링(기본)은 생존 신호로 터치, `spy` 는 무터치(REQ-034 R-4 의미 보존). +- NFR-2: 단일 표면 — 한 응답이 슬롯 테이블 하나로 자산·워치독을 모두 담아 소비자 조인이 불필요하다. +- NFR-3: cutover 안전 — 즉시 교체(과도기 없음, OQ-1)이므로 전략 모듈(orb-v1)을 같은 배포에 lockstep 전환한다. dcy-020 자산 중심 cutover(BAT-030)와 묶어 단일 전환점으로 관리한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - `trex.slots.req` 표면(요청 filter/spy, 응답 slots[]+degraded) + 슬롯 행 흡수(자산·워치독) + - `assets[]`/`available_slots`/`watchdog[]` 제거, `trex.snapshot.req` 폐기 + - interface-contract/runtime-flows 정합 +- Out of Scope + - strategy_id/key/params/active(이번 제외 — 인스턴스=다중 전략이나 할당 모델 portfolio-hub 미정의, 후속 discovery. loose JSON 미채택, 추후 명시 필드 additive 추가) + - intent admission/capacity 정책 자체(dcy-020 — read 표면만 변경) + - 포지션 사이징·실행 정책(dcy-021/022 — 별개) + - 전략 모듈(orb-v1) 내부 구현(외부 repo, 소비 계약만 합의) + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): **즉시 교체**(OQ-1, 과도기 없음)라 전략 모듈(외부 repo)이 같은 배포에 전환되지 않으면 전략이 스냅샷을 즉시 못 받는다. + - 완화 방안: 전략 모듈 lockstep cutover를 dcy-020 자산 중심 cutover(BAT-030, 이미 전략 계약 변경 동반)와 **단일 전환점으로 묶어** 조율. 배포 전 전략 모듈 `trex.slots.req` 대응 준비 확인. +- R-2 (Medium): 슬롯 행에 브로커 정본(qty/avg)과 hub 정본(cash_balance/asset_quantity)이 공존해 divergence(REQ-031/062) 시 소비자가 어느 값을 믿을지 모호. + - 완화 방안: 필드별 정본 출처·라벨을 OQ-3 에서 확정하고 계약 문서에 명시. +- R-3 (Medium): `available_slots` 제거로 전략이 capacity 사전 확인 불가 → 거부(`capacity_full`) 후 재시도 부담. + - 완화 방안: admission 게이트로 충분함을 합의(가정 A-3), 필요 시 별도 capacity 조회는 후속. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: dcy-020 자산 중심 영구 슬롯(슬롯=자산 1:1)이 구현 상태라 `assets[]`가 `slots[]`에 흡수 가능하다. +- A-2: (정정) 한 인스턴스 = **다중 전략**이다. 전략↔자산 할당은 **portfolio-hub 가 내려주는 미정의 설정**으로 별도 후속 discovery 에서 정의한다. 따라서 이번 read 표면은 전략 정보를 받지 않고(전략-무관), 전략 모델 확정 시 strategy 필드를 additive(비파괴)로 추가한다. +- A-3: capacity 사전 확인 없이 admission(`capacity_full`) 거부로 충분하다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: 큐 교체 방식 — `trex.slots.req` 신규 + `trex.snapshot.req` 즉시 폐기 vs 한시적 dual-listen/dual-publish 과도기. + - 상태: Answered (2026-06-11, 사용자 결정) + - 결정: **즉시 교체** — `trex.snapshot.req` 파이프라인 제거 + `trex.slots.req` 추가를 같은 배포에. dual-listen/dual-publish 과도기 **없음**. 전략 모듈(orb-v1)은 **lockstep cutover**(동시 전환)로 조율하며, dcy-020 자산 중심 cutover(BAT-030, 이미 전략 계약 변경 동반)와 함께 묶는 것을 권장. 반영: FR-1, NFR-3, R-1. +- OQ-2: `filter` 분류·조합 — status + overlay 혼합/단일, `inactive` 정의, `pending` buy/sell 합 여부. + - 상태: Answered (2026-06-11, 사용자 결정) + - 결정: `filter` = **status 단일 값**(상호배타) `{all, inactive, pending(=pending_buy∪pending_sell), holding, error}`. **status와 overlay는 직교 축** — overlay(`paused`/`warned`)는 필터 값이 아니라 **각 슬롯 행의 플래그**로 항상 노출, 서버측 overlay 필터는 후속(MVP는 행 플래그 + client 필터, "paused holding" 조합도 client에서). `inactive` 판정 키는 슬롯 **status**(hub `asset_quantity` 아님 — 상태머신 정본, 행에 asset_quantity 동반 노출). 임의 다중 status 조합 미지원(`pending` 묶음이 흡수). 반영: FR-2/3, §4. +- OQ-3: 병합 슬롯 행 필드·정본 — 수량 정본/미러 노출, divergence 표기, 워치독 형태. + - 상태: Answered (2026-06-11, 사용자 결정 (a)) + - 결정: **(a) 둘 다 노출** — `asset_quantity`(hub 정본, REQ-062)와 `qty`(슬롯 미러)를 행에 함께, 어긋나면 `warned`. `cash_balance`(hub)+`cash_estimated`(dcy-018). 워치독 = **nested `{expired,last_seen}`**. overlay `paused`/`warned` 플래그. 라이브 브로커 잔고는 행 미포함(청산/reconcile 시점 정본). interface-contract 에 필드별 정본 출처 라벨 명시. 반영: FR-3, §4. +- OQ-4: strategy 필드 제외의 전제·처리. + - 상태: Answered (2026-06-11, 사용자 결정) + - 결정: 인스턴스는 **다중 전략**이나 전략↔자산 할당 모델이 **portfolio-hub 주도로 미정의** → **후속 discovery 로 연기**. 이번 표면은 전략 정보(strategy_id/key/params/active)를 **제외**(받지 않음). **loose JSON 필드는 미채택**(undefined blob은 OQ-3 정본 라벨 원칙에 반하고, 나중에 명시 필드로 additive 추가가 비파괴). 한계: 미래 모델 전까지 `trex.slots.req` 는 전략-무관(인스턴스 전체 슬롯, per-strategy 스코핑 없음). 반영: A-2, FR-6, §5 Out of Scope, 후속 Discovery 참조. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: 전략이 `trex.slots.req {filter}` 로 슬롯 테이블을 받고, 응답에 `assets[]`/`available_slots`/`watchdog[]` 가 없으며 `slots[]`+`degraded`/`reason`/`message` 만 있다. +- S-2: `slots[]` 각 행이 슬롯 상태 + 자산(symbol/portfolio_id/cash_balance/asset_quantity) + 워치독(expired/last_seen)을 함께 담아, 별도 배열 조인 없이 소비된다. +- S-3: `spy=true` 조회는 워치독을 갱신하지 않고(생존 신호 미발생), 기본 조회는 holding 워치독을 터치한다. `filter=inactive` 가 매수 가능 목록을 반환한다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: `trex.slots.req` 응답 키 집합 단언(assets/available_slots/watchdog 부재) 단위/통합 / 데이터 출처: RPC 응답 payload +- S-2 측정 방식: 슬롯 행 필드 단언(자산·워치독 포함) / 데이터 출처: slot_to_dict 확장 결과 +- S-3 측정 방식: spy on/off 워치독 갱신 여부 + filter=inactive 결과 단언 / 데이터 출처: WatchdogStore, 슬롯 상태 + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: 필터 분류(OQ-2)·strategy 제외 전제(OQ-4) 합의 완료 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~3 측정 시나리오·데이터 출처가 테스트 가능한 형태로 정의됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(큐 cutover)은 즉시교체(OQ-1) lockstep + dcy-020 cutover(BAT-030) 묶음으로 해소 방향 확정. 책임 = 구현 batch + 전략팀 조율. +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1~4 전부 Answered (2026-06-11 사용자 결정) +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: 승인자 Justin confirm-discovery 수행 + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-025_20260611_strategy-read-surface-slots-req.md +- 참조 문서: + - docs/discovery/dcy-020_20260610_asset-centric-slot-model.md (자산 중심 영구 슬롯·REQ-063 slots 단일 뷰 — 토대) + - docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md (스냅샷 universe read 기원) + - docs/interface-contract.md(snapshot 계약), docs/runtime-flows.md (baseline — 갱신 대상) + - 코드: trex_core/application/portfolio.py(View/build_snapshot_envelope), messaging_handlers.py(snapshot 핸들러/_attach_watchdog) + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- 없음 (OQ-1/2/3 Answered — §6 참조) + +## CONFIRM +- 없음 (OQ-4 Answered — 인스턴스=다중 전략, 할당 모델은 후속 discovery, 이번 표면 전략 정보 제외) + +## DATA +- 없음 (외부 데이터 의존 없음 — 내부 계약/토폴로지 + 전략 발신 합의) + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: OQ-1~4 전부 Answered(즉시교체·filter=status 단일·행 정본(a)·전략 정보 제외). C형 플레이스홀더 0, 저장소 사실 재확인 일치(trex.snapshot.req·View enum·_attach_watchdog 존재). REQ Drafting 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin +- Confirmed At (KST): 2026-06-11 13:32 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 455a2c9..cba80e9 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -29,3 +29,5 @@ | dcy-021_20260610_position-sizing-target-intent | 20260610 | 포지션 사이징 — 활성 자산 슬롯 보유 수량 변경(HOLDING→HOLDING)·부분 매도·선언형 target intent (issue #1, dcy-020 OQ-4 후속) | [Discovery](./dcy-021_20260610_position-sizing-target-intent.md) | confirmed | | dcy-022_20260610_stuck-pending-order-recovery | 20260610 | 전략 발신 intent 실행 정책 — 주문 timeout(TIF)/슬리피지 허용 + 미체결 고착 백스톱 (issue #21, dcy-020/021 모델 전제) | [Discovery](./dcy-022_20260610_stuck-pending-order-recovery.md) | confirmed | | dcy-023_20260610_allocated-capital-dead-field-removal | 20260610 | allocated_capital 죽은 필드 제거 — `enter --capital` 옵션 + slot/intent/snapshot 계약 정리 (issue #22) | [Discovery](./dcy-023_20260610_allocated-capital-dead-field-removal.md) | confirmed | +| dcy-024_20260611_safety-sell-disable-mode | 20260611 | safety-sell 자율 청산 비활성화 모드 — per-instance TOML `safety_sell_enabled=false` 시 워커 미기동 (issue #15) | [Discovery](./dcy-024_20260611_safety-sell-disable-mode.md) | confirmed | +| dcy-025_20260611_strategy-read-surface-slots-req | 20260611 | 전략 read 표면 재설계 — `trex.snapshot.req`→`trex.slots.req` + 슬롯 상태 필터 + 슬롯 단일 행(assets[]/available_slots/watchdog[] 흡수·제거) | [Discovery](./dcy-025_20260611_strategy-read-surface-slots-req.md) | confirmed | diff --git a/docs/interface-contract.md b/docs/interface-contract.md index f474898..867f142 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -25,7 +25,7 @@ ## Interface Summary -- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007/BAT-008 envelope) +- RabbitMQ `trex.slots.req` :: 전략 모듈/운영자의 슬롯 읽기 RPC (request-reply, BAT-035 — REQ-028 `trex.snapshot.req` 대체) - RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007) - RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007) - RabbitMQ `trex.control.req` :: 운영자 제어 RPC — pause/resume/list + kill_switch/resume_kill_switch (request-reply, BAT-012; 구 `trex.pause.req` 확장) @@ -52,7 +52,7 @@ | 종류 | 이름 | 타입/속성 | 용도 | |---|---|---|---| -| 큐 | `trex.snapshot.req` | durable | 스냅샷 RPC 요청 큐 (read-only) | +| 큐 | `trex.slots.req` | durable | 슬롯 RPC 요청 큐 (read-only) — REQ-028 `trex.snapshot.req` 대체(BAT-035) | | 큐 | `trex.intent.req` | durable, `x-dead-letter-exchange=trex.events.dlq` | intent admission RPC 요청 큐 | | 익스체인지 | `trex.events` | topic, durable | 체결/슬롯 상태 이벤트 발행 | | 익스체인지 | `trex.events.dlq` | fanout, durable | 처리 실패/unroutable 메시지 격리 | @@ -63,145 +63,69 @@ | 패턴 | 표면 | 요청 속성 | 응답 | |---|---|---|---| -| 스냅샷 RPC | `trex.snapshot.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 envelope(`assets[]`/`slots[]`/`available_slots`/…); stub 경로는 `{view, strategy_id, slots[], capacity{}}` | +| 슬롯 RPC | `trex.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message}` (REQ-073/074, BAT-035) | | intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. 체결/종료는 응답 아닌 이벤트로 | | update_thresholds | `trex.intent.req` | `action="update_thresholds"`, `reply_to` | reply-to 큐로 `{status:updated|ignored, slot_id, reason?}` (REQ-040) | | 이벤트 pub/sub | `trex.events` (topic) | routing key `{event_type}.{strategy_id}.{symbol}` | 전략 큐가 자기 `strategy_id` 패턴으로 바인딩 | ### Message contracts -#### `trex.snapshot.req` 요청 payload +#### `trex.slots.req` 요청 payload (REQ-073 AC-1, BAT-035) ``` -{ "view": "open" | "all", "strategy_id": str | null, - "trading_date": "YYYY-MM-DD"? # optional — kill-switch 당일 제외 마스크 기준일만 (BAT-030) +{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all" + "spy": true | false # default false; true = watchdog 갱신 안 함(운영자 조회) } ``` -응답 payload는 슬롯 목록과 capacity 를 운반한다. view 모드와 strategy_id 는 payload(요청 매핑 원칙 — REQ-021). BAT-030: 슬롯 조회는 instance 스코프(영구 슬롯)라 `trading_date` 로 필터하지 않는다. +- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리. +- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01). -#### `trex.intent.req` 요청 payload (buy) -``` -{ "intent_type": "buy", - "intent_id": "", - "slot_id": str(UUID), # CANONICAL key (dcy-020/REQ-061) — snapshot slots[] round-trip - "portfolio_id": str?, # legacy dual-accept 매핑(점진 전환용) - "qty": float?, "allocated_capital": float?, - "entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?, - "forced_close_at": "ISO-8601"?, - "stop_loss_price": number?, "take_profit_price": number?, - "timeout_sec": int?, # dcy-022/REQ-068: N초 미체결 시 자동 취소·롤백 (optional) - "slippage_pct": float? } # dcy-022/REQ-068: entry_price*(1+pct/100) 실효 지정가 (optional) -``` -- **`slot_id` 정본(dcy-020/REQ-061, BAT-029/030)**: 매수의 정식 키 — 전략 모듈은 snapshot `slots[]` 의 `slot_id` 를 그대로 회신한다. 미스 시 `unknown_slot` 거부. `portfolio_id` 는 legacy dual-accept(미스 시 `unknown_portfolio`); 둘 다 없으면 `slot_id_or_portfolio_required`. `trading_date` 는 계약에서 제거(BAT-030). -- admission(REQ-059): 대상 영구 자산 슬롯이 active(holding/pending) 면 `already_active`; instance active 수 ≥ `max_active_slots`(M) 면 `capacity_full`(FCFS·무치환). **`symbol` 은 client payload 에 없다** — core 가 슬롯의 `portfolio_id` 로 universe 에서 resolve 한다. -- `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성). -- `forced_close_at`(선택, REQ-066/037): 시간 기반 자동청산(만기) 절대시각. 운영자 CLI `enter --forced-close-at `(절대시간)로 설정하며(REQ-032 CHG-20260609-01, issue #17, BAT-024), 미지정 시 payload 에 미포함(NULL). 설정 시 forced-close 만기 매도 루프(REQ-037)가 그 시각에 시장가 청산한다. 상대시간 옵션은 제공하지 않는다. -- **`timeout_sec`(선택, dcy-022/REQ-068)**: 주문 유효시간(초). N초 내 미체결이면 core 가 `cancel_order` 를 브로커에 송신하고 다음 poll 에서 슬롯을 롤백한다(pending_buy→inactive 또는 pending_sell→holding). 미지정 시 per-instance TOML `default_order_timeout_sec`(opt-in, 미설정=OFF=무기한). per-intent 값이 우선. -- **`slippage_pct`(선택, dcy-022/REQ-068)**: 체결 보호 밴드(%). `entry_price` 있을 때만 유효 — 실효 지정가 = `entry_price*(1+pct/100)` (매수, 올림) / `*(1-pct/100)` (매도, 내림). `entry_price` 없으면(시장가) 무시. 미지정=보호밴드 없음. -sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":..., "limit_price": float?, "qty": float?, "timeout_sec": int?, "slippage_pct": float?}`. -- **`limit_price` 선택(dcy-012/REQ-007)**: 매도 지정가. **지정 시 지정가 매도(`ORD_DVSN="00"`, 그 가격)**, **미지정 시 시장가 매도(`ORD_DVSN="01"`)**. 자율 매도(safety-sell/kill-switch)는 가격을 싣지 않아 항상 시장가로 체결된다. 매수가(과거 `entry_price`)는 매도 가격으로 **재사용하지 않는다**(issue #10 — 슬롯 `limit_price` 는 pending 이탈 시 clear). buy payload 의 `entry_price` 필드명은 본 주기에서 유지(하위호환). -- **하위호환(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 두 필드 모두 optional — 미포함 시 기존 동작(무기한 지정가/시장가)과 동일(byte-동등). core 수신 측 먼저 배포 가능(발신 측과 lockstep 불필요). - -#### `trex.intent.req` `update_thresholds` 액션 (BAT-011 / REQ-040) -``` -{ "action": "update_thresholds", - "slot_id": str, - "stop_loss_price": number | null?, # 생략=유지, null=해제 - "take_profit_price": number | null?, # 생략=유지, null=해제 - "forced_close_at": "ISO-8601" | null?, # 생략=유지, null=해제 - "actor": str? } -``` -- 매수 이후 손절·익절·강제 만기 시각을 갱신한다. 세 필드는 독립적이며 payload 에 **키가 없으면 기존 값을 유지**, 명시적 `null` 이면 해제한다(REQ-040 AC-4/AC-5). -- `holding` 슬롯에서만 적용된다. 처리 성공 시 해당 슬롯의 워치독 `last_seen_at` 을 리셋한다(전략 모듈 생존 신호, REQ-034). -- 응답: `{status: "updated", slot_id}` / 비-holding 은 `{status: "ignored", slot_id, reason: "not_holding"}` / 미존재는 `{status: "ignored", slot_id, reason: "slot_not_found"}`. - -#### `trex.events` payload (BAT-007) -``` -{ "event_type": "slot_filled_buy"|"slot_filled_sell"|..., - "slot_id": str, "strategy_id": str?, "symbol": str?, - "status": "", "qty": float?, "avg_price": float?, - "portfolio_id": str?, - "sequence_at": "ISO-8601" } # BAT-030: trading_date 키 제거(영구 슬롯) -``` -- `sequence_at` = 슬롯 `updated_at` 의 단조 증가 토큰. 수신자는 last-wins 적용 가능 → 순서 보증 불요. -- 이벤트의 `strategy_id`/`symbol` 은 routing key 에, 본문에도 함께 운반(이벤트 매핑 원칙 — REQ-021). - -### Reliability rules (REQ-023) - -- intent RPC: durable + persistent + manual ack-after-commit + `intent_log` correlation_id dedup. 단일 consumer + prefetch=1 → FIFO. -- 스냅샷 RPC: read-only, manual ack, 별도 dedup 불요. -- 이벤트: publisher confirms 활성, unroutable → DLQ. 페이로드는 현재 상태 + 단조 시퀀스(last-wins). -- per-strategy 이벤트 queue: TTL/max-length 초과 시 `drop-head` + 스냅샷 RPC reconcile 로 보정. - -### Degrade (REQ-024) - -- 어댑터 `TransportHealth` ∈ {connected, disconnected, reconnecting}. 어플리케이션 레이어는 disconnected 상태에서 `TransportUnavailableError` 로 빠르게 실패한다(버퍼링 0, 보수적 중단). -- 재연결은 백오프 1s→2s→4s→8s→16s→30s cap, 어댑터가 자체 구현. -- core 자율 안전 매도는 transport 와 무관하게 `trex-safety-sell` worker 가 동작한다(forced_close / stop-loss·take-profit / 장 마감, dcy-007 REQ-037~041, BAT-010/011 구현 완료). - -### `trex.snapshot.req` envelope (BAT-008 / REQ-028) - -전략 모듈이 보내는 요청: -``` -{ "view": "all" | "buyable" | "held" # default "all" - "strategy_key": "" # optional, == filter on active_strategy_id - "trading_date": "YYYY-MM-DD" # optional, default UTC today - "touch_watchdog": bool # optional, default true (dcy-016/REQ-021) - "actor": "operator" # optional; "operator" => touch_watchdog 강제 false -} -``` -- **워치독 억제(dcy-016/REQ-021/034)**: 기본(`touch_watchdog` 미지정/true)은 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신한다(전략 폴링=생존 신호, REQ-034). `touch_watchdog=false` 또는 `actor="operator"` 면 갱신을 건너뛴다 — 운영자 CLI 조회(`universe`/`slots`)가 죽은 전략을 살아있는 것으로 오인시켜 safety-sell 을 무력화하지 않도록(R-4). 운영자 CLI 는 항상 억제 플래그를 보낸다. 기본값은 현행 유지(전략 모듈 무변경, 하위호환). - -코어가 reply-to 큐로 보내는 응답 envelope: -``` -{ "assets": [ # always a list (REQ-028 AC-1, never null) - { "symbol": str, - "asset_id": str, # == portfolio_id (dcy-004 OQ-1) - "cash_balance": number, - "asset_quantity": number, - "active_strategy_id": str | null, - "strategy_params": object, # opaque, pass-through - "cash_estimated": true? # dcy-018/OQ-4 — cash_balance 가 미정산 추정값일 때만(라벨) - }, ... +코어가 reply-to 큐로 보내는 응답 payload: +```json +{ + "slots": [ + { "slot_id": "str (UUID)", + "status": "inactive | pending_buy | pending_sell | holding | error", + "symbol": "str | null", + "qty": "float | null", // slot mirror (REQ-062) + "asset_quantity": "float | null", // hub authority (REQ-062) + "avg_price": "float | null", + "entry_price": "float | null", + "portfolio_id": "str | null", + "forced_close_at": "ISO-8601 | null", + "universe_detached": "bool", + "cash_balance": "float | null", // hub authority + "cash_estimated": "bool", // true = pending ledger outbox delta 반영 + "warned": "bool", // qty != asset_quantity 시 true (REQ-062) + "watchdog": { // null if watchdog store unavailable + "expired": "bool", + "last_seen": "ISO-8601 | null" + }, + "paused": "bool" + } ], - "view": "all" | "buyable" | "held", # echo - "available_slots": int, # admission headroom = max(0, M − active); M 미설정 시 inactive 수 (REQ-059) - "degraded": bool, # REQ-028 AC-2 - "reason": "portfolio_hub_unavailable" | "snapshot_fetch_failed" - | "cash_position_stale" | null, - "message": str | null # sanitized — no credentials/raw exceptions + "degraded": "bool", + "reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | null", + "message": "str | null" // sanitized — no credentials/raw exceptions } ``` -**No `price` field on `assets[]` items** (REQ-028 AC-3 / REQ-027 AC-1) — strategy-facing universe. -- 슬롯 상태 (REQ-028 AC-4, BAT-022): envelope 은 strategy `assets[]`(universe) 와 **별도로** 데몬의 per-slot 상태를 운반한다. 운영자용(`trex-core slots`)이며 universe 와 명확히 구분된다. -``` -"slots": [ # always a list (REQ-028 AC-4, never null) - { "slot_id": str, # buy intent 의 canonical key (REQ-061) - "status": "inactive"|"pending_buy"|"holding"|"pending_sell"|"error", - "symbol": str | null, - "qty": number | null, - "avg_price": number | null, # 매수 체결가/평단 - "entry_price": number | null, # pending 주문 지정가 (dcy-012 외부 호환명; 내부 limit_price) - "allocated_capital": number | null, - "strategy_id": str | null, "sell_strategy_id": str | null, - "portfolio_id": str | null, # == asset_id (영구 자산 바인딩) - "forced_close_at": "ISO-8601" | null, - "universe_detached": bool } # soft-retire/tombstone 마커 (REQ-058/063) -] -``` - - BAT-007 stub 경로와 BAT-008 envelope 경로가 **동일 `slots[]` 스키마**(`portfolio.slot_to_dict`)를 낸다. `assets[]`(portfolio 단위, universe, no-price) 와 독립이며 strategy 소비자는 무시 가능(additive, 하위호환). `slots[]` 는 operator-facing 이라 `entry_price`/`avg_price` 가격을 노출한다(no-price 규칙은 `assets[]` 한정). +**필드 소스 테이블** (REQ-074 AC-1): -- 워치독 상태 (dcy-017/REQ-034, 선택 필드): 응답에 holding 슬롯의 워치독 상태를 read-only 로 동봉한다. -``` -"watchdog": [ # best-effort; 실패 시 생략(NFR-1) - { "slot_id": str, "portfolio_id": str | null, "symbol": str | null, - "expired": bool, # true => 전략 이탈(안전망 소유) - "last_seen": "ISO-8601" | null } # 마지막 스냅샷 폴링 시각 -] -``` - - `trex-core slots` 가 `expired=true` 를 **WATCHDOG-OWNED(전략 이탈)** 로 표시한다. 운영자 조회(`actor="operator"`/`touch_watchdog=false`)는 이 노출이 워치독을 리셋하지 않는다(비침습, dcy-016). +| 필드 | 권위 소스 | 비고 | +|---|---|---| +| `slot_id`, `status`, `avg_price`, `entry_price`, `forced_close_at`, `universe_detached` | slot (core) | 슬롯 row 직접 | +| `qty` | slot (core) | 슬롯 mirror — broker position | +| `symbol`, `portfolio_id` | slot (core) | 자산 바인딩 | +| `asset_quantity` | hub (SnapshotStore) | portfolio-hub 권위값 (REQ-062) | +| `cash_balance` | hub (SnapshotStore) | portfolio-hub 권위값; `cash_estimated=true` 시 ledger outbox delta 추정값 | +| `cash_estimated` | ledger outbox | pending delta 있을 때 true (REQ-029) | +| `warned` | 파생 | `qty != asset_quantity` (REQ-074 AC-2) | +| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 | +| `paused` | PauseStore | durable pause overlay (REQ-032) | -- 워치독 신호 (REQ-034): `trex.snapshot.req` 처리는 read-only 응답 산출에 더해, 응답에 포함된 모든 `holding` 슬롯의 워치독 `last_seen_at` 을 갱신한다. 즉 전략 모듈이 스냅샷을 폴링하는 것 자체가 해당 슬롯의 생존 신호이며, 폴링이 끊긴(워치독 만료) 슬롯만 safety-sell 트리거 대상이 된다. (dcy-016: `touch_watchdog=false`/`actor="operator"` 요청은 이 갱신을 건너뛴다 — 운영자 조회 제외.) +- **strategy 필드 없음** (REQ-074 AC-5): `strategy_id`, `sell_strategy_id`, `strategy_params`, `active_strategy_id` 는 응답에 포함되지 않는다. +- `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호. +- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용. ### 운영자 CLI ↔ RPC 매핑 (dcy-016 / REQ-032) @@ -209,8 +133,8 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason | CLI | RPC 큐 | payload | 비고 | |---|---|---|---| -| `universe` | `trex.snapshot.req[.{instance}]` | `{view:"all", actor:"operator", touch_watchdog:false}` | 자산 목록 조회(워치독 억제) | -| `slots [--status all\|holding\|buyable\|pending]` | `trex.snapshot.req[.{instance}]` | `{view:"all", actor:"operator", touch_watchdog:false}` | envelope `slots[]`(실제 슬롯 상태) 표시(REQ-028 AC-4/REQ-032, BAT-022) — universe `assets[]` 아님. `--status` 는 슬롯 status 기준 client-side 필터 | +| `universe` | `trex.slots.req[.{instance}]` | `{filter:"inactive", spy:true}` | inactive 슬롯 목록 조회(워치독 억제) | +| `slots [--status all\|holding\|buyable\|pending\|error]` | `trex.slots.req[.{instance}]` | `{filter:"", spy:true}` — `buyable`→`filter:"inactive"` | 슬롯 상태 목록(server-side filter, 워치독 억제, BAT-035) | | `enter [--capital/--qty/--stop-loss/--take-profit/--forced-close-at ...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve; `--forced-close-at `→`forced_close_at`, REQ-032 CHG-20260609-01) | 안전장치 필수; `--forced-close-at` 형식은 제출 전 검증 | | `exit [--price]` | `trex.intent.req[.{instance}]` | sell intent(`slot_id`, optional `limit_price`) | 가격 미지정 시 시장가 | | `pause/resume/paused` | (RPC 아님) | — | direct-DB 유지(OQ-3) | @@ -251,8 +175,8 @@ resume_kill_switch 요청·응답 (REQ-046): 다중 인스턴스 운영 시 큐명과 라우팅을 `instance_key` 로 분리한다(dcy-009). -- **큐명 규칙**: `{base}.{instance_key}`(예: `trex.intent.req.inst-1`). `instance_key = "default"` 는 기존 리터럴 큐명(`trex.intent.req` 등)을 그대로 사용한다(하위호환, FR-5). -- **`trex.direct`(topic exchange)**: 특정 인스턴스 지정 전달. routing key = `{instance_key}.{channel_type}`(`channel_type ∈ {intent, snapshot, control}`). 각 인스턴스 큐가 자신의 routing key 로 바인딩 → `inst-1.intent` publish 는 `trex.intent.req.inst-1` 에만 도달. +- **큐명 규칙**: `{base}.{instance_key}`(예: `trex.intent.req.inst-1`). `instance_key = "default"` 는 기존 리터럴 큐명(`trex.intent.req` 등)을 그대로 사용한다(하위호환, FR-5). `trex.snapshot.req` → `trex.slots.req` 로 교체됨(BAT-035). +- **`trex.direct`(topic exchange)**: 특정 인스턴스 지정 전달. routing key = `{instance_key}.{channel_type}`(`channel_type ∈ {intent, slots, control}`). 각 인스턴스 큐가 자신의 routing key 로 바인딩 → `inst-1.intent` publish 는 `trex.intent.req.inst-1` 에만 도달. - **`trex.broadcast`(fanout exchange)**: 전역 제어. 각 인스턴스의 **control 큐가 dual 바인딩**되어, broadcast publish 는 모든 인스턴스 control 큐에 동시 전달(global kill-switch 등). 중복 전달은 핸들러 idempotency(REQ-036/advisory lock)로 무해. - **기동**: 데몬은 `--config .toml`(필수)로 `instance_key`/`portfolio_hub_token` 을 읽는다(`.env` 아님, REQ-048). @@ -524,4 +448,6 @@ resume_kill_switch 요청·응답 (REQ-046): - 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026). - 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후). - 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065). +- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`. +- 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01). - 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index eecc4eb..1a69cf6 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -23,7 +23,7 @@ - `trex-core-daemon` :: 슬롯 코어 데몬 (lifecycle + RabbitMQ 통신, BAT-002/BAT-007) - `trex-core-reset` :: DB 를 자산 중심 스키마로 wipe + 재초기화 (REQ-064, 운영자 실행 — 데이터 보존 cutover 대체; 파괴적·DB 전역, dry-run 기본) -- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007) +- RabbitMQ `trex.slots.req` :: 전략 모듈/운영자의 슬롯 읽기 RPC (request-reply, BAT-035 — REQ-028 `trex.snapshot.req` 대체) - RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007) - RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007) - SlotService :: 슬롯 생명주기 도메인 서비스 (transit / sell / thresholds / capacity — 자산 중심, BAT-030) @@ -38,18 +38,19 @@ - `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062) - `trex-ledger` :: ledger outbox 드레인 (REQ-019) - `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040) - - `trex-snapshot-rpc` :: `trex.snapshot.req` 소비자 (응답 holding 슬롯마다 워치독 `last_seen_at` 갱신 REQ-034) + - `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036) - `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043) - `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062) - `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039) 4. shutdown: `SIGINT`/`SIGTERM` → `stop.set()` → 모든 스레드 join + 모든 transport `close()`. HTTP 표면 0, 별도 포트 바인딩 없음. -## Flow: `스냅샷 RPC` (RabbitMQ `trex.snapshot.req`, BAT-007) +## Flow: `슬롯 RPC` (RabbitMQ `trex.slots.req`, BAT-035 — REQ-028 `trex.snapshot.req` 대체) -1. 전략 모듈이 RPC 요청을 `trex.snapshot.req` 큐로 publish — payload 에 `view`/`strategy_id`/`trading_date`, properties 에 `correlation_id` + `reply_to`(direct reply-to). -2. `trex-snapshot-rpc` 스레드가 메시지 수신 → `make_snapshot_rpc_handler(slot_repo, slot_service)` 로 슬롯 목록 + 가용성 계산. -3. 응답을 `reply_to` 큐로 publish (`correlation_id` 일치) → manual ack. -4. read-only 이므로 dedup 불요; 재요청 시 동일 응답을 다시 생성한다. +1. 전략 모듈 또는 운영자가 RPC 요청을 `trex.slots.req` 큐로 publish — payload 에 `filter`(status 필터) + `spy`(워치독 억제 여부), properties 에 `correlation_id` + `reply_to`. +2. `trex-slots-rpc` 스레드가 메시지 수신 → `make_slots_rpc_handler(slot_repo, ...)` 로 슬롯 목록 산출. +3. `spy=false`(기본) 면 holding 슬롯 `WatchdogStore.update(slot_id)` 호출(전략 폴링 = 생존 신호, REQ-034). +4. 응답 `{slots[], degraded, reason, message}` 를 `reply_to` 큐로 publish → manual ack. +5. read-only 이므로 dedup 불요; 재요청 시 동일 응답을 다시 생성한다. ## Flow: `매수/매도 intent admission RPC` (RabbitMQ `trex.intent.req`, BAT-007) @@ -112,30 +113,34 @@ 운영자 CLI(`trex-core`)는 조회·매매를 데몬 RabbitMQ RPC 로 통일해 단일 writer admission 과 불변식을 보존한다(DB 직접 쓰기 없음). pause/resume/paused 만 기존 direct-DB 유지(OQ-3). 1. 인스턴스 라우팅: `--config `(canonical, REQ-048 `instance_key`) 또는 `--instance `(단축·override), 미지정 시 `default`. 큐명은 `_instance_queue` 규칙(`{base}` 또는 `{base}.{instance}`). 모든 출력에 대상 인스턴스 표시(R-2). -2. `universe`/`slots`: `trex.snapshot.req` RPC(`actor="operator"`/`touch_watchdog=false`) → 응답 envelope 의 assets 출력. **워치독 억제**로 운영자 조회가 전략 생존 신호로 오인되지 않는다(REQ-034, R-4). `slots --status holding`→`view=held`. +2. `universe`/`slots`: `trex.slots.req` RPC(`spy=true`) → 응답 `slots[]` 출력. **워치독 억제**(`spy=true`)로 운영자 조회가 전략 생존 신호로 오인되지 않는다(REQ-034 CHG-01, R-4). `universe`→`filter=inactive`; `slots --status `→server-side `filter=`(`buyable`→`inactive`). BAT-035. 3. `enter `/`exit `: `trex.intent.req` buy/sell RPC. buy 는 portfolio_id 필수(symbol 서버 resolve, dcy-011), sell 은 slot_id + optional price(시장가 기본, dcy-012). admission 응답(`status/slot_id/reason`)을 표시, rejected 면 비0 종료. 4. enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON(대상 인스턴스·broker·요약, 기본 No) → `--yes`(paper 생략) → **live 는 `--yes --live` 이중 ack**(`TREX_KIS_ALLOW_LIVE` 배포 게이트 위 액션 게이트) → `--dry-run` 미제출 미리보기. `--json` 비대화형은 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러). 5. 실패 표시: RPC 타임아웃·데몬 미가동·라우팅 오류는 명확한 에러(비0 종료)로 표시(NFR-4). RPC 통일이라 데몬·RabbitMQ 미가동 시 조회 불가는 의도된 트레이드오프(R-3). -## Flow: `스냅샷 RPC 응답 산출` (BAT-008 / REQ-028, REQ-029) +## Flow: `슬롯 RPC 응답 산출` (BAT-035 / REQ-073/074) -`trex.snapshot.req` 메시지 처리. +`trex.slots.req` 메시지 처리 (`make_slots_rpc_handler`). -1. 요청 payload 디코드: `{view ∈ {all, buyable, held}, strategy_key?, trading_date?}`. -2. `slot_service.compute_slot_capacity(max_active_slots)` → `available_slots = available_count`(= M 설정 시 `max(0, M − active)`, 미설정 시 inactive 수; REQ-059). -3. `build_snapshot_envelope(store, pause_store, slot_repo, view, strategy_key, available_slots, trading_date)`: - - base 집합: ALL = `universe ∪ holding`, BUYABLE = `universe − (pending ∪ holding)`(`available_slots=0` 이면 `[]`), HELD = `holding 슬롯만`. - - 마스킹: pending(slot) OR warning(REQ-031) OR pause(REQ-032) — 어느 하나라도면 제외. slot overlay > portfolio-hub 보유 여부(불변식 A). - - `strategy_key` 일치(`==`) 직교 필터. - - degraded 상태가 있으면 `degraded=true + reason + sanitized message` 동봉. -4. envelope: `{assets[], view, available_slots, degraded, reason, message}` — `assets` 는 항상 배열, null 금지. 항목 필드는 `symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params`, **price 필드는 없음**. -5. 추정 현금 오버레이(dcy-018/REQ-029, OQ-4): 미정산(pending) ledger outbox 행이 있는 자산은 `cash_balance += pending cash delta`(매수 −, 매도 +) + `cash_estimated:true` 라벨로 노출 → option D write-back 도착 시 권위값으로 수렴(pending 제거). best-effort(실패 시 응답 불변). 수량은 슬롯 오버레이(REQ-029)가 정확값이라 추정 불요. +1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool}`. +2. `slot_repo.list_slots(instance_key)` → status 기준 서버 필터 적용(`inactive`/`pending`/`holding`/`error`; 미인식 = `all`). +3. `spy=false` 면 `slot_repo.list_holding_slots()` 의 모든 holding 슬롯에 `WatchdogStore.update(slot_id)`. +4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 → 병합 row 산출: + - slot 코어 필드: `slot_id/status/symbol/qty/avg_price/entry_price/portfolio_id/forced_close_at/universe_detached` + - hub 권위 필드: `asset_quantity`/`cash_balance` (SnapshotStore) + - 파생: `warned = (qty != asset_quantity)` (REQ-074 AC-2) + - 추정 현금(REQ-029 OQ-4): `cash_estimated=True` + `cash_balance += pending ledger delta`(best-effort) + - 워치독: `watchdog: {expired, last_seen}` inline (WatchdogStore) + - 오버레이: `paused` (PauseStore, REQ-032) + - strategy 필드 없음 (REQ-074 AC-5) +5. degraded 상태 동봉: `SnapshotStore.degraded_state()` → `{degraded, reason, message}`. +6. 응답: `{slots[], degraded, reason, message}` — `slots` 는 항상 배열, null 금지. ## Flow: `per-slot 워치독 갱신` (BAT-010/011 / REQ-034, REQ-040) 전략 모듈의 생존 신호를 추적해 자율 안전 매도가 정상 운용 슬롯에 개입하지 않게 한다. -1. `trex-snapshot-rpc` 가 `trex.snapshot.req` 를 처리할 때 응답에 포함된 모든 `holding` 슬롯에 대해 `WatchdogStore.update(slot_id)`(`last_seen_at = now`)를 호출한다. 전략 모듈이 스냅샷을 폴링하는 한 그 슬롯은 "활성"으로 판정된다. +1. `trex-slots-rpc` 가 `trex.slots.req` 를 처리할 때, 요청 `spy=false`(기본) 인 경우 모든 `holding` 슬롯에 대해 `WatchdogStore.update(slot_id)`(`last_seen_at = now`)를 호출한다. 전략 모듈이 슬롯 RPC를 폴링하는 한 그 슬롯은 "활성"으로 판정된다. `spy=true` 면 갱신 생략(운영자 조회 — REQ-034 CHG-01). 2. `update_thresholds` intent(REQ-040)도 수신 시 해당 슬롯 `last_seen_at` 을 리셋한다(임계값 갱신 = 생존 신호). 3. `WatchdogStore.is_expired(slot_id, TREX_WATCHDOG_TIMEOUT)` 가 True 이고 grace period(`TREX_WATCHDOG_GRACE_PERIOD`, 데몬 부팅 직후) 밖이면 그 슬롯은 워치독 만료 → forced_close/가격 트리거 대상이 된다. 4. in-memory store 라 데몬 재시작 시 초기화되며 grace period 가 자동 재시작된다(부팅 직후 오탐 방지). @@ -320,4 +325,6 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분: 1. **증액**: 보유 슬롯에 매수 intent(qty+price) → admission 이 `holding→pending_buy`(M 캡 미적용, active 수 불변) → dispatch 매수(qty=주문) → 체결 `pending_buy→holding`: 보유 qty=**브로커 잔고**, avg_price=가중평균(`(old_qty×old_avg+filled×fill)/new_total`, old_qty=브로커총−체결). 거부 시 브로커 잔고>0 → holding 복귀(기존 보유 유지), 0 → inactive. 2. **부분 감액**: 보유 슬롯에 매도 intent(qty+price) → `holding→pending_sell`(slot.qty=주문) → dispatch 매도 `min(주문, 브로커잔고)` → 체결 후 브로커 잔고>0 → `pending_sell→holding`(잔량, cost basis 유지) / 0 → `pending_sell→inactive`. 전량 매도(qty 미지정, safety-sell/kill-switch)는 slot.qty=None → dispatch=브로커 잔고. 3. **부분 체결**: 실제 체결분만 ledger(REQ-018/019)·슬롯 반영(브로커 정본 resync), 미체결 잔량은 core 추적 안 함 — 전략이 다음 스냅샷 기준 재발행. +- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: `trex-snapshot-rpc` → `trex-slots-rpc` worker, `make_snapshot_rpc_handler` → `make_slots_rpc_handler`, `build_snapshot_envelope`/`View` 제거. 슬롯 RPC 응답 산출 flow, 워치독 spy=true 갱신 억제 flow 추가. 운영자 CLI flow: `trex.snapshot.req` → `trex.slots.req` (REQ-073/074/075). +- 2026-06-11 :: BAT-036 (dcy-024) — 데몬 lifecycle: `safety_sell_enabled=false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-048 CHG-01/REQ-005 CHG-01). - 2026-06-10 :: BAT-031 (dcy-021) — 포지션 사이징(증액·부분 감액) 흐름 추가, transit 13 전이. diff --git a/docs/srs/Configuration/req-048_per-instance-toml-config.md b/docs/srs/Configuration/req-048_per-instance-toml-config.md index d51e9aa..4e0d447 100644 --- a/docs/srs/Configuration/req-048_per-instance-toml-config.md +++ b/docs/srs/Configuration/req-048_per-instance-toml-config.md @@ -29,13 +29,14 @@ 7. (CHG-20260609-01) Given `--config [--config-path ]`, When `resolve_config_toml`, Then `/.toml` 로 해석되고 `.toml` 이 없으면 자동 부착된다. 8. (CHG-20260609-01) Given 슬래시 포함(경로형) `--config` 값, When 해석, Then `--config-path` 와 무관하게 명시 경로로 그대로 사용된다(하위호환). 9. (CHG-20260609-01) Given 운영자 CLI 가 `--config-path` 와 함께 호출, When `_resolve_instance`, Then 동일 `resolve_config_toml` 규칙으로 TOML 을 찾아 instance_key 를 해석한다. +10. (CHG-20260611-01) Given per-instance TOML `safety_sell_enabled`(부재 시 `true`), When `from_toml`/`from_config`, Then 값이 파싱되어 `DaemonConfig` 로 전파된다. ## Impacted Area - Docs: - `docs/project-structure.md` — 설정 파일 계층(TOML + env) (REQ-051 처리) - Modules: - - `trex_core/runtime/config.py` — `DaemonInstanceConfig` + `from_toml` + `from_config` 병합, (CHG-20260609-01) `resolve_config_toml`/`DEFAULT_CONFIG_DIR` + - `trex_core/runtime/config.py` — `DaemonInstanceConfig` + `from_toml` + `from_config` 병합, (CHG-20260609-01) `resolve_config_toml`/`DEFAULT_CONFIG_DIR`, (CHG-20260611-01) `safety_sell_enabled` 필드 - 데몬 진입점(`trex_core/runtime/daemon.py:main` 또는 `__main__`) — `--config` 필수 파싱(argparse), (CHG-20260609-01) `--config-path` - 운영자 CLI `trex_core/runtime/cli.py` — (CHG-20260609-01) `--config-path` + `_resolve_instance` 통일 - `.gitignore` — 인스턴스 TOML 패턴 @@ -269,3 +270,44 @@ #### Validation Plan - tests/runtime/test_config_resolution.py(resolve_config_toml/경로형 폴백/CLI `--config-path` 라우팅) + 라이브 스모크(name·path 형태 동일 결과). 전체 375 passed, ruff clean. + +### CHG-20260611-01 + +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-024) per-instance TOML 에 **`safety_sell_enabled: bool = True`** 키 추가 — 자율 청산(safety-sell 워커) 인스턴스별 ON/OFF. `DaemonInstanceConfig.from_toml` 파싱 + `from_config` → `DaemonConfig` 전파. 기본값 `true`(미지정 시 기존 동작). env 미노출(`max_active_slots`/`default_order_timeout_sec` 선례, dcy-024 OQ-2). 실제 워커 게이팅은 REQ-005 CHG-20260611-01. SafetySellConfig(env) 변경 없음. + +#### Intent + +- 당일 매매 인스턴스(enabled=true)와 중장기 보유 인스턴스(enabled=false)가 인스턴스마다 다른 자율 청산 모드를 갖도록 운영 모드를 TOML 로 일원화(dcy-024 FR-1, issue #15). + +#### Acceptance Criteria Delta + +- Added: + - AC-10: per-instance TOML `safety_sell_enabled` optional 키(부재 시 `true`)를 `DaemonInstanceConfig.from_toml` 가 파싱하고 `from_config` 가 `DaemonConfig` 로 전파한다. +- Updated/Removed: none + +#### Impacted Area Delta + +- Modules: `trex_core/runtime/config.py`(`DaemonInstanceConfig.safety_sell_enabled` + from_toml/from_config 전파) +- Docs: `config/instance.example.toml` +- Tests: tests/runtime/test_instance_config.py(기본값 true / TOML false 전파) + +#### Delivery Trace Delta + +- Batches: 신규 batch(REQ-005 CHG-20260611-01 과 동일 배치, dcy-024) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (신규 batch 에서 AC-10 검증) +- Existing Implementation Invalidated: no (optional 키 additive, 기존 TOML 호환) — 단 신규 AC 미구현 +- Status Recommendation: Implemented → Approved (신규 AC-10 미구현, batch 재검증 후 Implemented 복귀) +- Follow-up Action: draft-batch → run-batch-implementation → verification → confirm-req-implemented + +#### Validation Plan + +- 신규 BAT: `from_toml` 기본값(true) + TOML `safety_sell_enabled = false` from_config 전파 단위. diff --git a/docs/srs/Data/req-074_merged-slot-row-schema.md b/docs/srs/Data/req-074_merged-slot-row-schema.md new file mode 100644 index 0000000..c341add --- /dev/null +++ b/docs/srs/Data/req-074_merged-slot-row-schema.md @@ -0,0 +1,71 @@ +# req-074: 슬롯 단일 행 스키마 — 자산·워치독·overlay 흡수 + 필드별 정본 라벨 + +- Status: Implemented +- Type: Data +- Priority: High +- Owner: project maintainers + +## Intent +- `trex.slots.req`(REQ-073) 응답의 슬롯 행 하나가 슬롯 상태·자산 데이터·워치독·overlay 를 모두 운반해 소비자 측 배열 조인(assets[]/watchdog[])을 없앤다. 각 필드의 정본 출처(hub/슬롯·코어/워치독/overlay)를 계약에 라벨해 divergence 시 어느 값을 믿을지 모호하지 않게 한다. (dcy-025 FR-3, OQ-3 (a)) + +## Requirement +- `slots[]` 각 행은 아래 필드를 운반한다(필드별 정본 출처 명시): + - **슬롯/코어 정본**: `slot_id`, `status`(inactive/pending_buy/holding/pending_sell/error), `portfolio_id`, `symbol`, `avg_price`, `limit_price`, `forced_close_at`(+기존 슬롯 행 필드 유지) + - **수량(REQ-062 정합, 둘 다 노출)**: `asset_quantity`(**hub 정본** — 전략 reasoning primary) + `qty`(**슬롯 미러** — 코어 confirmed local). 정상 시 일치하며, 어긋나면 `warned=true` 로 표면화한다. 라이브 브로커 잔고는 행에 포함하지 않는다(청산/reconcile 시점 정본 — REQ-062). + - **현금(hub 정본)**: `cash_balance` + `cash_estimated`(bool, dcy-018 추정 현금 오버레이) + - **워치독(WatchdogStore)**: `watchdog: {expired: bool, last_seen: ISO-8601|null}` nested object — 기존 응답 top-level `watchdog[]` 배열은 폐기(REQ-073). + - **overlay 플래그(직교 축)**: `paused`(PauseStore), `warned`(reconcile/REQ-031·062) — status 와 독립이며 항상 노출(서버측 overlay 필터는 후속). +- strategy 계열 필드(`strategy_id`/`sell_strategy_id`/`strategy_params`/`active_strategy_id`)는 행에 포함하지 않는다(REQ-073, dcy-025 OQ-4 — 후속 discovery 확정 시 additive 재도입). +- `docs/interface-contract.md` 에 각 필드의 정본 출처(hub / 슬롯·코어 / 브로커(행 미포함) / watchdog / overlay)를 표로 명시한다(REQ-075). + +## Acceptance Criteria +1. Given holding 자산 슬롯, When `trex.slots.req` 조회, Then 행에 슬롯 상태(slot_id/status/avg_price/limit_price/forced_close_at)·자산(symbol/portfolio_id/cash_balance/asset_quantity)·`qty` 미러·`watchdog:{expired,last_seen}`·`paused`/`warned` 가 모두 포함된다(별도 배열 조인 불요). +2. Given hub `asset_quantity` 와 슬롯 미러 `qty` 가 어긋난 자산, When 조회, Then 두 값이 그대로 노출되고 `warned=true` 다(조용한 자동 교정·숨김 없음). +3. Given 추정 현금 구간(dcy-018), When 조회, Then `cash_balance` 와 `cash_estimated=true` 가 함께 노출되고 권위값 수렴 시 `cash_estimated=false` 로 돌아온다. +4. Given paused 처리된 holding 자산, When `filter=holding` 조회, Then 행이 반환되며 `paused=true` 플래그로 표시된다(필터는 status, overlay 는 플래그 — 직교). +5. Given 행 스키마, When 키 검사, Then strategy 계열 필드가 없다. + +## Impacted Area +- Docs: docs/interface-contract.md(행 스키마 + 정본 라벨 표 — REQ-075), docs/data-model.md(노출 스키마 참조) +- Modules: trex_core/application/portfolio.py(`slot_to_dict` 확장 — 자산·워치독·overlay 병합), trex_core/application/messaging_handlers.py(`_attach_watchdog` 제거·행 흡수) +- Tests: tests/application/(행 필드·정본·divergence·overlay 플래그) + +## Notes +- 수량 정본 모델은 REQ-062(hub 정본 + 슬롯 미러 + 브로커 ground truth)를 그대로 따른다 — 본 REQ 는 그 정본을 행에 표현·라벨하는 계약이다. +- NFR-2(단일 표면·조인 불필요)는 AC-1 에 흡수. +- 출처: dcy-025 FR-3(OQ-2/3). + +## Change Log + +### CHG-20260611-01 +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary +- dcy-025 FR-3(OQ-3 (a)) 로부터 최초 생성(Proposed). 슬롯 단일 행 스키마(자산·워치독·overlay 흡수 + hub 정본/슬롯 미러 둘 다 노출 + 정본 라벨). + +#### Intent +- 병렬 배열 조인 제거 + 필드별 정본 명확화. + +#### Acceptance Criteria Delta +- Added: + - AC-1~5 신규 +- Updated/Removed: none + +#### Impacted Area Delta +- Docs: interface-contract/data-model +- Modules: portfolio.py(slot_to_dict)/messaging_handlers.py +- Tests: 행 스키마/divergence/overlay + +#### Delivery Trace Delta +- Batches: none(승인 후 draft-batch) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no(신규) +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current(Proposed) +- Follow-up Action: confirm-req + +#### Validation Plan +- 신규 batch: 행 필드 단언, qty↔asset_quantity divergence+warned, cash_estimated 수렴, paused holding 직교, strategy 필드 부재. diff --git a/docs/srs/Documentation/req-075_slots-read-surface-baseline-alignment.md b/docs/srs/Documentation/req-075_slots-read-surface-baseline-alignment.md new file mode 100644 index 0000000..dfedfcb --- /dev/null +++ b/docs/srs/Documentation/req-075_slots-read-surface-baseline-alignment.md @@ -0,0 +1,61 @@ +# req-075: slots read 표면 baseline 문서 정합 + +- Status: Implemented +- Type: Documentation +- Priority: Medium +- Owner: project maintainers + +## Intent +- `trex.snapshot.req`→`trex.slots.req` 표면 교체(REQ-073)·슬롯 단일 행 스키마(REQ-074)를 baseline 문서에 반영해, REQ/batch/운영이 참조하는 계약 문서가 실제 표면과 어긋나지 않게 한다. (dcy-025 FR-7) + +## Requirement +- `docs/interface-contract.md`: `trex.snapshot.req` 섹션(요청 view/strategy_key, 응답 assets[]/available_slots/watchdog[])을 제거하고 `trex.slots.req` 계약(요청 {filter, spy}, 응답 {slots[], degraded/reason/message}, 행 스키마 + **필드별 정본 출처 표**(hub/슬롯·코어/브로커(행 미포함)/watchdog/overlay))으로 대체한다. 토폴로지 표의 큐명도 갱신한다. +- `docs/runtime-flows.md`: 전략/운영자 read 흐름을 새 표면(slots.req, filter/spy, 워치독 터치 시맨틱)으로 갱신하고 snapshot envelope 흐름 서술을 제거·대체한다. +- 제거되는 표면(view 모드, assets[], available_slots, watchdog[] 배열)이 문서 어디에도 현행 계약으로 남지 않는다(이력/Change Log 는 보존). + +## Acceptance Criteria +1. Given `docs/interface-contract.md`, When 검토, Then `trex.slots.req` 계약(요청/응답/행 스키마/정본 표)이 기술되어 있고 `trex.snapshot.req` 가 현행 계약으로 남아 있지 않다. +2. Given `docs/runtime-flows.md`, When 검토, Then read 흐름이 slots.req(filter/spy/워치독 시맨틱) 기준으로 기술되어 있다. +3. Given 저장소 전체 문서, When grep(`trex.snapshot.req`/`available_slots`/`assets[]` 현행 서술), Then 현행 계약으로서의 잔존 서술이 없다(이력 제외). + +## Impacted Area +- Docs: docs/interface-contract.md, docs/runtime-flows.md +- Modules: 없음(문서 전용) +- Tests: 없음(문서 검토) + +## Notes +- REQ-073/074 와 같은 batch 에서 구현과 동시 정합(코드-문서 drift 방지). +- 출처: dcy-025 FR-7. + +## Change Log + +### CHG-20260611-01 +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary +- dcy-025 FR-7 로부터 최초 생성(Proposed). interface-contract/runtime-flows 의 snapshot→slots 표면 정합. + +#### Intent +- 표면 교체의 계약 문서 동기화. + +#### Acceptance Criteria Delta +- Added: + - AC-1~3 신규 +- Updated/Removed: none + +#### Impacted Area Delta +- Docs: interface-contract/runtime-flows + +#### Delivery Trace Delta +- Batches: none(승인 후 draft-batch) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no(신규) +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current(Proposed) +- Follow-up Action: confirm-req + +#### Validation Plan +- 신규 batch verification 에서 문서 검토 + grep 잔존 확인. diff --git a/docs/srs/Installation/req-005_slot-core-daemon-runtime.md b/docs/srs/Installation/req-005_slot-core-daemon-runtime.md index 73ad4b8..66211ee 100644 --- a/docs/srs/Installation/req-005_slot-core-daemon-runtime.md +++ b/docs/srs/Installation/req-005_slot-core-daemon-runtime.md @@ -16,11 +16,13 @@ 2. Given 기동된 데몬, When 종료 시그널, Then 진행 중 요청을 정리하고 graceful shutdown 한다. 3. Given 데몬 lifecycle, When 거래일 시작(기동/스케줄), Then 슬롯 preallocate 가 멱등하게 1회 수행된다(공개 엔드포인트 없이). 4. Given `pyproject.toml`, When 확인, Then FastAPI·uvicorn·psycopg 의존성과 데몬 console_scripts 가 존재한다. +5. (CHG-20260611-01) Given `safety_sell_enabled=false`, When `daemon.start()`, Then 스레드 목록에 `trex-safety-sell` 이 없고 RUNNING 로그에 `safety_sell_enabled=false` + `no autonomous liquidation` 경고가 포함된다. `true`(기본)이면 기존과 동일. +6. (CHG-20260611-01) Given `safety_sell_enabled` 임의 값, When 데몬 동작, Then `ExecutionService`·운영자 CLI exit/cancel·kill-switch·전략 sell intent 는 영향받지 않는다(자율 청산 워커만 게이팅). ## Impacted Area -- Docs: docs/runtime-flows.md(데몬 흐름), docs/project-structure.md(런타임 레이어) — REQ-009 에서 정합 -- Modules: pyproject.toml, trex_core/ 하위 데몬 런타임/엔트리포인트(애플리케이션 레이어) -- Tests: 데몬 기동/healthz/shutdown 통합테스트 +- Docs: docs/runtime-flows.md(데몬 흐름; CHG-20260611-01 safety-sell 비활성 모드 플로우), docs/project-structure.md(런타임 레이어) — REQ-009 에서 정합 +- Modules: pyproject.toml, trex_core/ 하위 데몬 런타임/엔트리포인트(애플리케이션 레이어), (CHG-20260611-01) `trex_core/runtime/daemon.py` `start()` 워커 조건부 구성 +- Tests: 데몬 기동/healthz/shutdown 통합테스트, (CHG-20260611-01) safety-sell 워커 게이팅 ## Notes - 프레임워크 FastAPI+uvicorn(dcy-002 OQ-2). API 엔드포인트 자체는 REQ-007/008, 영속화는 REQ-006. @@ -229,3 +231,45 @@ #### Validation Plan - dcy-020 reconcile 배치 verification 으로 대체. + +### CHG-20260611-01 + +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-024) 데몬 부트스트랩 `start()` 의 workers 구성을 **`safety_sell_enabled`(REQ-048 CHG-20260611-01) 조건부**로 한다 — `false` 면 `trex-safety-sell` 워커 스레드를 workers 리스트(`daemon.py:335`)에 포함하지 않는다(스레드 미기동이 물리적으로 자율 청산 차단, run_once if-가드보다 강함). 기동 RUNNING 로그에 `safety_sell_enabled=` 를 출력하고 `false` 면 `no autonomous liquidation` 경고를 남긴다. `ExecutionService`·운영자 CLI(`enter`/`exit`/`cancel`)·kill-switch·messaging 핸들러는 불변(자율 청산 워커만 OFF). `docs/runtime-flows.md` 에 비활성 모드 플로우 반영. + +#### Intent + +- 관찰 전용·중장기 보유 인스턴스에서 자율 청산(forced_close/SL·TP/장마감)을 끄고 전략·운영자 지시 매도만 허용(dcy-024 FR-2/FR-3, issue #15). + +#### Acceptance Criteria Delta + +- Added: + - AC-5: `safety_sell_enabled == False` 이면 `daemon.start()` 후 스레드 목록에 `trex-safety-sell` 이 없고, RUNNING 로그에 `safety_sell_enabled=false` + `no autonomous liquidation` 경고가 포함된다. `True`(기본)이면 기존과 동일 기동 + 회귀 없음. + - AC-6: `safety_sell_enabled` 와 무관하게 `ExecutionService`·운영자 CLI exit/cancel·kill-switch·전략 sell intent 는 정상 동작한다(자율 청산 워커만 게이팅). +- Updated/Removed: none + +#### Impacted Area Delta + +- Modules: `trex_core/runtime/daemon.py`(`start()` workers 조건부 구성 + 기동 로그) +- Docs: `docs/runtime-flows.md`(safety-sell 비활성 모드 플로우) +- Tests: tests/runtime/(enabled=False → `trex-safety-sell` 스레드 부재, 로그 경고; enabled=True 회귀) + +#### Delivery Trace Delta + +- Batches: 신규 batch(REQ-048 CHG-20260611-01 과 동일 배치, dcy-024) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (신규 batch 에서 AC-5/6 검증) +- Existing Implementation Invalidated: no (기본 true 시 기존 동작 불변, 조건부 게이팅 additive) — 단 신규 AC 미구현 +- Status Recommendation: Implemented → Approved (신규 AC-5/6 미구현, batch 재검증 후 Implemented 복귀) +- Follow-up Action: draft-batch → run-batch-implementation → verification → confirm-req-implemented + +#### Validation Plan + +- 신규 BAT: `enabled=False` config 주입 + `daemon.start()` 후 thread 이름에 `trex-safety-sell` 부재 단위, RUNNING 로그 경고, run-the-app 스모크(비활성 모드 holding 유지·전략 sell 정상), enabled=True 회귀. diff --git a/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md b/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md index 0b84351..2d7f4dd 100644 --- a/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md +++ b/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md @@ -262,3 +262,46 @@ #### Validation Plan - BAT-021: snapshot 응답 watchdog 필드 shape 단위. + +### CHG-20260611-01 + +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-025) 토폴로지의 스냅샷 RPC 큐를 **`trex.snapshot.req` → `trex.slots.req`** 로 교체한다(즉시 교체, 과도기 없음 — dcy-025 OQ-1). 요청 payload 매핑 원칙도 갱신: `view`/`strategy_id` 대신 **`filter`/`spy`**(REQ-073). 응답은 slots[]+health 만(REQ-073/074). intent RPC·이벤트 pub/sub 토폴로지는 불변. 전략 모듈(trex-orb-v1) 소비 측 lockstep cutover 필요. + +#### Intent + +- 자산 중심 모델의 read 표면 재설계(dcy-025)에 따른 토폴로지·매핑 계약 정합. + +#### Acceptance Criteria Delta + +- Updated: + - AC-1: 기동 시 선언되는 read 큐가 `trex.snapshot.req` 가 아니라 `trex.slots.req`(per-instance)다. + - AC-2: read RPC 응답의 logical 필드 기준이 snapshot envelope(assets/view) 가 아니라 REQ-073/074 의 slots 계약이다. + - AC-5: 요청 payload 매핑 원칙의 필드가 `view`/`strategy_id` → `filter`/`spy` 로 바뀐다(payload 에 싣는 원칙은 유지). +- Added/Removed: none + +#### Impacted Area Delta + +- Modules: trex_core/adapters/messaging/topology.py(SNAPSHOT_REQ_QUEUE→SLOTS_REQ_QUEUE), messaging_handlers.py +- Docs: docs/interface-contract.md(REQ-075) +- Tests: 토폴로지 선언/큐명 단위 + +#### Delivery Trace Delta + +- Batches: 신규 batch(REQ-073/074/075 와 동일 배치, dcy-025) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (신규 batch 에서 갱신 AC 검증) +- Existing Implementation Invalidated: yes (snapshot 큐·view 매핑의 기존 evidence 가 표면 교체로 무효화) +- Status Recommendation: Implemented → Approved (batch 재구현·재검증 후 복귀) +- Follow-up Action: draft-batch → 구현/검증 → confirm-req-implemented + +#### Validation Plan + +- 신규 BAT: 토폴로지 선언(slots.req 존재·snapshot.req 부재) 단위 + filter/spy payload 매핑 단위. diff --git a/docs/srs/Interface/req-028_snapshot-read-message-contract-and-envelope.md b/docs/srs/Interface/req-028_snapshot-read-message-contract-and-envelope.md index 50d008d..682a9d3 100644 --- a/docs/srs/Interface/req-028_snapshot-read-message-contract-and-envelope.md +++ b/docs/srs/Interface/req-028_snapshot-read-message-contract-and-envelope.md @@ -1,6 +1,7 @@ # req-028: 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호 -- Status: Implemented +- Status: Deprecated +- Deprecated-By: REQ-073, REQ-074 (BAT-035, dcy-025, 2026-06-11) - Type: Interface - Priority: High - Owner: project maintainers @@ -256,3 +257,41 @@ - Author: Justin Ko - Type: status - Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음. + +### CHG-20260611-01 + +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-025) 본 REQ 가 소유한 snapshot read 계약(`trex.snapshot.req` 요청 view/strategy_key, 응답 envelope `assets[]`/`available_slots`/`watchdog[]`/degraded)이 **폐기 예정** — `trex.slots.req`(REQ-073) + 슬롯 단일 행 스키마(REQ-074)로 대체된다(즉시 교체, dcy-025 OQ-1). degraded/reason/message health 신호 의미는 REQ-073 으로 승계. 현재 코드는 불변이므로 Implemented 유지(keep-current, REQ-005 CHG-20260610-01 선례), dcy-025 delivery 배치에서 표면 제거와 함께 **Deprecated** 전환. + +#### Intent + +- snapshot→slots 표면 교체의 추적성 forward-link. + +#### Acceptance Criteria Delta + +- Updated: 없음(dcy-025 delivery 배치에서 본 REQ 의 envelope AC 폐기) + +#### Impacted Area Delta + +- Docs: dcy-025, REQ-073/074/075 +- Modules: (예정) messaging_handlers.py snapshot 핸들러·portfolio.py envelope 빌더 제거/대체 + +#### Delivery Trace Delta + +- Batches: (예정) dcy-025 delivery 배치 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no (현 시점 — 코드 불변) +- Existing Implementation Invalidated: no (배치 구현 시점에 무효화·Deprecated 전환) +- Status Recommendation: keep-current +- Follow-up Action: dcy-025 배치에서 표면 제거 + 본 REQ Deprecated 전환. + +#### Validation Plan + +- dcy-025 배치 verification 으로 대체(snapshot 표면 부재 + slots 표면 계약 검증). diff --git a/docs/srs/Interface/req-034_per-slot-watchdog.md b/docs/srs/Interface/req-034_per-slot-watchdog.md index fea5610..784f82b 100644 --- a/docs/srs/Interface/req-034_per-slot-watchdog.md +++ b/docs/srs/Interface/req-034_per-slot-watchdog.md @@ -191,3 +191,46 @@ #### Validation Plan - BAT-021: 워치독 만료 holding 슬롯의 expired/last_seen 노출 단위. + +### CHG-20260611-01 + +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-025) 워치독 갱신 트리거 표면을 `trex.snapshot.req` → **`trex.slots.req`**(REQ-073)로 정합한다. 갱신 시맨틱: 기본 조회(holding 슬롯 포함)가 `last_seen_at` 을 터치하고, **`spy=true` 면 무터치**(기존 `actor="operator"`/`touch_watchdog=false` ad-hoc 플래그의 일반화·대체). 응답 노출은 top-level `watchdog[]` 배열이 아니라 슬롯 행의 `watchdog:{expired,last_seen}`(REQ-074). 만료 판정·grace period·in-memory store 는 불변. + +#### Intent + +- read 표면 교체 후에도 전략 폴링=생존 신호 / 비침습 조회 구분(R-4)을 보존. + +#### Acceptance Criteria Delta + +- Updated: + - 워치독 갱신 트리거: `trex.snapshot.req` 처리 시 → `trex.slots.req` 기본 조회 시(holding 슬롯 last_seen 갱신). + - 비침습 조회: `actor="operator"`/`touch_watchdog=false` → `spy=true`(무터치). + - 노출: 응답 `watchdog[]` 배열 → 슬롯 행 `watchdog:{expired,last_seen}`. +- Added/Removed: none + +#### Impacted Area Delta + +- Modules: messaging_handlers.py(터치 분기), portfolio.py(slot_to_dict watchdog 흡수) +- Docs: docs/interface-contract.md(REQ-075) +- Tests: spy 무터치/기본 터치, 행 watchdog 필드 + +#### Delivery Trace Delta + +- Batches: 신규 batch(REQ-073/074/075 와 동일 배치, dcy-025) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (신규 batch 에서 갱신 AC 검증) +- Existing Implementation Invalidated: yes (snapshot 표면 기반 터치·watchdog[] 노출 evidence 가 표면 교체로 무효화) +- Status Recommendation: Implemented → Approved (batch 재구현·재검증 후 복귀) +- Follow-up Action: draft-batch → 구현/검증 → confirm-req-implemented + +#### Validation Plan + +- 신규 BAT: 기본 조회 터치/spy 무터치 단위(REQ-073 AC-4 와 공유), 행 watchdog 필드(REQ-074 AC-1), 만료·grace 회귀. diff --git a/docs/srs/Interface/req-063_unified-operator-slot-view.md b/docs/srs/Interface/req-063_unified-operator-slot-view.md index ccdb3b5..a317970 100644 --- a/docs/srs/Interface/req-063_unified-operator-slot-view.md +++ b/docs/srs/Interface/req-063_unified-operator-slot-view.md @@ -90,3 +90,46 @@ - Author: Justin Ko - Type: status - Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-029(+BAT-030 trading_date 키 제거·buyable=inactive 필터) — 모든 AC 가 release-candidate 배치 verification 의 evidence 와 연결됨(단위 367/PG 29/MQ 21 passed + run-the-app 자산 중심 e2e). Blocking Issues 없음. + +### CHG-20260611-01 + +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-025) 운영자 CLI(`trex-core slots`/`universe`)의 데몬 RPC 를 `trex.snapshot.req` → **`trex.slots.req`**(REQ-073)로 교체한다. 운영자 조회는 **`spy=true`** 로 발신(기존 `actor="operator"`/`touch_watchdog=false` 대체 — 비침습 유지). `--status` 필터는 새 `filter`(status 단일: all/inactive/pending/holding/error)로 매핑. 출력 행은 REQ-074 병합 스키마(asset_quantity+qty 미러+warned, cash_balance+cash_estimated, watchdog nested, paused) 사용. universe↔slots 단일 표면 의도(본 REQ Intent)는 새 표면에서 완성된다. + +#### Intent + +- 운영자 단일 뷰를 새 read 표면 위로 이전(비침습·필터·병합 행 유지). + +#### Acceptance Criteria Delta + +- Updated: + - AC-1~3 의 underlying RPC 가 `trex.slots.req` 이고, 조회는 `spy=true`(워치독 무터치)로 수행된다. `--status` → `filter` 매핑(buyable→inactive 동치 포함). +- Added: + - AC-4: `slots` 출력 행에 REQ-074 병합 필드(asset_quantity/qty/warned/cash_balance/cash_estimated/watchdog/paused)가 표시된다. +- Removed: none + +#### Impacted Area Delta + +- Modules: trex_core/runtime/cli.py(`_cmd_slots`/`_cmd_universe` — 새 큐·spy·filter·행 필드) +- Docs: docs/interface-contract.md CLI↔RPC 매핑(REQ-075) +- Tests: tests/runtime/test_cli_operator.py(새 payload·행 표시) + +#### Delivery Trace Delta + +- Batches: 신규 batch(REQ-073/074/075 와 동일 배치, dcy-025) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (신규 batch 에서 갱신 AC 검증) +- Existing Implementation Invalidated: yes (snapshot RPC 기반 CLI evidence 가 표면 교체로 무효화) +- Status Recommendation: Implemented → Approved (batch 재구현·재검증 후 복귀) +- Follow-up Action: draft-batch → 구현/검증 → confirm-req-implemented + +#### Validation Plan + +- 신규 BAT: CLI payload(`trex.slots.req`/spy=true/filter 매핑) 단위 + 행 필드 표시 단위 + buyable→inactive 동치 회귀. diff --git a/docs/srs/Interface/req-073_slots-req-rpc-contract.md b/docs/srs/Interface/req-073_slots-req-rpc-contract.md new file mode 100644 index 0000000..59053e7 --- /dev/null +++ b/docs/srs/Interface/req-073_slots-req-rpc-contract.md @@ -0,0 +1,70 @@ +# req-073: 전략 read 표면 — `trex.slots.req` RPC 계약 (filter/spy, snapshot.req 즉시 교체) + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent +- 자산 중심 영구 슬롯 모델(dcy-020)에서 전략 read 표면을 슬롯 단일 테이블 RPC 로 재설계한다. `trex.snapshot.req`(view/assets[]/available_slots/watchdog[] 구조)를 폐기하고 `trex.slots.req` 하나로 교체해, 전략·운영자가 슬롯 테이블 하나로 universe·포지션·워치독을 읽게 한다. (dcy-025 FR-1/2/4/5/6) + +## Requirement +- per-instance 큐 `trex.slots.req`(기존 큐명 규칙 — default 는 literal, 그 외 `.{instance_key}` 접미)를 신설하고 `trex.snapshot.req` 파이프라인(큐 선언·핸들러·legacy stub 경로)을 **같은 배포에서 제거**한다(즉시 교체, 과도기 없음 — 전략 모듈 lockstep cutover, dcy-025 OQ-1). +- 요청 payload: `{filter?: str, spy?: bool}`. + - `filter` 는 **status 단일 값**(상호배타) `{all, inactive, pending, holding, error}`. 미지정 시 `all`. `pending` = `pending_buy ∪ pending_sell`. `inactive` 판정 키는 슬롯 **status**(hub `asset_quantity` 아님). 파생 view(ALL/BUYABLE/HELD)와 `View` enum 은 폐기한다 — 매수 가능 목록 = `filter=inactive`. + - `spy=true` 면 워치독을 갱신하지 않는다(생존 신호 미발생 — 기존 `actor="operator"`/`touch_watchdog=false` 의 일반화, REQ-034 정합). 기본 `false`(holding 슬롯 워치독 터치). +- 응답 payload: `{slots: [...], degraded: bool, reason, message}` **만**. `assets[]`·`available_slots`·`watchdog[]` 는 응답에 포함하지 않는다(워치독은 슬롯 행에 흡수 — REQ-074). capacity 사전 확인은 제공하지 않으며 admission(`capacity_full`)이 게이트다. +- `strategy_id`/`strategy_key`/`strategy_params`/`active_strategy_id` 는 본 표면에서 제외한다(요청 필터·응답 필드 모두 없음). 인스턴스=다중 전략이나 전략↔자산 할당 모델은 portfolio-hub 주도 후속 discovery — 확정 시 strategy 필드를 additive 로 재도입한다(dcy-025 OQ-4). + +## Acceptance Criteria +1. Given 기동된 데몬, When 토폴로지 선언, Then `trex.slots.req`(per-instance) 큐가 존재하고 `trex.snapshot.req` 큐·핸들러·legacy stub 경로는 존재하지 않는다. +2. Given `{filter: "inactive"}` 요청, When 처리, Then status 가 `inactive` 인 슬롯 행만 반환된다(매수 가능 목록). `{filter: "pending"}` 은 `pending_buy ∪ pending_sell`, 미지정은 전체. 잘못된 filter 값은 `all` 또는 명시 거부로 일관 처리된다. +3. Given 응답 payload, When 키 집합 검사, Then `slots`/`degraded`/`reason`/`message` 만 있고 `assets`·`available_slots`·`watchdog`·strategy 계열 키는 없다. +4. Given `spy=true` 요청, When holding 슬롯 포함 조회, Then 워치독 `last_seen_at` 이 갱신되지 않는다. Given 기본(`spy` 미지정/false) 요청, Then holding 슬롯 워치독이 갱신된다(REQ-034 시맨틱 보존). +5. Given degraded 상태(universe fetch 실패 등), When 조회, Then `degraded=true` + `reason` 이 기존 REQ-028 신호 의미대로 운반된다. + +## Impacted Area +- Docs: docs/interface-contract.md(snapshot→slots 계약 — REQ-075), docs/runtime-flows.md +- Modules: trex_core/adapters/messaging/topology.py(큐명), trex_core/application/messaging_handlers.py(slots 핸들러·snapshot 핸들러 제거), trex_core/application/portfolio.py(`View` enum 폐기) +- Tests: tests/application/(slots 핸들러 filter/spy/응답 키 집합), tests/adapters/messaging/(토폴로지) + +## Notes +- 즉시 교체(과도기 없음)이므로 전략 모듈(trex-orb-v1) 소비 측을 같은 전환점에 lockstep 변경해야 한다(dcy-025 R-1/NFR-3) — 배포 전 orb-v1 의 `trex.slots.req` 대응 준비 확인. dcy-020 cutover 선례와 동일하게 단일 전환점 관리. +- 행 스키마(자산·워치독·overlay 흡수, 정본 라벨)는 REQ-074 가 소유한다. +- 운영자 CLI 의 새 표면 사용은 REQ-063 change-req(CHG-20260611-01). +- 출처: dcy-025 FR-1/2/4/5/6, NFR-1(워치독 일관성)·NFR-3(cutover 안전)은 본 REQ AC/Notes 에 흡수. + +## Change Log + +### CHG-20260611-01 +- Date: 2026-06-11 +- Author: Justin Ko + +#### Change Summary +- dcy-025 FR-1/2/4/5/6 로부터 최초 생성(Proposed). `trex.slots.req` RPC 계약(filter=status 단일/spy 워치독 무터치/응답 slots+health 만/strategy 제외/즉시 교체). + +#### Intent +- 전략 read 표면을 슬롯 단일 테이블로 재설계 — assets/available_slots/watchdog 병렬 구조와 파생 view 폐기. + +#### Acceptance Criteria Delta +- Added: + - AC-1~5 신규 +- Updated/Removed: none + +#### Impacted Area Delta +- Docs: interface-contract/runtime-flows +- Modules: topology/messaging_handlers/portfolio(View 폐기) +- Tests: slots 핸들러/토폴로지 + +#### Delivery Trace Delta +- Batches: none(승인 후 draft-batch) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no(신규) +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current(Proposed) +- Follow-up Action: confirm-req + +#### Validation Plan +- 신규 batch: 토폴로지 선언/제거 단위, filter·spy·응답 키 집합 단위, 워치독 터치/무터치, degraded 신호 회귀. diff --git a/docs/srs/index.md b/docs/srs/index.md index b12d0d6..94e8608 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-073` +- Current: `REQ-076` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -108,7 +108,7 @@ | REQ-025 | HTTP `/v1` API 완전 제거 (healthz 포함, HTTP 표면 0) | Interface | Implemented | High | project maintainers | [req-025](./Interface/req-025_http-api-complete-removal.md) | | REQ-026 | messaging 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-026](./Documentation/req-026_messaging-baseline-alignment.md) | | REQ-027 | portfolio-hub read 포트 + 어댑터 + stub | Integration | Implemented | High | project maintainers | [req-027](./Integration/req-027_portfolio-hub-read-port-and-adapter.md) | -| REQ-028 | 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호 | Interface | Implemented | High | project maintainers | [req-028](./Interface/req-028_snapshot-read-message-contract-and-envelope.md) | +| REQ-028 | 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호 | Interface | Deprecated | High | project maintainers | [req-028](./Interface/req-028_snapshot-read-message-contract-and-envelope.md) | | REQ-029 | 자산 선별(slot overlay) + view 모드 + strategy_key 필터 | Interface | Implemented | High | project maintainers | [req-029](./Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md) | | REQ-030 | 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신 | Integration | Implemented | High | project maintainers | [req-030](./Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md) | | REQ-031 | 포지션 정합 검증 + warning 마스킹 | Exception | Implemented | High | project maintainers | [req-031](./Exception/req-031_position-reconciliation-and-warning.md) | @@ -153,11 +153,16 @@ | REQ-070 | slippage 보호 지정가 집행 | Integration | Implemented | High | project maintainers | [req-070](./Integration/req-070_slippage-limit-price-execution.md) | | REQ-071 | 운영자 강제 취소 백스톱 (trex-core cancel) | Interface | Implemented | Medium | project maintainers | [req-071](./Interface/req-071_operator-cancel-pending-backstop.md) | | REQ-072 | 실행 정책 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-072](./Documentation/req-072_execution-policy-baseline-alignment.md) | +| REQ-073 | 전략 read 표면 — trex.slots.req RPC 계약 (filter/spy, snapshot.req 즉시 교체) | Interface | Implemented | High | project maintainers | [req-073](./Interface/req-073_slots-req-rpc-contract.md) | +| REQ-074 | 슬롯 단일 행 스키마 — 자산·워치독·overlay 흡수 + 필드별 정본 라벨 | Data | Implemented | High | project maintainers | [req-074](./Data/req-074_merged-slot-row-schema.md) | +| REQ-075 | slots read 표면 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-075](./Documentation/req-075_slots-read-surface-baseline-alignment.md) | ## Recent Change Log Summary +- 2026-06-11 :: BAT-035/036 완료 — REQ-073/074/075/021/034/063/048/005 Approved → **Implemented**, REQ-028 → **Deprecated** (trex.snapshot.req 코드 삭제·trex.slots.req 대체) +- 2026-06-11 :: REQ-028 Implemented → **Deprecated** (BAT-035, dcy-025): `trex.snapshot.req` envelope (`assets[]`/`View`/`build_snapshot_envelope`) 코드 삭제 완료. 후속 표면은 REQ-073/074 (`trex.slots.req`) 로 대체. +- 2026-06-11 :: REQ-073/074/075 신규 생성(Proposed) + REQ-021/034/063 change-req Implemented→**Approved** + REQ-028 폐기 예정 forward-link (draft-req, dcy-025 전략 read 표면 재설계): `trex.snapshot.req`→`trex.slots.req` 즉시 교체 RPC 계약(요청 {filter: status 단일, spy}, 응답 {slots[], degraded/reason/message}, strategy 제외)(REQ-073), 슬롯 단일 행 스키마(asset_quantity hub 정본+qty 미러+warned, cash_balance+cash_estimated, watchdog nested, paused — 필드별 정본 라벨)(REQ-074), baseline 정합(REQ-075). 토폴로지 큐 교체(REQ-021 CHG-01), 워치독 터치=slots.req 기본/spy 무터치·행 흡수(REQ-034 CHG-01), 운영자 CLI 새 표면+spy(REQ-063 CHG-01). REQ-028 은 keep-current + delivery 배치에서 Deprecated 전환(REQ-005 선례). 전략 모듈 lockstep cutover 필요. Next ID: REQ-076. +- 2026-06-11 :: REQ-048/005 change-req (dcy-024 safety-sell 비활성화 모드/issue #15, CHG: 048-20260611-01·005-20260611-01): per-instance TOML `safety_sell_enabled: bool=True`(env 제외, REQ-048 AC-10) + `daemon.start()` 조건부 워커 게이팅(false 시 `trex-safety-sell` 미기동·물리 차단) + 기동 로그 `no autonomous liquidation` 경고 + runtime-flows(REQ-005 AC-5/6). `ExecutionService`·운영자 exit/cancel·kill-switch·전략 sell intent 불변. 모드는 기동 로그만 노출(스냅샷 응답 미포함, OQ-3). **신규 REQ 없음**(change-req only, dcy-024 OQ-1). 둘 다 Implemented→**Approved**, 차기 batch 구현·검증 후 복귀. - 2026-06-11 :: REQ-068~072 + REQ-013/061(CHG) → Implemented (confirm-req-implemented, BAT-032 dcy-022 실행 정책): timeout_sec·slippage_pct intent 계약·슬롯 lifecycle(REQ-068), timeout 초과 자동 취소·cancel-fill race(REQ-069), slippage 보호 지정가 dispatch(REQ-070), 운영자 `trex-core cancel` 백스톱(REQ-071), baseline 정합(REQ-072), 집행 루프 확장(REQ-013 CHG-03)·intent 필드 추가(REQ-061 CHG-05). BAT-032 verification — 신규 71 tests, 425 passed 회귀 없음, Blocking Issues 없음. - 2026-06-10 :: REQ-002/006/007/028/032 → Implemented (confirm-req-implemented, BAT-034 dcy-023 allocated_capital 제거): 도메인(Slot/EXPECTED_SLOT_FIELDS)·intent(BuyIntent·수신 무시)·영속(마이그레이션 013 drop + flat 매핑)·snapshot(slots[])·CLI(--capital argparse 거부) 전 경로 제거. 테스트 DSN 013 멱등 round-trip + 컬럼 부재, 428 passed + PG 21 passed + ruff clean, grep 전수 0건. BAT-034 release-candidate. release 시 사전 백업·롤백 절차 필수(비가역 drop). - 2026-06-10 :: REQ-002/006/007/028/032 change-req (dcy-023 allocated_capital 죽은 필드 제거/issue #22, CHG: 002-02/006-02/007-02/028-01/032-07): 전부 Implemented→**Approved** 되돌림(dcy-023 batch 재구현 대기). Slot 모델·`EXPECTED_SLOT_FIELDS` 필드 제거(REQ-002), `daily_slot` 컬럼 drop 마이그레이션 013 + flat 매핑(REQ-006, 코드 배포와 동시 적용·사전 백업), buy intent payload 즉시 제거·수신 무시(REQ-007), snapshot `slots[]` 노출 제거(REQ-028), CLI `--capital` argparse 제거 + slots detail 행 제거(REQ-032 AC-8). 외부 발신자(trex-orb-v1) 송신 잔존은 unknown key 무시로 무해(운영자 확인). -- 2026-06-10 :: REQ-032 CHG-20260610-06 → Implemented (confirm-req-implemented, BAT-033 batch-lite): `trex-core enter` `--qty` 미지정 시 RPC 호출 전 즉시 rc=1 반환(AC-7). `_cmd_enter` 상단 qty guard 추가, 기존 enter 테스트 --qty 보완 + AC-7 신규 테스트. 426 passed, 0 failed. -- 2026-06-10 :: REQ-068~072 신규 생성(Approved) + REQ-013/061 change-req Implemented→Approved (draft-req + confirm-req, dcy-022 실행 정책/issue #21): intent 실행 정책(timeout_sec·slippage_pct) 계약(REQ-068), timeout 집행·race(REQ-069), slippage dispatch(REQ-070), 운영자 cancel 백스톱(REQ-071), baseline 정합(REQ-072). REQ-061 CHG-05(timeout_sec/slippage_pct 필드 추가), REQ-013 CHG-03(timeout 취소·slippage dispatch 집행 루프 확장) → 둘 다 Implemented→Approved(BAT-032 재구현 대기). Next ID: REQ-073. diff --git a/tests/adapters/messaging/test_instance_topology.py b/tests/adapters/messaging/test_instance_topology.py index 08bd3e1..aa2fb99 100644 --- a/tests/adapters/messaging/test_instance_topology.py +++ b/tests/adapters/messaging/test_instance_topology.py @@ -20,14 +20,14 @@ from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport def test_named_instance_suffix(): """REQ-049 AC-1.""" assert topology.intent_queue("inst-1") == "trex.intent.req.inst-1" - assert topology.snapshot_queue("inst-1") == "trex.snapshot.req.inst-1" + assert topology.slots_queue("inst-1") == "trex.slots.req.inst-1" assert topology.control_queue("inst-1") == "trex.control.req.inst-1" def test_default_is_literal(): """REQ-049 AC-2: default keeps legacy literal names (backward-compat).""" assert topology.intent_queue("default") == "trex.intent.req" - assert topology.snapshot_queue(None) == "trex.snapshot.req" + assert topology.slots_queue(None) == "trex.slots.req" assert topology.control_queue() == "trex.control.req" @@ -52,7 +52,7 @@ def _delete_instance_queues(url, instance_key): conn = pika.BlockingConnection(pika.URLParameters(url)) try: ch = conn.channel() - for q in (topology.intent_queue(instance_key), topology.snapshot_queue(instance_key), + for q in (topology.intent_queue(instance_key), topology.slots_queue(instance_key), topology.control_queue(instance_key)): try: ch.queue_delete(queue=q) diff --git a/tests/adapters/messaging/test_topology.py b/tests/adapters/messaging/test_topology.py index cba29fa..7510347 100644 --- a/tests/adapters/messaging/test_topology.py +++ b/tests/adapters/messaging/test_topology.py @@ -5,10 +5,11 @@ from trex_core.adapters.messaging import topology def test_topology_constants(): - assert topology.SNAPSHOT_REQ_QUEUE == "trex.snapshot.req" + assert topology.SLOTS_REQ_QUEUE == "trex.slots.req" assert topology.INTENT_REQ_QUEUE == "trex.intent.req" assert topology.EVENTS_EXCHANGE == "trex.events" assert topology.EVENTS_DLQ_EXCHANGE == "trex.events.dlq" + assert not hasattr(topology, "SNAPSHOT_REQ_QUEUE") def test_event_routing_key_format(): diff --git a/tests/application/test_killswitch_masking_resume.py b/tests/application/test_killswitch_masking_resume.py index 9a78a74..65a2c35 100644 --- a/tests/application/test_killswitch_masking_resume.py +++ b/tests/application/test_killswitch_masking_resume.py @@ -1,4 +1,9 @@ -"""Kill-switch day-exclusion masking + resume tests (REQ-046 / BAT-012).""" +"""Kill-switch day-exclusion store + resume tests (REQ-046 / BAT-012). + +Tests the InMemoryKillSwitchStore and KillSwitchService.resume(). +View-masking through build_snapshot_envelope was removed in BAT-035 (dcy-025). +Exclusion semantics are now tested at the store level. +""" from __future__ import annotations from datetime import date @@ -9,11 +14,6 @@ from trex_core.application.killswitch import KillSwitchService from trex_core.application.portfolio import ( GLOBAL_KILLSWITCH, InMemoryKillSwitchStore, - InMemoryPauseStore, - PortfolioItem, - SnapshotStore, - View, - build_snapshot_envelope, ) from trex_core.domain.slot import SlotService from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository @@ -22,86 +22,56 @@ _DAY = date(2026, 6, 7) _DAY_NEXT = date(2026, 6, 8) -@pytest.fixture -def store(): - s = SnapshotStore() - s.swap([ - PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=0), - PortfolioItem(portfolio_id="pf-B", symbol="000660", cash_balance=0, asset_quantity=0), - ]) - return s - - -@pytest.fixture -def repo(): - return InMemorySlotRepository() - - @pytest.fixture def ks_store(): return InMemoryKillSwitchStore() -def _asset_ids(envelope): - return {a["asset_id"] for a in envelope.to_dict()["assets"]} - - -def _envelope(store, repo, ks_store, *, view=View.ALL, day=_DAY): - return build_snapshot_envelope( - store=store, pause_store=InMemoryPauseStore(), slot_repo=repo, - available_slots=10, view=view, trading_date=day, killswitch_store=ks_store, - ) - - -def test_portfolio_exclusion_masks_that_asset(store, repo, ks_store): - """AC-1: an excluded portfolio is masked from the envelope.""" +def test_portfolio_exclusion_recorded(ks_store): + """AC-1: excluding a portfolio records it in the store for the day.""" ks_store.exclude("pf-A", _DAY) - ids = _asset_ids(_envelope(store, repo, ks_store)) - assert "pf-A" not in ids - assert "pf-B" in ids + assert "pf-A" in ks_store.list_excluded(_DAY) + assert "pf-B" not in ks_store.list_excluded(_DAY) -def test_global_exclusion_masks_all(store, repo, ks_store): - """AC-2: global ('*') exclusion masks the whole universe.""" +def test_global_exclusion_uses_sentinel(ks_store): + """AC-2: global exclusion uses GLOBAL_KILLSWITCH sentinel.""" ks_store.exclude(GLOBAL_KILLSWITCH, _DAY) - assert _asset_ids(_envelope(store, repo, ks_store)) == set() + assert GLOBAL_KILLSWITCH in ks_store.list_excluded(_DAY) -def test_buyable_view_excludes(store, repo, ks_store): - """AC-3: excluded asset is not buyable (blocks new buys).""" +def test_day_scoped_auto_recovery(ks_store): + """AC-4: next trading day, the exclusion is absent (day-scoped).""" ks_store.exclude("pf-A", _DAY) - ids = _asset_ids(_envelope(store, repo, ks_store, view=View.BUYABLE)) - assert "pf-A" not in ids and "pf-B" in ids + assert "pf-A" not in ks_store.list_excluded(_DAY_NEXT) -def test_day_scoped_auto_recovery(store, repo, ks_store): - """AC-4: next trading day, the exclusion is gone (auto recovery).""" +def test_persistence_same_day(ks_store): + """AC-5: same day, exclusion persists across repeated calls.""" ks_store.exclude("pf-A", _DAY) - ids = _asset_ids(_envelope(store, repo, ks_store, day=_DAY_NEXT)) - assert "pf-A" in ids # back on the next day + assert "pf-A" in ks_store.list_excluded(_DAY) + assert "pf-A" in ks_store.list_excluded(_DAY) -def test_persistence_same_day(store, repo, ks_store): - """AC-5: a fresh envelope build same day still masks (store-backed persistence).""" +def test_resume_clears_exclusion(ks_store): + """AC-6: resume (clear) removes the exclusion for that day.""" + repo = InMemorySlotRepository() + svc = KillSwitchService(slot_repo=repo, slot_service=SlotService(repo), + killswitch_store=ks_store) ks_store.exclude("pf-A", _DAY) - # simulate restart: new envelope build reading the same store - assert "pf-A" not in _asset_ids(_envelope(store, repo, ks_store)) - - -def test_resume_reincludes(store, repo, ks_store): - """AC-6: resume (clear) re-includes the asset from the next build.""" - svc = KillSwitchService(slot_repo=repo, slot_service=SlotService(repo), killswitch_store=ks_store) - ks_store.exclude("pf-A", _DAY) - assert "pf-A" not in _asset_ids(_envelope(store, repo, ks_store)) + assert "pf-A" in ks_store.list_excluded(_DAY) svc.resume(scope="portfolio", portfolio_id="pf-A", - now=__import__("datetime").datetime(2026, 6, 7, 6, tzinfo=__import__("datetime").timezone.utc)) - assert "pf-A" in _asset_ids(_envelope(store, repo, ks_store)) + now=__import__("datetime").datetime(2026, 6, 7, 6, + tzinfo=__import__("datetime").timezone.utc)) + assert "pf-A" not in ks_store.list_excluded(_DAY) -def test_no_killswitch_store_no_masking(store, repo): - """Backward compat: without killswitch_store nothing extra is masked.""" - env = build_snapshot_envelope( - store=store, pause_store=InMemoryPauseStore(), slot_repo=repo, - available_slots=10, view=View.ALL, trading_date=_DAY, - ) - assert _asset_ids(env) == {"pf-A", "pf-B"} +def test_no_exclusion_empty_list(ks_store): + """Baseline: with no exclusions, list_excluded returns empty set.""" + assert ks_store.list_excluded(_DAY) == set() + + +def test_clear_without_prior_exclude_is_noop(ks_store): + """Clearing a non-excluded portfolio is safe (no exception).""" + ks_store.clear("pf-X", _DAY) + assert ks_store.list_excluded(_DAY) == set() diff --git a/tests/application/test_ledger_option_d.py b/tests/application/test_ledger_option_d.py index 8b637e2..6584d7c 100644 --- a/tests/application/test_ledger_option_d.py +++ b/tests/application/test_ledger_option_d.py @@ -1,7 +1,7 @@ """Option D cash/position write-back + estimate overlay tests (dcy-018, issue: cash stale). Covers: LedgerWriter write-back to SnapshotStore on new/duplicate, skip when -*_after missing, best-effort isolation, and the snapshot handler's estimated-cash +*_after missing, best-effort isolation, and the slots handler's estimated-cash overlay from pending outbox deltas (OQ-4). """ from __future__ import annotations @@ -15,8 +15,10 @@ from trex_core.application.ledger_outbox import ( trade_cash_delta, ) from trex_core.application.ledger_port import LedgerTrade -from trex_core.application.messaging_handlers import _apply_cash_estimate +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import make_slots_rpc_handler from trex_core.application.portfolio import PortfolioItem, SnapshotStore +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository UTC = timezone.utc @@ -88,7 +90,7 @@ def test_no_writeback_without_store(): assert writer.run_once().completed == 1 # no crash -# --- estimate overlay (REQ-029 / OQ-4) ------------------------------------- +# --- estimate overlay (REQ-029 / OQ-4) — via make_slots_rpc_handler ---------- def test_pending_cash_delta_sums_by_asset(): outbox = InMemoryLedgerOutbox() outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0)) @@ -98,14 +100,18 @@ def test_pending_cash_delta_sums_by_asset(): def test_apply_cash_estimate_overlays_pending_delta(): + """Cash-estimated overlay applied through slots handler (REQ-029 OQ-4).""" outbox = InMemoryLedgerOutbox() outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0)) - payload = {"assets": [{"asset_id": "PF-1", "symbol": "005930", - "cash_balance": 1_000_000.0, "asset_quantity": 0.0}]} - _apply_cash_estimate(payload, outbox) - a = payload["assets"][0] - assert a["cash_estimated"] is True - assert a["cash_balance"] == 1_000_000.0 - (10 * 1000.0 + 1.0) # estimated cash + store = _store_with(pid="PF-1", cash=1_000_000.0, qty=0.0) + repo = InMemorySlotRepository() + repo.reconcile_universe("default", {"PF-1"}) + handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox) + resp = handler(RpcRequest(payload={"filter": "all"}, correlation_id="test")) + slots = resp.payload["slots"] + pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1") + assert pf1["cash_estimated"] is True + assert pf1["cash_balance"] == 1_000_000.0 - (10 * 1000.0 + 1.0) # estimated cash def test_estimate_clears_after_writeback(): diff --git a/tests/application/test_messaging_snapshot_consumer.py b/tests/application/test_messaging_snapshot_consumer.py deleted file mode 100644 index 2ad560d..0000000 --- a/tests/application/test_messaging_snapshot_consumer.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Snapshot RPC handler tests (REQ-021 AC-2, BAT-007 stub-shape).""" -from __future__ import annotations - -from datetime import date - -from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore -from trex_core.application.intents import BuyIntent, IntentService -from trex_core.application.messaging import RpcRequest -from trex_core.application.messaging_handlers import make_snapshot_rpc_handler -from trex_core.domain.slot import SlotService -from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository - -DAY = date(2026, 6, 5) - - -def _wire(): - repo = InMemorySlotRepository() - svc = SlotService(repo) - intents = IntentService(svc, repo, InMemoryIntentStore(), - resolve_symbol={"PF-1": "005930", "PF-2": "000660"}.get) - repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3"}) - return repo, svc, intents - - -def _req(view="all", strategy_id=None): - return RpcRequest( - payload={ - "view": view, - "strategy_id": strategy_id, - "trading_date": DAY.isoformat(), - }, - correlation_id="snap-cid", - ) - - -def test_snapshot_returns_all_slots_and_capacity(): - repo, svc, intents = _wire() - intents.submit_buy(BuyIntent( - intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1", - )) - handler = make_snapshot_rpc_handler(repo, svc) - resp = handler(_req()) - assert resp.payload["view"] == "all" - assert len(resp.payload["slots"]) == 3 # all 3 slots returned - cap = resp.payload["capacity"] - assert cap["pending_count"] == 1 and cap["inactive_count"] == 2 - assert cap["active_count"] == 1 and cap["available_count"] == 2 - - -def test_snapshot_open_view_filters_to_active_slots(): - repo, svc, intents = _wire() - intents.submit_buy(BuyIntent( - intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1", - )) - handler = make_snapshot_rpc_handler(repo, svc) - resp = handler(_req(view="open")) - statuses = {s["status"] for s in resp.payload["slots"]} - assert "inactive" not in statuses - assert statuses == {"pending_buy"} - - -def test_snapshot_strategy_id_filter(): - repo, svc, intents = _wire() - intents.submit_buy(BuyIntent( - intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1", - )) - intents.submit_buy(BuyIntent( - intent_id="b2", qty=5, strategy_id="B", portfolio_id="PF-2", - )) - handler = make_snapshot_rpc_handler(repo, svc) - resp = handler(_req(view="open", strategy_id="A")) - assert len(resp.payload["slots"]) == 1 - assert resp.payload["slots"][0]["strategy_id"] == "A" - assert resp.payload["slots"][0]["symbol"] == "005930" - - -# --- dcy-016 / REQ-021/034: operator query watchdog suppression ------------- -class _RecWatchdog: - def __init__(self): - self.updated = [] - - def update(self, slot_id): - self.updated.append(str(slot_id)) - - -def _holding_slot(repo, svc): - from trex_core.domain.slot import SlotState - slot = repo.find_slot_by_portfolio("default", "PF-1") - svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, - reason="t", actor="t", symbol="005930", qty=10) - svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, - reason="t", actor="t", avg_price=1000) - return str(slot.slot_id) - - -def test_strategy_poll_touches_watchdog_by_default(): - repo, svc, intents = _wire() - sid = _holding_slot(repo, svc) - wd = _RecWatchdog() - handler = make_snapshot_rpc_handler(repo, svc, watchdog_store=wd) - handler(_req()) # no actor flag => legacy liveness touch (REQ-034) - assert sid in wd.updated - - -def test_operator_query_suppresses_watchdog_touch(): - repo, svc, intents = _wire() - _holding_slot(repo, svc) - wd = _RecWatchdog() - handler = make_snapshot_rpc_handler(repo, svc, watchdog_store=wd) - req = RpcRequest( - payload={"view": "all", "trading_date": DAY.isoformat(), - "actor": "operator", "touch_watchdog": False}, - correlation_id="op", - ) - handler(req) - assert wd.updated == [] # dcy-016 R-4: operator query is not a liveness signal - - -def test_snapshot_exposes_watchdog_state_for_holding_slots(): - """dcy-017 / REQ-034: snapshot response carries holding-slot watchdog state.""" - from trex_core.application.watchdog import WatchdogStore - repo, svc, intents = _wire() - sid = _holding_slot(repo, svc) - wd = WatchdogStore() # never updated => expired (strategy absent) - handler = make_snapshot_rpc_handler(repo, svc, watchdog_store=wd, watchdog_timeout_sec=120) - # operator query so the response does not touch the watchdog before we read it - req = RpcRequest( - payload={"view": "all", "trading_date": DAY.isoformat(), "actor": "operator"}, - correlation_id="op", - ) - states = handler(req).payload["watchdog"] - assert len(states) == 1 - assert states[0]["slot_id"] == sid and states[0]["expired"] is True - assert states[0]["last_seen"] is None diff --git a/tests/application/test_slots_handler.py b/tests/application/test_slots_handler.py new file mode 100644 index 0000000..fec6375 --- /dev/null +++ b/tests/application/test_slots_handler.py @@ -0,0 +1,212 @@ +"""slots RPC handler tests (REQ-073 AC-1~5, REQ-074 AC-1~5).""" +from __future__ import annotations + +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import make_slots_rpc_handler +from trex_core.application.portfolio import ( + DegradedReason, + InMemoryPauseStore, + PortfolioItem, + SnapshotStore, +) +from trex_core.application.watchdog import WatchdogStore +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + + +def _wire(items=None, *, max_active_slots=None): + repo = InMemorySlotRepository() + svc = SlotService(repo) + repo.reconcile_universe("default", {"pf-A", "pf-B"}) + store = SnapshotStore() + store.swap(items or [ + PortfolioItem(portfolio_id="pf-A", symbol="005930", + cash_balance=1_000_000, asset_quantity=0), + PortfolioItem(portfolio_id="pf-B", symbol="000660", + cash_balance=500_000, asset_quantity=10), + ]) + pause = InMemoryPauseStore() + watchdog = WatchdogStore() + handler = make_slots_rpc_handler( + repo, + snapshot_store=store, + pause_store=pause, + watchdog_store=watchdog, + ) + return repo, svc, store, pause, watchdog, handler + + +def _req(filter=None, spy=False): + payload = {} + if filter is not None: + payload["filter"] = filter + if spy: + payload["spy"] = True + return RpcRequest(payload=payload, correlation_id="test-corr") + + +# REQ-073 AC-3: response keys only {slots/degraded/reason/message} +def test_response_keys_only_slots_health(): + _, _, _, _, _, handler = _wire() + resp = handler(_req()) + assert set(resp.payload.keys()) == {"slots", "degraded", "reason", "message"} + assert "assets" not in resp.payload + assert "available_slots" not in resp.payload + assert "watchdog" not in resp.payload + + +# REQ-073 AC-2: filter=all returns all slots +def test_filter_all_returns_all_slots(): + _, _, _, _, _, handler = _wire() + resp = handler(_req(filter="all")) + assert len(resp.payload["slots"]) == 2 + + +# REQ-073 AC-2: filter=inactive returns only inactive slots +def test_filter_inactive_returns_inactive_slots(): + repo, svc, store, _, _, handler = _wire() + # Buy pf-A into holding + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=5.0) + resp = handler(_req(filter="inactive")) + slots = resp.payload["slots"] + assert all(s["status"] == "inactive" for s in slots) + assert len(slots) == 1 # pf-B remains inactive + + +# REQ-073 AC-2: filter=holding returns holding slots +def test_filter_holding_returns_holding_slots(): + repo, svc, _, _, _, handler = _wire() + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=5.0) + resp = handler(_req(filter="holding")) + slots = resp.payload["slots"] + assert all(s["status"] == "holding" for s in slots) + assert len(slots) == 1 + + +# REQ-073 AC-2: filter=pending returns pending_buy ∪ pending_sell +def test_filter_pending_returns_pending_buy_and_sell(): + repo, svc, _, _, _, handler = _wire() + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + resp = handler(_req(filter="pending")) + slots = resp.payload["slots"] + assert all(s["status"] in ("pending_buy", "pending_sell") for s in slots) + assert len(slots) == 1 + + +# REQ-073 AC-2: unrecognized filter → treated as all +def test_invalid_filter_treated_as_all(): + _, _, _, _, _, handler = _wire() + resp = handler(_req(filter="buyable")) # old View value + assert len(resp.payload["slots"]) == 2 + + +# REQ-073 AC-5: degraded=True + reason propagated +def test_degraded_signal_propagated(): + _, _, store, _, _, handler = _wire() + store.set_degraded(DegradedReason.PORTFOLIO_HUB_UNAVAILABLE, + "GET hub failed: Bearer secret-token") + resp = handler(_req()) + body = resp.payload + assert body["degraded"] is True + assert body["reason"] == "portfolio_hub_unavailable" + assert body["message"] is not None + assert "secret-token" not in body["message"] + + +# REQ-074 AC-1: slot row contains merged fields +def test_slot_row_contains_merged_fields(): + _, _, _, _, _, handler = _wire() + resp = handler(_req()) + for s in resp.payload["slots"]: + assert "slot_id" in s + assert "status" in s + assert "symbol" in s + assert "qty" in s + assert "asset_quantity" in s + assert "cash_balance" in s + assert "cash_estimated" in s + assert "warned" in s + assert "watchdog" in s + assert "paused" in s + + +# REQ-074 AC-2: qty ≠ asset_quantity → warned=True +def test_warned_true_when_qty_diverges(): + repo, svc, store, _, _, handler = _wire([ + PortfolioItem(portfolio_id="pf-A", symbol="005930", + cash_balance=1_000_000, asset_quantity=3), + ]) + repo.reconcile_universe("default", {"pf-A"}) + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=5.0) + resp = handler(_req(filter="holding")) + s = resp.payload["slots"][0] + # slot.qty=5 vs hub asset_quantity=3 → divergence + assert s["qty"] == 5.0 + assert s["asset_quantity"] == 3.0 + assert s["warned"] is True + + +# REQ-074 AC-4: paused holding slot → paused=True, filter=holding still returns it +def test_paused_holding_orthogonal_to_filter(): + repo, svc, store, pause, _, handler = _wire() + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=5.0) + pause.pause("pf-A") + resp = handler(_req(filter="holding")) + assert len(resp.payload["slots"]) == 1 + assert resp.payload["slots"][0]["paused"] is True + + +# REQ-074 AC-5: strategy fields absent from row +def test_strategy_fields_absent(): + _, _, _, _, _, handler = _wire() + resp = handler(_req()) + for s in resp.payload["slots"]: + assert "strategy_id" not in s + assert "sell_strategy_id" not in s + assert "strategy_params" not in s + assert "active_strategy_id" not in s + + +# REQ-073 AC-4: spy=True → watchdog NOT updated +def test_spy_true_no_watchdog_update(): + repo, svc, _, _, watchdog, handler = _wire() + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + holding = svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=5.0) + sid = str(holding.slot_id) + assert watchdog.last_seen(sid) is None # not yet seen + handler(_req(filter="holding", spy=True)) + assert watchdog.last_seen(sid) is None # still not updated (spy=True) + + +# REQ-073 AC-4: spy=False (default) → holding watchdog updated +def test_spy_false_updates_watchdog(): + repo, svc, _, _, watchdog, handler = _wire() + slot_a = repo.find_slot_by_portfolio("default", "pf-A") + pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930") + holding = svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=5.0) + sid = str(holding.slot_id) + handler(_req(filter="holding", spy=False)) + assert watchdog.last_seen(sid) is not None # updated diff --git a/tests/application/test_snapshot_handler.py b/tests/application/test_snapshot_handler.py deleted file mode 100644 index 0e873d9..0000000 --- a/tests/application/test_snapshot_handler.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Snapshot RPC handler envelope path (REQ-028 AC-1/AC-2/AC-3).""" -from __future__ import annotations - -from datetime import date - -from trex_core.application.messaging import RpcRequest -from trex_core.application.messaging_handlers import make_snapshot_rpc_handler -from trex_core.application.portfolio import ( - DegradedReason, - InMemoryPauseStore, - PortfolioItem, - SnapshotStore, -) -from trex_core.domain.slot import SlotService -from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository - -DAY = date(2026, 6, 5) - - -def _wire(items=None): - repo = InMemorySlotRepository() - svc = SlotService(repo) - repo.reconcile_universe("default", {"pf-A"}) - store = SnapshotStore() - store.swap(items or [ - PortfolioItem(portfolio_id="pf-A", symbol="005930", - cash_balance=1_000_000, asset_quantity=0, - active_strategy_id="target-value-v1", - strategy_params={"k": 1}), - ]) - pause = InMemoryPauseStore() - handler = make_snapshot_rpc_handler( - repo, svc, - snapshot_store=store, pause_store=pause, max_active_slots=5, - ) - return store, pause, handler - - -def test_envelope_shape_returned(): - _, _, handler = _wire() - resp = handler(RpcRequest( - payload={"view": "all", "trading_date": DAY.isoformat()}, - correlation_id="snap-1", - )) - body = resp.payload - # BAT-022: `slots[]` additive operator-facing per-slot state. - assert set(body.keys()) == {"assets", "view", "available_slots", "degraded", - "reason", "message", "slots"} - assert isinstance(body["assets"], list) - assert isinstance(body["slots"], list) - assert body["view"] == "all" - assert body["degraded"] is False - assert body["reason"] is None - - -def test_envelope_strategy_key_filter(): - _, _, handler = _wire([ - PortfolioItem(portfolio_id="x", symbol="X", cash_balance=0, asset_quantity=0, - active_strategy_id="alpha"), - PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=0, asset_quantity=0, - active_strategy_id="beta"), - ]) - resp = handler(RpcRequest( - payload={"view": "all", "strategy_key": "alpha"}, correlation_id="snap-2", - )) - pids = {a["asset_id"] for a in resp.payload["assets"]} - assert pids == {"x"} - - -def test_degraded_signal_does_not_leak_credentials(): - store, _, handler = _wire() - store.set_degraded( - DegradedReason.PORTFOLIO_HUB_UNAVAILABLE, - "GET https://hub failed: 401 Bearer secret-token-abc", - ) - resp = handler(RpcRequest(payload={"view": "all"}, correlation_id="snap-3")) - body = resp.payload - assert body["degraded"] is True - assert body["reason"] == "portfolio_hub_unavailable" - assert body["message"] is not None - assert "secret-token-abc" not in body["message"] - - -def test_bat007_legacy_path_when_stores_unset(): - """Make sure pre-BAT-008 callers still work (slots+capacity stub shape).""" - repo = InMemorySlotRepository() - svc = SlotService(repo) - repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"}) - handler = make_snapshot_rpc_handler(repo, svc) - resp = handler(RpcRequest( - payload={"view": "all", "trading_date": DAY.isoformat()}, correlation_id="legacy", - )) - body = resp.payload - assert "slots" in body and "capacity" in body - assert "assets" not in body diff --git a/tests/application/test_snapshot_rpc_watchdog.py b/tests/application/test_snapshot_rpc_watchdog.py deleted file mode 100644 index 30d58fc..0000000 --- a/tests/application/test_snapshot_rpc_watchdog.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Snapshot RPC handler watchdog integration tests (REQ-034 AC-1 / BAT-010).""" -from __future__ import annotations - -from datetime import date - -import pytest - -from trex_core.application.messaging import RpcRequest -from trex_core.application.messaging_handlers import make_snapshot_rpc_handler -from trex_core.application.watchdog import WatchdogStore -from trex_core.domain.slot import SlotService, SlotState -from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository - - -@pytest.fixture -def trading_day(): - return date(2026, 6, 7) - - -@pytest.fixture -def repo(): - return InMemorySlotRepository() - - -@pytest.fixture -def service(repo): - return SlotService(repo) - - -def _make_req(trading_day): - return RpcRequest( - payload={"trading_date": trading_day.isoformat(), "view": "all"}, - correlation_id="test-corr", - ) - - -def _buy_slot(service, repo, trading_day, symbol="005930"): - """Seed an asset slot + buy it into HOLDING state.""" - pid = f"pf-{symbol}" - repo.reconcile_universe("default", {pid}) - slot = repo.find_slot_by_portfolio("default", pid) - pending = service.transit_slot( - slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, - reason="buy", actor="test", symbol=symbol, - ) - holding = service.transit_slot( - pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, - reason="filled", actor="test", avg_price=100.0, qty=10.0, - ) - return holding - - -class TestSnapshotRpcWatchdog: - def test_holding_slot_watchdog_updated_on_snapshot_rpc(self, repo, service, trading_day): - """AC-1: snapshot RPC updates watchdog for each holding slot in response.""" - holding = _buy_slot(service, repo, trading_day) - watchdog = WatchdogStore() - handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog) - handler(_make_req(trading_day)) - assert str(holding.slot_id) in watchdog._store - - def test_idle_slot_watchdog_not_updated(self, repo, service, trading_day): - """Only HOLDING slots update the watchdog — inactive slots are ignored.""" - repo.reconcile_universe("default", {"pf-X"}) - inactive = repo.find_slot_by_portfolio("default", "pf-X") - watchdog = WatchdogStore() - handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog) - handler(_make_req(trading_day)) - assert str(inactive.slot_id) not in watchdog._store - - def test_no_watchdog_store_does_not_raise(self, repo, service, trading_day): - """Without watchdog_store= the handler works as before (backward compat).""" - _buy_slot(service, repo, trading_day) - handler = make_snapshot_rpc_handler(repo, service) - resp = handler(_make_req(trading_day)) - assert resp is not None - - def test_multiple_holding_slots_all_updated(self, repo, service, trading_day): - """All holding slots in the response have their watchdog updated.""" - slots = [] - for sym in ("A", "B", "C"): - repo.reconcile_universe("default", {f"pf-{sym}"}) - slot = repo.find_slot_by_portfolio("default", f"pf-{sym}") - p = service.transit_slot( - slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, - reason="buy", actor="test", symbol=sym, - ) - h = service.transit_slot( - p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, - reason="filled", actor="test", avg_price=100.0, qty=1.0, - ) - slots.append(h) - watchdog = WatchdogStore() - handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog) - handler(_make_req(trading_day)) - for s in slots: - assert str(s.slot_id) in watchdog._store, f"{s.slot_id} not in watchdog" diff --git a/tests/application/test_snapshot_selection.py b/tests/application/test_snapshot_selection.py deleted file mode 100644 index 8574c78..0000000 --- a/tests/application/test_snapshot_selection.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Snapshot selection + masking + view composition (REQ-029, REQ-028).""" -from __future__ import annotations - -from datetime import date - -from trex_core.application.portfolio import ( - DegradedReason, - InMemoryPauseStore, - PortfolioItem, - SnapshotStore, - View, - build_snapshot_envelope, -) -from trex_core.domain.slot import SlotService, SlotState -from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository - -DAY = date(2026, 6, 5) - - -def _universe(): - return [ - PortfolioItem(portfolio_id="pf-A", symbol="005930", - cash_balance=1_000_000, asset_quantity=0, - active_strategy_id="target-value-v1"), - PortfolioItem(portfolio_id="pf-B", symbol="000660", - cash_balance=2_000_000, asset_quantity=10, - active_strategy_id="trend-follow-v1"), - PortfolioItem(portfolio_id="pf-C", symbol="035720", - cash_balance=3_000_000, asset_quantity=0, - active_strategy_id="target-value-v1"), - ] - - -def _wire(*, slots_setup=None): - store = SnapshotStore() - store.swap(_universe()) - pause = InMemoryPauseStore() - repo = InMemorySlotRepository() - svc = SlotService(repo) - repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"}) - if slots_setup: - slots_setup(svc, repo) - return store, pause, repo, svc - - -def _take_slot(svc, repo, *, portfolio_id, symbol, strategy_id, status): - s = repo.find_slot_by_portfolio("default", portfolio_id) - assert s is not None - svc.transit_slot( - s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, - reason="buy_intent", actor="t", - symbol=symbol, strategy_id=strategy_id, qty=10, - ) - if status is SlotState.PENDING_BUY: - return s.slot_id - svc.transit_slot( - s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, - reason="filled", actor="t", avg_price=100.0, qty=10, - ) - return s.slot_id - - -def _env(store, pause, repo, **kw): - """Helper: always pin trading_date to the test's hardcoded DAY.""" - return build_snapshot_envelope( - store=store, pause_store=pause, slot_repo=repo, trading_date=DAY, **kw, - ) - - -def test_view_all_includes_holding_excludes_pending_for_same_symbol(): - def setup(svc, repo): - _take_slot(svc, repo, portfolio_id="pf-A", symbol="005930", - strategy_id="target-value-v1", status=SlotState.HOLDING) - _take_slot(svc, repo, portfolio_id="pf-B", symbol="000660", - strategy_id="trend-follow-v1", status=SlotState.PENDING_BUY) - store, pause, repo, svc = _wire(slots_setup=setup) - env = _env(store, pause, repo, available_slots=3, view=View.ALL) - pids = {a["asset_id"] for a in env.assets} - assert "pf-A" in pids # holding -> included - assert "pf-B" not in pids # pending -> masked - assert "pf-C" in pids # universe-only -> included - - -def test_view_buyable_with_zero_available_slots_returns_empty(): - store, pause, repo, svc = _wire() - env = _env(store, pause, repo, available_slots=0, view=View.BUYABLE) - assert env.assets == [] - assert env.available_slots == 0 - - -def test_view_buyable_excludes_occupied_portfolios(): - def setup(svc, repo): - _take_slot(svc, repo, portfolio_id="pf-A", symbol="005930", - strategy_id="target-value-v1", status=SlotState.PENDING_BUY) - _take_slot(svc, repo, portfolio_id="pf-B", symbol="000660", - strategy_id="trend-follow-v1", status=SlotState.HOLDING) - store, pause, repo, svc = _wire(slots_setup=setup) - env = _env(store, pause, repo, available_slots=3, view=View.BUYABLE) - pids = {a["asset_id"] for a in env.assets} - assert pids == {"pf-C"} # A occupied (pending), B occupied (holding) - - -def test_view_held_returns_only_holding_slot_assets(): - def setup(svc, repo): - _take_slot(svc, repo, portfolio_id="pf-A", symbol="005930", - strategy_id="target-value-v1", status=SlotState.HOLDING) - _take_slot(svc, repo, portfolio_id="pf-B", symbol="000660", - strategy_id="trend-follow-v1", status=SlotState.PENDING_BUY) - store, pause, repo, svc = _wire(slots_setup=setup) - env = _env(store, pause, repo, available_slots=3, view=View.HELD) - pids = {a["asset_id"] for a in env.assets} - assert pids == {"pf-A"} - - -def test_strategy_key_orthogonal_filter(): - store, pause, repo, svc = _wire() - env = _env(store, pause, repo, available_slots=3, view=View.ALL, - strategy_key="target-value-v1") - assert {a["active_strategy_id"] for a in env.assets} == {"target-value-v1"} - - -def test_strategy_key_missing_includes_everything_unmasked(): - store, pause, repo, svc = _wire() - env = _env(store, pause, repo, available_slots=3, view=View.ALL, strategy_key=None) - assert {a["asset_id"] for a in env.assets} == {"pf-A", "pf-B", "pf-C"} - - -def test_warning_overlay_masks_asset_uniformly_across_views(): - store, pause, repo, svc = _wire() - store.set_warning("pf-A", True) - for view in (View.ALL, View.BUYABLE): - env = _env(store, pause, repo, available_slots=3, view=view) - pids = {a["asset_id"] for a in env.assets} - assert "pf-A" not in pids - - -def test_pause_overlay_masks_asset(): - store, pause, repo, svc = _wire() - pause.pause("pf-B") - env = _env(store, pause, repo, available_slots=3, view=View.ALL) - assert all(a["asset_id"] != "pf-B" for a in env.assets) - - -def test_envelope_shape_no_price_field(): - store, pause, repo, svc = _wire() - env = _env(store, pause, repo, available_slots=3, view=View.ALL) - d = env.to_dict() - # BAT-022: `slots[]` is an additive operator-facing field alongside assets. - assert set(d.keys()) == {"assets", "view", "available_slots", "degraded", - "reason", "message", "slots"} - assert isinstance(d["assets"], list) - assert isinstance(d["slots"], list) - # strategy-facing assets[] keep the strict no-price shape (REQ-028 AC-3). - for asset in d["assets"]: - assert set(asset.keys()) == {"symbol", "asset_id", "cash_balance", - "asset_quantity", "active_strategy_id", - "strategy_params"} - assert "price" not in asset - - -def test_empty_envelope_assets_is_list_not_null(): - store, pause, repo, svc = _wire() - pause.pause("pf-A") - pause.pause("pf-B") - pause.pause("pf-C") - env = _env(store, pause, repo, available_slots=3, view=View.ALL) - d = env.to_dict() - assert d["assets"] == [] - assert d["assets"] is not None - - -def test_degraded_signal_in_envelope(): - store, pause, repo, svc = _wire() - store.set_degraded(DegradedReason.PORTFOLIO_HUB_UNAVAILABLE, - "auth.daemon get failed (Bearer abc123)") - env = _env(store, pause, repo, available_slots=3, view=View.ALL) - d = env.to_dict() - assert d["degraded"] is True - assert d["reason"] == "portfolio_hub_unavailable" - # sanitized — credentials stripped - assert "Bearer abc123" not in d["message"] - assert "" in d["message"] or "auth" in d["message"] diff --git a/tests/runtime/test_cli_operator.py b/tests/runtime/test_cli_operator.py index 7959561..0982b3f 100644 --- a/tests/runtime/test_cli_operator.py +++ b/tests/runtime/test_cli_operator.py @@ -25,137 +25,136 @@ class _RpcRecorder: return self.response -_SNAPSHOT = { - "assets": [{"asset_id": "PF-1", "symbol": "005930", "asset_quantity": 0, - "active_strategy_id": "orb"}], - "view": "all", "available_slots": 9, "degraded": False, "reason": None, - # BAT-022: envelope per-slot state (operator-facing). `slots` queries read - # this, not the universe `assets[]`. - "slots": [ - {"slot_id": "S-1", "status": "holding", - "symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None, - "strategy_id": "orb", "sell_strategy_id": None, - "portfolio_id": "PF-1", "forced_close_at": None}, - {"slot_id": "S-2", "status": "inactive", - "symbol": None, "qty": None, "avg_price": None, "entry_price": None, - "strategy_id": None, "sell_strategy_id": None, - "portfolio_id": None, "forced_close_at": None}, - ], +_SLOT_HOLDING = { + "slot_id": "S-1", "status": "holding", "symbol": "005930", + "qty": 10.0, "asset_quantity": 10.0, "warned": False, + "avg_price": 28700.0, "entry_price": None, + "portfolio_id": "PF-1", "forced_close_at": None, + "cash_balance": 1_000_000.0, "cash_estimated": False, + "watchdog": {"expired": False, "last_seen": None}, + "paused": False, "universe_detached": False, +} +_SLOT_INACTIVE = { + "slot_id": "S-2", "status": "inactive", "symbol": None, + "qty": None, "asset_quantity": None, "warned": False, + "avg_price": None, "entry_price": None, + "portfolio_id": None, "forced_close_at": None, + "cash_balance": None, "cash_estimated": False, + "watchdog": None, "paused": False, "universe_detached": False, +} +_SLOTS_RESP = { + "slots": [_SLOT_HOLDING, _SLOT_INACTIVE], + "degraded": False, "reason": None, "message": None, } _ADMITTED = {"intent_id": "i", "status": "admitted", "slot_id": "S-1", "reason": None} _REJECTED = {"intent_id": "i", "status": "rejected", "slot_id": None, "reason": "unknown_portfolio"} -# --- universe / slots (snapshot RPC + watchdog suppression) ------------------ -def test_universe_uses_snapshot_queue_with_operator_suppression(capsys): - rpc = _RpcRecorder(_SNAPSHOT) +# --- universe / slots (slots RPC + spy=True suppression) --------------------- +def test_universe_uses_slots_queue_with_spy(capsys): + rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, + "reason": None, "message": None}) rc = cli.main(["universe"], rpc=rpc) assert rc == 0 call = rpc.calls[0] - assert call["queue"] == "trex.snapshot.req" # default instance - assert call["payload"]["actor"] == "operator" - assert call["payload"]["touch_watchdog"] is False # FR-2a / R-4 - assert "PF-1" in capsys.readouterr().out + assert call["queue"] == "trex.slots.req" # default instance + assert call["payload"]["spy"] is True # operator view — no watchdog touch + assert call["payload"]["filter"] == "inactive" -def test_universe_json_is_scoped_to_universe(capsys): - """BAT-022: `universe --json` emits universe data only — `slots[]`/`watchdog[]` - (operator slot table) are NOT leaked into the universe view.""" - rpc = _RpcRecorder(_SNAPSHOT) +def test_universe_json_emits_slots_and_degraded(capsys): + rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, + "reason": None, "message": None}) cli.main(["universe", "--json"], rpc=rpc) out = json.loads(capsys.readouterr().out) - assert set(out.keys()) == {"instance", "view", "assets", "available_slots", - "degraded", "reason"} - assert "slots" not in out and "watchdog" not in out + assert "slots" in out and "degraded" in out and "filter" in out + assert "assets" not in out and "available_slots" not in out -def test_slots_shows_real_slot_state_not_universe(capsys): - """BAT-022: `slots` shows envelope `slots[]` (slot table) with status, - not the universe `assets[]`.""" - rpc = _RpcRecorder(_SNAPSHOT) +def test_slots_shows_slot_state(capsys): + rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots"], rpc=rpc) out = capsys.readouterr().out - # request stays non-invasive (operator suppression) - assert rpc.calls[0]["payload"]["actor"] == "operator" - assert rpc.calls[0]["payload"]["touch_watchdog"] is False - # both slots shown, with status column (slot state, not universe assets) + # request uses slots.req + spy=True + assert rpc.calls[0]["queue"] == "trex.slots.req" + assert rpc.calls[0]["payload"]["spy"] is True assert "count=2" in out - assert "S-1\tholding" in out and "S-2\tinactive" in out + assert "S-1" in out and "S-2" in out -def test_slots_status_filters_by_slot_status(capsys): - """BAT-022: `--status` filters by slot status client-side (no view mapping).""" - rpc = _RpcRecorder(_SNAPSHOT) +def test_slots_status_sends_server_side_filter(capsys): + rpc = _RpcRecorder({"slots": [_SLOT_HOLDING], "degraded": False, + "reason": None, "message": None}) cli.main(["slots", "--status", "holding"], rpc=rpc) + assert rpc.calls[0]["payload"]["filter"] == "holding" out = capsys.readouterr().out - assert "count=1" in out and "S-1\tholding" in out and "S-2" not in out + assert "count=1" in out and "S-1" in out + + +def test_slots_buyable_maps_to_inactive_filter(): + rpc = _RpcRecorder({"slots": [], "degraded": False, "reason": None, "message": None}) + cli.main(["slots", "--status", "buyable"], rpc=rpc) + assert rpc.calls[0]["payload"]["filter"] == "inactive" def test_slots_symbol_filters_to_one_symbol(capsys): - """`--symbol` shows only the slot(s) bound to that symbol (client-side filter).""" - rpc = _RpcRecorder(_SNAPSHOT) + rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots", "--symbol", "005930"], rpc=rpc) out = capsys.readouterr().out assert "count=1" in out and "S-1" in out and "S-2" not in out def test_slots_symbol_combines_with_status(capsys): - """`--symbol` composes with `--status` (both must match).""" - rpc = _RpcRecorder(_SNAPSHOT) + rpc = _RpcRecorder({"slots": [_SLOT_HOLDING], "degraded": False, + "reason": None, "message": None}) cli.main(["slots", "--status", "holding", "--symbol", "999999"], rpc=rpc) out = capsys.readouterr().out - assert "count=0" in out # S-1 is holding but symbol != 999999 + assert "count=0" in out def test_slots_slot_id_filters_to_one_slot(capsys): - """`--slot-id` shows only the matching slot, in the detailed block.""" - rpc = _RpcRecorder(_SNAPSHOT) + rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots", "--slot-id", "S-2"], rpc=rpc) out = capsys.readouterr().out assert "count=1" in out and "slot S-2" in out and "S-1" not in out - # detailed block fields present assert "portfolio_id" in out and "universe_detached" in out def test_slots_symbol_detailed_output(capsys): - """`--symbol` switches to the detailed per-slot block (not the terse table).""" - rpc = _RpcRecorder(_SNAPSHOT) + rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots", "--symbol", "005930"], rpc=rpc) out = capsys.readouterr().out assert "slot S-1" in out and "status" in out and "avg_price" in out -def test_slots_marks_watchdog_owned(capsys): - """dcy-017: slots flags strategy-absent (watchdog-owned) positions.""" +def test_slots_marks_watchdog_expired(capsys): + """REQ-034: slots flags watchdog-expired (strategy absent) positions.""" resp = { - "assets": [], "view": "all", "available_slots": 9, "degraded": False, - "reason": None, - "slots": [{"slot_id": "S-1", - "status": "holding", "symbol": "005930", "qty": 10.0, - "avg_price": 28700.0, "entry_price": None, - "strategy_id": "orb", - "sell_strategy_id": None, "portfolio_id": "PF-1", - "forced_close_at": None}], - "watchdog": [{"slot_id": "S-1", "portfolio_id": "PF-1", "symbol": "005930", - "expired": True, "last_seen": None}], + "slots": [{ + **_SLOT_HOLDING, + "watchdog": {"expired": True, "last_seen": None}, + }], + "degraded": False, "reason": None, "message": None, } - cli.main(["slots", "--status", "held"], rpc=_RpcRecorder(resp)) + cli.main(["slots", "--status", "holding"], rpc=_RpcRecorder(resp)) out = capsys.readouterr().out - assert "watchdog_owned=1" in out and "WATCHDOG-OWNED" in out + assert "watchdog_expired=1" in out and "WATCHDOG" in out def test_instance_shortcut_routes_to_instance_queue(): - rpc = _RpcRecorder(_SNAPSHOT) + rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, + "reason": None, "message": None}) cli.main(["universe", "--instance", "inst-2"], rpc=rpc) - assert rpc.calls[0]["queue"] == "trex.snapshot.req.inst-2" + assert rpc.calls[0]["queue"] == "trex.slots.req.inst-2" def test_json_output_is_valid_json(capsys): - rpc = _RpcRecorder(_SNAPSHOT) + rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, + "reason": None, "message": None}) cli.main(["universe", "--json"], rpc=rpc) parsed = json.loads(capsys.readouterr().out) - assert parsed["assets"][0]["asset_id"] == "PF-1" + assert "slots" in parsed # --- enter (buy intent RPC) + safeguards ------------------------------------ diff --git a/tests/runtime/test_instance_config.py b/tests/runtime/test_instance_config.py index 1d1c212..0559330 100644 --- a/tests/runtime/test_instance_config.py +++ b/tests/runtime/test_instance_config.py @@ -88,3 +88,30 @@ def test_gitignore_excludes_instance_toml(): assert "/config/*" in gi assert "!/config/*.example.toml" in gi assert "!/config/.env.example" in gi + + +# --- safety_sell_enabled (REQ-048 CHG-20260611-01, BAT-036) ----------------- + +def test_safety_sell_enabled_default_true(tmp_path, monkeypatch): + """AC-10: absent from TOML → safety_sell_enabled=True (default on).""" + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + inst = DaemonInstanceConfig.from_toml(_write_toml(tmp_path, 'instance_key = "i3"\n')) + assert inst.safety_sell_enabled is True + cfg = DaemonConfig.from_config(_write_toml(tmp_path, 'instance_key = "i3"\n')) + assert cfg.safety_sell_enabled is True + + +def test_safety_sell_enabled_false_from_toml(tmp_path, monkeypatch): + """AC-10: safety_sell_enabled = false in TOML → DaemonConfig.safety_sell_enabled=False.""" + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + path = _write_toml(tmp_path, 'instance_key = "i4"\nsafety_sell_enabled = false\n') + cfg = DaemonConfig.from_config(path) + assert cfg.safety_sell_enabled is False + + +def test_safety_sell_enabled_true_explicit_from_toml(tmp_path, monkeypatch): + """AC-10: safety_sell_enabled = true in TOML → DaemonConfig.safety_sell_enabled=True.""" + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + path = _write_toml(tmp_path, 'instance_key = "i5"\nsafety_sell_enabled = true\n') + cfg = DaemonConfig.from_config(path) + assert cfg.safety_sell_enabled is True diff --git a/trex_core/adapters/messaging/rabbitmq.py b/trex_core/adapters/messaging/rabbitmq.py index 54d200d..b4b7087 100644 --- a/trex_core/adapters/messaging/rabbitmq.py +++ b/trex_core/adapters/messaging/rabbitmq.py @@ -40,7 +40,7 @@ from trex_core.adapters.messaging.topology import ( control_queue, direct_routing_key, intent_queue, - snapshot_queue, + slots_queue, ) from trex_core.application.messaging import ( EventHandler, @@ -138,9 +138,9 @@ class RabbitMQTransport: ch.exchange_declare(BROADCAST_EXCHANGE, exchange_type="fanout", durable=True) # Per-instance RPC queues (default → literal names, REQ-049 backward-compat). iq = intent_queue(instance_key) - sq = snapshot_queue(instance_key) + sq = slots_queue(instance_key) cq = control_queue(instance_key) - for q, channel_type in ((iq, "intent"), (sq, "snapshot"), (cq, "control")): + for q, channel_type in ((iq, "intent"), (sq, "slots"), (cq, "control")): ch.queue_declare(q, durable=True, arguments=dlx) # Directed binding: trex.direct → {instance_key}.{channel_type} ch.queue_bind(q, DIRECT_EXCHANGE, diff --git a/trex_core/adapters/messaging/topology.py b/trex_core/adapters/messaging/topology.py index 3438870..94c1f61 100644 --- a/trex_core/adapters/messaging/topology.py +++ b/trex_core/adapters/messaging/topology.py @@ -1,19 +1,19 @@ """Messaging topology constants + builders (REQ-021, dcy-006 OQ-2). Three patterns: - 1. snapshot RPC — direct queue `trex.snapshot.req`, reply via amq.rabbitmq.reply-to + 1. slots RPC — direct queue `trex.slots.req`, reply via amq.rabbitmq.reply-to 2. intent RPC — direct queue `trex.intent.req`, reply via amq.rabbitmq.reply-to 3. events — topic exchange `trex.events`, routing key {event_type}.{strategy_id}.{symbol} -Mapping principle: requests carry view/strategy_id in *payload*; events carry +Mapping principle: requests carry filter/spy in *payload*; events carry strategy_id/symbol in the *routing key* so brokers can filter per-strategy queues with patterns like `*.{strategy_id}.#`. """ from __future__ import annotations -# --- RPC queues (durable for intent; transient-ish for snapshot but kept +# --- RPC queues (durable for intent; transient-ish for slots but kept # durable for simplicity since the queue itself is shared) --- -SNAPSHOT_REQ_QUEUE = "trex.snapshot.req" +SLOTS_REQ_QUEUE = "trex.slots.req" # REQ-073 / dcy-025: replaces snapshot.req INTENT_REQ_QUEUE = "trex.intent.req" PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 (legacy alias of control) CONTROL_REQ_QUEUE = "trex.control.req" # BAT-012 / REQ-043 operator control channel @@ -40,8 +40,8 @@ def intent_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: return _instance_queue(INTENT_REQ_QUEUE, instance_key) -def snapshot_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: - return _instance_queue(SNAPSHOT_REQ_QUEUE, instance_key) +def slots_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: + return _instance_queue(SLOTS_REQ_QUEUE, instance_key) def control_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 296f4db..c9d3f67 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -17,8 +17,6 @@ from trex_core.application.messaging import RpcRequest, RpcResponse from trex_core.application.portfolio import ( PauseStore, SnapshotStore, - View, - build_snapshot_envelope, slot_to_dict, ) from trex_core.application.watchdog import WatchdogStore @@ -168,158 +166,98 @@ def _handle_update_thresholds( return RpcResponse(payload=payload) -# ---- snapshot RPC handler --------------------------------------------------- +# ---- slots RPC handler (REQ-073/074) ---------------------------------------- -def make_snapshot_rpc_handler( +def make_slots_rpc_handler( slot_repo: SlotRepository, - slot_service: SlotService, *, instance_key: str = "default", snapshot_store: Optional[SnapshotStore] = None, pause_store: Optional[PauseStore] = None, - max_active_slots: Optional[int] = None, watchdog_store: Optional[WatchdogStore] = None, watchdog_timeout_sec: int = 120, - killswitch_store=None, ledger_outbox=None, ): - """Build the RPC handler for `trex.snapshot.req` (REQ-028). + """Build the RPC handler for `trex.slots.req` (REQ-073/074). - When `snapshot_store` + `pause_store` are provided (production wiring), - returns the BAT-008 envelope: - { - "assets": list of {symbol, asset_id, cash_balance, - asset_quantity, active_strategy_id, - strategy_params}, - "view": "all" | "buyable" | "held", - "available_slots": int, - "degraded": bool, - "reason": DegradedReason value or null, - "message": sanitized text or null - } + Request: {filter?: str, spy?: bool} + filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all") + spy=true: watchdog not updated (operator view, REQ-034 CHG-01) - Without those, falls back to the BAT-007 slot+capacity stub shape so - older callers and existing tests don't break. + Response: {slots: [...], degraded: bool, reason: str|null, message: str|null} + Each slot row carries slot/core state + hub asset data + watchdog + overlay + flags (strategy fields excluded — REQ-074 AC-5). """ def handler(req: RpcRequest) -> RpcResponse: body = req.payload or {} - day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today() + filter_val = str(body.get("filter") or "all").lower() + spy = bool(body.get("spy", False)) - # REQ-034: update watchdog for all holding slots visible in this RPC - # (strategy polling == liveness signal). dcy-016/REQ-021: an operator - # query (`touch_watchdog=false` / `actor="operator"`) must NOT count as - # liveness — otherwise an operator looking at slots would suppress - # safety-sell on a dead strategy (R-4). Default keeps legacy behavior. - touch_watchdog = bool(body.get("touch_watchdog", True)) - if str(body.get("actor") or "").lower() == "operator": - touch_watchdog = False - if watchdog_store is not None and touch_watchdog: + # Retrieve and filter slots by status + slots = slot_repo.list_slots(instance_key) + if filter_val == "inactive": + slots = [s for s in slots if s.status.value == "inactive"] + elif filter_val == "pending": + slots = [s for s in slots if s.status.value in ("pending_buy", "pending_sell")] + elif filter_val == "holding": + slots = [s for s in slots if s.status.value == "holding"] + elif filter_val == "error": + slots = [s for s in slots if s.status.value == "error"] + # "all" or unrecognized → no filter (REQ-073 AC-2) + + # REQ-034 CHG-01: update watchdog for holding slots unless spy=True + if not spy and watchdog_store is not None: for s in slot_repo.list_holding_slots(instance_key): watchdog_store.update(str(s.slot_id)) - # --- BAT-008 envelope path (snapshot store wired) --- - if snapshot_store is not None and pause_store is not None: - requested_view = str(body.get("view") or "all").lower() - try: - view = View(requested_view) - except ValueError: - view = View.ALL - strategy_key = _opt_str(body.get("strategy_key")) - # dcy-020 / REQ-059: available = remaining admission headroom under - # the M cap (max(0, M − active)); inactive count when no cap. - cap = slot_service.compute_slot_capacity(max_active_slots) - envelope = build_snapshot_envelope( - store=snapshot_store, + # Build merged slot rows (REQ-074) + slot_rows = [ + slot_to_dict( + s, + snapshot_store=snapshot_store, + watchdog_store=watchdog_store, pause_store=pause_store, - slot_repo=slot_repo, - instance_key=instance_key, - available_slots=cap.available_count, - view=view, - strategy_key=strategy_key, - trading_date=day, - killswitch_store=killswitch_store, + watchdog_timeout_sec=watchdog_timeout_sec, ) - payload = envelope.to_dict() - _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, watchdog_timeout_sec) - _apply_cash_estimate(payload, ledger_outbox) - return RpcResponse(payload=payload) + for s in slots + ] - # --- BAT-007 legacy stub-shape path --- - view_raw = str(body.get("view") or "all") - strategy_filter = _opt_str(body.get("strategy_id")) - slots = slot_repo.list_slots(instance_key) - if view_raw == "open": - slots = [s for s in slots if s.status.value in ("pending_buy", "holding", "pending_sell")] - if strategy_filter is not None: - slots = [s for s in slots if s.strategy_id == strategy_filter] - cap = slot_service.compute_slot_capacity(max_active_slots) - payload = { - "view": view_raw, - "strategy_id": strategy_filter, - "slots": [_slot_to_dict(s) for s in slots], - "capacity": { - "inactive_count": cap.inactive_count, - "holding_count": cap.holding_count, - "pending_count": cap.pending_count, - "active_count": cap.active_count, - "available_count": cap.available_count, - "total_slots": cap.total_slots, - "scarcity_ratio": cap.scarcity_ratio, - }, - } - _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, watchdog_timeout_sec) - return RpcResponse(payload=payload) + # dcy-018 / REQ-029: cash-estimated overlay (best-effort) + if ledger_outbox is not None: + try: + deltas = ledger_outbox.pending_cash_delta() + if deltas: + for row in slot_rows: + pid = row.get("portfolio_id") + d = deltas.get(pid) + if d: + base = row.get("cash_balance") or 0.0 + row["cash_balance"] = base + d + row["cash_estimated"] = True + except Exception: # noqa: BLE001 - estimate must never break the response + pass + + # Degraded state from snapshot store + degraded = False + reason = None + message = None + if snapshot_store is not None: + reason_code, msg = snapshot_store.degraded_state() + degraded = reason_code is not None + reason = reason_code.value if reason_code is not None else None + message = msg + + return RpcResponse(payload={ + "slots": slot_rows, + "degraded": degraded, + "reason": reason, + "message": message, + }) return handler -def _apply_cash_estimate(payload, ledger_outbox) -> None: - """Overlay an estimated cash for assets with unsettled (pending) ledger - writes (dcy-018 / REQ-029, OQ-4). cash_balance += pending cash delta and the - asset is flagged `cash_estimated=true` until option-D write-back lands. - Best-effort: any failure leaves the response unchanged (NFR-1).""" - if ledger_outbox is None: - return - try: - deltas = ledger_outbox.pending_cash_delta() - if not deltas: - return - for a in payload.get("assets", []): - d = deltas.get(a.get("asset_id")) - if d: - base = a.get("cash_balance") or 0.0 - a["cash_balance"] = base + d - a["cash_estimated"] = True - except Exception: # noqa: BLE001 - estimate must never break the response - return - - -def _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, timeout_sec) -> None: - """Attach holding-slot watchdog state to a snapshot response (dcy-017/REQ-034). - - `watchdog` = list of {slot_id, portfolio_id, symbol, expired, last_seen} for - holding slots — lets `trex-core slots` surface watchdog ownership (strategy - absence). Best-effort: any failure leaves the response unchanged (FR-4).""" - if watchdog_store is None: - return - try: - states = [] - for s in slot_repo.list_holding_slots(instance_key): - sid = str(s.slot_id) - last = watchdog_store.last_seen(sid) - states.append({ - "slot_id": sid, - "portfolio_id": s.portfolio_id, - "symbol": s.symbol, - "expired": watchdog_store.is_expired(sid, timeout_sec), - "last_seen": last.isoformat() if last else None, - }) - payload["watchdog"] = states - except Exception: # noqa: BLE001 - visibility must never break the response - return - - # ---- pause/resume RPC handler (REQ-032 AC-4) -------------------------------- def make_pause_rpc_handler(pause_store: PauseStore): @@ -528,7 +466,3 @@ def _today() -> date_cls: return datetime.now(tz=timezone.utc).date() -# BAT-022: canonical slot dict shared with build_snapshot_envelope (single -# source of truth so BAT-007 stub and BAT-008 envelope emit an identical -# `slots[]` shape). Local alias kept for existing call sites. -_slot_to_dict = slot_to_dict diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index 66beb63..1ec63c3 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -60,15 +60,6 @@ class PortfolioHubReadPort(Protocol): ... -# ---------------------------------------------------------------- selection enums - -class View(str, Enum): - """Snapshot view modes (REQ-029, dcy-005 OQ-11/OQ-12).""" - ALL = "all" # universe ∪ holding slots - BUYABLE = "buyable" # universe − occupied slots - HELD = "held" # holding slots only - - class DegradedReason(str, Enum): """Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5).""" PORTFOLIO_HUB_UNAVAILABLE = "portfolio_hub_unavailable" @@ -255,76 +246,74 @@ class InMemoryKillSwitchStore: self._excluded.discard((portfolio_id, trading_date)) -# ---------------------------------------------------------------- selection -@dataclass(frozen=True) -class SnapshotEnvelope: - """REQ-028 response envelope (transport-agnostic).""" - assets: list[dict] - view: str - available_slots: int - degraded: bool - reason: Optional[str] - message: Optional[str] = None - # REQ-028 CHG-20260608-01 (BAT-022): operator-facing per-slot state list, - # additive and independent of strategy-facing `assets[]` (universe). Lets - # `trex-core slots` show the daemon's real slot table (idle/holding/pending) - # instead of universe portfolios. Always a list ([] when no slots). - slots: list[dict] = field(default_factory=list) +def slot_to_dict( + s, + *, + snapshot_store: "Optional[SnapshotStore]" = None, + watchdog_store=None, + pause_store: "Optional[PauseStore]" = None, + watchdog_timeout_sec: int = 120, +) -> dict: + """Merged slot row for trex.slots.req (REQ-073/074). - def to_dict(self) -> dict: - return { - "assets": list(self.assets), - "view": self.view, - "available_slots": self.available_slots, - "degraded": self.degraded, - "reason": self.reason, - "message": self.message, - "slots": list(self.slots), - } - - -def _asset_to_dict(item: PortfolioItem) -> dict: - """REQ-027 AC-1 / REQ-028 AC-3 — strict no-price field shape.""" - return { - "symbol": item.symbol, - "asset_id": item.portfolio_id, - "cash_balance": item.cash_balance, - "asset_quantity": item.asset_quantity, - "active_strategy_id": item.active_strategy_id, - "strategy_params": dict(item.strategy_params or {}), - } - - -def slot_to_dict(s) -> dict: - """Canonical operator-facing per-slot state shape (REQ-028 AC-4, BAT-022). - - Shared by the BAT-008 envelope (`build_snapshot_envelope`) and the BAT-007 - stub-shape handler so `trex-core slots` sees an identical slot dict on either - path. Unlike `_asset_to_dict` (strategy universe, no-price), this is - operator-only and exposes prices. External read field name `entry_price` - is kept for contract compatibility (dcy-012 / NFR-3 — internally backed by - the renamed `limit_price`, the pending order limit cleared once holding). + Carries slot/core state + hub asset data + watchdog + overlay flags. + Field-level authority: slot/core = slot_id/status/qty/avg_price/entry_price/ + forced_close_at; hub = asset_quantity/cash_balance; watchdog = watchdog{}; + overlay = paused/warned. strategy fields excluded (REQ-074 AC-5). """ - return { + d = { "slot_id": str(s.slot_id), "status": s.status.value, "symbol": s.symbol, "qty": float(s.qty) if s.qty is not None else None, "avg_price": float(s.avg_price) if s.avg_price is not None else None, "entry_price": float(s.limit_price) if s.limit_price is not None else None, - "strategy_id": s.strategy_id, - "sell_strategy_id": s.sell_strategy_id, "portfolio_id": s.portfolio_id, "forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None, - # dcy-020 / REQ-063 (BAT-029): asset-centric single view — universe-detached - # (soft-retired/tombstone) marker so the operator sees retired assets. "universe_detached": getattr(s, "retired_at", None) is not None, } + # hub authority: asset_quantity (hub primary) + cash_balance + asset_qty: Optional[float] = None + cash_balance: Optional[float] = None + if snapshot_store is not None and s.portfolio_id: + snap = snapshot_store.get(s.portfolio_id) + if snap is not None: + asset_qty = float(snap.item.asset_quantity) + cash_balance = float(snap.item.cash_balance) + + # qty (slot mirror) vs asset_quantity (hub): divergence → warned=True + slot_qty = float(s.qty) if s.qty is not None else None + warned = bool(slot_qty != asset_qty) if (slot_qty is not None and asset_qty is not None) else False + + d["asset_quantity"] = asset_qty + d["cash_balance"] = cash_balance + d["cash_estimated"] = False # handler overlays ledger_outbox estimate if available + d["warned"] = warned + + # watchdog nested object (WatchdogStore) + if watchdog_store is not None: + sid = str(s.slot_id) + last = watchdog_store.last_seen(sid) + d["watchdog"] = { + "expired": watchdog_store.is_expired(sid, watchdog_timeout_sec), + "last_seen": last.isoformat() if last else None, + } + else: + d["watchdog"] = None + + # paused overlay (PauseStore) — orthogonal to status + paused = False + if pause_store is not None and s.portfolio_id: + paused = s.portfolio_id in pause_store.list_paused() + d["paused"] = paused + + return d + def _sanitize(message: str) -> str: - """Strip credentials/URLs/tokens — REQ-028 AC-2 sanitization.""" + """Strip credentials/URLs/tokens from degraded messages.""" if not message: return "" out = message @@ -333,114 +322,6 @@ def _sanitize(message: str) -> str: if idx >= 0: out = out[:idx] + "" break - # Truncate to keep response payload bounded. return out[:200] -def build_snapshot_envelope( - *, - store: SnapshotStore, - pause_store: PauseStore, - slot_repo, - instance_key: str = "default", - available_slots: int, - view: View = View.ALL, - strategy_key: Optional[str] = None, - trading_date=None, - killswitch_store: "Optional[KillSwitchStore]" = None, -) -> SnapshotEnvelope: - """Compose base(view) − mask(pending·warning·pause·killswitch) − strategy_key. - - `slot_repo` is the SlotRepository used to read holding/pending state — - instance-scoped (dcy-020/REQ-056). `trading_date` (default UTC today) is - used only for the day-scoped kill-switch exclusion mask. - `killswitch_store` (REQ-046) adds the day-scoped kill-switch exclusion to - the mask: a `GLOBAL_KILLSWITCH` ("*") entry masks the whole universe. - """ - # Read core authoritative slot state once. - day = trading_date or datetime.now(tz=timezone.utc).date() - slots = slot_repo.list_slots(instance_key) - holding_pids: set[str] = set() - pending_pids: set[str] = set() - holding_qty: dict[str, float] = {} - for s in slots: - if s.portfolio_id is None: - continue - if s.status.value == "holding": - holding_pids.add(s.portfolio_id) - holding_qty[s.portfolio_id] = float(s.qty or 0.0) - elif s.status.value in ("pending_buy", "pending_sell"): - pending_pids.add(s.portfolio_id) - - assets = store.list_assets() - paused = pause_store.list_paused() - warned = {a.item.portfolio_id for a in assets if a.warning} - # REQ-046: day-scoped kill-switch exclusion. '*' sentinel = whole universe. - excluded = killswitch_store.list_excluded(day) if killswitch_store is not None else set() - killswitch_global = GLOBAL_KILLSWITCH in excluded - - # Build the base set (view). - by_pid = {a.item.portfolio_id: a for a in assets} - base_pids: list[str] - if view is View.HELD: - base_pids = sorted(holding_pids) - elif view is View.BUYABLE: - if available_slots <= 0: - base_pids = [] - else: - # universe − occupied slots (pending OR holding occupy) - occupied = holding_pids | pending_pids - base_pids = [a.item.portfolio_id for a in assets - if a.item.portfolio_id not in occupied] - else: # ALL - base_pids = list({a.item.portfolio_id for a in assets} | holding_pids) - base_pids.sort() - - # Apply masks: pending OR warning OR pause OR kill-switch day-exclusion. - masked = pending_pids | warned | paused | excluded - - # Filter and shape. - out: list[dict] = [] - for pid in base_pids: - if killswitch_global or pid in masked: - continue - if pid in by_pid: - item = by_pid[pid].item - elif pid in holding_pids: - # holding slot for an asset NOT in the current universe (e.g. fetch - # gap) — synthesize a minimal item using slot data so the response - # still carries authoritative position info (REQ-029 slot overlay - # priority over portfolio-hub presence). - slot = next((s for s in slots if s.portfolio_id == pid), None) - item = PortfolioItem( - portfolio_id=pid, - symbol=(slot.symbol if slot else "") or "", - cash_balance=0.0, - asset_quantity=float((slot.qty if slot else 0.0) or 0.0), - active_strategy_id=(slot.strategy_id if slot else None), - strategy_params={}, - ) - else: - continue - if strategy_key is not None and item.active_strategy_id != strategy_key: - continue - d = _asset_to_dict(item) - # holding slot quantity wins for held assets (slot-overlay priority). - if pid in holding_qty: - d["asset_quantity"] = holding_qty[pid] - out.append(d) - - # REQ-028 CHG-20260608-01 (BAT-022): operator-facing per-slot state — the - # full daemon slot table for `day`, independent of the asset view filter. - slot_states = [slot_to_dict(s) for s in slots] - - reason_code, message = store.degraded_state() - return SnapshotEnvelope( - assets=out, - view=view.value, - available_slots=max(0, int(available_slots)), - degraded=reason_code is not None, - reason=reason_code.value if reason_code is not None else None, - message=message, - slots=slot_states, - ) diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index a60b51e..1f0872c 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -102,101 +102,90 @@ def _today_iso(cfg: DaemonConfig) -> str: def _cmd_universe(args, cfg, rpc) -> int: + # Universe = admittable slots (filter=inactive, spy=true — operator view) instance = _resolve_instance(args) - payload = {"view": "all", "actor": "operator", "touch_watchdog": False, - "trading_date": _today_iso(cfg)} - resp = rpc(topology.snapshot_queue(instance), payload, + payload = {"filter": "inactive", "spy": True} + resp = rpc(topology.slots_queue(instance), payload, correlation_id=str(uuid.uuid4()), timeout=args.timeout) - assets = resp.get("assets", []) + slots = resp.get("slots", []) if args.json: - # Scope to universe data — `slots[]`/`watchdog[]` belong to `slots`. - _emit({"instance": instance, "view": resp.get("view"), "assets": assets, - "available_slots": resp.get("available_slots"), + _emit({"instance": instance, "filter": "inactive", "slots": slots, "degraded": resp.get("degraded"), "reason": resp.get("reason")}, as_json=True) else: - print(f"instance={instance} view=all assets={len(assets)} " + print(f"instance={instance} filter=inactive count={len(slots)} " f"degraded={resp.get('degraded')}") - for a in assets: - print(f" {a.get('asset_id')}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}") + for s in slots: + print(f" {s.get('portfolio_id')}\t{s.get('symbol')}\t" + f"qty(hub)={s.get('asset_quantity')}\tqty(slot)={s.get('qty')}") return 0 def _cmd_slots(args, cfg, rpc) -> int: instance = _resolve_instance(args) - payload = {"view": "all", "actor": "operator", "touch_watchdog": False, - "trading_date": _today_iso(cfg)} - resp = rpc(topology.snapshot_queue(instance), payload, - correlation_id=str(uuid.uuid4()), timeout=args.timeout) - # REQ-032 CHG-20260608-03 (BAT-022): show the real daemon slot table - # (envelope `slots[]`), not the universe `assets[]`. `--status` filters by - # slot status; `universe` keeps showing `assets[]`. - slots = resp.get("slots", []) status = (args.status or "all").lower() - if status in ("holding", "held"): - slots = [s for s in slots if s.get("status") == "holding"] - elif status == "buyable": - # dcy-020 (BAT-030): admittable = inactive, not universe-detached. - slots = [s for s in slots - if s.get("status") == "inactive" and not s.get("universe_detached")] - elif status == "pending": - slots = [s for s in slots if s.get("status") in ("pending_buy", "pending_sell")] - # --symbol / --slot-id: client-side filters (composable with --status). + # REQ-063 CHG-20260611-01: map --status to filter; buyable → inactive + filter_val = "inactive" if status == "buyable" else status + payload = {"filter": filter_val, "spy": True} + resp = rpc(topology.slots_queue(instance), payload, + correlation_id=str(uuid.uuid4()), timeout=args.timeout) + slots = resp.get("slots", []) + # --symbol / --slot-id: client-side filters (composable with server-side filter) if args.symbol: slots = [s for s in slots if s.get("symbol") == args.symbol] if args.slot_id: slots = [s for s in slots if s.get("slot_id") == args.slot_id] - # dcy-017: holding-slot watchdog state (strategy-absent ownership). - owned = {w.get("portfolio_id") for w in resp.get("watchdog", []) if w.get("expired")} - wd_by_slot = {w.get("slot_id"): w for w in resp.get("watchdog", [])} + # watchdog-expired ownership: slots with watchdog.expired=True + owned = {s.get("portfolio_id") for s in slots + if (s.get("watchdog") or {}).get("expired")} if args.json: - _emit({"instance": instance, "status": status, "slots": slots}, as_json=True) + _emit({"instance": instance, "filter": filter_val, "slots": slots}, as_json=True) else: - header = f"instance={instance} status={status}" + header = f"instance={instance} filter={filter_val}" if args.symbol: header += f" symbol={args.symbol}" if args.slot_id: header += f" slot_id={args.slot_id}" print(f"{header} count={len(slots)} " - f"watchdog_owned={len([s for s in slots if s.get('portfolio_id') in owned])}") - # When filtered (--symbol / --slot-id), print a detailed per-slot block; - # otherwise the terse one-line table. + f"watchdog_expired={len([s for s in slots if s.get('portfolio_id') in owned])}") if args.symbol or args.slot_id: for s in slots: - _print_slot_detail(s, wd_by_slot.get(s.get("slot_id"))) + _print_slot_detail(s) else: for s in slots: pid = s.get("portfolio_id") - tag = " [WATCHDOG-OWNED: strategy absent]" if pid in owned else "" + wd = s.get("watchdog") or {} + tag = " [WATCHDOG: strategy absent]" if wd.get("expired") else "" print(f" {s.get('slot_id')}\t{s.get('status')}\t{s.get('symbol')}\t" - f"qty={s.get('qty')}\tlimit={s.get('entry_price')}\t" - f"avg={s.get('avg_price')}\tpid={s.get('portfolio_id')}{tag}") + f"qty={s.get('qty')}\taqty={s.get('asset_quantity')}\t" + f"limit={s.get('entry_price')}\tavg={s.get('avg_price')}\t" + f"pid={pid}\tpaused={s.get('paused')}\twarned={s.get('warned')}{tag}") return 0 -def _print_slot_detail(s: dict, wd: dict | None) -> None: +def _print_slot_detail(s: dict) -> None: """Verbose per-slot block used when `slots` is filtered (e.g. --symbol).""" + wd = s.get("watchdog") or {} fields = [ ("status", s.get("status")), ("symbol", s.get("symbol")), ("portfolio_id", s.get("portfolio_id")), - ("qty", s.get("qty")), + ("qty (slot mirror)", s.get("qty")), + ("asset_quantity (hub)", s.get("asset_quantity")), + ("warned", s.get("warned")), ("avg_price", s.get("avg_price")), ("entry_price (limit)", s.get("entry_price")), - ("strategy_id", s.get("strategy_id")), - ("sell_strategy_id", s.get("sell_strategy_id")), + ("cash_balance", s.get("cash_balance")), + ("cash_estimated", s.get("cash_estimated")), + ("paused", s.get("paused")), ("forced_close_at", s.get("forced_close_at")), ("universe_detached", s.get("universe_detached")), + ("watchdog.expired", wd.get("expired")), + ("watchdog.last_seen", wd.get("last_seen")), ] - if wd is not None: - fields.append(( - "watchdog", - f"expired={wd.get('expired')} last_seen={wd.get('last_seen')}", - )) print(f"slot {s.get('slot_id')}") for label, value in fields: - print(f" {label:<20}: {value}") - return 0 + print(f" {label:<24}: {value}") def _intent_guard(args, cfg, summary: dict, action: str, confirm) -> int | None: diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 23b2cb3..3b88802 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -148,6 +148,9 @@ class DaemonInstanceConfig: # dcy-022 / REQ-068: default order timeout in seconds (opt-in per instance). # None = OFF = indefinite wait (legacy behavior). Per-intent timeout_sec overrides. default_order_timeout_sec: Optional[int] = None + # dcy-024 / REQ-048 CHG-20260611-01: disable autonomous safety-sell worker. + # Absent = True (safe default). False → trex-safety-sell thread not started. + safety_sell_enabled: bool = True @staticmethod def from_toml(path: str) -> "DaemonInstanceConfig": @@ -178,6 +181,7 @@ class DaemonInstanceConfig: trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"), max_active_slots=max_active, default_order_timeout_sec=default_timeout, + safety_sell_enabled=bool(data.get("safety_sell_enabled", True)), ) @@ -216,6 +220,9 @@ class DaemonConfig: # dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The # daily preallocation trigger was removed with the pool model (BAT-030). trading_timezone: str = "Asia/Seoul" + # dcy-024 / REQ-005 CHG-20260611-01: disable autonomous safety-sell worker. + # False → trex-safety-sell thread not started in daemon.start(). + safety_sell_enabled: bool = True # dcy-015 / REQ-019 (BAT-019): ledger outbox retry backoff. base*2^(n-1) # capped at cap (+jitter); after max_attempts a retryable row dead-letters. ledger_retry_base_sec: float = 1.0 @@ -335,4 +342,5 @@ class DaemonConfig: portfolio_hub=pf, trading_timezone=inst.trading_timezone, max_active_slots=inst.max_active_slots, + safety_sell_enabled=inst.safety_sell_enabled, ) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index ee8e8db..a5a7c89 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -53,7 +53,7 @@ from trex_core.application.killswitch import KillSwitchService from trex_core.application.messaging_handlers import ( make_control_rpc_handler, make_intent_rpc_handler, - make_snapshot_rpc_handler, + make_slots_rpc_handler, slot_transition_event_payload, ) from trex_core.application.portfolio import ( @@ -259,26 +259,24 @@ class Daemon: transport.consume_rpc_forever( topology.intent_queue(self.config.instance_key), handler, self.stop) - def _snapshot_rpc_loop(self) -> None: + def _slots_rpc_loop(self) -> None: transport = _build_transport(self.config) self._transports.append(transport) try: transport.declare_topology(self.config.instance_key) except Exception: # noqa: BLE001 pass - handler = make_snapshot_rpc_handler( - self.slot_repo, self.slot_service, + handler = make_slots_rpc_handler( + self.slot_repo, instance_key=self.config.instance_key, snapshot_store=self.snapshot_store, pause_store=self.pause_store, - max_active_slots=self.config.max_active_slots, watchdog_store=self.watchdog, watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec, - killswitch_store=self.killswitch_store, - ledger_outbox=self.outbox, # dcy-018 / REQ-029: estimated-cash overlay + ledger_outbox=self.outbox, ) transport.consume_rpc_forever( - topology.snapshot_queue(self.config.instance_key), handler, self.stop) + topology.slots_queue(self.config.instance_key), handler, self.stop) def _control_rpc_loop(self) -> None: # REQ-043: operator control channel (pause/resume/list + kill_switch/resume_kill_switch). @@ -329,11 +327,17 @@ class Daemon: ("trex-execution", self._execution_loop), ("trex-ledger", self._ledger_loop), ("trex-intent-rpc", self._intent_rpc_loop), - ("trex-snapshot-rpc", self._snapshot_rpc_loop), + ("trex-slots-rpc", self._slots_rpc_loop), ("trex-control-rpc", self._control_rpc_loop), ("trex-snapshot-scheduler", self._snapshot_scheduler_loop), - ("trex-safety-sell", self._safety_sell_loop), ] + if self.config.safety_sell_enabled: + workers.append(("trex-safety-sell", self._safety_sell_loop)) + else: + _log.warning( + "safety_sell_enabled=false — trex-safety-sell worker not started. " + "Autonomous safety-sell will NOT run." + ) for name, target in workers: t = threading.Thread(target=target, name=name, daemon=True) t.start()