feat(rel-007): dcy-025/024 + BAT-035/036 — slots.req 즉시 교체 + safety_sell_enabled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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 대응 확인 전 배포 불가
|
||||
@@ -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 가 그 계약을 행에 표현한다.
|
||||
@@ -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
|
||||
@@ -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: 없음
|
||||
@@ -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: 없음
|
||||
@@ -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
|
||||
- 없음
|
||||
@@ -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 값과 무관하게 동작 — 이번 배치 구현에서 그 경로를 건드리지 않는 것이 검증 기준.
|
||||
@@ -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
|
||||
@@ -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: 없음
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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=<true|false>` 필드를 포함한다. 비활성 모드(`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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
+55
-129
@@ -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": "<correlation_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 <ISO8601>`(절대시간)로 설정하며(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": "<SlotState 값>", "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": "<slug>" # 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:"<status>", spy:true}` — `buyable`→`filter:"inactive"` | 슬롯 상태 목록(server-side filter, 워치독 억제, BAT-035) |
|
||||
| `enter <portfolio_id> [--capital/--qty/--stop-loss/--take-profit/--forced-close-at ...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve; `--forced-close-at <ISO8601>`→`forced_close_at`, REQ-032 CHG-20260609-01) | 안전장치 필수; `--forced-close-at` 형식은 제출 전 검증 |
|
||||
| `exit <slot_id> [--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 <path>.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)
|
||||
|
||||
+27
-20
@@ -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 <toml>`(canonical, REQ-048 `instance_key`) 또는 `--instance <key>`(단축·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 <val>`→server-side `filter=<val>`(`buyable`→`inactive`). BAT-035.
|
||||
3. `enter <portfolio_id>`/`exit <slot_id>`: `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 전이.
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
7. (CHG-20260609-01) Given `--config <name> [--config-path <dir>]`, When `resolve_config_toml`, Then `<dir or ./config>/<name>.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 전파 단위.
|
||||
|
||||
@@ -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 필드 부재.
|
||||
@@ -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 잔존 확인.
|
||||
@@ -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=<true|false>` 를 출력하고 `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 회귀.
|
||||
|
||||
@@ -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 매핑 단위.
|
||||
|
||||
@@ -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 표면 계약 검증).
|
||||
|
||||
@@ -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 회귀.
|
||||
|
||||
@@ -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 동치 회귀.
|
||||
|
||||
@@ -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 신호 회귀.
|
||||
+9
-4
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 "<redacted>" in d["message"] or "auth" in d["message"]
|
||||
@@ -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 ------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,156 +166,96 @@ 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)
|
||||
|
||||
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
|
||||
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
|
||||
if ledger_outbox is not None:
|
||||
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 deltas:
|
||||
for row in slot_rows:
|
||||
pid = row.get("portfolio_id")
|
||||
d = deltas.get(pid)
|
||||
if d:
|
||||
base = a.get("cash_balance") or 0.0
|
||||
a["cash_balance"] = base + d
|
||||
a["cash_estimated"] = True
|
||||
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
|
||||
return
|
||||
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
|
||||
|
||||
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,
|
||||
return RpcResponse(payload={
|
||||
"slots": slot_rows,
|
||||
"degraded": degraded,
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
})
|
||||
payload["watchdog"] = states
|
||||
except Exception: # noqa: BLE001 - visibility must never break the response
|
||||
return
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# ---- pause/resume RPC handler (REQ-032 AC-4) --------------------------------
|
||||
@@ -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
|
||||
|
||||
@@ -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] + "<redacted>"
|
||||
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,
|
||||
)
|
||||
|
||||
+39
-50
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+14
-10
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user