From d09b93195f052e817560a4fbc57507aecf03600d Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Mon, 8 Jun 2026 16:48:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(cli):=20dcy-016=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EC=9E=90=20CLI=20=ED=99=95=EC=9E=A5=20(universe/slots/enter/ex?= =?UTF-8?q?it)=20+=20=EC=9B=8C=EC=B9=98=EB=8F=85=20=EC=96=B5=EC=A0=9C=20(B?= =?UTF-8?q?AT-020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영자가 조회·매수·매도를 CLI 로 수행하되 데몬 RabbitMQ RPC 로 통일해 단일 writer admission·불변식을 보존(DB 직접 쓰기 없음)하고, 운영자 조회가 전략 생존 신호로 오인되지 않게 워치독을 억제한다. 거버넌스: 전부 change-req (신규 REQ 없음, dcy-016 OQ-3). - REQ-032: CLI universe/slots(snapshot RPC) + enter(buy)/exit(sell) intent RPC + 인스턴스 라우팅(--config/--instance/default) + enter/exit 안전장치 (확인 프롬프트 기본 ON / --yes(paper) / --yes --live(이중 ack) / --dry-run) + 전 명령 --json. pause/resume 는 direct-DB 유지 - REQ-022: CLI request-reply RPC 클라이언트(call_rpc) 재사용 - REQ-021/034: snapshot 요청 워치독 억제 플래그(touch_watchdog/actor=operator) → 핸들러가 last_seen_at 갱신 스킵. 기본값 현행 유지(하위호환). 운영자 조회가 죽은 전략의 safety-sell 을 무력화하던 결함(R-4) 제거 - REQ-026: interface-contract/runtime-flows 정합 CLI 는 rpc/confirm 주입 가능(테스트 broker/DB-free). snapshot envelope 은 asset-centric 이라 exit 은 운영자 제공 slot_id 사용(slot_id 노출은 본 주기 밖). 검증: 단위 327 passed/34 skipped, 인프라 게이트(PG+RabbitMQ) 361 passed, ruff clean. 신규 test_cli_operator(14) + snapshot 워치독 억제(2). BAT-020 release-candidate. Co-Authored-By: Claude Opus 4.8 --- .../design.md | 45 +++ .../implementation.md | 36 +++ .../index.md | 34 ++ .../planning.md | 49 +++ .../verification.md | 60 ++++ docs/batches/index.md | 1 + ...-016_20260608_operator-cli-rpc-commands.md | 250 ++++++++++++++ docs/discovery/index.md | 3 +- docs/interface-contract.md | 23 +- docs/runtime-flows.md | 12 +- .../req-026_messaging-baseline-alignment.md | 38 +++ ...messaging-topology-and-contract-mapping.md | 40 +++ ...ing-transport-port-and-rabbitmq-adapter.md | 39 +++ .../req-032_runtime-pause-resume-control.md | 40 +++ .../Interface/req-034_per-slot-watchdog.md | 39 +++ docs/srs/index.md | 2 +- .../test_messaging_snapshot_consumer.py | 42 +++ tests/runtime/test_cli_operator.py | 143 ++++++++ trex_core/application/messaging_handlers.py | 11 +- trex_core/runtime/cli.py | 306 ++++++++++++++++-- 20 files changed, 1185 insertions(+), 28 deletions(-) create mode 100644 docs/batches/bat-020_20260608_operator-cli-rpc-commands/design.md create mode 100644 docs/batches/bat-020_20260608_operator-cli-rpc-commands/implementation.md create mode 100644 docs/batches/bat-020_20260608_operator-cli-rpc-commands/index.md create mode 100644 docs/batches/bat-020_20260608_operator-cli-rpc-commands/planning.md create mode 100644 docs/batches/bat-020_20260608_operator-cli-rpc-commands/verification.md create mode 100644 docs/discovery/dcy-016_20260608_operator-cli-rpc-commands.md create mode 100644 tests/runtime/test_cli_operator.py diff --git a/docs/batches/bat-020_20260608_operator-cli-rpc-commands/design.md b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/design.md new file mode 100644 index 0000000..fb12bf8 --- /dev/null +++ b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/design.md @@ -0,0 +1,45 @@ +# BAT-020 Design + +- Status: confirmed +- Batch: BAT-020 + +## 설계 목표 +- 운영자가 CLI 로 조회·매수·매도·일시정지를 수행하되, 매매는 데몬 admission(단일 writer)을 거쳐 불변식을 보존한다. 운영자 조회가 전략 생존 신호로 오인되지 않게 한다(R-4). + +## 1. 워치독 억제 (REQ-021/034, 서버) +- `make_snapshot_rpc_handler`: `touch_watchdog = bool(body.get("touch_watchdog", True))`; `actor=="operator"` 면 강제 false. `watchdog_store and touch_watchdog` 일 때만 holding 슬롯 `last_seen_at` 갱신. +- 기본값(플래그 미지정) = 현행(전략 폴링=갱신) → 전략 모듈 무변경(하위호환). 운영자 CLI 는 항상 억제. + +## 2. CLI 구조 (REQ-032/022) +- `main(argv, *, rpc=None, confirm=None)`: `rpc(queue, payload, *, correlation_id, timeout)->dict`, `confirm(prompt)->bool` 주입 가능(테스트 broker-free). 미주입 시 config 기반 transport(`call_rpc`) + 대화형 confirm. +- 인스턴스 라우팅 `_resolve_instance`: `--instance` > `--config`(TOML instance_key) > `default`(OQ-1). +- 큐: `topology.snapshot_queue(instance)`/`intent_queue(instance)`. +- pause/resume/paused: 기존 `PostgreSQLPauseStore` direct-DB 유지(OQ-3). + +## 3. 명령별 매핑 +- `universe`: snapshot RPC `{view:"all", actor:"operator", touch_watchdog:false, trading_date}` → assets 출력. +- `slots [--status held|buyable|all]`: snapshot RPC(억제) → assets; `holding`→`held` view 매핑. +- `enter [--capital/--qty/--entry-price/--strategy-id/--stop-loss/--take-profit]`: buy intent RPC(portfolio_id 필수, symbol 서버 resolve; symbol 미전송). +- `exit [--price/--reason]`: sell intent RPC(slot_id + optional limit_price; 미지정 시 시장가). +- trading_date 는 설정 타임존(dcy-014) 기준 today. + +## 4. enter/exit 안전장치 (REQ-032, OQ-2) — `_intent_guard` +- `--dry-run`: resolved intent + 대상 인스턴스만 출력, 미제출(exit 0). +- live(`broker=kis`+allow_live) + `--live` 없음 → 에러(exit 2), 미제출. +- `--yes` 없음: `--json`(비대화형)이면 에러(exit 2); 아니면 confirm 프롬프트(기본 No) — 거부 시 미제출(exit 1). +- 통과 시 intent RPC 제출. admission 응답(`status/slot_id/reason`) 표시, `admitted` 아니면 exit 1. +- `_broker_mode(cfg)` → (broker, is_live). is_live = kis + allow_live. + +## 5. 출력 (REQ-032) +- 사람용: 요약/표. `--json`: RPC 응답을 그대로 JSON(`json.dumps`). 실패: stderr 에 error(json 모드는 `{"error":...}`), exit 3. + +## 6. baseline 문서 (REQ-026) +- interface-contract: 운영자 CLI↔RPC 매핑 표 + snapshot 요청 `touch_watchdog`/`actor` 플래그. +- runtime-flows: 운영자 CLI flow(라우팅/억제/안전장치/실패). + +## 범위 밖 정합 +- snapshot envelope 은 asset-centric(REQ-028 strict no-price) — slot_id 미포함. `exit` 는 운영자 제공 slot_id 를 intent RPC 로 전달(snapshot 에 slot_id 노출은 별도 계약 변경, 본 주기 밖). `slots` 는 보유 자산 상황 인지용. + +## 테스트 설계 +- `tests/runtime/test_cli_operator.py`(주입 rpc recorder/confirm): universe 억제 플래그/큐, slots held view, 인스턴스 라우팅, `--json` 유효성, enter 확인 거부 미제출/`--yes` 제출/rejected 비0/`--dry-run`/`--json` 요구 `--yes`, exit 가격/시장가, live 이중 ack(gate), RPC 실패 표시. +- `tests/application/test_messaging_snapshot_consumer.py`: 기본 요청 watchdog touch / operator 요청 suppress. diff --git a/docs/batches/bat-020_20260608_operator-cli-rpc-commands/implementation.md b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/implementation.md new file mode 100644 index 0000000..d035c4f --- /dev/null +++ b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/implementation.md @@ -0,0 +1,36 @@ +# BAT-020 Implementation + +- Status: completed +- Batch: BAT-020 + +## 구현 요약 +- dcy-016 운영자 CLI 확장(universe/slots/enter/exit, 데몬 RPC 통일) + 운영자 조회 워치독 억제를 전부 change-req 로 구현(신규 REQ 없음). + +## 변경 내역 + +### REQ-021/034 — 워치독 억제 (서버) +- `trex_core/application/messaging_handlers.py::make_snapshot_rpc_handler`: `touch_watchdog = bool(body.get("touch_watchdog", True))`; `actor=="operator"` → 강제 false. `watchdog_store and touch_watchdog` 일 때만 holding `last_seen_at` 갱신. 기본값=현행(전략 폴링 갱신, 하위호환). + +### REQ-032/022 — 운영자 CLI +- `trex_core/runtime/cli.py`(재작성): `main(argv, *, rpc=None, confirm=None)` 주입 가능. + - 라우팅 `_resolve_instance`(`--instance` > `--config` instance_key > `default`). + - `universe`/`slots`: snapshot RPC(`actor="operator"`/`touch_watchdog=false`), `holding`→`held`. + - `enter `: buy intent RPC(portfolio_id 필수, symbol 미전송 — 서버 resolve). + - `exit `: sell intent RPC(optional `--price`→limit_price, 미지정 시 시장가). + - 안전장치 `_intent_guard`: `--dry-run`(미제출), live(`broker=kis`+allow_live)는 `--yes --live` 이중 ack, `--yes` 없으면 confirm(기본 No)/`--json` 비대화형은 `--yes` 필수. + - `--json` 출력, RPC 실패는 stderr error + exit 3. admission rejected → exit 1. + - pause/resume/paused: 기존 `PostgreSQLPauseStore` direct-DB 유지(OQ-3). + - transport RPC: config 기반 `RabbitMQTransport`/`InMemoryTransport` `call_rpc` 재사용(REQ-022). + +### REQ-026 — baseline 문서 +- `docs/interface-contract.md`: 운영자 CLI↔RPC 매핑 표 + snapshot 요청 `touch_watchdog`/`actor` 플래그. Last Updated → BAT-020. +- `docs/runtime-flows.md`: 운영자 CLI flow(라우팅/억제/안전장치/실패). Last Updated → BAT-020. + +## 테스트 +- 신규 `tests/runtime/test_cli_operator.py`(주입 rpc/confirm, broker/DB free): 14건 — universe 억제 플래그/큐, slots held view, 인스턴스 라우팅, `--json` 유효성, enter 확인거부 미제출/`--yes` 제출/rejected 비0/`--dry-run`/`--json` 요구 `--yes`, exit 가격/시장가, live 이중 ack(gate+proceed), RPC 실패 표시. +- `tests/application/test_messaging_snapshot_consumer.py` 확장: 기본 요청 watchdog touch / operator 요청 suppress 2건. + +## 검증 실행 +- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q` → `327 passed / 34 skipped`(`::test_sandbox_token_and_submit` deselect — LIVE `.env` 환경 의존, 본 batch 무관). +- 인프라 게이트: `TREX_POSTGRESQL_TEST_DSN=...(trex_core_test) TREX_RABBITMQ_TEST_URL=... pytest -q` → `361 passed`. +- `ruff check trex_core tests` → All checks passed. diff --git a/docs/batches/bat-020_20260608_operator-cli-rpc-commands/index.md b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/index.md new file mode 100644 index 0000000..567a80f --- /dev/null +++ b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/index.md @@ -0,0 +1,34 @@ +# BAT-020: 운영자 CLI 확장 (universe/slots/enter/exit) + 워치독 억제 (dcy-016) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-016 +- Included REQ: REQ-032 (CHG-20260608-01), REQ-022 (CHG-20260608-01), REQ-021 (CHG-20260608-02), REQ-034 (CHG-20260608-01), REQ-026 (CHG-20260608-01) + +## Summary +- dcy-016(운영자 CLI 명령 확장 — universe/슬롯 확인 + enter/exit + pause/resume, 데몬 RPC 통일)을 단일 standard batch 로 delivery 한다. 거버넌스는 dcy-016 OQ-3 결정대로 **전부 change-req, 신규 REQ 없음**. +- 핵심: `trex-core` CLI 에 `universe`/`slots`(snapshot RPC)·`enter`(buy intent RPC)·`exit`(sell intent RPC) 추가. 전부 데몬 RabbitMQ RPC 로 통일해 단일 writer admission·불변식 보존(DB 직접 쓰기 없음). 운영자 조회는 `actor="operator"`/`touch_watchdog=false` 로 워치독 생존 신호를 억제(R-4). enter/exit 안전장치(확인 프롬프트/`--yes`/`--yes --live`/`--dry-run`). pause/resume 은 기존 direct-DB 유지. + +## Scope +- In Scope: + - `universe`/`slots`/`enter`/`exit` CLI(전부 데몬 RPC) + 응답 표시 (REQ-032) + - CLI request-reply RPC 클라이언트 재사용 (REQ-022) + - snapshot 요청 워치독 억제 플래그(`touch_watchdog`/`actor`) (REQ-021) + 핸들러 억제 동작 (REQ-034) + - enter/exit 안전장치(확인/`--yes`/live 이중 ack/`--dry-run`) + 인스턴스 라우팅(`--config`/`--instance`/default) + 전 명령 `--json` + - interface-contract/runtime-flows 정합 (REQ-026) +- Out of Scope: + - 신규 서버측 RPC 표면/도메인 로직(기존 표면 재사용 — 워치독 억제만 snapshot 핸들러 국소 변경) + - buy/sell 계약 자체 변경(dcy-011/dcy-012) + - kill_switch CLI 노출(별도 결정) + - pause/resume 의 control RPC 통일(미채택 — 기존 direct-DB 유지, OQ-3) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- Source discovery 단일(dcy-016). OQ-1(`--config`+`--instance`/default)·OQ-2(안전장치 A안)·OQ-3(전부 change-req)·OQ-4(워치독 플래그) Answered. +- snapshot envelope 은 asset-centric(REQ-028 strict no-price) — slot_id 미포함. 따라서 `exit ` 는 운영자 제공 slot_id 를 intent RPC 로 전달(snapshot 에 slot_id 노출은 별도 계약 변경이라 본 주기 밖). `slots` 는 보유 자산(asset_id/symbol/qty) 상황 인지용. +- 테스트 결정론: CLI 는 `rpc`/`confirm` 주입 가능(no broker/DB), snapshot 핸들러는 fake watchdog recorder. diff --git a/docs/batches/bat-020_20260608_operator-cli-rpc-commands/planning.md b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/planning.md new file mode 100644 index 0000000..4638fea --- /dev/null +++ b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/planning.md @@ -0,0 +1,49 @@ +# BAT-020 Planning + +- Status: confirmed +- Batch: BAT-020 +- Profile: standard + +## Included REQ +- REQ-032 (Interface, change-req): CLI universe/slots/enter/exit + 인스턴스 라우팅 + enter/exit 안전장치 + `--json`. +- REQ-022 (Integration, change-req): CLI request-reply RPC 클라이언트(`call_rpc`) 재사용. +- REQ-021 (Integration, change-req): snapshot 요청 워치독 억제 플래그. +- REQ-034 (Interface, change-req): 핸들러가 억제 플래그 시 `last_seen_at` 미갱신. +- REQ-026 (Documentation, change-req): interface-contract/runtime-flows 정합. + +## Delivery Plan +1. REQ-021/034(서버): `messaging_handlers.py::make_snapshot_rpc_handler` 에 `touch_watchdog`/`actor` 억제 분기(기본 현행 유지). +2. REQ-032/022(CLI): `cli.py` 재작성 — pause/resume/paused(direct-DB 유지) + universe/slots/enter/exit(RPC). 인스턴스 라우팅(`_resolve_instance`), RPC 빌드/주입(`rpc`), 안전장치(`_intent_guard`: confirm/`--yes`/`--yes --live`/`--dry-run`), `--json`, 실패 표시. +3. REQ-026(문서): interface-contract(CLI↔RPC 매핑 + 억제 플래그), runtime-flows(운영자 CLI flow). +4. 테스트: CLI(주입 rpc/confirm) + snapshot 핸들러 억제 + 전체 회귀 + ruff + 인프라 게이트. + +## Dependencies +- Internal: + - dcy-008/BAT-008 CLI(REQ-032 pause/resume) — 확장 대상. + - dcy-006/BAT-007 transport `call_rpc`(REQ-022) — 재사용. + - dcy-005/BAT-008 snapshot 핸들러/envelope(REQ-028/021) + 워치독(REQ-034) — 억제 대상. + - dcy-011 buy(portfolio_id 필수)·dcy-012 sell(slot_id+optional price) 계약 — CLI payload 근거. + - dcy-009 `_instance_queue`(REQ-049) — 라우팅. +- External: + - RabbitMQ(실 RPC). 단위는 주입 rpc 로 broker-free. + +## Design Gate +- Design Required Before Implementation: yes +- Reason: (a) enter/exit 는 실주문 표면(R-1) — 안전장치(확인/`--yes`/live 이중 ack/`--dry-run`)와 `--json` 비대화형 상호작용 규칙을 설계에서 못박지 않으면 오발주 위험. (b) 워치독 억제는 snapshot 핸들러 서버 동작 변경(REQ-034) — 기본값 현행 유지(하위호환) 경계와 플래그 의미를 설계에서 고정해야 안전 기능(safety-sell) 무력화/회귀를 막는다. (c) 인스턴스 라우팅(`--config`/`--instance`/default) 우선순위와 테스트용 rpc/confirm 주입 구조를 설계에서 명시. → design.md 동일 턴 scaffold. + +## Milestones +1. M1 — REQ-021/034 snapshot 억제 분기, 핸들러 테스트 green +2. M2 — REQ-032/022 CLI universe/slots/enter/exit + 안전장치, CLI 테스트 green +3. M3 — REQ-026 문서 정합 +4. M4 — 전체 회귀 + ruff + 인프라 게이트 + +## Risks +- R-1 (High): enter/exit 오발주/실주문. 대응: 확인 프롬프트 기본 ON·`--yes`(paper)·`--yes --live` 이중 ack·`--dry-run`·rejected 표시·대상 인스턴스 항상 표시. admission portfolio_id 검증(dcy-011)이 1차 방어선. +- R-2 (Medium): 잘못된 인스턴스 라우팅. 대응: `--config` canonical + `--instance` override + default + 출력에 대상 인스턴스 표시. +- R-3 (Low): RPC 통일로 데몬 미가동 시 조회 불가. 대응: 의도된 트레이드오프 문서화 + 명확한 에러 표시. +- R-4 (High): 운영자 조회의 워치독 부수효과로 safety-sell 무력화. 대응: 억제 플래그(REQ-021/034), 기본값 현행 유지. + +## Notes +- 1차 게이트: 전체 회귀 green(워치독 기본 동작 무회귀). +- CLI 단위는 broker/DB 없이 rpc/confirm 주입으로 결정론 확보. +- 실 RabbitMQ 왕복 e2e 는 기존 transport 인프라 게이트로 커버(call_rpc 자체). diff --git a/docs/batches/bat-020_20260608_operator-cli-rpc-commands/verification.md b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/verification.md new file mode 100644 index 0000000..3e37a1c --- /dev/null +++ b/docs/batches/bat-020_20260608_operator-cli-rpc-commands/verification.md @@ -0,0 +1,60 @@ +# BAT-020 Verification + +- Status: verified +- Batch: BAT-020 + +## Acceptance Mapping + +### REQ-032 (운영자 CLI, change-req) +| AC | Evidence | +|----|----------| +| universe snapshot RPC 조회(직접 DB 없음) | tests/runtime/test_cli_operator.py::test_universe_uses_snapshot_queue_with_operator_suppression | +| slots view 매핑(holding→held) | ::test_slots_status_holding_maps_to_held_view | +| enter buy intent RPC 제출 | ::test_enter_with_yes_submits_buy_intent | +| exit sell intent RPC 제출(가격/시장가) | ::test_exit_with_yes_submits_sell_with_limit_price / ::test_exit_market_when_no_price | +| enter/exit 안전장치(확인/dry-run/json 요구 yes) | ::test_enter_aborts_without_confirmation_no_rpc / ::test_enter_dry_run_does_not_submit / ::test_enter_json_requires_yes | +| live 이중 ack | ::test_live_requires_live_flag / ::test_live_proceeds_with_yes_live | +| 인스턴스 라우팅 | ::test_instance_shortcut_routes_to_instance_queue | +| rejected 비0 종료 | ::test_enter_rejected_returns_nonzero | + +### REQ-022 (CLI RPC 클라이언트, change-req) +| AC | Evidence | +|----|----------| +| call_rpc 재사용(주입 가능) | test_cli_operator.py 전반(주입 rpc recorder) | +| RPC 실패 명확 표시 | ::test_rpc_failure_surfaces_error | + +### REQ-021/034 (워치독 억제, change-req) +| AC | Evidence | +|----|----------| +| 기본 요청 watchdog touch | tests/application/test_messaging_snapshot_consumer.py::test_strategy_poll_touches_watchdog_by_default | +| operator 요청 suppress | ::test_operator_query_suppresses_watchdog_touch | +| CLI 가 억제 플래그 전송 | test_cli_operator.py::test_universe_uses_snapshot_queue_with_operator_suppression | + +### REQ-026 (baseline 문서, change-req) +| AC | Evidence | +|----|----------| +| interface-contract/runtime-flows 정합 | 문서 diff(BAT-020) | + +## Evidence +- Test: + - 단위 전체 회귀: `327 passed / 34 skipped`(신규 CLI 14 + 워치독 2 포함; `::test_sandbox_token_and_submit` deselect). + - 인프라 게이트(PG trex_core_test + RabbitMQ): `361 passed`. + - `ruff check trex_core tests` → All checks passed. +- Logs: `327 passed, 34 skipped, 1 deselected` / `361 passed, 1 deselected ~17.8s`. + +## 성공 기준(dcy-016) 대응 +- S-1(universe/slots snapshot RPC 조회, 직접 DB 없음): test_universe_*/test_slots_*. +- S-2(enter/exit intent RPC 제출 + admitted/rejected 표시, DB 쓰기 0): test_enter_with_yes_*/test_exit_*/test_enter_rejected_*. +- S-3(확인 프롬프트/`--yes`/live 가드, 미확인 시 미제출): test_enter_aborts_*/test_live_requires_live_flag/test_live_proceeds_*. +- S-4(인스턴스 라우팅 + 실패 표시): test_instance_shortcut_*/test_rpc_failure_surfaces_error. +- S-5(`--json` 유효 + enter/exit `--json` 요구 `--yes`): test_json_output_is_valid_json/test_enter_json_requires_yes. + +## Carried-Over / 비차단 +- 실 RabbitMQ 왕복 CLI e2e: 기존 transport `call_rpc` 인프라 게이트로 커버(CLI 는 주입 rpc 단위). 실 다중 인스턴스 라우팅 e2e 는 운영 검증 비차단. +- snapshot envelope 에 slot_id 미노출 → `exit` 는 운영자 제공 slot_id 사용. snapshot 에 slot_id 노출은 REQ-028 strict shape 변경이라 본 주기 밖(별도 결정). +- kill_switch CLI 노출은 Out of Scope(후속). + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. CLI 명령·안전장치·워치독 억제 모두 단위 green(361 passed 회귀, 워치독 기본 동작 무회귀). LIVE sandbox 토큰 deselect 는 `.env` 환경 의존(본 batch 무관). diff --git a/docs/batches/index.md b/docs/batches/index.md index 5ed6d05..92fc9bf 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -27,3 +27,4 @@ | BAT-017 | standard | release-candidate | REQ-012 (CHG-20260608-01), REQ-013 (CHG-20260608-02), REQ-018 (CHG-20260608-02), REQ-031 (CHG-20260608-01), REQ-014 (CHG-20260608-01), REQ-015 (CHG-20260608-02) | dcy-013 | [bat-017](./bat-017_20260608_external-cancel-detection-stale-watchdog/) | | BAT-018 | standard | release-candidate | REQ-005 (CHG-20260608-01), REQ-048 (CHG-20260608-01), REQ-009 (CHG-20260608-01) | dcy-014 | [bat-018](./bat-018_20260608_trading-date-timezone-daily-preallocation/) | | BAT-019 | standard | release-candidate | REQ-017 (CHG-20260608-01), REQ-019 (CHG-20260608-01), REQ-018 (CHG-20260608-03), REQ-020 (CHG-20260608-01) | dcy-015 | [bat-019](./bat-019_20260608_ledger-outbox-permanent-error-backoff/) | +| BAT-020 | standard | release-candidate | REQ-032 (CHG-20260608-01), REQ-022 (CHG-20260608-01), REQ-021 (CHG-20260608-02), REQ-034 (CHG-20260608-01), REQ-026 (CHG-20260608-01) | dcy-016 | [bat-020](./bat-020_20260608_operator-cli-rpc-commands/) | diff --git a/docs/discovery/dcy-016_20260608_operator-cli-rpc-commands.md b/docs/discovery/dcy-016_20260608_operator-cli-rpc-commands.md new file mode 100644 index 0000000..4221e73 --- /dev/null +++ b/docs/discovery/dcy-016_20260608_operator-cli-rpc-commands.md @@ -0,0 +1,250 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-016_20260608_operator-cli-rpc-commands +- 이슈명: 운영자 CLI 명령 확장 — universe/슬롯 확인 + enter/exit + pause/resume (데몬 RPC 통일) +- 작성 시각(KST): 2026-06-08 15:49 KST +- 마지막 갱신 시각(KST): 2026-06-08 19:00 KST +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-032/022/021/034/026 (전부 change-req, 신규 REQ 없음 — OQ-3). REQ-021=CHG-20260608-02, 그 외=CHG-20260608-01. pause/resume 는 direct-DB 유지. delivery: BAT-020 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: 사용자 요청(운영자 CLI 명령 추가) +- 해석: + - 현재 `trex-core` CLI 는 `pause/resume/paused` 만 제공하고 PostgreSQL `PauseStore` 에 직접 접근한다. 운영자가 universe 확인·슬롯 상태 확인·매수(enter)·매도(exit) 를 CLI 로 수행할 표면이 없어, 운영 편의와 일관성을 위해 CLI 명령을 확장한다. + - 이번 Discovery에서는 universe/슬롯 읽기·enter/exit·pause/resume 를 **데몬의 기존 RabbitMQ RPC 표면(`trex.snapshot.req`/`trex.intent.req`/`trex.control.req`)으로 통일**해 단일 writer admission 을 보존하고, enter/exit 에 운영 안전장치를 넣는 범위를 판정한다. 서버 표면은 대체로 존재하나 운영자 조회의 워치독 부수효과(REQ-034)를 막는 서버측 억제 모드가 동반된다. 거버넌스는 OQ-3 결정에 따라 기존 REQ(REQ-032/022/021/034/026) `change-req` 로 처리하고 신규 REQ 는 원칙적으로 생성하지 않는다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 기존 Discovery(dcy-001~dcy-015) 중 운영자 CLI 의 universe/슬롯/enter/exit 명령을 다룬 문서는 없다. dcy-002(데몬+HTTP API), dcy-006(전략 transport), dcy-008(kill-switch) 등이 RPC 표면을 도입했으나 CLI 클라이언트 확장을 정의하지 않았다(REQ-032 의 CLI 는 pause/resume 만). + - 변경이 CLI 진입점(REQ-032)과 messaging transport(REQ-022) 사용을 확장하고 운영자에게 매수/매도 실행 표면을 추가하므로, 기존 Discovery 미세 조정이 아니라 독립 주기로 추적하는 것이 명확하다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - `trex_core/runtime/cli.py`(전체 47줄)는 `pause`/`resume`/`paused` 3개 서브커맨드만 제공하고, `PostgreSQLPauseStore`(cli.py:32~42)에 **직접 접근**한다. universe/슬롯/매수/매도 명령은 없다. + - 데몬 RPC 표면은 이미 존재한다 — `trex_core/adapters/messaging/topology.py` 에 `SNAPSHOT_REQ_QUEUE="trex.snapshot.req"`(read-only), `INTENT_REQ_QUEUE="trex.intent.req"`(admission), `CONTROL_REQ_QUEUE="trex.control.req"`(pause/resume/kill_switch) 가 정의돼 있고, 모두 request-reply(`reply_to=amq.rabbitmq.reply-to`)다. + - 인스턴스 라우팅도 존재한다 — `_instance_queue(base, instance_key)`(topology.py:28~)는 named instance 면 `{base}.{instance_key}`, default 면 literal `base` 를 쓴다(dcy-009 다중 인스턴스). + - **주의(워치독 부수효과)**: `make_snapshot_rpc_handler`(messaging_handlers.py:196~200)는 read-only 응답 산출에 더해 **응답에 포함된 모든 holding 슬롯의 워치독 `last_seen_at` 을 갱신**한다(REQ-034). 이는 "전략 모듈 폴링 = 생존 신호"로, 폴링이 끊긴(워치독 만기) 슬롯만 safety-sell 트리거 대상이 되게 한다. 현재 이 갱신은 actor/플래그 구분 없이 모든 snapshot 요청에 적용된다. + - buy/sell 계약은 최근 Discovery 로 정리됐다 — buy 는 `portfolio_id` 필수 + symbol 서버 resolve(dcy-011, 구현 완료), sell 은 slot_id 대상 + optional 가격(없으면 시장가)(dcy-012, confirmed). + - baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md`)는 존재하며 최신이다. CLI/운영 흐름은 runtime flow·interface-contract 에 속한다. +- 기대 상태 + - 운영자가 CLI 로 universe(snapshot RPC)·슬롯 상태(snapshot RPC)·enter(buy intent RPC)·exit(sell intent RPC)·pause/resume 를 수행한다. + - 모든 명령이 데몬 RPC 를 거쳐 단일 writer admission·불변식을 보존한다(특히 enter/exit 은 DB 직접 쓰기 금지). + - enter/exit 실행 전 확인 프롬프트·live 가드 등 안전장치가 동작해 오발주를 방지한다. + - 다중 인스턴스 환경에서 CLI 가 대상 인스턴스 큐로 정확히 라우팅한다. +- 이해관계자 + - 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물 작성 + - 리뷰어/승인자 — CLI 표면·안전장치·트랜스포트 승인 + - 운영 담당자 — CLI 로 일상 운영(조회·매매·일시정지) 수행 + - 전략 모듈 개발자 — 동일 RPC 표면을 공유하므로 계약 일관성 관심 +- 제약사항 + - enter/exit(매수/매도)는 단일 writer 인 데몬 admission(슬롯 할당·portfolio_id 검증·상태 전이)을 반드시 거쳐야 하며 DB 직접 쓰기로 우회할 수 없다. + - RPC 통일은 데몬·RabbitMQ 가 가동 중일 때만 동작한다(오프라인 조회 불가는 의도된 트레이드오프). + - 다중 인스턴스(dcy-009)에서 CLI 는 대상 인스턴스를 지정해야 큐 라우팅이 맞는다. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 운영자가 `trex-core universe` 로 당일 거래 대상 자산(portfolio_id/symbol)을 확인한다(snapshot RPC). +- 운영자가 `trex-core slots --status holding` 으로 현재 슬롯 상태를 조회한다(snapshot RPC). +- 운영자가 `trex-core enter --capital N` 으로 매수를 넣되, 확인 프롬프트(또는 `--yes`)와 live 가드를 거쳐 안전하게 admission 한다. +- 운영자가 `trex-core exit [--price P]` 로 매도를 넣는다(가격 없으면 시장가, dcy-012). +- 운영자가 `trex-core pause/resume ` 로 portfolio 를 일시정지/해제한다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- `trex-core` CLI 에 `universe`/`slots`/`enter`/`exit` 서브커맨드를 추가하고, 모두 **데몬 RabbitMQ RPC**(`trex.snapshot.req`/`trex.intent.req`)로 request-reply 한다(읽기도 RPC 통일 — 결정됨). +- enter/exit 에 운영 안전장치를 추가한다(OQ-2 A안) — 확인 프롬프트 기본 ON + `--yes`(paper) / `--yes --live`(live 이중 ack) + `--dry-run` 미리보기. +- 운영자 조회(`universe`/`slots`)가 워치독 생존 신호로 작동하지 않도록 snapshot RPC 에 워치독 억제 플래그(`touch_watchdog=false`/`actor="operator"`, OQ-4)를 추가한다(REQ-034 서버 부수효과 대응; 기본값=현행 유지로 하위호환). +- 전 명령에 `--json` 출력 모드를 추가한다(기계용 비대화형 — enter/exit 는 `--yes`/`--yes --live` 필수). +- CLI 에 인스턴스 라우팅 옵션을 추가해 다중 인스턴스 큐로 정확히 라우팅한다(OQ-1: `--config` canonical + `--instance` 단축, 미지정 시 `default`, 대상 인스턴스 항상 표시). +- `pause/resume` 는 기존 동작을 유지하되, 전 명령 RPC 통일 관점에서 `trex.control.req` 정합 여부를 정한다(범위 경계 참조). +- `docs/interface-contract.md`·`docs/runtime-flows.md` 에 CLI 명령·RPC 매핑·안전장치 흐름을 반영한다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `trex_core/runtime/cli.py` — `universe`/`slots`/`enter`/`exit` 서브커맨드 + 인스턴스 옵션 + enter/exit 가드 + RPC 클라이언트 호출 + - `trex_core/adapters/messaging/`(rabbitmq.py/messaging.py) — CLI 용 request-reply RPC 클라이언트 재사용/노출(전략 모듈이 쓰는 동일 클라이언트) + - `trex_core/application/messaging_handlers.py` — `make_snapshot_rpc_handler`(196~200)에 워치독 억제 모드 추가(운영자 조회 시 `last_seen_at` 미갱신, REQ-034/021) — R-4 대응, 서버측 변경 + - `trex_core/runtime/config.py` — CLI 가 RabbitMQ 접속·인스턴스 키를 읽도록 설정 활용(기존 env/TOML 재사용) +- 문서 영향: + - `docs/interface-contract.md` — CLI 명령 ↔ RPC payload 매핑(buy=portfolio_id 필수, sell=slot_id+optional price) 반영 + - `docs/runtime-flows.md` — 운영자 CLI → RPC → admission 흐름·안전장치 반영 +- 운영 영향: + - 운영자가 CLI 로 조회·매매·일시정지 수행 → 운영 편의 향상 + - enter/exit 실주문 표면 추가 → 안전장치·권한·paper/live 운영 정책 필요 + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: `trex-core universe` 는 `trex.snapshot.req` RPC 로 당일 universe(자산 목록: portfolio_id/symbol 등)를 조회·출력한다. +- FR-2: `trex-core slots [--status ...]` 는 snapshot RPC 응답의 슬롯 상태(상태/심볼/수량/가격 등)를 조회·출력한다(`view=held`/holding 필터 포함). +- FR-2a: 운영자 CLI 조회(`universe`/`slots`)는 **워치독 생존 신호로 작동하지 않아야 한다** — snapshot RPC 요청에 워치독 억제 플래그(`touch_watchdog=false`/`actor="operator"`, OQ-4)를 실어, 서버 핸들러가 응답 holding 슬롯의 `last_seen_at` 갱신(REQ-034)을 건너뛴다. 기본값(플래그 미지정)은 현행 전략 폴링 갱신 유지(하위호환). CLI 조회는 항상 억제 플래그를 보낸다. +- FR-3: `trex-core enter [--capital/--qty ...]` 는 `trex.intent.req` buy 인텐트를 RPC 로 제출한다(symbol 은 서버 resolve, dcy-011). DB 직접 쓰기는 하지 않는다. +- FR-4: `trex-core exit [--price P]` 는 `trex.intent.req` sell 인텐트를 RPC 로 제출한다(가격 미지정 시 시장가, dcy-012). DB 직접 쓰기는 하지 않는다. +- FR-5: enter/exit 안전장치(OQ-2 A안) — 확인 프롬프트 기본 ON(대상 인스턴스·paper/live·portfolio_id/slot_id·qty/capital·가격 요약, 기본 No), `--yes` 로 생략(paper), **live 는 `--yes --live` 이중 ack**, `--dry-run` 으로 미제출 미리보기. paper/live 는 데몬 동일 env/`--config` 로 판별·표시한다. +- FR-6: 모든 RPC 명령은 대상 인스턴스 큐로 라우팅한다 — `--config `(canonical, REQ-048) 또는 `--instance `(단축, override), 미지정 시 `default`(OQ-1). 대상 인스턴스를 명령 출력에 항상 표시하고, RPC 응답(admitted/rejected/reason)을 운영자에게 표시한다. +- FR-7: 전 명령에 `--json` 출력 모드를 제공한다 — 사람용 표 대신 RPC 응답을 JSON 으로 출력(스크립트/`jq` 용). `--json` 은 기계용 비대화형 모드이므로 확인 프롬프트와 양립하지 않는다: enter/exit 에 `--json` 을 쓰면 `--yes`(live 는 `--yes --live`)가 필수이며, 미지정 시 프롬프트 대신 에러로 거부한다(자동 제출 금지). +- FR-8: `docs/interface-contract.md`·`docs/runtime-flows.md` 에 CLI 명령·RPC 매핑·안전장치·출력 모드를 반영한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: 단일 writer 보존 — enter/exit 는 데몬 admission 을 거쳐 슬롯 할당·portfolio_id 검증·상태 전이 불변식이 깨지지 않아야 한다. +- NFR-2: 안전성 — enter/exit 오발주를 막는 확인/가드가 기본 동작이며, live 계좌에서는 더 엄격해야 한다. +- NFR-3: 일관성 — CLI 와 전략 모듈이 동일 RPC 계약을 사용해, 표면 분기로 인한 동작 차이가 없어야 한다. +- NFR-4: 운영성 — RPC 실패(데몬 미가동·타임아웃·라우팅 오류)를 명확한 에러로 표시해 운영자가 원인을 알 수 있어야 한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - `universe`/`slots`/`enter`/`exit` CLI 명령(전부 데몬 RPC) + 응답 표시 + - enter/exit 안전장치(확인 프롬프트/`--yes`/live 가드) + - 운영자 조회의 워치독 억제 모드(snapshot RPC 서버 변경, REQ-034 — R-4 대응) + - 인스턴스 라우팅 옵션 + - 전 명령 `--json` 출력 모드(비대화형 — enter/exit 는 `--yes` 필수) + - interface-contract/runtime-flows 문서 갱신 +- Out of Scope + - 신규 서버측 RPC 표면/도메인 로직(기존 표면 재사용 — 단, 워치독 억제 모드는 snapshot 핸들러의 국소 서버 변경으로 In Scope) + - buy/sell 계약 자체 변경(dcy-011/dcy-012 에서 처리) + - kill_switch CLI 노출(별도 결정 — 본 주기는 enter/exit/pause/resume 까지) + - pause/resume 의 control RPC 통일 — 미채택(기존 direct-DB 유지). PauseStore 가 durable source 라 동작에 문제 없어 변경하지 않는다(OQ-3 결정). + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): enter/exit 가 실주문을 내는 표면이라, 오발주(잘못된 portfolio_id/slot_id/가격)나 live 오작동 시 금전 손실로 직결된다. + - 완화 방안: FR-5(OQ-2 A안)의 확인 프롬프트 기본 ON·`--yes`(paper)·`--yes --live` 이중 ack·`--dry-run` 을 기본 동작으로 하고, RPC 응답(rejected/reason)을 명확히 표시한다. admission 의 portfolio_id 검증(dcy-011)·slot 상태 검증이 1차 방어선이다. +- R-2 (Medium): 다중 인스턴스에서 CLI 가 잘못된 인스턴스 큐로 라우팅하면 의도와 다른 인스턴스에 매매가 들어간다. + - 완화 방안: (OQ-1) `--config`(canonical, 데몬과 동일 경로) 우선 + `--instance` 단축, 미지정 시 `default`. 모든 명령 출력·enter/exit 확인 프롬프트에 대상 인스턴스를 항상 표시해 오라우팅을 사전 차단한다. +- R-3 (Low): RPC 통일로 데몬·RabbitMQ 미가동 시 조회조차 불가하다(기존 pause 는 direct-DB 라 가능했음). + - 완화 방안: 의도된 트레이드오프임을 문서화하고, RPC 실패를 명확한 에러로 표시한다. 필요 시 읽기 전용 direct-DB 폴백은 후속 검토. +- R-4 (High): 운영자 CLI 조회(`universe`/`slots`)가 snapshot RPC 의 워치독 갱신 부수효과(REQ-034)를 일으키면, 전략이 실제로 죽어 있어도 운영자 조회가 생존 신호로 오인되어 **safety-sell(워치독 만기 강제청산)이 억제**된다 — 안전 기능 무력화. + - 완화 방안: FR-2a 로 운영자 조회는 워치독 억제 플래그(OQ-4)를 실어 서버가 `last_seen_at` 갱신을 건너뛴다. 스냅샷 RPC 계약/핸들러(REQ-034/021) 변경이 동반되며, 기본값은 현행(전략 폴링=워치독 갱신) 유지로 하위호환. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: 서버측 RPC 표면(`trex.snapshot.req`/`trex.intent.req`/`trex.control.req`)과 request-reply 클라이언트가 이미 구현돼 있어, CLI 는 대부분 thin 확장으로 충분하다. 단, 워치독 억제(R-4/FR-2a)는 클라이언트만으로 불가하며 snapshot 핸들러(REQ-034)의 서버측 변경이 동반된다. +- A-2: dcy-011 구현 완료로 buy 는 portfolio_id 필수 + symbol 서버 resolve 이므로, `enter ` 만으로 매수 제출이 가능하다. +- A-3: dcy-012 구현이 완료되면 sell 인텐트에 optional 가격 필드가 있어 `exit [--price]` 가 성립한다(미구현 시 exit 은 시장가 전용으로 한정 가능). +- A-4: 인스턴스 라우팅 헬퍼(`_instance_queue`)가 존재하므로 CLI 도 동일 규칙으로 큐를 계산할 수 있다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: 인스턴스 라우팅 지정 방식 — `--instance ` 플래그, `--config `(REQ-048) 재사용, env default 중 무엇을 표준으로 하는가? default 인스턴스 처리 규칙은? + - 상태: Answered (2026-06-08, 사용자 결정 — `--config` canonical + `--instance` 단축) + - 결정: **`--config .toml`(REQ-048) 를 canonical** 로 한다(데몬 기동과 동일 경로·`instance_key` 일치 보장). **`--instance ` 는 단축/override**(둘 다 주면 `--instance` 우선). 미지정 시 **literal `default` 인스턴스**. RabbitMQ 접속은 env(`TREX_RABBITMQ_*`) 재사용, 큐 네이밍은 `_instance_queue` 규칙. 오발주 방지를 위해 **모든 명령 출력·enter/exit 확인 프롬프트에 대상 인스턴스를 항상 표시**(R-2 완화, OQ-2 가드와 연결). + - 반영 위치: FR-6, R-2 +- OQ-2: enter/exit 안전장치 구체 정책 — 확인 프롬프트 기본 on + `--yes` 생략, live 계좌 가드(예: 추가 확인/환경변수)·dry-run 제공 범위는? + - 상태: Answered (2026-06-08, 사용자 결정 — A안) + - 결정: (1) **확인 프롬프트 기본 ON** — 실행 전 대상 인스턴스·broker(paper/live)·portfolio_id/slot_id·qty/capital·가격(또는 시장가) 요약을 표시하고 `y/N`(기본 No). (2) **`--yes` 로 프롬프트 생략**(paper 자동화). (3) **live 는 이중 ack** — `--yes` 만으로 부족하고 `--yes --live`(명시적 live 승인) 를 요구해 muscle-memory 오발주 차단(데몬 `TREX_KIS_ALLOW_LIVE` 배포 게이트 위에 액션 레벨 게이트 추가). (4) **`--dry-run`** — 해석된 intent + 대상 인스턴스만 출력, 미제출. paper/live 판별은 CLI 가 데몬과 동일 env(`TREX_BROKER`/`TREX_KIS_ALLOW_LIVE`)/`--config` 로 표시. + - 반영 위치: FR-5, NFR-2, R-1 +- OQ-3: 거버넌스 경로 — CLI 진입점(REQ-032)·messaging transport(REQ-022)·제어 채널(REQ-043)·**스냅샷 워치독 부수효과(REQ-034)/메시지 계약(REQ-021)**의 change-req 로 처리할지, CLI 명령을 신규 REQ 로 추적할지? + - 상태: Answered (2026-06-08, 사용자 결정 — change-req 우선) + - 결정: 기존 Implemented REQ 본문 수정(change-req)으로 처리한다. change-req 대상 — REQ-032(런타임 제어 CLI: `universe`/`slots`/`enter`/`exit` 서브커맨드 + 인스턴스 라우팅 + enter/exit 안전장치), REQ-022(messaging transport: CLI request-reply RPC 클라이언트 배선), REQ-021(메시지 계약: snapshot 요청 payload 에 워치독 억제 플래그 추가), REQ-034(워치독: 핸들러가 억제 플래그 시 `last_seen_at` 미갱신), REQ-026(messaging baseline 문서 정합: interface-contract/runtime-flows CLI·RPC 매핑·플래그 반영). pause/resume 는 기존 direct-DB 유지(REQ-043 control RPC 통일 미채택). 신규 REQ 는 원칙적으로 생성하지 않으며, CLI 명령군을 독립 추적해야 한다고 판단되면 REQ 정제에서 소형 신규 REQ 가능(조건부). + - 반영 위치: 1. 거버넌스 해석(추가), R-1·R-4 완화, 8. 확인 체크 +- OQ-4: 운영자 조회의 워치독 억제 메커니즘 — snapshot RPC 요청에 `actor="operator"`/`touch_watchdog=false` 플래그를 추가해 서버가 워치독 갱신을 건너뛰는 방식인가, 아니면 워치독 갱신을 요청 `strategy_id` 의 슬롯에만 적용하도록 REQ-034 동작을 좁히는 방식인가? + - 상태: Answered (2026-06-08, 사용자 결정 — 플래그 방식) + - 결정: snapshot RPC 요청에 워치독 억제 플래그(예: `touch_watchdog=false` 또는 `actor="operator"`)를 추가하고, `make_snapshot_rpc_handler` 가 그 플래그면 `watchdog_store.update(...)` 를 건너뛴다. **기본값 = 현행(플래그 미지정 시 전략 폴링이 워치독 갱신)** 으로 기존 전략 모듈 무변경(하위호환). 운영자 CLI(`universe`/`slots`)는 항상 억제 플래그를 보낸다. strategy_id-scoped 방식은 기존 REQ-034 동작을 바꿔 미채택. + - 반영 위치: FR-2a, 4. 변경 항목/영향 범위, R-4 + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: `trex-core universe`·`slots` 가 데몬 snapshot RPC 응답을 정확히 조회·출력한다(직접 DB 접근 없이). +- S-2: `trex-core enter`·`exit` 가 `trex.intent.req` RPC 로 인텐트를 제출하고 admitted/rejected 응답을 표시한다(DB 직접 쓰기 0건). +- S-3: enter/exit 가 확인 프롬프트/`--yes`·live 가드를 거치며, 미확인 시 주문이 나가지 않는다. +- S-4: 지정한 인스턴스 큐로 RPC 가 라우팅되고, 잘못된 인스턴스/데몬 미가동 시 명확한 에러를 표시한다. +- S-5: `--json` 출력이 유효한 JSON 이며, `--json` + enter/exit 에 `--yes` 미지정 시 자동 제출 없이 에러로 거부된다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: stub/in-memory messaging 으로 snapshot RPC 응답 주입 후 CLI 출력 검증 / 데이터 출처: CLI 테스트, snapshot RPC 응답 fixture +- S-2 측정 방식: intent RPC mock 으로 admitted/rejected 응답 시 CLI 동작·출력 검증, DB 미접근 확인 / 데이터 출처: CLI 테스트, intent RPC 응답, 슬롯 저장소 무변경 +- S-3 측정 방식: 확인 프롬프트/`--yes`/live 가드 분기 테스트(미확인 시 RPC 미호출) / 데이터 출처: CLI 테스트, RPC 호출 여부 +- S-4 측정 방식: 인스턴스 키별 라우팅 큐 검증 + 데몬 미가동/타임아웃 에러 표시 검증 / 데이터 출처: 라우팅 계산, RPC 타임아웃 케이스 +- S-5 측정 방식: `--json` 출력 파싱(유효 JSON) + `--json` enter/exit 의 `--yes` 미지정 거부 테스트 / 데이터 출처: CLI 테스트, 출력 파서, RPC 호출 여부 + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: OQ-1 라우팅·OQ-2 안전장치·OQ-3 거버넌스·OQ-4 워치독 억제·pause/resume 정합(기존 유지) 모두 확정됨 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-5 의 테스트/검증 출처가 실제 실행 가능한 형태로 지정됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(오발주/실주문)·R-4(워치독 억제)의 change-req 대상(REQ-032/022/021/034/026)이 OQ-3 으로 확정됨. 안전장치는 OQ-2(A안)로 확정. 해소 실무 책임자 배정은 REQ/batch planning 단계로 넘김(비차단). +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1~OQ-4 모두 `Answered`, `Open` 0개 +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: Confirmed By = Justin Ko (사람 승인자 지정 완료) + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-016_20260608_operator-cli-rpc-commands.md +- 참조 문서: + - 사용자 요청(운영자 CLI 명령 추가: universe/슬롯/enter/exit/pause/resume) + - trex_core/runtime/cli.py — 기존 CLI(pause/resume/paused, direct-DB) + - trex_core/adapters/messaging/topology.py — snapshot/intent/control RPC 큐 + 인스턴스 라우팅 + - docs/discovery/dcy-011_..._buy-intent-portfolio-id-required-symbol-resolve.md — buy 계약(portfolio_id 필수, A-2) + - docs/discovery/dcy-012_..._sell-order-pricing-entry-price-bug.md — sell 계약(slot_id+optional price, A-3) + - docs/interface-contract.md — RPC payload 계약(intent/snapshot/control) + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- (없음 — OQ-1/OQ-2/OQ-3/OQ-4 및 pause/resume 정합 확정으로 해소) + +> 결정 완료(2026-06-08): OQ-1 인스턴스 라우팅 = `--config`(canonical, REQ-048) + `--instance`(단축 override), 미지정 시 `default`, 대상 인스턴스 항상 표시 / OQ-2 안전장치 = 확인 프롬프트 기본 ON + `--yes`(paper) / `--yes --live`(live 이중 ack) + `--dry-run` / OQ-3 거버넌스 = REQ-032/022/021/034/026 change-req(신규 REQ 없음) / OQ-4 워치독 억제 = snapshot RPC 플래그 방식(`touch_watchdog=false`/`actor`, 기본=현행 유지) / pause-resume = 기존 direct-DB 유지(control RPC 통일 미채택). 결정 항목에서 제외됨. + +## CONFIRM +- 없음 — 거버넌스 경로는 OQ-3 으로 확정됨(REQ-032/022/021/034/026 change-req, pause/resume 기존 유지, 신규 REQ 원칙적 없음). + +## DATA +- 없음 + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: confirm-discovery 완료. 트랜스포트(전부 RPC)·enter/exit 안전장치·`--json` 모드 반영. 운영자 조회의 워치독 부수효과(R-4) 발견·억제 반영. 오픈 질문 OQ-1~OQ-4 모두 Answered (인스턴스 라우팅=`--config`+`--instance` / 안전장치=confirm+`--yes`/`--yes --live`+`--dry-run` / 거버넌스=REQ-032/022/021/034/026 change-req / 워치독 억제=플래그). pause/resume 기존 유지. C 유형 플레이스홀더 0개, 저장소 사실관계 일치. REQ 초안 작성 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-08 17:00 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index d9262e3..00e1e6e 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -20,4 +20,5 @@ | dcy-012_20260608_sell-order-pricing-entry-price-bug | 20260608 | 매도 주문이 현재가가 아닌 entry_price(매수가) 지정가로 나가는 버그 — sell dispatch 가격 정책 부재 (issue #10) | [Discovery](./dcy-012_20260608_sell-order-pricing-entry-price-bug.md) | confirmed | | dcy-013_20260608_kis-external-cancel-pending-sell-stuck | 20260608 | KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구히 갇힘 — cncl_yn 미파싱 (issue #9) | [Discovery](./dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md) | confirmed | | dcy-014_20260608_trading-date-configurable-timezone-kst | 20260608 | 당일 슬롯 생성 기준 시간을 KST(설정 가능)로 분리 — trading_date 타임존 분리 + 일자 경계 preallocation (issue #14) | [Discovery](./dcy-014_20260608_trading-date-configurable-timezone-kst.md) | confirmed | -| dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap | 20260608 | portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도됨 (issue #13) | [Discovery](./dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md) | confirmed | \ No newline at end of file +| dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap | 20260608 | portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도됨 (issue #13) | [Discovery](./dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md) | confirmed | +| dcy-016_20260608_operator-cli-rpc-commands | 20260608 | 운영자 CLI 명령 확장 — universe/슬롯 확인 + enter/exit + pause/resume (데몬 RPC 통일) | [Discovery](./dcy-016_20260608_operator-cli-rpc-commands.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index b630c46..6960417 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-019) +- Last Updated (KST): 2026-06-08 (BAT-020) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -29,7 +29,7 @@ - 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` 확장) -- `trex-core` CLI :: pause/resume/paused 운영 명령 (BAT-008) +- `trex-core` CLI :: 운영 명령 — pause/resume/paused(direct-DB, BAT-008) + universe/slots/enter/exit(데몬 RPC, dcy-016/BAT-020) - SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트) - SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약 - BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus` @@ -136,8 +136,11 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason { "view": "all" | "buyable" | "held" # default "all" "strategy_key": "" # optional, == filter on active_strategy_id "trading_date": "YYYY-MM-DD" # optional, default UTC today + "touch_watchdog": bool # optional, default true (dcy-016/REQ-021) + "actor": "operator" # optional; "operator" => touch_watchdog 강제 false } ``` +- **워치독 억제(dcy-016/REQ-021/034)**: 기본(`touch_watchdog` 미지정/true)은 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신한다(전략 폴링=생존 신호, REQ-034). `touch_watchdog=false` 또는 `actor="operator"` 면 갱신을 건너뛴다 — 운영자 CLI 조회(`universe`/`slots`)가 죽은 전략을 살아있는 것으로 오인시켜 safety-sell 을 무력화하지 않도록(R-4). 운영자 CLI 는 항상 억제 플래그를 보낸다. 기본값은 현행 유지(전략 모듈 무변경, 하위호환). 코어가 reply-to 큐로 보내는 응답 envelope: ``` @@ -160,7 +163,21 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason ``` **No `price` field on items** (REQ-028 AC-3 / REQ-027 AC-1). -- 워치독 신호 (REQ-034): `trex.snapshot.req` 처리는 read-only 응답 산출에 더해, 응답에 포함된 모든 `holding` 슬롯의 워치독 `last_seen_at` 을 갱신한다. 즉 전략 모듈이 스냅샷을 폴링하는 것 자체가 해당 슬롯의 생존 신호이며, 폴링이 끊긴(워치독 만료) 슬롯만 safety-sell 트리거 대상이 된다. +- 워치독 신호 (REQ-034): `trex.snapshot.req` 처리는 read-only 응답 산출에 더해, 응답에 포함된 모든 `holding` 슬롯의 워치독 `last_seen_at` 을 갱신한다. 즉 전략 모듈이 스냅샷을 폴링하는 것 자체가 해당 슬롯의 생존 신호이며, 폴링이 끊긴(워치독 만료) 슬롯만 safety-sell 트리거 대상이 된다. (dcy-016: `touch_watchdog=false`/`actor="operator"` 요청은 이 갱신을 건너뛴다 — 운영자 조회 제외.) + +### 운영자 CLI ↔ RPC 매핑 (dcy-016 / REQ-032) + +운영자 CLI 명령은 데몬 RabbitMQ RPC 로 통일된다(단일 writer admission 보존, DB 직접 쓰기 없음). 인스턴스 라우팅: `--config `(canonical, REQ-048) 또는 `--instance `(단축·override), 미지정 시 `default`. + +| CLI | RPC 큐 | payload | 비고 | +|---|---|---|---| +| `universe` | `trex.snapshot.req[.{instance}]` | `{view:"all", actor:"operator", touch_watchdog:false}` | 자산 목록 조회(워치독 억제) | +| `slots [--status held\|buyable\|all]` | `trex.snapshot.req[.{instance}]` | `{view, actor:"operator", touch_watchdog:false}` | `holding`→`held` view | +| `enter [--capital/--qty/...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve) | 안전장치 필수 | +| `exit [--price]` | `trex.intent.req[.{instance}]` | sell intent(`slot_id`, optional `limit_price`) | 가격 미지정 시 시장가 | +| `pause/resume/paused` | (RPC 아님) | — | direct-DB 유지(OQ-3) | + +- enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON, `--yes`(paper 생략), **live(`broker=kis`+allow_live)는 `--yes --live` 이중 ack**, `--dry-run` 미리보기. `--json` 은 비대화형이라 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러). 모든 명령 출력에 대상 인스턴스 표시, admission 응답(`status/slot_id/reason`)을 표시하며 rejected 면 비0 종료. ### `trex.control.req` 제어 RPC (BAT-012 / REQ-043, REQ-032) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 1304ab6..594ab50 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-019) +- Last Updated (KST): 2026-06-08 (BAT-020) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -107,6 +107,16 @@ 3. 응답 산출: `build_snapshot_envelope` 가 `pause_store.list_paused()` 를 매 요청마다 읽음 → `mask(pending | warning | pause)` 합성. resume 후 **다음 요청부터 즉시 복귀**(별도 reload 불필요). 4. 영속: daemon 재시작 + 일일 refresh 후에도 `snapshot_pause` row 유지(universe swap 은 pause 를 만지지 않음). +## Flow: `운영자 CLI` (BAT-020 / dcy-016, REQ-032) + +운영자 CLI(`trex-core`)는 조회·매매를 데몬 RabbitMQ RPC 로 통일해 단일 writer admission 과 불변식을 보존한다(DB 직접 쓰기 없음). pause/resume/paused 만 기존 direct-DB 유지(OQ-3). + +1. 인스턴스 라우팅: `--config `(canonical, REQ-048 `instance_key`) 또는 `--instance `(단축·override), 미지정 시 `default`. 큐명은 `_instance_queue` 규칙(`{base}` 또는 `{base}.{instance}`). 모든 출력에 대상 인스턴스 표시(R-2). +2. `universe`/`slots`: `trex.snapshot.req` RPC(`actor="operator"`/`touch_watchdog=false`) → 응답 envelope 의 assets 출력. **워치독 억제**로 운영자 조회가 전략 생존 신호로 오인되지 않는다(REQ-034, R-4). `slots --status holding`→`view=held`. +3. `enter `/`exit `: `trex.intent.req` buy/sell RPC. buy 는 portfolio_id 필수(symbol 서버 resolve, dcy-011), sell 은 slot_id + optional price(시장가 기본, dcy-012). admission 응답(`status/slot_id/reason`)을 표시, rejected 면 비0 종료. +4. enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON(대상 인스턴스·broker·요약, 기본 No) → `--yes`(paper 생략) → **live 는 `--yes --live` 이중 ack**(`TREX_KIS_ALLOW_LIVE` 배포 게이트 위 액션 게이트) → `--dry-run` 미제출 미리보기. `--json` 비대화형은 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러). +5. 실패 표시: RPC 타임아웃·데몬 미가동·라우팅 오류는 명확한 에러(비0 종료)로 표시(NFR-4). RPC 통일이라 데몬·RabbitMQ 미가동 시 조회 불가는 의도된 트레이드오프(R-3). + ## Flow: `스냅샷 RPC 응답 산출` (BAT-008 / REQ-028, REQ-029) `trex.snapshot.req` 메시지 처리. diff --git a/docs/srs/Documentation/req-026_messaging-baseline-alignment.md b/docs/srs/Documentation/req-026_messaging-baseline-alignment.md index 92302e1..c2b3a0e 100644 --- a/docs/srs/Documentation/req-026_messaging-baseline-alignment.md +++ b/docs/srs/Documentation/req-026_messaging-baseline-alignment.md @@ -146,3 +146,41 @@ - AC-2: baseline ↔ 코드 정합 manual review (BAT-007 verification.md Manual 섹션). - AC-3: 3 baseline 문서 Change Log 모두 2026-06-05 BAT-007 항목 추가. - AC-4: docs/interface-contract.md 의 HTTP `/v1` 섹션 제거 + project-structure 트리에서 adapters/api 제거. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-016) baseline 문서를 운영자 CLI·RPC 매핑·워치독 억제 플래그에 맞춰 정합: `docs/interface-contract.md`(운영자 CLI↔RPC 매핑 표 + snapshot 요청 `touch_watchdog`/`actor` 플래그), `docs/runtime-flows.md`(운영자 CLI flow: 라우팅·워치독 억제·enter/exit 안전장치·실패 표시). BAT-020 정합 완료 → Implemented 유지. + +#### Intent + +- 코드(cli/messaging_handlers) 변경과 messaging baseline 문서를 일치시켜 운영자 참조 정합 유지. + +#### Acceptance Criteria Delta + +- Updated: + - interface-contract/runtime-flows 의 CLI 명령·RPC 매핑·워치독 억제 기술 정합. + +#### Impacted Area Delta + +- Docs: docs/interface-contract.md, docs/runtime-flows.md + +#### Delivery Trace Delta + +- Batches: BAT-020 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no (문서 정합) +- Existing Implementation Invalidated: no +- Status Recommendation: Implemented (change-req → BAT-020 정합) +- Follow-up Action: confirm-req-implemented(BAT-020). + +#### Validation Plan + +- BAT-020: 코드-문서 diff 대조. diff --git a/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md b/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md index 0568898..6b3f021 100644 --- a/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md +++ b/docs/srs/Integration/req-021_messaging-topology-and-contract-mapping.md @@ -182,3 +182,43 @@ #### Validation Plan - BAT-015: buy payload portfolio_id 기반 핸들러 단위 + RabbitMQ e2e. + +### CHG-20260608-02 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-016/REQ-034 연계) snapshot 요청 payload 에 워치독 억제 플래그 추가: `touch_watchdog`(기본 true) / `actor="operator"`. `make_snapshot_rpc_handler` 가 플래그 시 holding 슬롯 `last_seen_at` 갱신을 건너뛴다. 기본값=현행(전략 폴링=워치독 갱신) 유지(하위호환). BAT-020 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 운영자 CLI 조회가 전략 생존 신호로 오인되어 safety-sell 을 무력화하는 것 방지(R-4). 메시지 계약에 억제 플래그를 정식화. + +#### Acceptance Criteria Delta + +- Added: + - snapshot 요청 payload `touch_watchdog`/`actor` 플래그(기본 미지정=touch). 운영자 조회는 억제. + +#### Impacted Area Delta + +- Modules: `trex_core/application/messaging_handlers.py`(make_snapshot_rpc_handler 억제 분기) +- Docs: `docs/interface-contract.md` +- Tests: tests/application/test_messaging_snapshot_consumer.py(touch vs suppress) + +#### Delivery Trace Delta + +- Batches: BAT-020 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-020 에서 완료) +- Existing Implementation Invalidated: no (기본값 현행 유지, 플래그 추가) +- Status Recommendation: Implemented (change-req → BAT-020 재검증) +- Follow-up Action: confirm-req-implemented(BAT-020). + +#### Validation Plan + +- BAT-020: 기본 요청 touch / operator 요청 suppress 단위(caplog/recorder). diff --git a/docs/srs/Integration/req-022_messaging-transport-port-and-rabbitmq-adapter.md b/docs/srs/Integration/req-022_messaging-transport-port-and-rabbitmq-adapter.md index fbb0d36..6de0ea4 100644 --- a/docs/srs/Integration/req-022_messaging-transport-port-and-rabbitmq-adapter.md +++ b/docs/srs/Integration/req-022_messaging-transport-port-and-rabbitmq-adapter.md @@ -152,3 +152,42 @@ - AC-3: tests/runtime/test_daemon_no_http_surface.py::test_daemon_module_has_no_fastapi_or_uvicorn_import + Daemon 4 스레드 코드. - AC-4: RabbitMQTransport 재연결 백오프 코드 + tests/adapters/messaging/test_in_memory_transport.py::test_unavailable_transport_raises_on_publish_and_rpc. - AC-5: tests/domain/test_domain_import_sweep.py. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-016) 운영자 CLI 가 transport 의 request-reply RPC 클라이언트(`call_rpc`)를 재사용해 snapshot/intent 큐로 요청한다(전략 모듈과 동일 클라이언트, NFR-3). CLI 는 config 기반으로 transport 를 빌드하고 `call_rpc` 후 close 한다. BAT-020 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- CLI 와 전략 모듈이 동일 transport RPC 계약을 공유해 표면 분기 없는 일관 동작. + +#### Acceptance Criteria Delta + +- Added: + - CLI 가 `MessagingTransport.call_rpc(queue, payload, correlation_id, timeout)` 로 RPC 수행(주입 가능, 테스트는 fake rpc). + +#### Impacted Area Delta + +- Modules: `trex_core/runtime/cli.py`(transport 빌드 + call_rpc), 기존 `RabbitMQTransport.call_rpc` 재사용 +- Tests: tests/runtime/test_cli_operator.py(주입 rpc) + +#### Delivery Trace Delta + +- Batches: BAT-020 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-020 에서 완료) +- Existing Implementation Invalidated: no (클라이언트 재사용) +- Status Recommendation: Implemented (change-req → BAT-020 재검증) +- Follow-up Action: confirm-req-implemented(BAT-020). + +#### Validation Plan + +- BAT-020: CLI RPC 호출 큐/payload/타임아웃 + 실패 표시 단위. diff --git a/docs/srs/Interface/req-032_runtime-pause-resume-control.md b/docs/srs/Interface/req-032_runtime-pause-resume-control.md index 8e65a8f..4a7a27c 100644 --- a/docs/srs/Interface/req-032_runtime-pause-resume-control.md +++ b/docs/srs/Interface/req-032_runtime-pause-resume-control.md @@ -134,3 +134,43 @@ - AC-2: tests/adapters/portfolio/test_postgresql_pause_store.py::test_durability_simulated_via_fresh_store_instance. - AC-3: tests/application/test_pause_handler.py::test_resume_clears_pause. - AC-4: tests/application/test_pause_handler.py::test_cli_pause_visible_to_rpc_list_via_shared_store + 실 daemon RPC 운영 검증. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-016) 운영자 CLI 확장: `universe`/`slots`/`enter`/`exit` 서브커맨드 추가(전부 데몬 RabbitMQ RPC). 인스턴스 라우팅(`--config` canonical/`--instance` 단축/default), enter/exit 안전장치(확인 프롬프트 기본 ON + `--yes`(paper)/`--yes --live`(live 이중 ack)/`--dry-run`), 전 명령 `--json`. pause/resume/paused 는 기존 direct-DB 유지(OQ-3). BAT-020 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 운영자가 조회·매수(enter)·매도(exit)·일시정지를 CLI 로 수행하되, 매매는 데몬 admission(단일 writer)을 거쳐 불변식·portfolio_id 검증을 보존(DB 직접 쓰기 없음, NFR-1). + +#### Acceptance Criteria Delta + +- Added: + - universe/slots(snapshot RPC)·enter(buy intent RPC)·exit(sell intent RPC) CLI + 인스턴스 라우팅 + enter/exit 안전장치 + `--json`. + +#### Impacted Area Delta + +- Modules: `trex_core/runtime/cli.py`(서브커맨드/가드/라우팅/RPC 클라이언트) +- Docs: `docs/interface-contract.md`, `docs/runtime-flows.md` +- Tests: tests/runtime/test_cli_operator.py + +#### Delivery Trace Delta + +- Batches: BAT-020 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-020 에서 완료) +- Existing Implementation Invalidated: no (pause/resume 불변, 명령 추가) +- Status Recommendation: Implemented (change-req → BAT-020 재검증) +- Follow-up Action: confirm-req-implemented(BAT-020). + +#### Validation Plan + +- BAT-020: CLI universe/slots/enter/exit RPC payload·안전장치·라우팅·--json 단위. diff --git a/docs/srs/Interface/req-034_per-slot-watchdog.md b/docs/srs/Interface/req-034_per-slot-watchdog.md index e967749..f063a86 100644 --- a/docs/srs/Interface/req-034_per-slot-watchdog.md +++ b/docs/srs/Interface/req-034_per-slot-watchdog.md @@ -113,3 +113,42 @@ - Existing Implementation Invalidated: no - Status Recommendation: keep-current - Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-016) 워치독 갱신 부수효과를 운영자 조회에서 억제: snapshot RPC 핸들러가 `touch_watchdog=false`/`actor="operator"` 요청에 대해 holding 슬롯 `last_seen_at` 갱신을 건너뛴다. 전략 폴링 기본 동작은 불변(REQ-034 핵심 의미 유지). BAT-020 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 운영자 CLI(`universe`/`slots`) 조회가 워치독 생존 신호로 작동해 죽은 전략의 safety-sell 을 억제하는 안전 결함(R-4)을 제거. + +#### Acceptance Criteria Delta + +- Updated: + - 워치독 `last_seen_at` 갱신은 전략 폴링(기본)에만 적용; 운영자 억제 플래그 요청은 제외. + +#### Impacted Area Delta + +- Modules: `trex_core/application/messaging_handlers.py`(snapshot 핸들러 워치독 분기) +- Tests: tests/application/test_messaging_snapshot_consumer.py::test_operator_query_suppresses_watchdog_touch + +#### Delivery Trace Delta + +- Batches: BAT-020 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-020 에서 완료) +- Existing Implementation Invalidated: no (기본 전략 폴링 동작 불변) +- Status Recommendation: Implemented (change-req → BAT-020 재검증) +- Follow-up Action: confirm-req-implemented(BAT-020). + +#### Validation Plan + +- BAT-020: operator 요청 시 watchdog.update 미호출, 기본 요청 시 호출 단위. diff --git a/docs/srs/index.md b/docs/srs/index.md index e1f9a8a..1f1f40a 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -139,8 +139,8 @@ ## Recent Change Log Summary +- 2026-06-08 :: REQ-032/022/021/034/026 change-req (dcy-016, BAT-020): 운영자 CLI 확장 — `universe`/`slots`(snapshot RPC)·`enter`(buy intent RPC)·`exit`(sell intent RPC) 추가(전부 데몬 RPC 통일, 단일 writer 보존, DB 직접 쓰기 없음)(REQ-032), CLI request-reply RPC 클라이언트 재사용(REQ-022), snapshot 요청 워치독 억제 플래그(`touch_watchdog`/`actor="operator"`)로 운영자 조회가 전략 생존 신호로 오인되지 않게(REQ-021/034, R-4), enter/exit 안전장치(확인 프롬프트/`--yes`/live 이중 ack/`--dry-run`)+`--json`+인스턴스 라우팅, baseline 2문서 정합(REQ-026). pause/resume 는 direct-DB 유지. 전부 change-req(신규 REQ 없음, OQ-3). 단위 327 passed, 인프라 게이트 361 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-017/019/018/020 change-req (dcy-015, BAT-019): portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도되던 버그(issue #13) 정정. 404/400/422→`LedgerPermanentError`(REQ-017), outbox 영속→터미널 `delivery_failed` 즉시 + retryable 지수 백오프+jitter+`next_retry_at`+max_attempts→dead-letter + `list_pending` 백오프 스킵 + 마이그레이션 007(status enum/컬럼) + 백오프 env(REQ-019), 터미널 WARN+카운터 가시화(REQ-018), baseline 3문서 정합(REQ-020). 전부 change-req(신규 REQ 없음, OQ-3). 단위 311 passed, 인프라 게이트 345 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-005/048/009 change-req (dcy-014, BAT-018): 당일 슬롯 생성 기준을 시스템 UTC 날짜에서 설정 타임존(기본 KST) 기준 `trading_date` 로 분리(timestamp 는 UTC 유지) + 데몬 1회 preallocation 을 `DailyPreallocationScheduler`(TZ 일자당 1회 멱등 fire, 재시작 없이 다음날 자동 생성)로 대체(REQ-005), TOML `trading_timezone`/`daily_preallocation_time` 키(REQ-048), runtime-flows/data-model 정합(REQ-009). 전부 change-req(신규 REQ 없음, OQ-3; 스케줄러는 REQ-030 패턴 준용). 단위 302 passed, 인프라 게이트 334 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-012/013/018/031/014/015 change-req (dcy-013, BAT-017): KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구 갇히던 버그(issue #9) 정정. KIS `poll_fill` 이 `cncl_yn` 파싱해 외부 취소 감지 — 체결 0 취소→`cancelled`(롤백 자동 복구), 부분 체결 후 취소→`is_partial_cancel`(체결분 원장 1회+qty 차감+holding 복귀, OQ-1 A안)(REQ-012/013/018), 잔여 포지션 정합 연계(REQ-031), pending stale 워치독 Alert-only(`TREX_PENDING_STALE_THRESHOLD`, WARN+카운터, 상태 미변경)(REQ-014), baseline 2문서 정합(REQ-015). 전부 change-req(신규 REQ 없음, OQ-3). 단위 294 passed, 인프라 게이트 326 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-013/007/036/002/006/003/015 change-req (dcy-012, BAT-016): 매도 주문이 매수가(entry_price) 지정가로 나가던 버그(issue #10) 정정. 슬롯 `entry_price`→`limit_price`(side 무관 pending 지정가) rename + pending 이탈 clear(REQ-002), 컬럼 rename 마이그레이션 006(REQ-006), `_dispatch` 가 slot.limit_price 단일 필드로 가격/주문유형 결정(REQ-013), SellIntent/sell payload optional limit_price·_admit_sell 운반(REQ-007), SlotService.sell optional 가격→자율 매도 시장가(REQ-036), 컬럼 계약 가드(REQ-003), baseline 3문서 정합(REQ-015). 전부 change-req(신규 REQ 없음, OQ-3). 단위 286 passed, 인프라 게이트 318 passed, ruff clean. (change-req/confirm-req-implemented) -- 2026-06-08 :: REQ-007/018/021 change-req + REQ-055 생성·Implemented (dcy-011, BAT-015): buy intent 계약 hard cutover — portfolio_id 필수화 + symbol 서버 resolve(SnapshotStore) + unknown_portfolio/portfolio_id_required 거부(REQ-007), buy payload symbol 제거(REQ-021), ledger skip 가시화(WARN+ledger_skipped 카운터, REQ-018), closed↔outbox anti-join 운영 점검 `find_ledger_gap_slots` 신규(REQ-055). 전체 인프라 게이트 314 passed, ruff clean. (change-req/draft-req/confirm-req-implemented) diff --git a/tests/application/test_messaging_snapshot_consumer.py b/tests/application/test_messaging_snapshot_consumer.py index 300f17e..b033813 100644 --- a/tests/application/test_messaging_snapshot_consumer.py +++ b/tests/application/test_messaging_snapshot_consumer.py @@ -75,3 +75,45 @@ def test_snapshot_strategy_id_filter(): 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 = next(s for s in repo.list_slots(DAY) if s.status is SlotState.IDLE) + svc.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="t", actor="t", symbol="005930", portfolio_id="PF-1", 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 diff --git a/tests/runtime/test_cli_operator.py b/tests/runtime/test_cli_operator.py new file mode 100644 index 0000000..6667d71 --- /dev/null +++ b/tests/runtime/test_cli_operator.py @@ -0,0 +1,143 @@ +"""Operator CLI tests (dcy-016 / REQ-032) — RPC injected, no broker/DB needed.""" +from __future__ import annotations + +import json + +import pytest + +from trex_core.runtime import cli + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + monkeypatch.setenv("TREX_BROKER", "paper") + monkeypatch.delenv("TREX_KIS_ALLOW_LIVE", raising=False) + + +class _RpcRecorder: + def __init__(self, response): + self.response = response + self.calls = [] + + def __call__(self, queue, payload, *, correlation_id, timeout): + self.calls.append({"queue": queue, "payload": payload, "cid": correlation_id}) + 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, +} +_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) + 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 + + +def test_slots_status_holding_maps_to_held_view(): + rpc = _RpcRecorder(_SNAPSHOT) + cli.main(["slots", "--status", "holding"], rpc=rpc) + assert rpc.calls[0]["payload"]["view"] == "held" + + +def test_instance_shortcut_routes_to_instance_queue(): + rpc = _RpcRecorder(_SNAPSHOT) + cli.main(["universe", "--instance", "inst-2"], rpc=rpc) + assert rpc.calls[0]["queue"] == "trex.snapshot.req.inst-2" + + +def test_json_output_is_valid_json(capsys): + rpc = _RpcRecorder(_SNAPSHOT) + cli.main(["universe", "--json"], rpc=rpc) + parsed = json.loads(capsys.readouterr().out) + assert parsed["assets"][0]["asset_id"] == "PF-1" + + +# --- enter (buy intent RPC) + safeguards ------------------------------------ +def test_enter_aborts_without_confirmation_no_rpc(): + rpc = _RpcRecorder(_ADMITTED) + rc = cli.main(["enter", "PF-1", "--capital", "100000"], + rpc=rpc, confirm=lambda _p: False) + assert rc == 1 and rpc.calls == [] # aborted -> no order + + +def test_enter_with_yes_submits_buy_intent(): + rpc = _RpcRecorder(_ADMITTED) + rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc) + assert rc == 0 + p = rpc.calls[0]["payload"] + assert rpc.calls[0]["queue"] == "trex.intent.req" + assert p["intent_type"] == "buy" and p["portfolio_id"] == "PF-1" + assert p["allocated_capital"] == 100000 + assert "symbol" not in p # server-resolved (dcy-011) + + +def test_enter_rejected_returns_nonzero(): + rpc = _RpcRecorder(_REJECTED) + rc = cli.main(["enter", "PF-x", "--yes"], rpc=rpc) + assert rc == 1 + + +def test_enter_dry_run_does_not_submit(capsys): + rpc = _RpcRecorder(_ADMITTED) + rc = cli.main(["enter", "PF-1", "--capital", "5", "--dry-run"], rpc=rpc) + assert rc == 0 and rpc.calls == [] + assert "dry-run" in capsys.readouterr().out + + +def test_enter_json_requires_yes(): + rpc = _RpcRecorder(_ADMITTED) + rc = cli.main(["enter", "PF-1", "--json"], rpc=rpc) + assert rc == 2 and rpc.calls == [] # no auto-submit (FR-7) + + +# --- exit (sell intent RPC) ------------------------------------------------- +def test_exit_with_yes_submits_sell_with_limit_price(): + rpc = _RpcRecorder(_ADMITTED) + cli.main(["exit", "S-1", "--price", "70000", "--yes"], rpc=rpc) + p = rpc.calls[0]["payload"] + assert p["intent_type"] == "sell" and p["slot_id"] == "S-1" + assert p["limit_price"] == 70000 + + +def test_exit_market_when_no_price(): + rpc = _RpcRecorder(_ADMITTED) + cli.main(["exit", "S-1", "--yes"], rpc=rpc) + assert rpc.calls[0]["payload"]["limit_price"] is None + + +# --- live guard (OQ-2 double ack) ------------------------------------------- +def test_live_requires_live_flag(monkeypatch): + monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) + rpc = _RpcRecorder(_ADMITTED) + rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc) # --yes but no --live + assert rc == 2 and rpc.calls == [] + + +def test_live_proceeds_with_yes_live(monkeypatch): + monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) + rpc = _RpcRecorder(_ADMITTED) + rc = cli.main(["enter", "PF-1", "--yes", "--live"], rpc=rpc) + assert rc == 0 and len(rpc.calls) == 1 + + +# --- transport failure surfaces as a clear error ---------------------------- +def test_rpc_failure_surfaces_error(capsys): + def boom(*a, **k): + raise RuntimeError("daemon unreachable") + rc = cli.main(["universe"], rpc=boom) + assert rc == 3 + assert "daemon unreachable" in capsys.readouterr().err diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index b4d6b92..4a59fab 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -193,8 +193,15 @@ def make_snapshot_rpc_handler( body = req.payload or {} day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today() - # REQ-034: update watchdog for all holding slots visible in this RPC. - if watchdog_store is not None: + # 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: for s in slot_repo.list_slots(day): if s.status is SlotState.HOLDING: watchdog_store.update(str(s.slot_id)) diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index 38fd636..5b538c3 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -1,45 +1,311 @@ -"""trex-core CLI entrypoints (REQ-032). +"""trex-core operator CLI (REQ-032, dcy-016). Subcommands: - pause — durably pause a portfolio (Postgres) - resume — clear the pause - paused — list paused portfolio_ids + pause — durably pause a portfolio (Postgres, direct-DB) + resume — clear the pause (direct-DB) + paused — list paused portfolio_ids (direct-DB) + universe — list today's universe assets (snapshot RPC) + slots [--status] — list positions/slots (snapshot RPC) + enter — submit a buy intent (intent RPC; safeguarded) + exit — submit a sell intent (intent RPC; safeguarded) + +universe/slots/enter/exit go through the daemon's RabbitMQ RPC surface so the +single-writer admission + invariants are preserved (no direct slot writes, +dcy-016 NFR-1). Operator queries carry `actor="operator"` so they do NOT count +as a strategy liveness signal (REQ-021/034 watchdog suppression, R-4). + +enter/exit safeguards (dcy-016 OQ-2): confirmation prompt ON by default; +`--yes` skips it (paper); live accounts require `--yes --live` (double ack); +`--dry-run` previews the resolved intent without submitting. `--json` is a +non-interactive machine mode, so enter/exit with `--json` require `--yes`. """ from __future__ import annotations import argparse +import json as _json import sys +import uuid +from datetime import datetime +from zoneinfo import ZoneInfo +from trex_core.adapters.messaging import topology from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore -from trex_core.runtime.config import DaemonConfig +from trex_core.runtime.config import DaemonConfig, DaemonInstanceConfig + +_DEFAULT_TIMEOUT = 10.0 -def main(argv: list[str] | None = None) -> int: +# --------------------------------------------------------------------------- # +# instance routing (OQ-1: --config canonical, --instance shortcut/override) +# --------------------------------------------------------------------------- # +def _resolve_instance(args) -> str: + if getattr(args, "instance", None): + return args.instance + if getattr(args, "config", None): + return DaemonInstanceConfig.from_toml(args.config).instance_key + return topology.DEFAULT_INSTANCE_KEY + + +# --------------------------------------------------------------------------- # +# default transport-backed RPC (overridable in tests) +# --------------------------------------------------------------------------- # +def _default_rpc(cfg: DaemonConfig): + """Return a callable(queue, payload, *, correlation_id, timeout) -> dict + backed by a real RabbitMQ transport built from config.""" + from trex_core.adapters.messaging import InMemoryTransport, RabbitMQTransport + + mq = cfg.rabbitmq + transport = RabbitMQTransport(mq.url, timeout=mq.timeout) if mq else InMemoryTransport() + + def _rpc(queue: str, payload: dict, *, correlation_id: str, timeout: float) -> dict: + try: + return transport.call_rpc( + queue, payload, correlation_id=correlation_id, timeout=timeout + ) + finally: + transport.close() + + return _rpc + + +def _default_confirm(prompt: str) -> bool: + try: + return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes") + except EOFError: + return False + + +def _broker_mode(cfg: DaemonConfig) -> tuple[str, bool]: + """(broker, is_live). live = kis broker with allow_live enabled.""" + broker = cfg.broker + is_live = broker == "kis" and bool(cfg.kis and cfg.kis.allow_live) + return broker, is_live + + +def _emit(data: dict, *, as_json: bool) -> None: + if as_json: + print(_json.dumps(data, ensure_ascii=False, default=str)) + + +# --------------------------------------------------------------------------- # +# RPC subcommand handlers +# --------------------------------------------------------------------------- # +def _today_iso(cfg: DaemonConfig) -> str: + return datetime.now(tz=ZoneInfo(cfg.trading_timezone)).date().isoformat() + + +def _cmd_universe(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) + assets = resp.get("assets", []) + if args.json: + _emit(resp, as_json=True) + else: + print(f"instance={instance} view=all assets={len(assets)} " + f"degraded={resp.get('degraded')}") + for a in assets: + print(f" {a.get('asset_id')}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}") + return 0 + + +def _cmd_slots(args, cfg, rpc) -> int: + instance = _resolve_instance(args) + view = {"holding": "held", "held": "held", "buyable": "buyable"}.get( + args.status or "all", "all" + ) + payload = {"view": view, "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) + assets = resp.get("assets", []) + if args.json: + _emit(resp, as_json=True) + else: + print(f"instance={instance} view={view} count={len(assets)}") + for a in assets: + print(f" {a.get('asset_id')}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}\t" + f"strategy={a.get('active_strategy_id')}") + return 0 + + +def _intent_guard(args, cfg, summary: dict, action: str, confirm) -> int | None: + """Shared enter/exit safeguard. Returns an exit code to short-circuit, or + None to proceed with submission.""" + broker, is_live = _broker_mode(cfg) + summary = {**summary, "instance": _resolve_instance(args), "broker": broker, + "live": is_live, "action": action} + if args.dry_run: + if args.json: + _emit({**summary, "dry_run": True}, as_json=True) + else: + print(f"[dry-run] {action} {summary}") + return 0 + # live double-ack gate (OQ-2) + if is_live and not args.live: + msg = "live account requires --live (with --yes) to confirm a real order" + _fail(msg, args.json) + return 2 + # confirmation: --json is non-interactive → --yes required + if not args.yes: + if args.json: + _fail("--json enter/exit requires --yes (no auto-submit)", True) + return 2 + prompt = (f"Submit {action} on instance={summary['instance']} " + f"broker={broker}{' LIVE' if is_live else ''}: {summary}?") + if not confirm(prompt): + print("aborted") + return 1 + return None + + +def _cmd_enter(args, cfg, rpc, confirm) -> int: + summary = {"portfolio_id": args.portfolio_id, "qty": args.qty, + "allocated_capital": args.capital, "entry_price": args.entry_price} + short = _intent_guard(args, cfg, summary, "enter(buy)", confirm) + if short is not None: + return short + payload = { + "intent_type": "buy", "intent_id": str(uuid.uuid4()), + "trading_date": _today_iso(cfg), "portfolio_id": args.portfolio_id, + "qty": args.qty, "allocated_capital": args.capital, + "entry_price": args.entry_price, "strategy_id": args.strategy_id, + "sell_strategy_id": args.sell_strategy_id, + "stop_loss_price": args.stop_loss, "take_profit_price": args.take_profit, + } + resp = rpc(topology.intent_queue(_resolve_instance(args)), payload, + correlation_id=payload["intent_id"], timeout=args.timeout) + return _report_intent(resp, args.json) + + +def _cmd_exit(args, cfg, rpc, confirm) -> int: + summary = {"slot_id": args.slot_id, "price": args.price or "market", + "reason": args.reason} + short = _intent_guard(args, cfg, summary, "exit(sell)", confirm) + if short is not None: + return short + payload = { + "intent_type": "sell", "intent_id": str(uuid.uuid4()), + "slot_id": args.slot_id, "reason": args.reason, + "sell_strategy_id": args.sell_strategy_id, "limit_price": args.price, + } + resp = rpc(topology.intent_queue(_resolve_instance(args)), payload, + correlation_id=payload["intent_id"], timeout=args.timeout) + return _report_intent(resp, args.json) + + +def _report_intent(resp: dict, as_json: bool) -> int: + if as_json: + _emit(resp, as_json=True) + else: + print(f"status={resp.get('status')} slot_id={resp.get('slot_id')} " + f"reason={resp.get('reason')}") + # admission rejection is a non-zero exit so scripts can detect it. + return 0 if resp.get("status") == "admitted" else 1 + + +def _fail(message: str, as_json: bool) -> None: + if as_json: + print(_json.dumps({"error": message}), file=sys.stderr) + else: + print(f"error: {message}", file=sys.stderr) + + +# --------------------------------------------------------------------------- # +# arg parsing +# --------------------------------------------------------------------------- # +def _add_common(p) -> None: + p.add_argument("--config", help="per-instance TOML (canonical instance routing)") + p.add_argument("--instance", help="instance key (shortcut; overrides --config)") + p.add_argument("--json", action="store_true", help="machine-readable JSON output") + p.add_argument("--timeout", type=float, default=_DEFAULT_TIMEOUT) + + +def _add_intent_guards(p) -> None: + p.add_argument("--yes", action="store_true", help="skip confirmation (paper)") + p.add_argument("--live", action="store_true", help="confirm a LIVE order (with --yes)") + p.add_argument("--dry-run", action="store_true", help="preview intent, do not submit") + p.add_argument("--sell-strategy-id", dest="sell_strategy_id", default=None) + + +def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="trex-core", description="trex-core admin CLI") sub = parser.add_subparsers(dest="cmd", required=True) p_pause = sub.add_parser("pause", help="Pause a portfolio_id") p_pause.add_argument("portfolio_id") p_pause.add_argument("--actor", default="cli") - p_resume = sub.add_parser("resume", help="Resume a portfolio_id") p_resume.add_argument("portfolio_id") - sub.add_parser("paused", help="List paused portfolio_ids") - args = parser.parse_args(argv) - cfg = DaemonConfig.from_env() - store = PostgreSQLPauseStore(cfg.dsn) + p_uni = sub.add_parser("universe", help="List today's universe (snapshot RPC)") + _add_common(p_uni) - if args.cmd == "pause": - store.pause(args.portfolio_id, actor=args.actor) - print(f"paused {args.portfolio_id}") - elif args.cmd == "resume": - store.resume(args.portfolio_id) - print(f"resumed {args.portfolio_id}") - elif args.cmd == "paused": - for pid in sorted(store.list_paused()): - print(pid) + p_slots = sub.add_parser("slots", help="List positions (snapshot RPC)") + p_slots.add_argument("--status", choices=["all", "held", "holding", "buyable"], default="all") + _add_common(p_slots) + + p_enter = sub.add_parser("enter", help="Submit a buy intent (intent RPC)") + p_enter.add_argument("portfolio_id") + p_enter.add_argument("--qty", type=float, default=None) + p_enter.add_argument("--capital", type=float, default=None) + p_enter.add_argument("--entry-price", dest="entry_price", type=float, default=None) + p_enter.add_argument("--strategy-id", dest="strategy_id", default=None) + p_enter.add_argument("--stop-loss", dest="stop_loss", type=float, default=None) + p_enter.add_argument("--take-profit", dest="take_profit", type=float, default=None) + _add_intent_guards(p_enter) + _add_common(p_enter) + + p_exit = sub.add_parser("exit", help="Submit a sell intent (intent RPC)") + p_exit.add_argument("slot_id") + p_exit.add_argument("--price", type=float, default=None) + p_exit.add_argument("--reason", default="operator_exit") + _add_intent_guards(p_exit) + _add_common(p_exit) + + return parser + + +def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int: + """CLI entrypoint. + + `rpc` and `confirm` are injectable for tests: `rpc(queue, payload, *, + correlation_id, timeout) -> dict`, `confirm(prompt) -> bool`. + """ + args = _build_parser().parse_args(argv) + cfg = DaemonConfig.from_env() + confirm = confirm or _default_confirm + + # direct-DB control commands (pause/resume stay direct-DB, OQ-3). + if args.cmd in ("pause", "resume", "paused"): + store = PostgreSQLPauseStore(cfg.dsn) + if args.cmd == "pause": + store.pause(args.portfolio_id, actor=args.actor) + print(f"paused {args.portfolio_id}") + elif args.cmd == "resume": + store.resume(args.portfolio_id) + print(f"resumed {args.portfolio_id}") + else: + for pid in sorted(store.list_paused()): + print(pid) + return 0 + + rpc = rpc or _default_rpc(cfg) + try: + if args.cmd == "universe": + return _cmd_universe(args, cfg, rpc) + if args.cmd == "slots": + return _cmd_slots(args, cfg, rpc) + if args.cmd == "enter": + return _cmd_enter(args, cfg, rpc, confirm) + if args.cmd == "exit": + return _cmd_exit(args, cfg, rpc, confirm) + except Exception as exc: # noqa: BLE001 - surface RPC/transport failure clearly + _fail(f"{type(exc).__name__}: {exc}", getattr(args, "json", False)) + return 3 return 0