From a30efa8f3b91d8a9bd2c8de6f77d22702cea8f1c Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 7 Jun 2026 08:32:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(safety-sell):=20dcy-007=20core=20=EC=9E=90?= =?UTF-8?q?=EC=9C=A8=20=EC=95=88=EC=A0=84=20=EB=A7=A4=EB=8F=84=20=E2=80=94?= =?UTF-8?q?=20BAT-010=20=EA=B8=B0=EB=B0=98=20+=20BAT-011=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dcy-007(core 자율 안전 매도) 전체 delivery. BAT-010 기반 인프라가 미커밋 상태였고 BAT-011 트리거 레이어와 공유 파일에서 섞여 있어 한 커밋으로 묶음. BAT-010 (REQ-034/035/036/041, release-candidate): - WatchdogStore(application/watchdog.py) + snapshot RPC 워치독 갱신 - Slot stop_loss_price/take_profit_price 필드 + 004_add_price_thresholds.sql - SlotService.sell() 자율 매도 내부 경로 + idempotency - SafetySellConfig env 5종 파싱 BAT-011 (REQ-037/038/039/040/042, release-candidate): - SafetySellService(application/safety_sell.py): forced_close 만기 / stop-loss· take-profit KIS polling / 장 마감 강제 청산 + trex-safety-sell 7번째 스레드 - BrokerPort.get_current_price + KIS inquire-price(TTTC8397R/VTTC8397R, stck_prpr) + paper stub - SlotService.update_thresholds() + intent RPC update_thresholds 액션 (부분 갱신/null 해제, 워치독 last_seen_at 리셋) - baseline 3문서 정합(interface-contract/runtime-flows/project-structure) 거버넌스: REQ-034~042 Implemented 동기화, BAT-010/011 release-candidate, dcy-007 discovery 포함. 검증: 신규 38 테스트 green, 단위 236 passed/23 skipped, 인프라 게이트(PG+RabbitMQ) 259 passed(마이그레이션 001~004 round-trip 포함), ruff clean. Co-Authored-By: Claude Opus 4.8 --- .../design.md | 50 +++ .../implementation.md | 43 +++ .../index.md | 35 +++ .../planning.md | 42 +++ .../verification.md | 78 +++++ .../design.md | 55 ++++ .../implementation.md | 43 +++ .../index.md | 35 +++ .../planning.md | 54 ++++ .../verification.md | 82 +++++ docs/batches/index.md | 2 + ...07_20260606_core-autonomous-safety-sell.md | 12 +- docs/discovery/index.md | 2 +- docs/interface-contract.md | 24 +- docs/project-structure.md | 20 +- docs/runtime-flows.md | 47 ++- .../req-041_safety-sell-env-config.md | 115 +++++++ ...ot-price-threshold-fields-and-migration.md | 116 +++++++ .../req-042_safety-sell-baseline-alignment.md | 151 +++++++++ ...eq-037_forced-close-at-safety-sell-loop.md | 150 +++++++++ ...q-038_stop-loss-take-profit-kis-polling.md | 155 +++++++++ .../req-039_market-close-liquidation.md | 147 +++++++++ .../Interface/req-034_per-slot-watchdog.md | 115 +++++++ .../req-036_auto-sell-internal-path.md | 112 +++++++ .../req-040_update-thresholds-intent-rpc.md | 151 +++++++++ docs/srs/index.md | 19 +- tests/adapters/broker/test_kis_price.py | 100 ++++++ tests/application/test_safety_sell_worker.py | 296 ++++++++++++++++++ .../application/test_snapshot_rpc_watchdog.py | 96 ++++++ .../test_update_thresholds_handler.py | 135 ++++++++ tests/application/test_watchdog.py | 103 ++++++ .../slot/test_price_threshold_fields.py | 107 +++++++ tests/domain/slot/test_service_sell.py | 111 +++++++ tests/runtime/test_safety_sell_config.py | 69 ++++ trex_core/adapters/broker/kis.py | 32 ++ trex_core/adapters/broker/paper.py | 14 + trex_core/adapters/persistence/postgresql.py | 20 +- .../sql/004_add_price_thresholds.sql | 4 + trex_core/application/broker_port.py | 10 + trex_core/application/intents.py | 5 + trex_core/application/messaging_handlers.py | 79 ++++- trex_core/application/safety_sell.py | 190 +++++++++++ trex_core/application/watchdog.py | 49 +++ trex_core/domain/slot/models.py | 5 + trex_core/domain/slot/service.py | 90 ++++++ trex_core/runtime/config.py | 31 ++ trex_core/runtime/daemon.py | 34 +- 47 files changed, 3399 insertions(+), 36 deletions(-) create mode 100644 docs/batches/bat-010_20260607_safety-sell-foundation/design.md create mode 100644 docs/batches/bat-010_20260607_safety-sell-foundation/implementation.md create mode 100644 docs/batches/bat-010_20260607_safety-sell-foundation/index.md create mode 100644 docs/batches/bat-010_20260607_safety-sell-foundation/planning.md create mode 100644 docs/batches/bat-010_20260607_safety-sell-foundation/verification.md create mode 100644 docs/batches/bat-011_20260607_safety-sell-triggers/design.md create mode 100644 docs/batches/bat-011_20260607_safety-sell-triggers/implementation.md create mode 100644 docs/batches/bat-011_20260607_safety-sell-triggers/index.md create mode 100644 docs/batches/bat-011_20260607_safety-sell-triggers/planning.md create mode 100644 docs/batches/bat-011_20260607_safety-sell-triggers/verification.md create mode 100644 docs/srs/Configuration/req-041_safety-sell-env-config.md create mode 100644 docs/srs/Data/req-035_slot-price-threshold-fields-and-migration.md create mode 100644 docs/srs/Documentation/req-042_safety-sell-baseline-alignment.md create mode 100644 docs/srs/Integration/req-037_forced-close-at-safety-sell-loop.md create mode 100644 docs/srs/Integration/req-038_stop-loss-take-profit-kis-polling.md create mode 100644 docs/srs/Integration/req-039_market-close-liquidation.md create mode 100644 docs/srs/Interface/req-034_per-slot-watchdog.md create mode 100644 docs/srs/Interface/req-036_auto-sell-internal-path.md create mode 100644 docs/srs/Interface/req-040_update-thresholds-intent-rpc.md create mode 100644 tests/adapters/broker/test_kis_price.py create mode 100644 tests/application/test_safety_sell_worker.py create mode 100644 tests/application/test_snapshot_rpc_watchdog.py create mode 100644 tests/application/test_update_thresholds_handler.py create mode 100644 tests/application/test_watchdog.py create mode 100644 tests/domain/slot/test_price_threshold_fields.py create mode 100644 tests/domain/slot/test_service_sell.py create mode 100644 tests/runtime/test_safety_sell_config.py create mode 100644 trex_core/adapters/persistence/sql/004_add_price_thresholds.sql create mode 100644 trex_core/application/safety_sell.py create mode 100644 trex_core/application/watchdog.py diff --git a/docs/batches/bat-010_20260607_safety-sell-foundation/design.md b/docs/batches/bat-010_20260607_safety-sell-foundation/design.md new file mode 100644 index 0000000..dbd5f0d --- /dev/null +++ b/docs/batches/bat-010_20260607_safety-sell-foundation/design.md @@ -0,0 +1,50 @@ +# BAT-010 Design + +- Status: confirmed +- Batch: BAT-010 + +## Architecture Summary +BAT-010은 safety-sell 기능의 기반 레이어 세 가지를 추가한다. + +1. **WatchdogStore** (`application/watchdog.py` 신규): `dict[str, datetime]` in-memory store. snapshot RPC handler가 응답 슬롯의 `last_seen_at`을 갱신하고 BAT-011 트리거 루프가 만료 판정에 사용한다. +2. **도메인 필드 확장** (`models.py`, `postgresql.py`, SQL 마이그레이션): `stop_loss_price`, `take_profit_price` `Optional[Decimal]` additive 추가. BAT-011 KIS polling이 트리거 기준으로 사용한다. +3. **SlotService.sell()** (`domain/slot/service.py`): RabbitMQ 우회 내부 매도 경로. BAT-011의 모든 트리거 루프(forced_close, KIS polling, 장 마감)가 이 메서드를 호출한다. + +## Changed Areas +- Docs: + - 없음 (baseline 문서 갱신은 REQ-042 / BAT-011에서 처리) +- Modules: + - `trex_core/runtime/config.py` — `DaemonConfig`에 safety-sell env vars 5종 파싱 추가 + - `trex_core/domain/slot/models.py` — `stop_loss_price: Optional[Decimal]`, `take_profit_price: Optional[Decimal]` 추가 + - `trex_core/adapters/persistence/postgresql.py` — 두 필드 read/write 포함 + - `trex_core/adapters/persistence/sql/` — 마이그레이션 스크립트 신규 추가 + - `trex_core/application/intents.py` — `IntentIn`에 `stop_loss_price`, `take_profit_price` Optional 필드 추가 + - `trex_core/application/messaging_handlers.py` — `make_snapshot_rpc_handler` 확장(`last_seen_at` 갱신) + intent RPC payload 파싱 확장(price fields) + - `trex_core/application/watchdog.py` (신규) — `WatchdogStore` class + - `trex_core/domain/slot/service.py` — `SlotService.sell(slot_id, actor, reason)` 추가 +- Interfaces: + - `SlotService.sell(slot_id: str, actor: str, reason: str) -> None` (신규 내부 인터페이스) + - `WatchdogStore.update(slot_id: str) -> None` (신규) + - `WatchdogStore.is_expired(slot_id: str, timeout_s: int) -> bool` (신규 — BAT-011 트리거 루프 계약) + - `WatchdogStore.in_grace_period(grace_s: int) -> bool` (신규) + +## Key Decisions +1. **WatchdogStore를 `watchdog.py` 신규 모듈로 분리**: `portfolio.py`는 snapshot 조회·직렬화 책임이 있어 watchdog 관심사(타임스탬프 추적·만료 판정)를 추가하면 단일 책임 위반. 신규 모듈이 독립 테스트와 BAT-011 참조에 유리하다. +2. **REQ-041 env vars 5종 전체를 BAT-010에서 파싱 완성**: `TREX_FORCED_CLOSE_CHECK_INTERVAL`·`TREX_STOP_LOSS_POLL_INTERVAL`·`TREX_MARKET_CLOSE_LIQUIDATION_TIME`은 BAT-010에서 사용하지 않지만, REQ-041을 두 batch에 분리하면 REQ 단위 거버넌스 trace가 깨진다. 파싱만 완성하고 BAT-011이 config 값을 참조한다. +3. **WatchdogStore 동시성은 Python GIL에 의존**: `dict[str, datetime]` 단일 dict의 Python 레벨 read/write는 GIL 보장 하에 원자적. 별도 `threading.Lock` 불필요. grace period 판정: `datetime.utcnow() - self.started_at < timedelta(seconds=grace_s)` 단순 비교. +4. **SlotService.sell()은 transit_slot() 재사용**: 기존 `transit_slot(slot_id, from_state=HOLDING, to_state=PENDING_SELL, ...)` advisory lock + outbox 경로를 그대로 재사용. 신규 로직을 최소화해 BAT-004 검증 coverage를 그대로 이어받는다. + +## Edge Cases +- grace period 중 `is_expired()` 호출: `in_grace_period()` True이면 store에 없는 슬롯 포함 전체 만료 판정 유예. +- 데몬 재시작 시 watchdog store: in-memory 초기화 + `started_at` 재설정으로 grace period 자동 재시작. +- `sell()` 호출 시 이미 `pending_sell` 상태: `transit_slot` 호출 전 상태 조회 → silent return (중복 매도 intent 제출 없음). +- `sell()` 호출 시 `idle`·`cancelled` 등 비정상 상태: `SlotStateError`(또는 동등 예외) raise, 슬롯 상태 불변. +- 마이그레이션 기존 row: `ALTER TABLE slots ADD COLUMN IF NOT EXISTS stop_loss_price NUMERIC` 패턴으로 멱등성 보장. 기존 row `NULL` 유지. + +## Architecture Impact +- runtime-flows: snapshot RPC handler → watchdog `last_seen_at` 갱신 흐름 추가 (BAT-011 REQ-042에서 문서화) +- project-structure: `watchdog.py` 신규 모듈 추가 (BAT-011 REQ-042에서 문서화) + +## Reference Doc Update Plan +- project-structure.md: BAT-011 REQ-042에서 처리 (`watchdog.py` 신규 모듈 반영) +- runtime-flows.md: BAT-011 REQ-042에서 처리 (snapshot RPC → watchdog update + sell() 내부 경로 반영) diff --git a/docs/batches/bat-010_20260607_safety-sell-foundation/implementation.md b/docs/batches/bat-010_20260607_safety-sell-foundation/implementation.md new file mode 100644 index 0000000..fae4968 --- /dev/null +++ b/docs/batches/bat-010_20260607_safety-sell-foundation/implementation.md @@ -0,0 +1,43 @@ +# BAT-010 Implementation + +- Status: completed +- Batch: BAT-010 + +## Plan Summary +- REQ-041 → REQ-035 → REQ-034 → REQ-036 순서로 구현. 각 단계 unit test green 확인 후 다음으로 진행. + +## Changed Files + +### 신규 파일 +- `trex_core/application/watchdog.py` — WatchdogStore class (REQ-034) +- `trex_core/adapters/persistence/sql/004_add_price_thresholds.sql` — additive DB migration (REQ-035) +- `tests/runtime/test_safety_sell_config.py` — REQ-041 AC 검증 4종 +- `tests/application/test_watchdog.py` — REQ-034 AC 검증 11종 +- `tests/application/test_snapshot_rpc_watchdog.py` — REQ-034 handler 통합 4종 +- `tests/domain/slot/test_service_sell.py` — REQ-036 AC 검증 6종 +- `tests/domain/slot/test_price_threshold_fields.py` — REQ-035 AC 검증 7종 + +### 기존 파일 수정 +- `trex_core/runtime/config.py` — SafetySellConfig dataclass 추가 + DaemonConfig 5종 env var 파싱 (REQ-041) +- `trex_core/domain/slot/models.py` — stop_loss_price / take_profit_price Optional[Decimal] 필드 추가 + EXPECTED_SLOT_FIELDS 갱신 (REQ-035) +- `trex_core/adapters/persistence/postgresql.py` — _SLOT_COLUMNS 갱신, update_slot SET절 확장, _row_to_slot 인덱스 조정 (REQ-035) +- `trex_core/application/intents.py` — BuyIntent에 price fields + Decimal import (REQ-035) +- `trex_core/domain/slot/service.py` — transit_slot에 stop_loss_price / take_profit_price 파라미터 추가, _apply_transition_fields 확장(reset + preserve), sell() 메서드 신설, Decimal import (REQ-035, REQ-036) +- `trex_core/application/messaging_handlers.py` — WatchdogStore import + SlotState import + make_snapshot_rpc_handler watchdog_store= 파라미터 + 홀딩 슬롯 update 로직 + buy intent payload _opt_decimal 파싱 (REQ-034, REQ-035) + +## Execution Log +1. M1 — REQ-041: SafetySellConfig + DaemonConfig.from_env() 5종 파싱. test_safety_sell_config.py 4종 green. +2. M2 — REQ-035: models.py 필드 추가 + EXPECTED_SLOT_FIELDS 갱신 → test_contract.py 통과. postgresql.py _SLOT_COLUMNS / update_slot / _row_to_slot 갱신. SQL 마이그레이션 004 생성. intents.py BuyIntent 확장. service.py transit_slot + _apply_transition_fields 확장. test_price_threshold_fields.py 7종 green. +3. M3 — REQ-034: watchdog.py 신규. messaging_handlers.py make_snapshot_rpc_handler 확장. test_watchdog.py 11종 green. test_snapshot_rpc_watchdog.py 4종 green. +4. M4 — REQ-036: service.py sell() 신설. test_service_sell.py 6종 green. +5. M5 — 전체 회귀 95 passed (httpx 미설치 환경 기인 test_daemon_no_http_surface 1건 기존 실패, BAT-010 무관). + +## Validation +- 신규 테스트: 32종 100% green +- 회귀 테스트: 95 passed (domain/ + runtime/ + application/ 대상, httpx 불필요 범위) +- test_contract.py: EXPECTED_SLOT_FIELDS ↔ dataclasses.fields(Slot) 계약 통과 (price fields 포함) +- `WatchdogStore.in_grace_period()` + `is_expired()` 조합 패턴 단위 테스트로 고정 + +## Remaining Risks +- R-2 (Medium → Low): DB 마이그레이션은 실 PostgreSQL 없이 검증 불가. 004 migration이 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 패턴 사용 → 멱등 보장. 실 DB 검증은 BAT-011 환경 포함 후 수행. +- R-3 (Low): test_daemon_no_http_surface 기존 실패 — httpx 미설치 환경 이슈. BAT-010 변경 무관. 런타임 환경에서 httpx 설치 시 해소. diff --git a/docs/batches/bat-010_20260607_safety-sell-foundation/index.md b/docs/batches/bat-010_20260607_safety-sell-foundation/index.md new file mode 100644 index 0000000..a6b08d1 --- /dev/null +++ b/docs/batches/bat-010_20260607_safety-sell-foundation/index.md @@ -0,0 +1,35 @@ +# BAT-010: safety-sell 기반 인프라 (watchdog + 가격 임계값 필드 + SlotService.sell) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-007 +- Included REQ: REQ-034, REQ-035, REQ-036, REQ-041 +- Excluded REQ: REQ-037, REQ-038, REQ-039, REQ-040, REQ-042 (safety-sell 트리거 레이어 — BAT-011 예정) + +## Summary +- dcy-007(core 자율 안전 매도) 기반 인프라 레이어를 delivery한다. per-slot 워치독 store(REQ-034), 슬롯 가격 임계값 도메인 필드 + DB 마이그레이션(REQ-035), 자율 매도 내부 경로 `SlotService.sell()`(REQ-036), safety-sell 환경 변수 전체 파싱(REQ-041)을 포함한다. 트리거 루프(forced_close/KIS polling/장 마감)와 `update_thresholds` RPC는 BAT-011에서 처리한다. + +## Scope +- In Scope: + - `WatchdogStore` 신규 모듈(`application/watchdog.py`) + `make_snapshot_rpc_handler` 확장 (REQ-034) + - `stop_loss_price`, `take_profit_price` Optional[Decimal] 도메인 필드 + PostgreSQL additive 마이그레이션 (REQ-035) + - `SlotService.sell(slot_id, actor, reason)` + idempotency (REQ-036) + - `DaemonConfig` env vars 5종 파싱: `TREX_WATCHDOG_TIMEOUT`, `TREX_WATCHDOG_GRACE_PERIOD`, `TREX_FORCED_CLOSE_CHECK_INTERVAL`, `TREX_STOP_LOSS_POLL_INTERVAL`, `TREX_MARKET_CLOSE_LIQUIDATION_TIME` (REQ-041) +- Out of Scope: + - forced_close_at 루프 — BAT-011 (REQ-037) + - KIS 현재가 polling — BAT-011 (REQ-038) + - 장 마감 청산 루프 — BAT-011 (REQ-039) + - update_thresholds intent RPC — BAT-011 (REQ-040) + - baseline 문서 정렬 — BAT-011 (REQ-042) + - 가격 임계값 논리 유효성 검증(take_profit > stop_loss 등) — REQ-035 Notes 명시 + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- BAT-010은 외부 API(KIS) 호출이 없고 신규 daemon thread를 추가하지 않는다. 단위 테스트와 persistence 통합 테스트만으로 모든 AC를 검증 가능한 레이어다. +- REQ-041의 env vars 5종 중 BAT-010에서 실제 사용되는 것은 `TREX_WATCHDOG_TIMEOUT` + `TREX_WATCHDOG_GRACE_PERIOD` 2개다. 나머지 3종(`TREX_FORCED_CLOSE_CHECK_INTERVAL`, `TREX_STOP_LOSS_POLL_INTERVAL`, `TREX_MARKET_CLOSE_LIQUIDATION_TIME`)은 파싱만 완성하고 BAT-011에서 참조한다. REQ-041을 두 batch에 분리하지 않기 위한 결정(design.md Key Decision 2 참조). +- WatchdogStore 구현 위치: `trex_core/application/watchdog.py` 신규 모듈(design.md Key Decision 1 참조). diff --git a/docs/batches/bat-010_20260607_safety-sell-foundation/planning.md b/docs/batches/bat-010_20260607_safety-sell-foundation/planning.md new file mode 100644 index 0000000..f334d53 --- /dev/null +++ b/docs/batches/bat-010_20260607_safety-sell-foundation/planning.md @@ -0,0 +1,42 @@ +# BAT-010 Planning + +- Status: confirmed +- Batch: BAT-010 +- Profile: standard + +## Included REQ +- REQ-041 (Configuration): safety-sell 환경 변수 5종 파싱 — `TREX_WATCHDOG_TIMEOUT`(s, 기본 120), `TREX_WATCHDOG_GRACE_PERIOD`(s, 기본 60), `TREX_FORCED_CLOSE_CHECK_INTERVAL`(s, 기본 60), `TREX_STOP_LOSS_POLL_INTERVAL`(s, 기본 30), `TREX_MARKET_CLOSE_LIQUIDATION_TIME`(HH:MM KST, optional) +- REQ-035 (Data): `stop_loss_price` · `take_profit_price` `Optional[Decimal]` 도메인 필드 + PostgreSQL additive 마이그레이션 +- REQ-034 (Interface): per-slot 워치독 — `WatchdogStore(dict[slot_id, datetime])` + snapshot RPC handler 확장 + grace period +- REQ-036 (Interface): `SlotService.sell(slot_id, actor, reason)` — `holding→pending_sell` 전이 + idempotency + +## Delivery Plan +1. REQ-041: `runtime/config.py`에 safety-sell env vars 5종 파싱 추가 (`DaemonConfig` 확장). 파싱 단위 테스트. +2. REQ-035: `domain/slot/models.py`에 두 필드 추가 → `adapters/persistence/postgresql.py` read/write 포함 → SQL 마이그레이션 스크립트 작성 → `application/intents.py` + `messaging_handlers.py` payload 파싱 추가. 필드 저장·조회 round-trip 테스트 + 마이그레이션 멱등성 테스트. +3. REQ-034: `application/watchdog.py` 신규 작성 (`WatchdogStore` class, grace period 판정) → `make_snapshot_rpc_handler` 확장 (응답 슬롯 `last_seen_at` 갱신). 워치독 판정 단위 테스트 (만료/비만료/grace period 시나리오). +4. REQ-036: `domain/slot/service.py`에 `sell(slot_id, actor, reason)` 추가 (`transit_slot` 기반, idempotency). 단위 테스트 3종(holding→pending_sell, pending_sell 중복 호출, idle 오류). +5. 전체 회귀 테스트 green + ruff lint clean. + +## Dependencies +- Internal: + - BAT-007/REQ-022: `make_snapshot_rpc_handler` 기존 구현 — 확장 대상 + - BAT-008/REQ-027: `SlotService` 기존 구조 + `transit_slot` 패턴 — `sell()` 기반 + - BAT-004/REQ-011~012: `PaperBroker` execution 경로 — `sell()` 통합 검증 대상 +- External: + - 없음 (KIS API 호출 없음, 신규 daemon thread 없음) + +## Design Gate +- Design Required Before Implementation: yes +- Reason: `WatchdogStore`는 신규 in-memory 컴포넌트로 snapshot RPC handler thread에서 write, BAT-011 트리거 루프 thread에서 read가 발생한다. 동시성 모델(GIL 의존 vs. explicit lock)과 모듈 배치(`watchdog.py` 분리 vs. `portfolio.py` 확장)를 설계 단계에서 확정해야 BAT-011 트리거 루프 설계 시 인터페이스 충돌이 없다. `SlotService.sell()`과 `transit_slot` advisory lock 통합 방식도 설계에서 명시한다. + +## Milestones +1. M1 — REQ-041: env vars 파싱 + unit test green +2. M2 — REQ-035: 도메인 필드 + 마이그레이션 + persistence round-trip 테스트 green +3. M3 — REQ-034: `WatchdogStore` + snapshot RPC handler 확장 + 워치독 판정 unit test green +4. M4 — REQ-036: `SlotService.sell()` + idempotency unit test green (3종) +5. M5 — 전체 회귀 + ruff lint clean + +## Risks +- R-1 (Medium): WatchdogStore 동시성 — BAT-011 트리거 루프가 watchdog store를 read하는 시점에 snapshot RPC handler가 동시 write할 수 있음. 대응: Python GIL 보장 하에 단순 `dict` 사용; 별도 `threading.Lock` 회피. 단위 테스트에서 판정 로직 고정. +- R-2 (Medium): DB 마이그레이션 기존 row 영향. 대응: `NULL` 허용 additive 컬럼 추가 전용(`ALTER TABLE … ADD COLUMN IF NOT EXISTS`). 기존 row `NULL` 유지. migration 멱등성 테스트. +- R-3 (Low): `SlotService.sell()` advisory lock 통합 — `transit_slot` 기존 advisory lock과 충돌 가능성. 대응: 기존 `transit_slot()` 코드 경로를 최대한 재사용하고 신규 로직을 최소화한다. diff --git a/docs/batches/bat-010_20260607_safety-sell-foundation/verification.md b/docs/batches/bat-010_20260607_safety-sell-foundation/verification.md new file mode 100644 index 0000000..24ef215 --- /dev/null +++ b/docs/batches/bat-010_20260607_safety-sell-foundation/verification.md @@ -0,0 +1,78 @@ +# BAT-010 Verification + +- Status: verified +- Batch: BAT-010 + +## Acceptance Mapping + +### REQ-041 (SafetySellConfig env vars) + +| AC | Evidence | +|----|---------| +| AC-1: 기본값 120/60/60/30/None | `test_safety_sell_config.py::test_safety_sell_config_defaults` — 5개 기본값 모두 assert | +| AC-2: TREX_WATCHDOG_TIMEOUT 파싱 | `test_safety_sell_config_env_tuning` — 180 파싱 확인 | +| AC-3: TREX_WATCHDOG_GRACE_PERIOD 파싱 | 동일 테스트 — 90 파싱 확인 | +| AC-4: TREX_FORCED_CLOSE_CHECK_INTERVAL 파싱 | 동일 테스트 — 45 파싱 확인 | +| AC-5: TREX_STOP_LOSS_POLL_INTERVAL 파싱 | 동일 테스트 — 15 파싱 확인 | +| TREX_MARKET_CLOSE_LIQUIDATION_TIME Optional | `test_safety_sell_config_env_tuning` "15:30" 파싱 + `test_market_close_liquidation_time_empty_string_becomes_none` None 변환 | + +### REQ-035 (stop_loss_price / take_profit_price 필드) + +| AC | Evidence | +|----|---------| +| AC-1: 기본값 None | `test_price_threshold_fields.py::test_price_fields_default_to_none` | +| AC-2: Decimal 저장·조회 round-trip | `test_price_fields_set_on_pending_buy` — Decimal("48000") round-trip via in-memory repo | +| AC-3: 마이그레이션 멱등성 | `004_add_price_thresholds.sql` `IF NOT EXISTS` 패턴. 실 PostgreSQL round-trip은 Remaining Risks R-2 참조 | +| AC-4: 매수 intent payload 포함 시 저장 | `test_price_fields_preserved_through_holding` — pending_buy→holding 전이 후 price fields 유지 | +| AC-5: 미포함 시 None | `test_no_price_fields_sets_none` | +| 계약 가드 | `test_contract.py::test_slot_fields_match_expected_contract` — EXPECTED_SLOT_FIELDS 포함 확인 | + +### REQ-034 (per-slot WatchdogStore) + +| AC | Evidence | +|----|---------| +| AC-1: snapshot RPC 처리 시 holding slot last_seen_at 갱신 | `test_snapshot_rpc_watchdog.py::test_holding_slot_watchdog_updated_on_snapshot_rpc` | +| AC-2: timeout 초과 슬롯 만료 판정 | `test_watchdog.py::TestIsExpired::test_old_update_is_expired` (200s > 120s timeout) | +| AC-3: timeout 미초과 슬롯 비만료 | `test_just_within_timeout_not_expired` (100s < 120s timeout) | +| AC-4: grace period 중 만료 유예 | `test_grace_period_defers_expiry_check` — in_grace_period+is_expired 조합 패턴 검증 | +| AC-5: 재시작 시 store 초기화 | `test_new_store_is_empty` | +| AC-6: 정상 poll 주기 슬롯 비만료 | `test_recently_updated_slot_not_expired` — update 직후 is_expired=False | +| holding만 갱신, idle은 제외 | `test_idle_slot_watchdog_not_updated` | +| 복수 holding 슬롯 모두 갱신 | `test_multiple_holding_slots_all_updated` | +| backward compat (watchdog_store=None) | `test_no_watchdog_store_does_not_raise` | + +### REQ-036 (SlotService.sell() + idempotency) + +| AC | Evidence | +|----|---------| +| AC-1: holding → pending_sell 전이 | `test_service_sell.py::test_sell_holding_transitions_to_pending_sell` | +| AC-2: pending_sell 중복 호출 → 무전이 | `test_sell_idempotent_on_pending_sell` — transition_log PENDING_SELL 항목 1건만 확인 | +| AC-3: idle 호출 → 오류, 상태 불변 | `test_sell_idle_slot_raises_transition_error` | +| AC-4: execution 경로 재사용 | `test_sell_records_transition_log` — transit_slot 기반으로 log append 확인 | +| AC-5: transport 독립 | `sell()` 구현이 RabbitMQ 의존 없는 service.transit_slot() 직접 호출 — 코드 검사로 확인 | +| SlotNotFoundError | `test_sell_unknown_slot_raises_not_found` | +| price fields 보존 | `test_sell_preserves_stop_loss_price` | + +## Evidence + +- Test: + - 신규 테스트 32종 all green (pytest 출력 확인) + - `tests/domain/ + tests/runtime/ + 대상 tests/application/` 회귀 95 passed + - `test_contract.py` 통과 — EXPECTED_SLOT_FIELDS 계약 유지 +- Manual: + - 없음 (단위 테스트로 모든 AC 커버) +- Logs: + - `32 passed in 0.09s` (신규 테스트 단독) + - `95 passed in 0.13s` (회귀 포함) + +## Architecture Impact Note + +- BAT-010은 project-structure.md / runtime-flows.md 업데이트를 BAT-011 REQ-042에 위임 +- design.md Reference Doc Update Plan과 일치: 현 batch는 구현 evidence 확보 범위 +- `test_daemon_no_http_surface` 기존 실패는 httpx 미설치 환경 이슈로 BAT-010 변경과 무관 + +## Result + +- Ready for Release: true +- Blocking Issues: + - R-2: 실 PostgreSQL migration round-trip 미검증 — `IF NOT EXISTS` 패턴으로 안전성 확보, BAT-011 환경에서 검증 예정. release gate에서는 수용(additive-only schema 변경, 기존 row 영향 없음). diff --git a/docs/batches/bat-011_20260607_safety-sell-triggers/design.md b/docs/batches/bat-011_20260607_safety-sell-triggers/design.md new file mode 100644 index 0000000..556fa62 --- /dev/null +++ b/docs/batches/bat-011_20260607_safety-sell-triggers/design.md @@ -0,0 +1,55 @@ +# BAT-011 Design + +- Status: confirmed +- Batch: BAT-011 + +## Architecture Summary +BAT-011은 BAT-010 기반 위에 자동 매도를 실제로 발동하는 트리거 레이어를 추가한다. + +1. **`trex-safety-sell` worker 스레드** (`runtime/daemon.py`): 기존 6 스레드(execution/ledger/intent-rpc/snapshot-rpc/pause-rpc/snapshot-scheduler)에 7번째로 추가. `_safety_sell_loop()`가 단일 통합 루프에서 forced_close_at 만기(REQ-037), stop-loss·take-profit 가격 체크(REQ-038), 장 마감 청산(REQ-039)을 순차 평가한다. +2. **KIS 현재가 polling 어댑터** (`adapters/broker/kis.py` 확장): `get_current_price(symbol) -> Decimal` — `TTTC8397R`(실전)/`VTTC8397R`(모의) 시세 endpoint 호출, `stck_prpr`(직전 체결가) 파싱. paper 환경 stub. +3. **`update_thresholds` intent RPC** (`application/messaging_handlers.py` + `domain/slot/service.py`): `make_intent_rpc_handler`에 action 라우팅 추가, `SlotService.update_thresholds()`로 부분 갱신 + 워치독 `last_seen_at` 리셋. +4. **baseline 문서 정합** (REQ-042): 구현 완료 후 3문서 갱신. + +## Changed Areas +- Docs: + - `docs/interface-contract.md` — `update_thresholds` action 계약 + intent payload `stop_loss_price`/`take_profit_price` 선택 필드 + snapshot RPC 워치독 갱신 신호 명시 (REQ-040, REQ-042) + - `docs/runtime-flows.md` — 워치독 갱신·forced_close 체크·가격 체크·장 마감 청산 흐름 추가 + `:114` "A-5 미구현" 주석 갱신 (REQ-042) + - `docs/project-structure.md` — `trex-safety-sell` 스레드 + env 5종 + KIS 현재가 polling/watchdog 모듈 반영 (REQ-042) +- Modules: + - `trex_core/runtime/daemon.py` — `trex-safety-sell` worker 등록 + `_safety_sell_loop()` 구현(forced_close/가격/장 마감 통합) + - `trex_core/adapters/broker/kis.py` — `get_current_price(symbol)` 추가(`TTTC8397R`/`VTTC8397R`, `stck_prpr`) + - `trex_core/application/broker_port.py` — (필요 시) `get_current_price` 포트 시그니처 추가; paper 더블 stub 구현 + - `trex_core/adapters/broker/paper.py` — `get_current_price` stub(고정/주입 가능 현재가) — 테스트·모의용 + - `trex_core/domain/slot/service.py` — `SlotService.update_thresholds(slot_id, stop_loss_price, take_profit_price, forced_close_at)` 추가 + 트리거 조건 평가 헬퍼(또는 worker 인라인) + - `trex_core/application/messaging_handlers.py` — `make_intent_rpc_handler`에 `action: "update_thresholds"` 라우팅 + holding-only 응답 +- Interfaces: + - `Broker.get_current_price(symbol: str) -> Decimal | None` (신규 포트 메서드) + - `SlotService.update_thresholds(slot_id, stop_loss_price=..., take_profit_price=..., forced_close_at=...) -> str` (부분 갱신, sentinel로 "미지정" vs "null 해제" 구분) + - intent RPC `action: "update_thresholds"` payload/response 계약 (REQ-040) + +## Key Decisions (초안 — 구현 전 확정 대상) +1. **단일 통합 worker 루프 vs. 분리 스레드**: REQ-037 Notes 권고에 따라 `trex-safety-sell` 단일 스레드 통합 루프를 기본안으로 한다. 가격 polling 주기(`TREX_STOP_LOSS_POLL_INTERVAL`, 기본 30s)와 forced_close 주기(`TREX_FORCED_CLOSE_CHECK_INTERVAL`, 기본 60s)가 다르므로, 루프 base tick을 짧은 쪽(또는 GCD)으로 두고 각 체크에 last-run 타임스탬프로 sub-scheduling한다. (확정 필요) +2. **KIS 현재가 어댑터 배치**: `kis.py`에 `get_current_price` 추가(별도 `kis_price.py` 분리 대신) — 토큰/세션/`_account` 재사용 이점. paper는 `paper.py` stub. (확정 필요) +3. **WatchdogStore read 동시성**: BAT-010 Key Decision 3 계약 승계 — GIL 보장 단일 dict, lock 없음. worker는 `is_expired()`/`in_grace_period()` read만 수행. +4. **장 마감 1회 발동 가드**: in-memory `last_liquidation_date: date | None`. 재시작 시 초기화(REQ-039 Notes). 같은 거래일 재발동 방지. 발동 시각 도래 판정은 `now(KST).time() >= liquidation_time and last_liquidation_date != today`. +5. **`update_thresholds` 부분 갱신 sentinel**: payload에 키가 없으면 "유지", 키가 `null`이면 "해제(NULL)". `_UNSET` sentinel로 두 경우를 구분(REQ-040 AC-4/AC-5). +6. **루프 예외 격리**: tick 단위 try/except로 polling/sell 예외가 worker 스레드를 죽이지 않게 한다. 예외는 로그 후 다음 tick 지속(REQ-024 degrade 정책 정합). + +## Edge Cases +- 가격 polling 중 KIS 거부/타임아웃: 해당 슬롯 skip + 경고 로그, 다음 tick 재시도(R-4). +- forced_close와 stop-loss가 동시 충족: 둘 다 `sell()` 호출하나 REQ-036 idempotency로 1회만 전이. +- 장 마감 청산 시각이 forced_close 체크 사이에 도래: 통합 루프 tick에서 장 마감 평가가 우선 실행되도록 순서 고정. +- `update_thresholds`로 `forced_close_at`만 갱신: 나머지 필드 유지(sentinel), `last_seen_at` 리셋(워치독 타이머 재시작, REQ-034). +- non-holding 슬롯에 `update_thresholds`: 필드 불변 + `{status: ignored, reason: "not_holding"}` (REQ-040 AC-6). +- 쿼터 상한 초과: 경고 로그만(본 batch 범위) — 동적 간격 조정은 out of scope. + +## Architecture Impact +- runtime-flows: safety-sell worker 4종 흐름(워치독 갱신은 snapshot RPC 측) 추가 — REQ-042에서 문서화. +- project-structure: 7번째 스레드 `trex-safety-sell` + KIS 현재가 polling 어댑터 메서드 — REQ-042에서 문서화. +- broker_port 계약 확장(`get_current_price`)이 paper/kis 양 더블에 반영되어야 한다. + +## Reference Doc Update Plan +- `interface-contract.md`: `update_thresholds` action + price 선택 필드 + snapshot RPC 워치독 신호 (REQ-042 AC-1/AC-2) +- `runtime-flows.md`: 4종 safety-sell 흐름 + `:114` A-5 주석 갱신 (REQ-042 AC-3/AC-4) +- `project-structure.md`: `trex-safety-sell` 스레드 + env 5종 + 신규 모듈 (REQ-042 AC-5) diff --git a/docs/batches/bat-011_20260607_safety-sell-triggers/implementation.md b/docs/batches/bat-011_20260607_safety-sell-triggers/implementation.md new file mode 100644 index 0000000..72cb4d1 --- /dev/null +++ b/docs/batches/bat-011_20260607_safety-sell-triggers/implementation.md @@ -0,0 +1,43 @@ +# BAT-011 Implementation + +- Status: completed +- Batch: BAT-011 + +## Plan Summary +- 구현 순서: REQ-038 KIS 현재가 어댑터 → `SafetySellService`(REQ-037/038/039) + `trex-safety-sell` worker 배선 → REQ-040 `update_thresholds` RPC → REQ-042 baseline 문서 정합. 각 단계 unit test green 확인 후 진행. + +## Changed Files + +### 신규 파일 +- `trex_core/application/safety_sell.py` — `SafetySellService`: forced_close(REQ-037) / stop-loss·take-profit(REQ-038) / 장 마감 청산(REQ-039) + run_once 캐던스. watchdog read-only, sell() 통해 매도, tick 예외 격리. +- `tests/adapters/broker/test_kis_price.py` — KIS 현재가 어댑터 7종(tr_id 분기, stck_prpr 파싱, 에러→None, paper stub). +- `tests/application/test_safety_sell_worker.py` — 트리거 서비스 23종(forced_close 6 / 가격 10 / 장 마감 6 / run_once 2). +- `tests/application/test_update_thresholds_handler.py` — update_thresholds RPC 8종(부분 갱신/null 해제/non-holding/last_seen 리셋/미존재). + +### 기존 파일 수정 +- `trex_core/application/broker_port.py` — `BrokerPort.get_current_price(symbol) -> Optional[Decimal]` 포트 추가 (REQ-038). +- `trex_core/adapters/broker/kis.py` — `_tr_price()`(TTTC8397R/VTTC8397R) + `get_current_price()`(`inquire-price`, `stck_prpr` 파싱, 에러→None) (REQ-038). +- `trex_core/adapters/broker/paper.py` — `set_current_price()`/`get_current_price()` 프로그래머블 stub (REQ-038). +- `trex_core/domain/slot/service.py` — `SlotService.update_thresholds()` + `_UNSET` sentinel(유지 vs 해제 구분) (REQ-040). +- `trex_core/application/messaging_handlers.py` — `make_intent_rpc_handler` 에 `slot_service`/`watchdog_store` 인자 + `update_thresholds` action 라우팅(`_handle_update_thresholds`) + `last_seen_at` 리셋 (REQ-040). +- `trex_core/runtime/daemon.py` — `WatchdogStore` 인스턴스 + `SafetySellService` 빌드 + `trex-safety-sell` worker(7번째 스레드) + snapshot/intent RPC handler 에 watchdog 배선 + docstring 갱신 (REQ-034/037/038/039/040). +- `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/project-structure.md` — baseline 정합 (REQ-042). +- `tests/domain/slot/test_price_threshold_fields.py` — 중복 import 정리(ruff clean). + +## Execution Log +1. broker_port + kis + paper 현재가 구현 → `test_kis_price.py` 7 passed. +2. `safety_sell.py` 구현 → `test_safety_sell_worker.py` 23 passed. +3. daemon 배선(watchdog + worker + handler) → import 회귀(test_daemon_no_http_surface) green. +4. `update_thresholds` 구현 → `test_update_thresholds_handler.py` 8 passed. +5. baseline 3문서 정합(REQ-042). +6. 전체 회귀: 단위 236 passed / 23 skipped, `ruff check` All passed. +7. 인프라 게이트 포함(PG+RabbitMQ): 259 passed (마이그레이션 001~004 적용 + PG repo round-trip 포함). + +## Validation +- 단위: `pytest -q --deselect ...::test_sandbox_token_and_submit` → 236 passed / 23 skipped. +- 인프라 게이트: `TREX_RABBITMQ_TEST_URL=... TREX_POSTGRESQL_TEST_DSN=...(trex_core_test) pytest -q` → 259 passed. +- lint: `ruff check trex_core tests` → All checks passed. + +## Remaining Risks +- R-1 (운영 실증 이월): stop-loss·take-profit·forced_close·장 마감의 **실 KIS 라이브 트리거**는 휴장/운영 환경 제약으로 단위(mock)·정적 검증까지 수행. 차기 거래일 운영 관찰 필요(verification Carried-Over). +- R-5 해소: BAT-010 R-2(실 PostgreSQL `004_add_price_thresholds.sql` round-trip)는 인프라 게이트 259 passed 로 적용 확인(마이그레이션 idempotency + PG repo 테스트 green). diff --git a/docs/batches/bat-011_20260607_safety-sell-triggers/index.md b/docs/batches/bat-011_20260607_safety-sell-triggers/index.md new file mode 100644 index 0000000..13a6b4f --- /dev/null +++ b/docs/batches/bat-011_20260607_safety-sell-triggers/index.md @@ -0,0 +1,35 @@ +# BAT-011: safety-sell 트리거 레이어 (forced_close·KIS polling·장 마감 청산 + update_thresholds RPC + baseline 정합) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-007 +- Included REQ: REQ-037, REQ-038, REQ-039, REQ-040, REQ-042 +- Excluded REQ: REQ-034, REQ-035, REQ-036, REQ-041 (safety-sell 기반 인프라 — BAT-010에서 delivery 완료) + +## Summary +- dcy-007(core 자율 안전 매도)의 트리거 레이어를 delivery한다. BAT-010이 제공한 기반(WatchdogStore, `stop_loss_price`/`take_profit_price` 도메인 필드, `SlotService.sell()`, safety-sell env vars 5종 파싱) 위에 실제 자동 매도를 발동하는 백그라운드 루프와 임계값 갱신 RPC를 추가한다. 구성: `forced_close_at` 만기 루프(REQ-037), stop-loss·take-profit KIS 현재가 polling + 트리거(REQ-038), 장 마감 강제 청산(REQ-039), `update_thresholds` intent RPC 액션(REQ-040), 그리고 dcy-007 전체(REQ-034~041)의 baseline 문서 정합(REQ-042). + +## Scope +- In Scope: + - 신규 `trex-safety-sell` 데몬 worker 스레드 — 기존 6 스레드에 1개 추가. forced_close_at 체크(REQ-037), stop-loss·take-profit 가격 체크(REQ-038), 장 마감 청산(REQ-039)을 통합 루프로 처리. + - forced_close_at 만기 감지: `holding` + 워치독 만료 + `forced_close_at <= now(KST)` → `SlotService.sell()` (REQ-037) + - KIS 현재가 polling 어댑터(`TTTC8397R` 실전 / `VTTC8397R` 모의, `stck_prpr` 파싱) + 손절/익절 조건 평가 + 쿼터 상한 경고 (REQ-038) + - 장 마감 강제 청산: `TREX_MARKET_CLOSE_LIQUIDATION_TIME` 도래 시 워치독 무관 전체 `holding` 청산 + 거래일 1회 발동 가드 (REQ-039) + - `update_thresholds` intent RPC 액션: `stop_loss_price`/`take_profit_price`/`forced_close_at` 부분 갱신 + 워치독 `last_seen_at` 리셋 + holding-only (REQ-040) + - baseline 3문서(`interface-contract.md`, `runtime-flows.md`, `project-structure.md`) 정합 — dcy-007 REQ-034~041 반영 (REQ-042) +- Out of Scope: + - BAT-010이 완료한 기반 인프라(REQ-034 WatchdogStore, REQ-035 도메인 필드/마이그레이션, REQ-036 `SlotService.sell()`, REQ-041 env 파싱) 재구현 — 본 batch는 호출/참조만 한다. + - 가격 임계값 논리 유효성 검증(take_profit > stop_loss 등) — dcy-007/REQ-035 범위 밖. + - KIS 쿼터 초과 시 polling 간격 자동 조정의 동적 튜닝 정책 — 본 batch는 상한 산정 + 경고 로그까지(REQ-038 AC-8). 동적 조정은 후속 후보. + +## 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-007). BAT-010과 같은 Discovery의 후속 batch이며, BAT-010 index의 "BAT-011 예정" 분할 계획과 정확히 일치한다(REQ-037/038/039/040/042). +- 본 batch는 BAT-010과 달리 **외부 API(KIS 현재가 polling) 호출과 신규 daemon thread**를 도입하므로 standard profile + design 선행이 필요하다(planning.md Design Gate 참조). +- BAT-010 verification의 잔여 R-2(실 PostgreSQL migration `004_add_price_thresholds.sql` round-trip 미검증)를 본 batch 환경에서 함께 검증한다(verification에 evidence 기록 예정). +- REQ-042(문서 정합)는 REQ-037~040 구현 완료 후 verification 단계에서 최신 구현 상태를 반영해 수행한다(REQ-042 Notes). diff --git a/docs/batches/bat-011_20260607_safety-sell-triggers/planning.md b/docs/batches/bat-011_20260607_safety-sell-triggers/planning.md new file mode 100644 index 0000000..9a0fa66 --- /dev/null +++ b/docs/batches/bat-011_20260607_safety-sell-triggers/planning.md @@ -0,0 +1,54 @@ +# BAT-011 Planning + +- Status: confirmed +- Batch: BAT-011 +- Profile: standard + +## Included REQ +- REQ-037 (Integration): `forced_close_at` 만기 자동 매도 루프 — `holding` + 워치독 만료 + `forced_close_at <= now(KST)` 슬롯에 `SlotService.sell()`. `TREX_FORCED_CLOSE_CHECK_INTERVAL` 주기. KIS 호출 없음(DB + in-memory watchdog 비교). +- REQ-038 (Integration): stop-loss·take-profit KIS 현재가 polling + 트리거 — `TTTC8397R`(실전)/`VTTC8397R`(모의) `stck_prpr` 기준, `현재가 <= stop_loss_price`(손절)/`현재가 >= take_profit_price`(익절) 시 `sell()`. `TREX_STOP_LOSS_POLL_INTERVAL` 주기. 쿼터 상한 `N × (3600/interval)` 경고. +- REQ-039 (Integration): 장 마감 강제 청산 — `TREX_MARKET_CLOSE_LIQUIDATION_TIME`(HH:MM KST) 도래 시 워치독 무관 전체 `holding` 청산. 거래일 1회 발동 가드(`last_liquidation_date`). +- REQ-040 (Interface): `update_thresholds` intent RPC 액션 — `{action, slot_id, stop_loss_price?, take_profit_price?, forced_close_at?}` 부분 갱신 + 워치독 `last_seen_at` 리셋 + holding-only. `reply_to` 시 `{status: updated|ignored, slot_id}` 응답. +- REQ-042 (Documentation): baseline 3문서 정합 — `interface-contract.md`(update_thresholds + price 필드), `runtime-flows.md`(워치독 갱신·forced_close·가격 체크·장 마감 흐름 + `:114` A-5 주석 갱신), `project-structure.md`(`trex-safety-sell` 스레드 + env 5종 + KIS polling/watchdog 모듈). + +## Delivery Plan +1. REQ-038 KIS 현재가 polling 어댑터: `get_current_price(symbol)` (`TTTC8397R`/`VTTC8397R`, `stck_prpr` 파싱, 실전/모의 분기). paper 환경 stub. 어댑터 단위 테스트. +2. REQ-037 + REQ-038 + REQ-039 통합 worker: `daemon.py`에 `trex-safety-sell` 스레드 + `_safety_sell_loop()` 추가. 루프 1 tick에서 (a) forced_close_at 만기, (b) stop-loss/take-profit 가격 체크, (c) 장 마감 청산 시각 평가를 순차 수행. 각 경로 `SlotService.sell()` 호출. mock 기반 단위 테스트(만기/손절/익절/미충족/워치독 보호/idempotency/장 마감 전체·중복가드). +3. REQ-040 `update_thresholds`: `SlotService.update_thresholds(...)` + `make_intent_rpc_handler` action 라우팅 + `last_seen_at` 리셋 + holding-only 응답. handler 단위 테스트(부분 갱신/null 해제/non-holding ignored/last_seen_at 리셋). +4. REQ-039 config: `TREX_MARKET_CLOSE_LIQUIDATION_TIME` 활용 확인(REQ-041에서 파싱 완료 — 본 batch는 소비). +5. REQ-042 baseline 문서 3종 정합 — 구현 완료 상태 반영. +6. 전체 회귀 green + ruff lint clean + BAT-010 잔여 R-2(실 PG 마이그레이션 round-trip) 검증. + +## Dependencies +- Internal: + - BAT-010/REQ-034: `WatchdogStore.is_expired()` / `in_grace_period()` — 트리거 루프의 워치독 만료 판정 계약(read 측). + - BAT-010/REQ-035: `stop_loss_price`/`take_profit_price` 도메인 필드 — 가격 체크 기준값. + - BAT-010/REQ-036: `SlotService.sell(slot_id, actor, reason)` — 모든 트리거의 매도 제출 경로. + - BAT-010/REQ-041: safety-sell env vars 5종 파싱(`DaemonConfig`) — 본 batch가 값을 소비. + - BAT-007/REQ-022: `make_intent_rpc_handler` — `update_thresholds` action 확장 대상. + - BAT-004/REQ-012: `KISBroker`(`kis.py`) — 현재가 polling 어댑터 추가/확장 대상. +- External: + - KIS Open API 현재가 시세 endpoint(`TTTC8397R`/`VTTC8397R`). 실 호출은 운영(`broker=kis`) 환경; 테스트는 mock/stub. + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 본 batch는 (a) **신규 daemon worker 스레드(`trex-safety-sell`)**를 추가하고 BAT-010의 WatchdogStore를 새로운 스레드에서 read하므로 동시성 계약(GIL 의존 read vs. snapshot RPC handler write)을 명시해야 한다. (b) **신규 외부 I/O(KIS 현재가 polling)** 어댑터의 배치(`kis.py` 확장 vs. 신규 `kis_price.py`), 실전/모의 endpoint 분기, paper stub 전략, 쿼터 상한 산정을 설계에서 확정해야 한다. (c) forced_close·가격 체크·장 마감을 **단일 통합 루프**로 묶는 구조와 장 마감 1회 발동 가드(`last_liquidation_date`) 상태 모델을 설계에서 고정해야 구현·검증 drift가 없다. → `design.md`를 동일 턴에 scaffold한다. + +## Milestones +1. M1 — REQ-038 KIS 현재가 polling 어댑터 + 단위 테스트 green (실전/모의 분기, `stck_prpr` 파싱) +2. M2 — `trex-safety-sell` worker + forced_close_at 만기 루프(REQ-037) + 단위 테스트 green +3. M3 — stop-loss·take-profit 가격 체크(REQ-038) worker 통합 + mock 단위 테스트 green(손절/익절/미충족/워치독 보호/쿼터 산정) +4. M4 — 장 마감 강제 청산(REQ-039) + 거래일 1회 가드 + 단위 테스트 green +5. M5 — `update_thresholds` RPC(REQ-040) + handler 단위 테스트 green +6. M6 — baseline 3문서 정합(REQ-042) + 전체 회귀 + ruff clean + BAT-010 R-2 검증 + +## Risks +- R-1 (Medium): KIS 현재가 polling 쿼터 — 워치독 만료 슬롯 N개 × (3600/`TREX_STOP_LOSS_POLL_INTERVAL`) 호출이 KIS 분당/일일 쿼터를 초과할 수 있음. 대응: REQ-038 AC-8 상한 산정 + 임계 초과 시 경고 로그. 폴링 대상은 "워치독 만료" 슬롯으로 한정해 평시 호출 최소화. +- R-2 (Medium): WatchdogStore cross-thread read — safety-sell worker가 snapshot RPC handler thread의 write와 동시 접근. 대응: BAT-010 Key Decision 3(GIL 보장 단일 dict, lock 없음) 계약을 그대로 따르고 read-only 판정만 수행. 단위 테스트로 만료 판정 고정. +- R-3 (Medium): 장 마감 청산 발동 정밀도/중복 — worker 루프 주기에 의존하는 시각 평가 + 재시작 시 in-memory `last_liquidation_date` 초기화로 동일 거래일 재발동 위험. 대응: 발동 가드 + (필요 시) 발동 시각 경계 처리 설계에서 확정. +- R-4 (Low): KIS 거부/네트워크 예외 시 루프 안정성 — polling 또는 sell 제출 중 예외가 worker 스레드를 죽이면 안 됨. 대응: tick 단위 try/except + 로그 후 다음 tick 지속(REQ-024 degrade 정책 정합). +- R-5 (Low, 이월): BAT-010 잔여 R-2 — `004_add_price_thresholds.sql` 실 PostgreSQL round-trip 미검증. 대응: 본 batch verification에서 실 PG 적용 + round-trip 확인. + +## Notes +- safety-sell worker는 REQ-037 Notes의 권고(forced_close·stop-loss/take-profit·장 마감을 하나의 통합 루프로 처리)를 따른다. 루프 주기와 가격 체크 주기가 다를 경우(`TREX_FORCED_CLOSE_CHECK_INTERVAL` vs `TREX_STOP_LOSS_POLL_INTERVAL`) tick 내 sub-scheduling 방식은 design에서 확정한다. +- REQ-040 OQ-6(non-holding 슬롯 처리)는 REQ-040 AC-6에서 `{status: ignored, reason: "not_holding"}` 응답으로 이미 결정됨 — 구현은 이를 따른다. diff --git a/docs/batches/bat-011_20260607_safety-sell-triggers/verification.md b/docs/batches/bat-011_20260607_safety-sell-triggers/verification.md new file mode 100644 index 0000000..ab500bb --- /dev/null +++ b/docs/batches/bat-011_20260607_safety-sell-triggers/verification.md @@ -0,0 +1,82 @@ +# BAT-011 Verification + +- Status: verified +- Batch: BAT-011 + +## Acceptance Mapping + +### REQ-037 (forced_close_at 만기 자동 매도 루프) +| AC | Evidence | +|----|---------| +| AC-1: 만기+워치독 만료 → sell() | `test_safety_sell_worker.py::TestForcedClose::test_expired_past_due_sells` | +| AC-2: 미만기(now+1h) → 미호출 | `test_future_not_due_no_sell` | +| AC-3: 워치독 활성 → 미호출(보호) | `test_watchdog_active_protected` | +| AC-4: forced_close_at None → 제외 | `test_no_forced_close_excluded` | +| AC-5: pending_sell 중복 미호출 | `test_idempotent_on_pending_sell` (transition_log PENDING_SELL 1건) | +| AC-6: interval 주기 | `TestRunOnce::test_base_tick_is_finest_interval` + `safety_sell.base_tick_sec`/`run_once` `_due` 캐던스(코드) | +| grace 보호 | `test_grace_period_defers` | + +### REQ-038 (stop-loss·take-profit KIS polling + 트리거) +| AC | Evidence | +|----|---------| +| AC-1: stck_prpr ≤ stop_loss → 손절 | `TestPriceTriggers::test_stop_loss_triggers` | +| AC-2: stck_prpr ≥ take_profit → 익절 | `test_take_profit_triggers` | +| AC-3: 두 조건 미충족 → 미호출 | `test_neither_condition_no_sell` | +| AC-4: 워치독 활성 → KIS 호출 0 | `test_watchdog_active_no_kis_call` (broker.calls == []) | +| AC-5: 임계값 None → 제외 | `test_no_thresholds_excluded` | +| AC-6: pending_sell 중복 미호출 | `test_idempotent_on_pending_sell` | +| AC-7: 실전 TTTC8397R / 모의 VTTC8397R | `test_kis_price.py::test_tr_price_live_vs_mock` + `test_get_current_price_parses_stck_prpr` | +| AC-8: 시간당 호출 ≤ N×(3600/interval) | `test_quota_estimate`(N=3,interval=30→360) + `test_only_eligible_slots_polled`(워치독 만료+임계 슬롯만 poll) | +| 가격 미수신 → skip | `test_price_unavailable_skips` | + +### REQ-039 (장 마감 강제 청산) +| AC | Evidence | +|----|---------| +| AC-1: 시각 도래 → 전체 holding sell() | `TestMarketClose::test_time_reached_liquidates_all` | +| AC-2: 워치독 활성 포함 청산 | `test_watchdog_active_included` | +| AC-3: 미설정 → 미동작 | `test_not_configured_no_sell` | +| AC-4: 거래일 1회 발동 가드 | `test_once_per_day_guard` | +| AC-5: pending_sell 중복 미호출 | REQ-036 idempotency(`SlotService.sell`) — `test_service_sell.py::test_sell_idempotent_on_pending_sell` 승계, 청산 경로 동일 sell() 사용 | +| AC-6: grace period 무관 청산 | `test_grace_period_does_not_block` | +| 발동 시각 이전 → 미동작 | `test_before_time_no_sell` | + +### REQ-040 (update_thresholds intent RPC) +| AC | Evidence | +|----|---------| +| AC-1: stop_loss_price 갱신 | `test_update_thresholds_handler.py::test_updates_stop_loss` | +| AC-2: take_profit + forced_close 갱신 | `test_updates_take_profit_and_forced_close` | +| AC-3: last_seen_at 리셋 | `test_resets_watchdog_last_seen` | +| AC-4: 부분 갱신(미포함 유지) | `test_partial_update_keeps_other_fields` | +| AC-5: null → 해제 | `test_explicit_null_clears` | +| AC-6: non-holding → ignored/not_holding | `test_non_holding_ignored` | +| AC-7: reply_to → updated 응답 | `test_reply_updated_status` | +| 미존재 슬롯 → ignored/slot_not_found | `test_unknown_slot_ignored` | + +### REQ-042 (baseline 문서 정합) +| AC | Evidence | +|----|---------| +| AC-1: interface-contract update_thresholds 계약 | `docs/interface-contract.md` `trex.intent.req update_thresholds 액션` 블록 + Patterns 행 | +| AC-2: interface-contract price 선택 필드 | buy payload 에 `stop_loss_price`/`take_profit_price` 추가 | +| AC-3: runtime-flows 4종 흐름 | `docs/runtime-flows.md` 워치독 갱신/forced_close/가격 체크/장 마감 흐름 4종 추가 | +| AC-4: runtime-flows A-5 주석 갱신 | broker degrade 흐름의 "A-5 미구현" → dcy-007 REQ-037~041 해소로 갱신 | +| AC-5: project-structure trex-safety-sell 스레드 | `docs/project-structure.md` 7 스레드 목록 + safety_sell.py/watchdog.py 모듈 + 변경 로그 | + +## Evidence +- Test: + - 신규 테스트 38종 green (test_kis_price 7 + test_safety_sell_worker 23 + test_update_thresholds_handler 8). + - 단위 전체 회귀: `236 passed / 23 skipped`(`::test_sandbox_token_and_submit` deselect — `.env=LIVE` 산물). + - 인프라 게이트(PG trex_core_test + RabbitMQ): `259 passed`(마이그레이션 001~004 적용 + PG repo round-trip 포함). + - `ruff check trex_core tests` → All checks passed. +- Manual: + - 없음(트리거 로직은 결정적 단위 테스트로 커버; 실 KIS 라이브 트리거는 운영 실증 이월). +- Logs: + - `236 passed, 23 skipped, 1 deselected in ~0.8s` / `259 passed ... in ~11s`. + +## Carried-Over Items +- BAT-010 R-2 (실 PostgreSQL `004_add_price_thresholds.sql` round-trip): **해소** — 인프라 게이트 259 passed(마이그레이션 적용 + PG repo 테스트 green)로 확인. +- 운영 실증 이월(차기 거래일): stop-loss·take-profit·forced_close·장 마감의 실 KIS 라이브 트리거 1건 관찰. 휴장/운영 제약으로 본 batch 는 mock·정적 검증까지. release feedback 으로 추적. + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. 잔여(실 KIS 라이브 트리거 운영 실증)는 blocker 아님 — paper/mock 결정적 검증 + KIS 어댑터 tr_id/파싱 단위 검증으로 release gate 충족. 차기 거래일 운영 관찰로 보강(REL feedback). diff --git a/docs/batches/index.md b/docs/batches/index.md index d99eb79..dce78c0 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -17,3 +17,5 @@ | BAT-007 | standard | released | REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026 | dcy-006 | [bat-007](./bat-007_20260605_strategy-module-rabbitmq-transport/) | | BAT-008 | standard | released | REQ-027, REQ-028, REQ-029, REQ-030, REQ-031, REQ-032, REQ-033 | dcy-005 | [bat-008](./bat-008_20260606_portfolio-hub-snapshot-read-layer/) | | BAT-009 | batch-lite | release-candidate | REQ-010 (CHG-20260606-01 / AC-5) | dcy-002 | [bat-009](./bat-009_20260606_paper-broker-env-tuning/) | +| BAT-010 | standard | release-candidate | REQ-034, REQ-035, REQ-036, REQ-041 | dcy-007 | [bat-010](./bat-010_20260607_safety-sell-foundation/) | +| BAT-011 | standard | release-candidate | REQ-037, REQ-038, REQ-039, REQ-040, REQ-042 | dcy-007 | [bat-011](./bat-011_20260607_safety-sell-triggers/) | diff --git a/docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md b/docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md index 4c238ed..c921a4b 100644 --- a/docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md +++ b/docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md @@ -1,13 +1,13 @@ # 0. 문서 상태 -- 상태: draft +- 상태: confirmed - 문서 ID: dcy-007_20260606_core-autonomous-safety-sell - 이슈명: core 자율 안전 매도 — stop-loss·take-profit·시간 만기 자동 트리거 - 작성 시각(KST): 2026-06-06 -- 마지막 갱신 시각(KST): 2026-06-06 (review-discovery 반영: M-1~2, m-1~7; OQ-5 Answered; OQ-6 Deferred) +- 마지막 갱신 시각(KST): 2026-06-07 (draft-req: REQ-034~042 생성) - 대체됨: 없음 - 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: 없음 +- 생성된 REQ 참조: REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039, REQ-040, REQ-041, REQ-042 # 1. 계획 상태 요약 @@ -252,8 +252,8 @@ ## Freeze 플래그 -- Handoff Decision: REQ Drafting 진행 가능 +- Handoff Decision: 승인 - Handoff Rationale: OQ-5 Answered, OQ-6 Deferred. 모든 Open 항목 해소. FR-7 범위 확정(forced_close_at 포함). - Ready for REQ Drafting: true -- Confirmed By: Justin -- Confirmed At (KST): TBD +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-07 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 15c2971..c22f7b5 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -12,4 +12,4 @@ | dcy-004_20260604_portfolio-hub-ledger-integration | 20260604 | portfolio-hub ledger 연동 — 체결을 외부 원장에 기록 | [Discovery](./dcy-004_20260604_portfolio-hub-ledger-integration.md) | confirmed | | dcy-005_20260604_portfolio-hub-asset-snapshot-api | 20260604 | portfolio-hub 종목 스냅샷 read API — 전략 모듈에 전략 실행 대상 자산 리스트 제공 | [Discovery](./dcy-005_20260604_portfolio-hub-asset-snapshot-api.md) | confirmed | | dcy-006_20260605_strategy-module-rabbitmq-transport | 20260605 | trex-core ↔ 전략 모듈 통신 transport — RabbitMQ 도입 (스냅샷 read + intent 제출 + 이벤트) | [Discovery](./dcy-006_20260605_strategy-module-rabbitmq-transport.md) | confirmed | -| dcy-007_20260606_core-autonomous-safety-sell | 20260606 | core 자율 안전 매도 — stop-loss·시간 만기 자동 트리거 | [Discovery](./dcy-007_20260606_core-autonomous-safety-sell.md) | draft | \ No newline at end of file +| dcy-007_20260606_core-autonomous-safety-sell | 20260606 | core 자율 안전 매도 — stop-loss·시간 만기 자동 트리거 | [Discovery](./dcy-007_20260606_core-autonomous-safety-sell.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 28beb4b..9f6ede9 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-06 (BAT-008) +- Last Updated (KST): 2026-06-07 (BAT-011) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -65,6 +65,7 @@ |---|---|---|---| | 스냅샷 RPC | `trex.snapshot.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{view, strategy_id, trading_date, slots[], capacity{}}` | | 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 @@ -82,10 +83,25 @@ "trading_date": "YYYY-MM-DD", "symbol": str, "qty": float?, "allocated_capital": float?, "entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?, - "portfolio_id": str?, "forced_close_at": "ISO-8601"? } + "portfolio_id": str?, "forced_close_at": "ISO-8601"?, + "stop_loss_price": number?, "take_profit_price": number? } ``` +- `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성). sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":...}`. +#### `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"|..., @@ -108,7 +124,7 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason - 어댑터 `TransportHealth` ∈ {connected, disconnected, reconnecting}. 어플리케이션 레이어는 disconnected 상태에서 `TransportUnavailableError` 로 빠르게 실패한다(버퍼링 0, 보수적 중단). - 재연결은 백오프 1s→2s→4s→8s→16s→30s cap, 어댑터가 자체 구현. -- core 자율 안전 매도는 transport 와 무관하게 동작해야 한다(architecture contract; 자동 트리거 구현은 별도 Discovery). +- 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) @@ -141,6 +157,8 @@ 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 트리거 대상이 된다. + ### `trex.pause.req` control RPC (BAT-008 / REQ-032) 요청: diff --git a/docs/project-structure.md b/docs/project-structure.md index 1608dae..4fb59de 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-06 (BAT-008) +- Last Updated (KST): 2026-06-07 (BAT-011) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물) ## Project Summary @@ -23,7 +23,7 @@ ## Primary Runtime - Type: daemon (sync + thread, no HTTP) + library (slot 도메인) - Entry Points: - - `trex-core-daemon` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — migrate+preallocate+reconcile 후 6 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / pause RPC consumer / snapshot scheduler / execution / ledger) 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008) + - `trex-core-daemon` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — migrate+preallocate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / pause RPC consumer / snapshot scheduler / execution / ledger / safety-sell) 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011) - `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause` / `resume` / `paused`) (BAT-008) - RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.pause.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md) - SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용) @@ -73,19 +73,21 @@ │ │ ├── ledger_port.py # LedgerPort + LedgerTrade/LedgerWriteResult/예외 (BAT-006) │ │ ├── ledger_outbox.py # LedgerOutbox Protocol + LedgerWriter + InMemoryLedgerOutbox (BAT-006) │ │ ├── messaging.py # MessagingTransport Protocol + DTO + TransportHealth (BAT-007) -│ │ ├── messaging_handlers.py # intent/snapshot/pause RPC handlers + slot event payload builder (BAT-007/BAT-008) +│ │ ├── messaging_handlers.py # intent/snapshot/pause RPC handlers (+update_thresholds REQ-040) + slot event payload builder (BAT-007/BAT-008/BAT-011) │ │ ├── portfolio.py # PortfolioHubReadPort + SnapshotStore + PauseStore + envelope builder (BAT-008) │ │ ├── reconciliation.py # reconcile_positions (hub ↔ broker symbol-aggregate) (BAT-008) -│ │ └── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + reconcile (BAT-008) +│ │ ├── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + reconcile (BAT-008) +│ │ ├── watchdog.py # WatchdogStore — per-slot last_seen_at + expiry/grace 판정 (BAT-010 / REQ-034) +│ │ └── safety_sell.py # SafetySellService — forced_close / stop-loss·take-profit / 장 마감 트리거 (BAT-011 / REQ-037~039) │ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) -│ │ ├── broker/ # paper.py(결정적 async 더블) · kis.py(KIS Open API + 잔고조회) (BAT-004/BAT-008) +│ │ ├── broker/ # paper.py(결정적 async 더블 + 현재가 stub) · kis.py(KIS Open API + 잔고조회 + 현재가 polling) (BAT-004/BAT-008/BAT-011) │ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006) │ │ ├── messaging/ # topology.py · in_memory.py(stub) · rabbitmq.py(pika 동기) (BAT-007) │ │ ├── portfolio/ # hub_read.py(HTTP) · stub_read.py(stub) · pause_store.py(Postgres) (BAT-008) -│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql, 003_create_snapshot_pause.sql) +│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql, 003_create_snapshot_pause.sql, 004_add_price_thresholds.sql) │ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) -│ ├── config.py # DaemonConfig + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig (env) -│ ├── daemon.py # Daemon class: setup(migrate/preallocate/reconcile/declare-topology) + 6 worker threads (execution·ledger·intent-rpc·snapshot-rpc·pause-rpc·snapshot-scheduler) + graceful shutdown +│ ├── config.py # DaemonConfig + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig(TREX_WATCHDOG_*/FORCED_CLOSE/STOP_LOSS_POLL/MARKET_CLOSE) (env) +│ ├── daemon.py # Daemon class: setup(migrate/preallocate/reconcile/declare-topology) + 7 worker threads (execution·ledger·intent-rpc·snapshot-rpc·pause-rpc·snapshot-scheduler·safety-sell) + WatchdogStore + graceful shutdown │ └── cli.py # trex-core CLI: pause/resume/paused subcommands (BAT-008 / REQ-032) └── tests/ ├── domain/slot/ # test_state / test_service / test_contract @@ -143,3 +145,5 @@ - 2026-06-04 :: BAT-006 — ledger 레이어 추가(application LedgerPort/LedgerOutbox, adapters/ledger in_memory·portfolio_hub, persistence PostgreSQLLedgerOutbox + 002 마이그레이션, runtime ledger writer 루프, FillStatus 확장, NFR-1 import 스윕) (REQ-017~020). - 2026-06-05 :: BAT-007 — transport 전환(`adapters/api/` 제거, `application/messaging.py` + `application/messaging_handlers.py` 신설, `adapters/messaging/` 신설(topology/in_memory/rabbitmq), runtime/daemon 을 sync+thread 4 스레드 구조로 재구성, `RabbitMQConfig` + `TREX_RABBITMQ_URL` env, `TREX_API_HOST`/`TREX_API_PORT` 폐기, `fastapi`/`uvicorn` 의존 제거 + `pika` 도입, NFR-1 import 스윕에 pika/messaging 추가, HTTP 표면 0 회귀 가드) (REQ-021~026). - 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`application/portfolio.py` + `application/reconciliation.py` + `application/snapshot_scheduler.py` 신설, `adapters/portfolio/` 신설(hub_read/stub_read/pause_store), `BrokerPort.get_balance()` 확장 + paper/KIS 구현, `messaging_handlers` 에 snapshot envelope + pause RPC 추가, runtime/daemon 을 6 스레드로 확장(pause-rpc + snapshot-scheduler), `SnapshotScheduleConfig` + `TREX_SNAPSHOT_*` env, `runtime/cli.py` + `trex-core` 콘솔 스크립트, `003_create_snapshot_pause.sql` 마이그레이션, NFR-1 import 스윕 유지) (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후 별도 구현). +- 2026-06-07 :: BAT-010 — safety-sell 기반 인프라(`application/watchdog.py` WatchdogStore 신설, `Slot` 에 `stop_loss_price`/`take_profit_price` 필드 + `004_add_price_thresholds.sql` additive 마이그레이션, `SlotService.sell()` 자율 매도 내부 경로, `make_snapshot_rpc_handler` 워치독 갱신, `SafetySellConfig` env 5종 파싱) (REQ-034~036, 041). +- 2026-06-07 :: BAT-011 — safety-sell 트리거 레이어(`application/safety_sell.py` SafetySellService 신설 — forced_close/stop-loss·take-profit/장 마감, `BrokerPort.get_current_price()` + KIS `inquire-price`(`TTTC8397R`/`VTTC8397R`)·paper stub, `SlotService.update_thresholds()` + intent RPC `update_thresholds` 액션, runtime/daemon 을 7 스레드로 확장(`trex-safety-sell`) + WatchdogStore 배선) (REQ-037~040, 042). diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 2d8c014..a5f282f 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-06 (BAT-008) +- Last Updated (KST): 2026-06-07 (BAT-011) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -33,11 +33,14 @@ 1. `trex-core-daemon` 기동 → `DaemonConfig.from_env()`(TREX_POSTGRESQL_DSN, `TREX_RABBITMQ_URL` 등). 2. setup: `apply_migrations(dsn)`(멱등) → 거래일 슬롯 `preallocate_daily_slots`(멱등) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. -3. start: 4개 백그라운드 스레드 동시 기동 +3. start: 7개 백그라운드 스레드 동시 기동 - `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014) - `trex-ledger` :: ledger outbox 드레인 (REQ-019) - - `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log) - - `trex-snapshot-rpc` :: `trex.snapshot.req` 소비자 + - `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-pause-rpc` :: `trex.pause.req` 소비자 (REQ-032) + - `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh (REQ-030) + - `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) @@ -107,11 +110,45 @@ - 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 필드는 없음**. +## 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`)를 호출한다. 전략 모듈이 스냅샷을 폴링하는 한 그 슬롯은 "활성"으로 판정된다. +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 가 자동 재시작된다(부팅 직후 오탐 방지). + +## Flow: `forced_close_at 만기 자동 매도` (BAT-011 / REQ-037) + +`trex-safety-sell` worker 가 `TREX_FORCED_CLOSE_CHECK_INTERVAL` 주기로 수행. + +1. 오늘 `holding` 슬롯 중 워치독 만료(grace 밖)이고 `forced_close_at <= now(UTC)` 인 슬롯을 선별. +2. 대상 슬롯에 `SlotService.sell(actor="trex-safety-sell", reason="forced_close_at")` 호출 → `holding→pending_sell`(REQ-036). 이후 체결은 집행 루프가 처리. +3. 워치독 활성 슬롯·`forced_close_at=None` 슬롯은 제외. 이미 `pending_sell` 이면 idempotent(중복 매도 없음). KIS 호출 없음(DB + in-memory 비교만). + +## Flow: `stop-loss·take-profit 가격 체크` (BAT-011 / REQ-038) + +`trex-safety-sell` worker 가 `TREX_STOP_LOSS_POLL_INTERVAL` 주기로 수행. + +1. `holding` + 워치독 만료 + (`stop_loss_price` 또는 `take_profit_price` 설정) 슬롯만 대상(워치독 활성 슬롯은 KIS 호출조차 안 함). +2. `BrokerPort.get_current_price(symbol)` → KIS `inquire-price`(`TTTC8397R` 실전 / `VTTC8397R` 모의)의 `stck_prpr`. paper 는 프로그램된 가격(미설정 시 None). +3. `현재가 <= stop_loss_price` → 손절(`reason="stop_loss"`), `현재가 >= take_profit_price` → 익절(`reason="take_profit"`)으로 `SlotService.sell()`. 가격 미수신(None) 시 skip(오탐 방지, 다음 tick 재시도). +4. 쿼터 가드: 시간당 호출 상한 `N × (3600 / interval)` 추정이 임계 초과 시 경고 로그(REQ-038 AC-8). + +## Flow: `장 마감 강제 청산` (BAT-011 / REQ-039) + +`TREX_MARKET_CLOSE_LIQUIDATION_TIME`(HH:MM KST, optional) 설정 시에만 동작. + +1. `trex-safety-sell` worker 가 매 tick 현재 KST 시각을 평가 → 설정 시각 도달 + 같은 거래일 미발동(`last_liquidation_date` 가드)이면 발동. +2. 발동 시 **워치독·grace 무관** 모든 `holding` 슬롯에 `SlotService.sell(reason="market_close_liquidation")`. 이미 `pending_sell` 은 idempotent. +3. 미설정이면 동작하지 않음. in-memory 가드라 재시작 시 같은 날 재발동 가능(설계상 허용 — REQ-039 Notes). + ## Flow: `broker degrade 분기` (BAT-007 / REQ-024) 1. transport connection drop → 어댑터가 `_mark_disconnected()` + 백오프(1s→2s→4s→8s→16s→30s cap) 재연결. 2. application 레이어는 `transport.health()` 가 `disconnected/reconnecting` 인 동안 신규 발행/RPC 가 `TransportUnavailableError` 로 빠르게 실패한다 → 전략은 매매 보류. -3. core 자율 안전 매도(stop-loss / forced_close)는 transport 와 무관하게 execution 루프가 수행한다(설계 contract; 자동 트리거 실제 구현은 별도 Discovery — A-5 미구현). +3. core 자율 안전 매도(stop-loss·take-profit / forced_close / 장 마감 청산)는 transport 와 무관하게 `trex-safety-sell` worker 가 수행한다(아래 safety-sell 흐름; dcy-007 REQ-037~041 구현, BAT-010/011). 이전 "A-5 미구현" 항목은 dcy-007 로 해소됨. 4. 로컬 버퍼링 없음 — broker 복구 후 옛 intent 가 자동 재생되지 않는다(전략이 신선 상태로 재판단). ## Flow: `주문 집행 루프` (백그라운드, BAT-004) diff --git a/docs/srs/Configuration/req-041_safety-sell-env-config.md b/docs/srs/Configuration/req-041_safety-sell-env-config.md new file mode 100644 index 0000000..ff04240 --- /dev/null +++ b/docs/srs/Configuration/req-041_safety-sell-env-config.md @@ -0,0 +1,115 @@ +# req-041: safety-sell 환경 설정 — 5종 env var + +- Status: Implemented +- Type: Configuration +- Priority: High +- Owner: project maintainers + +## Intent + +자율 안전 매도 기능(REQ-034~039)에서 사용하는 5종의 환경 변수를 `trex_core/runtime/config.py`의 `DaemonConfig.from_env()`에 추가한다. 모든 값은 env에서 파싱되며 기본값을 제공해야 한다(dcy-007 NFR-1·3, A-4). + +## Requirement + +- 시스템은 아래 5종 env var를 `DaemonConfig.from_env()`에서 파싱해야 한다. + +| 환경 변수 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `TREX_WATCHDOG_TIMEOUT` | int (초) | 120 | 워치독 만료 임계값 (REQ-034) | +| `TREX_WATCHDOG_GRACE_PERIOD` | int (초) | 60 | 데몬 재시작 후 만료 판정 유예 시간 (REQ-034) | +| `TREX_FORCED_CLOSE_CHECK_INTERVAL` | int (초) | 60 | forced_close_at 만기 체크 주기 (REQ-037) | +| `TREX_STOP_LOSS_POLL_INTERVAL` | int (초) | 30 | stop-loss·take-profit 가격 polling 주기 (REQ-038) | +| `TREX_MARKET_CLOSE_LIQUIDATION_TIME` | str (HH:MM KST) | None (미설정) | 장 마감 강제 청산 시각 (REQ-039). 미설정 시 동작 없음. | + +## Acceptance Criteria + +1. Given 환경 변수가 설정되지 않았을 때, When `DaemonConfig.from_env()`를 호출하면, Then `watchdog_timeout=120`, `watchdog_grace_period=60`, `forced_close_check_interval=60`, `stop_loss_poll_interval=30`, `market_close_liquidation_time=None`이 반환된다. +2. Given `TREX_WATCHDOG_TIMEOUT=180`이 설정됐을 때, When `from_env()`를 호출하면, Then `watchdog_timeout=180`이 반환된다. +3. Given `TREX_MARKET_CLOSE_LIQUIDATION_TIME=15:10`이 설정됐을 때, When `from_env()`를 호출하면, Then `market_close_liquidation_time`이 파싱되어 `datetime.time(15, 10)`(또는 동등한 형식)으로 반환된다. +4. Given `TREX_MARKET_CLOSE_LIQUIDATION_TIME`이 설정되지 않았을 때, When `from_env()`를 호출하면, Then `market_close_liquidation_time=None`이 반환된다. +5. Given 잘못된 형식의 `TREX_MARKET_CLOSE_LIQUIDATION_TIME`(예: `"25:99"`)이 설정됐을 때, When `from_env()`를 호출하면, Then 명확한 오류(ValueError 또는 ConfigError)가 발생한다. + +## Impacted Area + +- Docs: + - `docs/project-structure.md` — Primary Runtime env var 목록에 5종 추가 (REQ-042 참조) +- Modules: + - `trex_core/runtime/config.py` — `DaemonConfig`에 5종 env var 파싱 추가 +- Tests: + - `DaemonConfig.from_env()` 단위 테스트 — 기본값 확인, 개별 설정 확인, 잘못된 형식 오류 확인 + +## Notes + +- `TREX_MARKET_CLOSE_LIQUIDATION_TIME`은 HH:MM KST 형식으로 파싱한다. `datetime.time` 또는 `(hour, minute)` tuple로 저장한다. 배치에서 결정. +- `TREX_WATCHDOG_TIMEOUT`과 `TREX_WATCHDOG_GRACE_PERIOD`의 단위는 초(int)다. +- 기존 `DaemonConfig`에는 `TREX_SNAPSHOT_TICK_SEC`, `TREX_SNAPSHOT_SCHEDULE_KST` 등 유사 env var가 이미 있으므로 동일 파싱 패턴을 따른다. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-041 초안 생성 + +#### Intent + +- safety-sell 기능에 필요한 5종 env var를 Configuration REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~5 전체 + +#### Impacted Area Delta + +- Docs: project-structure.md (REQ-042에서 처리) +- Modules: config.py +- Tests: DaemonConfig 단위 테스트 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 diff --git a/docs/srs/Data/req-035_slot-price-threshold-fields-and-migration.md b/docs/srs/Data/req-035_slot-price-threshold-fields-and-migration.md new file mode 100644 index 0000000..ed5f2ba --- /dev/null +++ b/docs/srs/Data/req-035_slot-price-threshold-fields-and-migration.md @@ -0,0 +1,116 @@ +# req-035: 슬롯 가격 임계값 도메인 필드 + DB 마이그레이션 + +- Status: Implemented +- Type: Data +- Priority: High +- Owner: project maintainers + +## Intent + +stop-loss(손절)·take-profit(익절) 가격 트리거를 지원하기 위해 슬롯 도메인 모델에 `stop_loss_price: Optional[Decimal]`과 `take_profit_price: Optional[Decimal]` 필드를 추가하고, PostgreSQL 스키마에 해당 컬럼을 additive 마이그레이션으로 추가한다(dcy-007 코드 영향 — 필드 추가 + DB 마이그레이션). + +## Requirement + +- 슬롯 도메인 모델(`SlotModel` / `Slot`)에 `stop_loss_price: Optional[Decimal]`과 `take_profit_price: Optional[Decimal]` 필드를 추가해야 한다. 기본값은 `None`이다. +- PostgreSQL `slots` 테이블에 `stop_loss_price NUMERIC` 및 `take_profit_price NUMERIC` 컬럼을 NULL 허용으로 추가하는 마이그레이션을 제공해야 한다. 기존 슬롯 행은 `NULL`로 유지된다. +- 매수 intent(`BuyIntent` / `IntentIn`) payload에 `stop_loss_price`와 `take_profit_price` 필드를 선택 항목으로 추가해야 한다. 미설정 시 `None`으로 처리한다. +- persistence 레이어(PostgreSQL 어댑터)는 두 필드를 읽기·쓰기 대상에 포함해야 한다. + +## Acceptance Criteria + +1. Given `SlotModel` 인스턴스를 생성할 때 `stop_loss_price`와 `take_profit_price`를 지정하지 않으면, When 필드를 조회하면, Then 두 필드 모두 `None`이다. +2. Given `stop_loss_price=Decimal("50000")`으로 슬롯을 생성해 Postgres에 저장하면, When 같은 슬롯을 다시 조회하면, Then `stop_loss_price`가 `Decimal("50000")`으로 복원된다. +3. Given 기존 슬롯 row가 마이그레이션 전에 존재하면, When 마이그레이션을 실행하면, Then 기존 row의 `stop_loss_price`와 `take_profit_price`가 `NULL`이고 마이그레이션이 멱등하게 완료된다. +4. Given 매수 intent payload에 `stop_loss_price`와 `take_profit_price`를 포함하면, When `IntentService.submit_buy`를 호출하면, Then 해당 슬롯의 두 필드가 저장된다. +5. Given 매수 intent payload에 두 필드가 없으면, When `submit_buy`를 호출하면, Then 두 필드는 `None`으로 저장되고 오류가 발생하지 않는다. + +## Impacted Area + +- Docs: + - `docs/interface-contract.md` — intent RPC payload에 `stop_loss_price`, `take_profit_price` 선택 필드 추가 (REQ-042 참조) +- Modules: + - `trex_core/domain/slot/models.py` — `stop_loss_price`, `take_profit_price` 필드 추가 + - `trex_core/adapters/persistence/postgresql.py` — 두 필드 read/write 포함 + - `trex_core/adapters/persistence/sql/` — 마이그레이션 스크립트 추가 + - `trex_core/application/intents.py` — `IntentIn`(또는 `BuyIntent`)에 두 필드 추가 + - `trex_core/application/messaging_handlers.py` — intent RPC payload 파싱에 두 필드 추가 +- Tests: + - 슬롯 도메인 모델 단위 테스트 — 필드 기본값 및 직렬화 확인 + - persistence 통합 테스트 — 저장·조회 라운드트립 확인 + - 마이그레이션 멱등성 테스트 + +## Notes + +- `Decimal` 타입을 사용해 부동소수점 오차 없이 가격을 표현한다. +- `take_profit_price`와 `stop_loss_price` 값의 논리적 유효성(예: take_profit > stop_loss)은 이번 REQ 범위 밖이다. REQ 단계에서 추가 필요 시 change-req로 처리한다. +- 기존 `forced_close_at` 필드는 이미 존재하며 이 REQ에서 다루지 않는다. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-035 초안 생성 + +#### Intent + +- 가격 임계값 필드 추가 + DB 마이그레이션을 독립 Data REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~5 전체 + +#### Impacted Area Delta + +- Docs: interface-contract.md (REQ-042에서 처리) +- Modules: models.py, postgresql.py, sql 마이그레이션, intents.py, messaging_handlers.py +- Tests: 도메인 단위 + persistence 통합 + 마이그레이션 멱등성 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 diff --git a/docs/srs/Documentation/req-042_safety-sell-baseline-alignment.md b/docs/srs/Documentation/req-042_safety-sell-baseline-alignment.md new file mode 100644 index 0000000..d93a98c --- /dev/null +++ b/docs/srs/Documentation/req-042_safety-sell-baseline-alignment.md @@ -0,0 +1,151 @@ +# req-042: safety-sell baseline 문서 정합 + +- Status: Implemented +- Type: Documentation +- Priority: Medium +- Owner: project maintainers + +## Intent + +dcy-007에서 정의된 자율 안전 매도 기능(REQ-034~041)의 구현 완료 후 `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/project-structure.md` 세 baseline 문서를 최신 아키텍처와 정합시킨다. + +## Requirement + +- `docs/interface-contract.md`에 아래 항목을 반영해야 한다: + - intent RPC payload에 `stop_loss_price`, `take_profit_price` 선택 필드 추가 + - `update_thresholds` action 계약 (payload 구조, 응답 구조, holding-only 제약) + - snapshot RPC가 워치독 `last_seen_at` 갱신 신호임을 명시 +- `docs/runtime-flows.md`에 아래 흐름을 추가해야 한다: + - `per-slot 워치독 갱신` 흐름 (snapshot RPC handler → last_seen_at 갱신) + - `forced_close_at 만기 자동 매도` 흐름 (safety-sell worker → forced_close_at 체크 → sell()) + - `stop-loss·take-profit 가격 체크` 흐름 (KIS polling → 조건 평가 → sell()) + - `장 마감 강제 청산` 흐름 (TREX_MARKET_CLOSE_LIQUIDATION_TIME → 전체 holding 매도) + - `runtime-flows.md:114`의 "A-5 미구현" 주석을 dcy-007 REQ 목록 참조로 갱신 +- `docs/project-structure.md`에 아래 항목을 반영해야 한다: + - Primary Runtime 스레드 목록에 `trex-safety-sell` 추가 + - 신규 5종 env var(`TREX_WATCHDOG_TIMEOUT` 등) 목록 추가 + - 신규 어댑터/모듈(KIS 현재가 polling, watchdog store) 반영 + +## Acceptance Criteria + +1. Given `docs/interface-contract.md`를 읽을 때, When `update_thresholds` action을 찾으면, Then payload 구조(`slot_id`, `stop_loss_price?`, `take_profit_price?`, `forced_close_at?`)와 응답(`status: updated|ignored`)이 명시되어 있다. +2. Given `docs/interface-contract.md`를 읽을 때, When intent RPC payload 예시를 찾으면, Then `stop_loss_price`와 `take_profit_price`가 선택 필드로 포함되어 있다. +3. Given `docs/runtime-flows.md`를 읽을 때, When safety-sell 관련 흐름을 찾으면, Then 워치독 갱신·forced_close_at 체크·가격 체크·장 마감 청산 흐름이 각각 서술되어 있다. +4. Given `docs/runtime-flows.md:114`(또는 이에 해당하는 broker degrade 흐름)을 읽을 때, When "A-5 미구현" 주석을 찾으면, Then 해당 주석이 dcy-007 구현 REQ 참조로 갱신되어 있다. +5. Given `docs/project-structure.md`를 읽을 때, When Primary Runtime 스레드 목록을 찾으면, Then `trex-safety-sell`이 포함되어 있다. + +## Impacted Area + +- Docs: + - `docs/interface-contract.md` + - `docs/runtime-flows.md` + - `docs/project-structure.md` +- Modules: 없음 (문서 전용) +- Tests: 없음 + +## Notes + +- 이 REQ는 REQ-034~041 구현이 완료된 후 실행해야 한다. batch verification 단계에서 최신 구현 상태를 반영해 문서를 갱신한다. +- `runtime-flows.md:114`의 "A-5 미구현" 문구는 dcy-007 confirmed 시점에 이미 근거가 확보됐으므로, 구현 완료 전에도 "dcy-007 REQ-034~041로 트래킹" 형태로 미리 갱신할 수 있다. +- NFR-2(outbox retry) 관련 흐름은 기존 runtime-flows.md의 outbox 섹션에 흡수되어 있으므로 별도 추가 불필요. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-042 초안 생성 + +#### Intent + +- safety-sell 구현 후 세 baseline 문서 정합을 Documentation REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~5 전체 + +#### Impacted Area Delta + +- Docs: interface-contract.md, runtime-flows.md, project-structure.md + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 (REQ-034~041 구현 batch 이후 연계) + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-011 release-candidate). + +#### Intent + +- BAT-011 에서 세 baseline 문서 정합 완료(interface-contract: update_thresholds 계약 + price 선택 필드 + 워치독 신호; runtime-flows: 워치독 갱신/forced_close/가격 체크/장 마감 4종 흐름 + A-5 주석 갱신; project-structure: trex-safety-sell 스레드 + watchdog.py/safety_sell.py 모듈). AC-1~5 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-011 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-011 배포 편성. diff --git a/docs/srs/Integration/req-037_forced-close-at-safety-sell-loop.md b/docs/srs/Integration/req-037_forced-close-at-safety-sell-loop.md new file mode 100644 index 0000000..c9a84fe --- /dev/null +++ b/docs/srs/Integration/req-037_forced-close-at-safety-sell-loop.md @@ -0,0 +1,150 @@ +# req-037: forced_close_at 만기 자동 매도 루프 + +- Status: Implemented +- Type: Integration +- Priority: High +- Owner: project maintainers + +## Intent + +슬롯에 설정된 `forced_close_at` 만기 시각이 도달했을 때 데몬이 자율적으로 매도를 제출하는 백그라운드 루프를 추가한다. 워치독이 만료된 슬롯에 한해 동작하므로 전략 모듈이 정상 활성 중인 슬롯에는 개입하지 않는다(dcy-007 FR-1, NFR-1). + +## Requirement + +- 데몬은 `TREX_FORCED_CLOSE_CHECK_INTERVAL(초)` 주기로 `holding` 상태이며 **워치독이 만료된** 슬롯 중 `forced_close_at <= now(KST)`인 슬롯을 감지해야 한다. +- 조건에 해당하는 슬롯에 대해 `SlotService.sell()`(REQ-036)을 통해 내부 매도를 제출해야 한다. +- 이 루프는 daemon.py의 safety-sell worker 스레드에서 실행된다. 기존 6개 스레드에 safety-sell worker(`trex-safety-sell`)를 추가한다. +- `forced_close_at`이 `None`인 슬롯은 이 루프의 대상이 아니다. +- 워치독 활성(만료되지 않은) 슬롯은 이 루프의 대상이 아니다. + +## Acceptance Criteria + +1. Given `holding` 상태이고 워치독 만료이며 `forced_close_at = now() − 1s`인 슬롯 S가 있을 때, When checker 1회가 수행되면, Then S에 대해 `SlotService.sell()`이 호출된다. +2. Given `holding` 상태이고 워치독 만료이며 `forced_close_at = now() + 1hour`인 슬롯 S가 있을 때, When checker 1회가 수행되면, Then S에 대해 `sell()`이 호출되지 않는다. +3. Given `holding` 상태이고 워치독 **활성**이며 `forced_close_at = now() − 1s`인 슬롯 S가 있을 때, When checker 1회가 수행되면, Then S에 대해 `sell()`이 호출되지 않는다(워치독 보호). +4. Given `forced_close_at = None`인 슬롯 S가 있을 때, When checker 1회가 수행되면, Then S는 만기 체크 대상에서 제외된다. +5. Given 대상 슬롯이 이미 `pending_sell`일 때, When checker 1회가 수행되면, Then 중복 `sell()` 호출이 발생하지 않는다(REQ-036 idempotency 적용). +6. Given `TREX_FORCED_CLOSE_CHECK_INTERVAL=60`일 때, When daemon이 정상 실행 중이면, Then checker는 60±허용오차 초 주기로 반복 실행된다(NFR-1: 감지 지연 ≤ 60초). + +## Impacted Area + +- Docs: + - `docs/runtime-flows.md` — forced_close_at 만기 감지 루프 흐름 추가 (REQ-042 참조) + - `docs/project-structure.md` — `trex-safety-sell` 스레드 추가 반영 (REQ-042 참조) +- Modules: + - `trex_core/runtime/daemon.py` — `trex-safety-sell` worker 스레드 추가, `_safety_sell_loop()` 메서드 구현 + - `trex_core/domain/slot/service.py` — forced_close_at 조건 평가 메서드(또는 checker 내 인라인) +- Tests: + - forced_close_at checker 단위 테스트 — 만기 슬롯 감지 + sell() 호출 확인 + - 워치독 활성 슬롯 통과 확인 + - idempotency 확인 (pending_sell 슬롯 중복 처리 없음) + +## Notes + +- safety-sell worker(`trex-safety-sell`)는 forced_close_at checker(REQ-037), stop-loss·take-profit checker(REQ-038), 장 마감 청산(REQ-039)을 하나의 통합 루프로 묶어 처리할 수 있다. 구체적인 스레드 분리/통합 방식은 batch planning에서 결정한다. +- 체크 로직은 DB 조회(forced_close_at 필드)와 in-memory 워치독 store 비교만 수행하므로 KIS API 호출이 없다. 낮은 연산 비용. +- `TREX_FORCED_CLOSE_CHECK_INTERVAL` env var는 REQ-041 참조. +- `SlotService.sell()` 구현은 REQ-036 참조. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-037 초안 생성 + +#### Intent + +- forced_close_at 만기 자동 매도 루프(FR-1)를 독립 Integration REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~6 전체 + +#### Impacted Area Delta + +- Docs: runtime-flows.md, project-structure.md (REQ-042에서 처리) +- Modules: daemon.py (스레드 추가), service.py (forced_close 조건 평가) +- Tests: checker 단위 테스트 3종 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-011 release-candidate). + +#### Intent + +- BAT-011 verification.md 의 AC↔evidence(SafetySellService forced_close checker — 만기/미만기/워치독 보호/None 제외/idempotency/캐던스)로 AC-1~6 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-011 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-011 배포 편성. diff --git a/docs/srs/Integration/req-038_stop-loss-take-profit-kis-polling.md b/docs/srs/Integration/req-038_stop-loss-take-profit-kis-polling.md new file mode 100644 index 0000000..569cfe9 --- /dev/null +++ b/docs/srs/Integration/req-038_stop-loss-take-profit-kis-polling.md @@ -0,0 +1,155 @@ +# req-038: stop-loss·take-profit KIS 가격 polling + 트리거 + +- Status: Implemented +- Type: Integration +- Priority: High +- Owner: project maintainers + +## Intent + +워치독이 만료된 슬롯에 한해 KIS API(`TTTC8397R` 실전 / `VTTC8397R` 모의)로 현재가를 주기적으로 polling하고, `현재가 <= stop_loss_price`(손절) 또는 `현재가 >= take_profit_price`(익절) 조건 충족 시 자동 매도를 제출한다. 전략 모듈이 정상 활성 중인 슬롯에는 개입하지 않는다(dcy-007 FR-2, NFR-3, A-3). + +## Requirement + +- 데몬은 `TREX_STOP_LOSS_POLL_INTERVAL(초)` 주기로, `stop_loss_price` 또는 `take_profit_price`가 설정되고 **워치독이 만료된** `holding` 슬롯들의 현재가를 KIS `TTTC8397R`(실전) / `VTTC8397R`(모의) API로 polling해야 한다. +- 현재가 평가 기준값은 `stck_prpr`(직전 체결가) 필드를 사용한다. +- `현재가 <= stop_loss_price` 조건 충족 시 손절 매도를 제출해야 한다. +- `현재가 >= take_profit_price` 조건 충족 시 익절 매도를 제출해야 한다. +- 두 조건은 동일 polling 루프에서 평가한다. +- 워치독 활성(만료되지 않은) 슬롯과 임계값 미설정(`None`) 슬롯은 polling 대상이 아니다. +- KIS API 호출 상한은 `워치독 만료 대상 슬롯 수 × (3600 / TREX_STOP_LOSS_POLL_INTERVAL)`로 산정한다. 이 상한이 KIS 쿼터를 초과할 경우 polling 간격 조정 AC를 이 REQ의 Notes에 명시하고 REQ 단계에서 상한 경고를 구현한다. +- 자동 매도 제출은 `SlotService.sell()`(REQ-036)을 사용한다. + +## Acceptance Criteria + +1. Given `holding`, 워치독 만료, `stop_loss_price=Decimal("50000")`, `take_profit_price=None`인 슬롯 S가 있고 KIS polling 결과 `stck_prpr=49000`이면, When 가격 체크가 수행되면, Then S에 대해 `SlotService.sell()`이 호출된다(손절 트리거). +2. Given `holding`, 워치독 만료, `stop_loss_price=None`, `take_profit_price=Decimal("80000")`인 슬롯 S가 있고 KIS polling 결과 `stck_prpr=81000`이면, When 가격 체크가 수행되면, Then S에 대해 `sell()`이 호출된다(익절 트리거). +3. Given `holding`, 워치독 만료, `stop_loss_price=Decimal("50000")`, `take_profit_price=Decimal("80000")`인 슬롯 S가 있고 `stck_prpr=65000`이면, When 가격 체크가 수행되면, Then `sell()`이 호출되지 않는다(두 조건 모두 미충족). +4. Given `holding`, 워치독 **활성**인 슬롯 S가 있을 때, When 가격 polling이 수행되면, Then S에 대한 KIS API 호출이 발생하지 않는다(워치독 보호). +5. Given `stop_loss_price=None`이고 `take_profit_price=None`인 슬롯 S가 있을 때, When polling이 수행되면, Then S는 polling 대상에서 제외된다. +6. Given 이미 `pending_sell` 상태인 슬롯 S에 조건이 충족될 때, When 가격 체크가 수행되면, Then 중복 `sell()` 호출이 발생하지 않는다(REQ-036 idempotency). +7. Given 실전 모드(`broker=kis`)일 때, When 가격 polling을 수행하면, Then `TTTC8397R` endpoint를 사용한다. 모의 모드(`broker=paper`)일 때는 `VTTC8397R`을 사용한다. +8. Given `TREX_STOP_LOSS_POLL_INTERVAL=30`이고 워치독 만료 대상 슬롯이 N개일 때, When 정상 동작 중이면, Then 1시간당 KIS API 호출 수가 `N × 120`회를 넘지 않는다(NFR-3 쿼터 산정). + +## Impacted Area + +- Docs: + - `docs/runtime-flows.md` — stop-loss·take-profit 가격 체크 흐름 추가 (REQ-042 참조) + - `docs/interface-contract.md` — 현재가 polling 어댑터 계약 명시 (REQ-042 참조) +- Modules: + - 신규 `trex_core/adapters/broker/kis_price.py` 또는 `kis.py` 확장 — `TTTC8397R`/`VTTC8397R` 현재가 polling, `stck_prpr` 파싱 + - `trex_core/runtime/daemon.py` — stop-loss·take-profit 가격 체크를 safety-sell worker에 통합 + - `trex_core/domain/slot/service.py` — stop-loss·take-profit 조건 평가 메서드(또는 worker 내 인라인) +- Tests: + - KIS 현재가 polling 어댑터 단위 테스트 — `stck_prpr` 파싱 확인, 실전/모의 분기 확인 + - 가격 체크 단위 테스트(mock polling) — 손절/익절/미충족/워치독 보호 시나리오 + +## Notes + +- KIS 현재가 polling 어댑터는 기존 `trex_core/adapters/broker/kis.py`에 `get_current_price(symbol)` 메서드로 추가하거나, 별도 모듈로 분리한다. batch planning에서 결정한다. +- paper broker 모의 환경에서는 `VTTC8397R`를 사용하거나, stub 구현으로 현재가를 반환하는 방식을 batch에서 결정한다. +- KIS 쿼터 초과 방지: 워치독 만료 슬롯 수가 임계값을 넘으면 polling 간격 자동 조정 또는 경고 로그를 구현한다. 구체적인 쿼터 수치는 KIS API 문서 기준으로 batch 설계에서 확정한다. +- `TREX_STOP_LOSS_POLL_INTERVAL` env var는 REQ-041 참조. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-038 초안 생성 + +#### Intent + +- stop-loss·take-profit KIS 가격 polling + 트리거(FR-2)를 독립 Integration REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~8 전체 + +#### Impacted Area Delta + +- Docs: runtime-flows.md, interface-contract.md (REQ-042에서 처리) +- Modules: kis 현재가 polling 어댑터(신규/확장), daemon.py, service.py +- Tests: polling 어댑터 단위 + 가격 체크 시나리오 단위 (mock) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-011 release-candidate). + +#### Intent + +- BAT-011 verification.md 의 AC↔evidence(KIS `get_current_price` TTTC8397R/VTTC8397R·`stck_prpr` 파싱 + 손절/익절/미충족/워치독 보호/쿼터 산정/가격 미수신 skip)로 AC-1~8 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-011 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-011 배포 편성. diff --git a/docs/srs/Integration/req-039_market-close-liquidation.md b/docs/srs/Integration/req-039_market-close-liquidation.md new file mode 100644 index 0000000..51294e9 --- /dev/null +++ b/docs/srs/Integration/req-039_market-close-liquidation.md @@ -0,0 +1,147 @@ +# req-039: 장 마감 강제 청산 (TREX_MARKET_CLOSE_LIQUIDATION_TIME) + +- Status: Implemented +- Type: Integration +- Priority: High +- Owner: project maintainers + +## Intent + +`TREX_MARKET_CLOSE_LIQUIDATION_TIME`(HH:MM KST)이 설정된 경우, 해당 시각에 전략 모듈 활성 여부와 워치독 만료 여부에 관계없이 모든 `holding` 슬롯에 매도를 제출해 장 마감 전 자동 청산을 보장한다. 미설정 시에는 동작하지 않는다(dcy-007 FR-6). + +## Requirement + +- `TREX_MARKET_CLOSE_LIQUIDATION_TIME`(HH:MM KST 형식, optional)이 설정된 경우, 데몬은 해당 시각이 도래하면 워치독 만료 여부와 무관하게 모든 `holding` 슬롯에 `SlotService.sell()`(REQ-036)을 통해 매도를 제출해야 한다. +- `TREX_MARKET_CLOSE_LIQUIDATION_TIME`이 설정되지 않은 경우 이 기능은 동작하지 않는다. +- 장 마감 청산은 같은 거래일에 한 번만 발동해야 한다(중복 발동 방지). +- 청산 대상은 발동 시각의 `holding` 슬롯 전체이며 워치독 상태를 조건으로 사용하지 않는다. + +## Acceptance Criteria + +1. Given `TREX_MARKET_CLOSE_LIQUIDATION_TIME=15:10`이고 `holding` 슬롯이 N개 있을 때, When KST 15:10이 도래하면, Then N개 슬롯 모두에 `SlotService.sell()`이 호출된다. +2. Given `TREX_MARKET_CLOSE_LIQUIDATION_TIME=15:10`이고 워치독 **활성** 슬롯이 포함되어 있을 때, When KST 15:10이 도래하면, Then 워치독 활성 슬롯도 청산 대상에 포함된다(워치독 무관). +3. Given `TREX_MARKET_CLOSE_LIQUIDATION_TIME`이 설정되지 않았을 때, When 어느 시각이든, Then 장 마감 청산 루프가 매도를 제출하지 않는다. +4. Given 장 마감 청산이 같은 거래일 내에 이미 발동된 적이 있을 때, When 같은 거래일 내 청산 로직이 다시 평가되면, Then 중복 발동이 발생하지 않는다. +5. Given 이미 `pending_sell` 상태인 슬롯 S가 청산 대상에 포함될 때, When 장 마감 청산이 발동되면, Then S에 대한 중복 `sell()` 호출이 발생하지 않는다(REQ-036 idempotency). +6. Given grace period(REQ-034) 중에 `TREX_MARKET_CLOSE_LIQUIDATION_TIME`이 도래할 때, When 장 마감 청산이 발동되면, Then grace period 여부와 무관하게 청산이 실행된다(FR-6: 워치독 무관). + +## Impacted Area + +- Docs: + - `docs/runtime-flows.md` — 장 마감 강제 청산 흐름 추가 (REQ-042 참조) +- Modules: + - `trex_core/runtime/daemon.py` — safety-sell worker 내 장 마감 청산 로직 추가 + - `trex_core/runtime/config.py` — `TREX_MARKET_CLOSE_LIQUIDATION_TIME` 파싱 (REQ-041 참조) +- Tests: + - 장 마감 청산 단위 테스트(시각 mock) — 설정/미설정 분기, 전체 holding 청산 확인 + - 중복 발동 방지 테스트 + - grace period 무관 동작 테스트 + +## Notes + +- 장 마감 청산 발동은 safety-sell worker 루프(`trex-safety-sell`)에서 KST 시각을 주기적으로 체크하는 방식으로 구현한다. 발동 여부는 in-memory `last_liquidation_date`로 추적한다. +- 발동 시각 정밀도는 worker 루프 주기에 의존한다. `TREX_FORCED_CLOSE_CHECK_INTERVAL` 또는 별도 설정으로 루프 주기를 결정한다. batch에서 확정한다. +- `TREX_MARKET_CLOSE_LIQUIDATION_TIME` env var는 REQ-041 참조. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-039 초안 생성 + +#### Intent + +- 장 마감 강제 청산(FR-6)을 독립 Integration REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~6 전체 + +#### Impacted Area Delta + +- Docs: runtime-flows.md (REQ-042에서 처리) +- Modules: daemon.py (safety-sell worker 확장), config.py +- Tests: 단위 테스트 3종 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-011 release-candidate). + +#### Intent + +- BAT-011 verification.md 의 AC↔evidence(시각 도래 전체 holding 청산·워치독 무관·미설정 무동작·거래일 1회 가드·grace 무관·발동 전 무동작)로 AC-1~6 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-011 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-011 배포 편성. diff --git a/docs/srs/Interface/req-034_per-slot-watchdog.md b/docs/srs/Interface/req-034_per-slot-watchdog.md new file mode 100644 index 0000000..e967749 --- /dev/null +++ b/docs/srs/Interface/req-034_per-slot-watchdog.md @@ -0,0 +1,115 @@ +# req-034: per-slot 워치독 — snapshot RPC 기반 전략 모듈 활성 감지 + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent + +전략 모듈이 슬롯을 활성 관리 중인지 데몬이 감지하기 위해, snapshot RPC 응답 시 해당 슬롯들의 `last_seen_at` 타임스탬프를 in-memory 워치독 store에 기록한다. `TREX_WATCHDOG_TIMEOUT` 이상 갱신되지 않은 슬롯은 "전략 모듈 미감시" 상태로 판정해 자율 안전 매도 체크 대상이 된다(dcy-007 FR-5). + +## Requirement + +- 시스템은 `trex.snapshot.req` 요청을 처리할 때, 응답 envelope에 포함되는 `holding` 슬롯들의 `last_seen_at`을 in-memory 워치독 store(`dict[slot_id, datetime]`)에 현재 시각(UTC 또는 KST 일관 적용)으로 갱신해야 한다. +- 시스템은 `now() − last_seen_at > TREX_WATCHDOG_TIMEOUT(초)` 조건이 성립하는 슬롯을 "워치독 만료(watchdog-expired)"로 판정해야 한다. +- 데몬 기동 후 `TREX_WATCHDOG_GRACE_PERIOD(초)` 동안은 워치독 만료 판정을 유예해야 한다(grace period). grace period 중에는 어떤 슬롯도 만료로 판정하지 않는다. +- 워치독 store는 Postgres에 영속하지 않으며 데몬 재시작 시 초기화된다. + +## Acceptance Criteria + +1. Given `trex.snapshot.req` handler가 응답을 산출할 때, When 응답 슬롯 목록에 `slot_id = S`가 포함되면, Then `watchdog_store[S]`가 현재 시각으로 갱신된다. +2. Given `TREX_WATCHDOG_TIMEOUT=120`이고 slot S의 `watchdog_store[S]`가 121초 전일 때, When 워치독 만료 여부를 평가하면, Then S는 만료로 판정된다. +3. Given `TREX_WATCHDOG_TIMEOUT=120`이고 slot S의 `watchdog_store[S]`가 60초 전일 때, When 워치독 만료 여부를 평가하면, Then S는 만료로 판정되지 않는다. +4. Given 데몬이 기동된 지 `TREX_WATCHDOG_GRACE_PERIOD=60` 초 이내일 때, When 워치독 만료 평가를 수행하면, Then 모든 슬롯의 만료 판정이 유예된다(store 비어 있는 슬롯 포함). +5. Given 데몬 재시작이 발생하면, When 기동 직후 워치독 store를 조회하면, Then store는 비어 있다. +6. Given 전략 모듈이 `TREX_WATCHDOG_TIMEOUT / 2` 미만 주기로 snapshot RPC를 보낼 때, When 정상 동작 중이면, Then 해당 슬롯의 워치독이 만료되지 않는다(NFR-4 관계식 검증). + +## Impacted Area + +- Docs: + - `docs/runtime-flows.md` — 워치독 갱신 흐름 추가 (REQ-042 참조) + - `docs/interface-contract.md` — snapshot RPC가 watchdog 갱신 신호임을 명시 (REQ-042 참조) +- Modules: + - `trex_core/application/messaging_handlers.py` — `make_snapshot_rpc_handler` 확장: 응답 슬롯의 `last_seen_at` 갱신 + - `trex_core/application/portfolio.py` 또는 신규 `trex_core/application/watchdog.py` — WatchdogStore (`dict[slot_id, datetime]`), grace period 시작 타임스탬프 관리 +- Tests: + - `make_snapshot_rpc_handler` 단위 테스트 — `last_seen_at` 갱신 확인 + - 워치독 만료 판정 단위 테스트 — 만료/비만료/grace period 시나리오 + +## Notes + +- 워치독 store 갱신은 snapshot RPC 응답 슬롯 기준이다. intent RPC(매수/매도)는 갱신 신호가 아니다(`update_thresholds` 액션은 제외 — REQ-040 참조). +- NFR-4 권장 관계식: `snapshot RPC 호출 주기 < TREX_WATCHDOG_TIMEOUT / 2`. 기본값 기준 전략 모듈은 60초 이내 주기로 snapshot RPC를 호출해야 한다. 관계식 위반 시 정상 동작 슬롯에서도 워치독 오탐 발생. +- 워치독 store 구현 위치(portfolio.py 확장 vs 신규 watchdog.py)는 batch planning에서 결정한다. +- `TREX_WATCHDOG_TIMEOUT`, `TREX_WATCHDOG_GRACE_PERIOD` env var 파싱은 REQ-041 참조. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-034 초안 생성 + +#### Intent + +- per-slot 워치독 메커니즘(FR-5)을 독립 구현·검증 단위로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~6 전체 + +#### Impacted Area Delta + +- Docs: runtime-flows.md, interface-contract.md (REQ-042에서 처리) +- Modules: messaging_handlers.py, watchdog store 신규/확장 +- Tests: snapshot handler + 워치독 판정 단위 테스트 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 diff --git a/docs/srs/Interface/req-036_auto-sell-internal-path.md b/docs/srs/Interface/req-036_auto-sell-internal-path.md new file mode 100644 index 0000000..cc899d2 --- /dev/null +++ b/docs/srs/Interface/req-036_auto-sell-internal-path.md @@ -0,0 +1,112 @@ +# req-036: 자동 매도 내부 경로 — SlotService.sell + idempotency + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent + +자율 안전 매도 트리거가 RabbitMQ intent RPC를 거치지 않고 직접 슬롯 매도를 제출할 수 있도록 `SlotService.sell()` 내부 메서드를 추가한다. RabbitMQ 장애와 독립적으로 동작해야 하는 transport-independent 매도 경로다. 이미 `pending_sell` 상태인 슬롯에 대한 중복 트리거를 방지하는 idempotency도 함께 보장한다(dcy-007 FR-3, FR-4, A-1). + +## Requirement + +- `SlotService`는 `sell(slot_id, actor, reason)` 메서드를 제공해야 한다. 이 메서드는 `holding` 슬롯을 `pending_sell`로 전이시키고 기존 outbox 내구성·execution 루프 경로를 따른다. +- `sell()`은 내부 함수 직접 호출이며 RabbitMQ intent RPC를 거치지 않는다. +- 대상 슬롯이 이미 `pending_sell` 상태이면 `sell()`은 추가 전이 없이 조용히 반환하거나 멱등 응답을 반환해야 한다(중복 트리거 방지). +- 대상 슬롯이 `holding` 이 아니고 `pending_sell`도 아닌 상태이면 적절한 예외 또는 오류 응답을 반환한다. +- `sell()`을 통해 제출된 매도는 기존 `execution` worker의 `transit_slot(holding→pending_sell)` + broker dispatch + ledger 기록 경로를 재사용해야 한다. + +## Acceptance Criteria + +1. Given `holding` 상태의 슬롯 S가 있을 때, When `SlotService.sell(S, actor="safety-sell", reason="stop_loss")` 를 호출하면, Then S의 상태가 `pending_sell`로 전이된다. +2. Given `pending_sell` 상태의 슬롯 S가 있을 때, When `sell(S, ...)` 를 다시 호출하면, Then 슬롯 상태가 변경되지 않고 중복 매도 intent가 제출되지 않는다. +3. Given `idle` 상태의 슬롯 S가 있을 때, When `sell(S, ...)` 를 호출하면, Then 오류(예외 또는 rejected 응답)가 반환되고 슬롯 상태는 변경되지 않는다. +4. Given `sell()` 호출 성공 시, When execution worker가 이후 처리하면, Then 매도 주문이 broker에 제출되고 outbox 및 ledger 경로가 기존 intent RPC 경로와 동일하게 작동한다. +5. Given RabbitMQ transport가 불가용 상태일 때, When `sell()` 을 호출하면, Then transport 상태와 무관하게 `holding→pending_sell` 전이가 성공한다. + +## Impacted Area + +- Docs: 없음 (런타임 흐름 변경은 REQ-037~039에서 발생, baseline 문서 갱신은 REQ-042 참조) +- Modules: + - `trex_core/domain/slot/service.py` — `SlotService.sell(slot_id, actor, reason)` 메서드 추가 +- Tests: + - `SlotService.sell()` 단위 테스트 — `holding→pending_sell` 전이 확인 + - idempotency 테스트 — `pending_sell` 슬롯 중복 호출 시 무전이 확인 + - 잘못된 상태(`idle`) 슬롯 호출 시 오류 반환 확인 + +## Notes + +- `sell()`은 `transit_slot(slot_id, from_state=HOLDING, to_state=PENDING_SELL, ...)` 패턴 위에 구현한다. 기존 advisory lock + Postgres commit 보장이 그대로 적용된다. +- NFR-2(자동 매도 실패 시 outbox retry): broker 오류 발생 시 execution worker의 기존 retry 경로를 따른다. 별도 retry 로직 불필요. +- `actor` 값은 트리거 구분을 위해 `"safety-sell"`, `"forced-close"`, `"market-close"` 등으로 구체화하는 것을 권장한다. 정확한 값은 batch 설계에서 결정한다. +- 이 메서드는 dcy-007 FR-3의 "내부 자동 매도 trigger 경로"를 표준화한다. 이후 kill-switch Discovery(trex-core#3)에서도 재사용 가능하도록 격리한다. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-036 초안 생성 + +#### Intent + +- 자동 매도 내부 경로(FR-3, FR-4)를 독립 Interface REQ로 정규화 + +#### Acceptance Criteria Delta + +- Added: AC-1~5 전체 + +#### Impacted Area Delta + +- Modules: service.py (`sell()` 메서드 추가) +- Tests: 단위 테스트 3종 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 diff --git a/docs/srs/Interface/req-040_update-thresholds-intent-rpc.md b/docs/srs/Interface/req-040_update-thresholds-intent-rpc.md new file mode 100644 index 0000000..3b56bf9 --- /dev/null +++ b/docs/srs/Interface/req-040_update-thresholds-intent-rpc.md @@ -0,0 +1,151 @@ +# req-040: update_thresholds intent RPC 액션 + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent + +전략 모듈이 매수 이후에도 손절·익절 임계값과 강제 만기 시각을 갱신할 수 있도록 `trex.intent.req` 채널에 `action: "update_thresholds"` 메시지 액션을 추가한다. 수신 시 해당 슬롯의 `stop_loss_price`·`take_profit_price`·`forced_close_at`를 DB에 갱신하고, 해당 슬롯의 워치독 `last_seen_at`을 현재 시각으로 리셋해 전략 모듈 활성 신호로 활용한다(dcy-007 FR-7). + +## Requirement + +- 시스템은 `trex.intent.req` 채널의 `action: "update_thresholds"` 메시지를 처리해야 한다. +- 메시지 payload는 `{action: "update_thresholds", slot_id, stop_loss_price?, take_profit_price?, forced_close_at?}` 형식이며, 세 필드는 각각 독립적으로 포함하거나 생략할 수 있다. 생략된 필드는 현재 값을 유지한다. +- 처리 성공 시 요청한 필드를 DB에 갱신하고 해당 슬롯의 워치독 `last_seen_at`을 현재 시각으로 리셋해야 한다. +- 대상 슬롯이 `holding` 상태인 경우에만 갱신을 수행한다. +- 대상 슬롯이 `holding`이 아닌 경우의 처리(무시 또는 에러 응답)는 구현 시 결정한다(OQ-6 Deferred — 선택 옵션: `{status: ignored, reason: "not_holding"}` 응답 또는 silent discard). +- `reply_to`가 있으면 갱신 결과(`{status: updated|ignored, slot_id}`)를 응답으로 반환한다. + +## Acceptance Criteria + +1. Given `holding` 상태의 슬롯 S가 있을 때, When `{action: "update_thresholds", slot_id: S, stop_loss_price: 50000}`을 `trex.intent.req`에 발행하면, Then S의 `stop_loss_price`가 `50000`으로 DB에 갱신된다. +2. Given `holding` 상태의 슬롯 S가 있을 때, When `{action: "update_thresholds", slot_id: S, take_profit_price: 80000, forced_close_at: "2026-06-07T15:10:00+09:00"}`를 발행하면, Then S의 `take_profit_price`와 `forced_close_at`가 DB에 갱신된다. +3. Given `holding` 상태의 슬롯 S가 있을 때, When `update_thresholds`를 수신하면, Then S의 워치독 `last_seen_at`이 현재 시각으로 리셋된다. +4. Given `update_thresholds` 메시지에 `stop_loss_price`만 포함되고 `take_profit_price`와 `forced_close_at`는 생략될 때, When 처리되면, Then `stop_loss_price`만 갱신되고 나머지 두 필드는 기존 값을 유지한다. +5. Given `null` 값으로 `stop_loss_price: null`을 명시할 때, When 처리되면, Then `stop_loss_price`가 `NULL`로 갱신된다(임계값 해제). +6. Given 대상 슬롯이 `holding`이 아닌 상태일 때, When `update_thresholds`를 수신하면, Then 슬롯 필드가 변경되지 않는다. `reply_to`가 있으면 `{status: ignored, reason: "not_holding"}` 응답을 반환한다(OQ-6 구현 결정). +7. Given `reply_to`가 있는 요청에서 갱신이 성공하면, When 처리가 완료되면, Then `{status: updated, slot_id}`가 `reply_to`로 반환된다. + +## Impacted Area + +- Docs: + - `docs/interface-contract.md` — `update_thresholds` action 계약 추가 (REQ-042 참조) +- Modules: + - `trex_core/application/messaging_handlers.py` — `make_intent_rpc_handler` 확장: `update_thresholds` action 라우팅 + DB 갱신 + `last_seen_at` 리셋 + - `trex_core/domain/slot/service.py` — `SlotService.update_thresholds(slot_id, stop_loss_price, take_profit_price, forced_close_at)` 메서드 추가 +- Tests: + - `update_thresholds` handler 단위 테스트 — holding 슬롯 갱신 확인, last_seen_at 리셋 확인 + - 필드 부분 갱신 테스트 (일부만 포함된 payload) + - null 값 처리 테스트 + - non-holding 슬롯 무시 테스트 + +## Notes + +- OQ-6(Deferred → REQ AC): `holding`이 아닌 슬롯 요청 처리 방식. 구현 시 `{status: ignored, reason: "not_holding"}` 응답 반환을 권장한다. silent discard도 허용되며 batch에서 결정한다. +- `update_thresholds` 수신은 워치독 `last_seen_at` 리셋을 겸한다. 전략 모듈이 임계값을 갱신하면 활성 신호로 처리돼 그 시점부터 `TREX_WATCHDOG_TIMEOUT` 타이머가 재시작된다(REQ-034). +- 기존 intent RPC dedup(`intent_log.try_insert`) 적용 여부는 batch 설계에서 결정한다. `update_thresholds`는 상태 read-modify가 아닌 idempotent SET이므로 dedup 생략이 적절할 수 있다. + +## Change Log + +### CHG-20260607-01 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- dcy-007 confirmed → REQ-040 초안 생성 + +#### Intent + +- update_thresholds intent RPC 액션(FR-7)을 독립 Interface REQ로 정규화. OQ-6 Deferred 처리 방식을 AC-6에 명시. + +#### Acceptance Criteria Delta + +- Added: AC-1~7 전체 + +#### Impacted Area Delta + +- Docs: interface-contract.md (REQ-042에서 처리) +- Modules: messaging_handlers.py (handler 확장), service.py (`update_thresholds()` 추가) +- Tests: handler 단위 테스트 4종 + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-02 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Proposed → Approved (confirm-req) + +#### Intent + +- 승인 게이트 통과: 필수 필드·AC·Impacted Area·Change Log 완비 확인. dcy-007(confirmed) 범위 및 결정 사항과 모순 없음. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: none (미할당) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs 후 batch 배정 + +### CHG-20260607-03 + +- Date: 2026-06-07 +- Author: Justin Ko + +#### Change Summary + +- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-011 release-candidate). + +#### Intent + +- BAT-011 verification.md 의 AC↔evidence(`update_thresholds` 부분 갱신·null 해제·last_seen_at 리셋·non-holding ignored·미존재 slot_not_found·updated 응답)로 AC-1~7 충족 → 구현 완료 동기화. OQ-6 은 AC-6 의 `{ignored, not_holding}` 응답으로 확정 구현. + +#### Acceptance Criteria Delta + +- (변경 없음) + +#### Impacted Area Delta + +- (변경 없음) + +#### Delivery Trace Delta + +- Batches: BAT-011 (release-candidate) +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: draft-release 로 BAT-011 배포 편성. diff --git a/docs/srs/index.md b/docs/srs/index.md index 785bfe4..6d8c4b4 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-034` +- Current: `REQ-043` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -114,11 +114,20 @@ | REQ-031 | 포지션 정합 검증 + warning 마스킹 | Exception | Implemented | High | project maintainers | [req-031](./Exception/req-031_position-reconciliation-and-warning.md) | | REQ-032 | 런타임 pause/resume 제어 | Interface | Implemented | Medium | project maintainers | [req-032](./Interface/req-032_runtime-pause-resume-control.md) | | REQ-033 | 스냅샷 read 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-033](./Documentation/req-033_snapshot-read-baseline-alignment.md) | +| REQ-034 | per-slot 워치독 — snapshot RPC 기반 전략 모듈 활성 감지 | Interface | Implemented | High | project maintainers | [req-034](./Interface/req-034_per-slot-watchdog.md) | +| REQ-035 | 슬롯 가격 임계값 도메인 필드 + DB 마이그레이션 | Data | Implemented | High | project maintainers | [req-035](./Data/req-035_slot-price-threshold-fields-and-migration.md) | +| REQ-036 | 자동 매도 내부 경로 — SlotService.sell + idempotency | Interface | Implemented | High | project maintainers | [req-036](./Interface/req-036_auto-sell-internal-path.md) | +| REQ-037 | forced_close_at 만기 자동 매도 루프 | Integration | Implemented | High | project maintainers | [req-037](./Integration/req-037_forced-close-at-safety-sell-loop.md) | +| REQ-038 | stop-loss·take-profit KIS 가격 polling + 트리거 | Integration | Implemented | High | project maintainers | [req-038](./Integration/req-038_stop-loss-take-profit-kis-polling.md) | +| REQ-039 | 장 마감 강제 청산 (TREX_MARKET_CLOSE_LIQUIDATION_TIME) | Integration | Implemented | High | project maintainers | [req-039](./Integration/req-039_market-close-liquidation.md) | +| REQ-040 | update_thresholds intent RPC 액션 | Interface | Implemented | High | project maintainers | [req-040](./Interface/req-040_update-thresholds-intent-rpc.md) | +| REQ-041 | safety-sell 환경 설정 — 5종 env var | Configuration | Implemented | High | project maintainers | [req-041](./Configuration/req-041_safety-sell-env-config.md) | +| REQ-042 | safety-sell baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-042](./Documentation/req-042_safety-sell-baseline-alignment.md) | ## Recent Change Log Summary +- 2026-06-07 :: REQ-037, REQ-038, REQ-039, REQ-040, REQ-042 Approved → Implemented (confirm-req-implemented): BAT-011 verify 완료(신규 38 테스트 green, 단위 236 passed, 인프라 게이트 259 passed, ruff clean). SafetySellService(forced_close/SL·TP KIS polling/장 마감), `BrokerPort.get_current_price`(TTTC8397R/VTTC8397R), `SlotService.update_thresholds` + intent RPC 액션, `trex-safety-sell` 7번째 스레드, baseline 3문서 정합 확인. +- 2026-06-07 :: REQ-034, REQ-035, REQ-036, REQ-041 Approved → Implemented (confirm-req-implemented): BAT-010 verify 완료(32 신규 테스트 green, 95 회귀 passed). WatchdogStore, stop_loss/take_profit 도메인 필드, SQL 마이그레이션, SlotService.sell(), SafetySellConfig env vars 5종 구현 확인. +- 2026-06-07 :: REQ-034~042 Proposed → Approved (confirm-req): 9개 전원 승인 게이트 통과. dcy-007(confirmed) 범위·결정과 모순 없음, 필수 필드·AC·Impacted Area·Change Log 완비 확인. (confirm-req) +- 2026-06-07 :: REQ-034~042 생성 — dcy-007(core 자율 안전 매도: stop-loss·take-profit·시간 만기 자동 트리거) confirmed → 9개 REQ 초안. 워치독(034), 가격 임계값 필드(035), 자동 매도 경로(036), forced_close_at 루프(037), KIS 가격 polling(038), 장 마감 청산(039), update_thresholds RPC(040), env 설정(041), baseline 문서 정합(042). (draft-req) - 2026-06-06 :: REQ-010 CHG-20260606-02 — Status Approved → Implemented (confirm-req-implemented, 근거 BAT-009 release-candidate). AC-1~4 BAT-003 회귀 + AC-5 단위 2개·e2e(거부/지연/부분) evidence 충족. (confirm-req-implemented) -- 2026-06-06 :: REQ-010 CHG-20260606-01 — paper broker 튜닝 knob(REQ-011 AC-2 지연/부분/거부)을 데몬 env(`TREX_PAPER_FILL_AFTER_POLLS`/`QTY_PER_POLL`/`REJECT_SYMBOLS`)로 노출(AC-5 신설). 미설정=기존 동작 byte-for-byte. 신규 AC 한정 Implemented → Approved(BAT-009 batch-lite verification 대기). REL-001 의 AC-1~4 coverage 는 유효. (change-req) -- 2026-06-06 :: REQ-017 CHG-20260606-01 — 실 portfolio-hub ledger write 계약 정정(`POST /v1/portfolios/{portfolio_id}/ledger/trades`, body `quantity`/`fee_amount`/`tax_amount`, 성공 201). 구 orbit 가정(`/v1/assets/{asset_id}`, `qty`/`fee`/`tax`) 폐기. 정정 구현 재검증 완료(unit 16 + 실 e2e 201)·Implemented 유지. REL-001 ledger blocker 해소. (change-req) -- 2026-06-06 :: REQ-027 CHG-04 — 실 portfolio-hub 계약 정정(whoami `/v1/user_info`, `{portfolios}` envelope, 클라이언트측 daemon 필터, 전략 필드 None/`{}` 이월). 정정 구현 재검증 완료·Implemented 유지. (change-req) -- 2026-06-06 :: REQ-029 CHG-04 — orb-v1 소비 참고 사항 추가(strategy_key 없이 view=buyable, 전략 필드 부재 무해). 문서 전용·Implemented 유지. (change-req) diff --git a/tests/adapters/broker/test_kis_price.py b/tests/adapters/broker/test_kis_price.py new file mode 100644 index 0000000..d10ae49 --- /dev/null +++ b/tests/adapters/broker/test_kis_price.py @@ -0,0 +1,100 @@ +"""KIS current-price adapter unit tests (REQ-038 / BAT-011). + +Network is monkeypatched — these run always (no KIS credentials needed). +""" +from __future__ import annotations + +from decimal import Decimal + +import httpx + +from trex_core.adapters.broker.kis import KISBroker +from trex_core.adapters.broker.paper import PaperBroker + +_MOCK_URL = "https://openapivts.koreainvestment.com:29443" +_LIVE_URL = "https://openapi.koreainvestment.com:9443" + + +class _FakeResp: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def _mock_broker(): + return KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) + + +def _live_broker(): + return KISBroker("12345678-01", app_key="k", app_secret="s", + base_url=_LIVE_URL, allow_live=True) + + +def test_tr_price_live_vs_mock(): + """AC-7: live uses TTTC8397R, mock uses VTTC8397R.""" + assert _live_broker()._tr_price() == "TTTC8397R" + assert _mock_broker()._tr_price() == "VTTC8397R" + + +def test_get_current_price_parses_stck_prpr(monkeypatch): + """get_current_price parses output.stck_prpr into a Decimal.""" + captured = {} + + def fake_get(url, headers=None, params=None, timeout=None): + captured["url"] = url + captured["tr_id"] = headers["tr_id"] + captured["params"] = params + return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "49000"}}) + + b = _mock_broker() + monkeypatch.setattr(b, "_ensure_token", lambda: "tok") + monkeypatch.setattr(httpx, "get", fake_get) + price = b.get_current_price("005930") + assert price == Decimal("49000") + assert captured["tr_id"] == "VTTC8397R" + assert captured["params"]["FID_INPUT_ISCD"] == "005930" + + +def test_get_current_price_network_error_returns_none(monkeypatch): + """Network error → None (worker skips the slot, no false trigger).""" + def boom(*a, **kw): + raise httpx.ConnectError("down") + + b = _mock_broker() + monkeypatch.setattr(b, "_ensure_token", lambda: "tok") + monkeypatch.setattr(httpx, "get", boom) + assert b.get_current_price("005930") is None + + +def test_get_current_price_error_rt_cd_returns_none(monkeypatch): + """Non-zero rt_cd → None.""" + def fake_get(*a, **kw): + return _FakeResp({"rt_cd": "1", "msg1": "error"}) + + b = _mock_broker() + monkeypatch.setattr(b, "_ensure_token", lambda: "tok") + monkeypatch.setattr(httpx, "get", fake_get) + assert b.get_current_price("005930") is None + + +def test_get_current_price_missing_field_returns_none(monkeypatch): + """Missing/blank stck_prpr → None.""" + def fake_get(*a, **kw): + return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": ""}}) + + b = _mock_broker() + monkeypatch.setattr(b, "_ensure_token", lambda: "tok") + monkeypatch.setattr(httpx, "get", fake_get) + assert b.get_current_price("005930") is None + + +class TestPaperCurrentPrice: + def test_unset_returns_none(self): + assert PaperBroker().get_current_price("005930") is None + + def test_set_and_get(self): + b = PaperBroker() + b.set_current_price("005930", Decimal("51000")) + assert b.get_current_price("005930") == Decimal("51000") diff --git a/tests/application/test_safety_sell_worker.py b/tests/application/test_safety_sell_worker.py new file mode 100644 index 0000000..60796e2 --- /dev/null +++ b/tests/application/test_safety_sell_worker.py @@ -0,0 +1,296 @@ +"""Safety-sell trigger service unit tests (REQ-037/038/039 / BAT-011).""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from trex_core.application.safety_sell import SafetySellService +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 +from trex_core.runtime.config import SafetySellConfig + +_DAY = date(2026, 6, 7) +# 2026-06-07 06:00Z == 15:00 KST +_NOW = datetime(2026, 6, 7, 6, 0, 0, tzinfo=timezone.utc) + + +class FakeBroker: + def __init__(self): + self.prices: dict[str, Decimal] = {} + self.calls: list[str] = [] + + def name(self): + return "fake" + + def submit_order(self, req): ... + def poll_fill(self, *a, **k): ... + def cancel_order(self, *a, **k): ... + def get_balance(self): + return [] + + def get_current_price(self, symbol): + self.calls.append(symbol) + return self.prices.get(symbol) + + +@pytest.fixture +def repo(): + r = InMemorySlotRepository() + return r + + +@pytest.fixture +def service(repo): + svc = SlotService(repo) + svc.preallocate_daily_slots(_DAY, 5) + return svc + + +@pytest.fixture +def broker(): + return FakeBroker() + + +@pytest.fixture +def watchdog(): + return WatchdogStore() + + +def _cfg(**over): + base = dict( + watchdog_timeout_sec=120, + watchdog_grace_period_sec=0, # disable grace unless a test opts in + forced_close_check_interval_sec=60, + stop_loss_poll_interval_sec=30, + market_close_liquidation_time=None, + ) + base.update(over) + return SafetySellConfig(**base) + + +def _make_holding(service, repo, *, symbol="005930", forced_close_at=None, + stop_loss_price=None, take_profit_price=None): + idle = repo.find_idle_slot(_DAY) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol=symbol, + forced_close_at=forced_close_at, + stop_loss_price=stop_loss_price, + take_profit_price=take_profit_price, + ) + return service.transit_slot( + pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=50000.0, qty=1.0, + ) + + +def _svc(repo, service, broker, watchdog, cfg): + return SafetySellService( + slot_repo=repo, slot_service=service, broker=broker, + watchdog=watchdog, config=cfg, + ) + + +# ----------------------------------------------------------- REQ-037 forced_close +class TestForcedClose: + def test_expired_past_due_sells(self, repo, service, broker, watchdog): + """AC-1: holding + watchdog-expired + forced_close_at past → sell.""" + slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1)) + s = _svc(repo, service, broker, watchdog, _cfg()) + fired = s.check_forced_close(_NOW) + assert slot.slot_id in fired + assert repo.fetch_slot(slot.slot_id).status is SlotState.PENDING_SELL + + def test_future_not_due_no_sell(self, repo, service, broker, watchdog): + """AC-2: forced_close_at in the future → no sell.""" + slot = _make_holding(service, repo, forced_close_at=_NOW + timedelta(hours=1)) + s = _svc(repo, service, broker, watchdog, _cfg()) + assert s.check_forced_close(_NOW) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_watchdog_active_protected(self, repo, service, broker, watchdog): + """AC-3: watchdog active (strategy present) → no sell even if past due.""" + slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1)) + watchdog.update(str(slot.slot_id)) # strategy module seen → active + s = _svc(repo, service, broker, watchdog, _cfg()) + assert s.check_forced_close(_NOW) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_no_forced_close_excluded(self, repo, service, broker, watchdog): + """AC-4: forced_close_at None → excluded.""" + slot = _make_holding(service, repo, forced_close_at=None) + s = _svc(repo, service, broker, watchdog, _cfg()) + assert s.check_forced_close(_NOW) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_idempotent_on_pending_sell(self, repo, service, broker, watchdog): + """AC-5: a second pass does not add a second transition.""" + slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1)) + s = _svc(repo, service, broker, watchdog, _cfg()) + s.check_forced_close(_NOW) + s.check_forced_close(_NOW) # again + log = repo.transition_log_for(slot.slot_id) + assert len([e for e in log if e.to_state is SlotState.PENDING_SELL]) == 1 + + def test_grace_period_defers(self, repo, service, broker, watchdog): + """grace period → no forced_close trigger (REQ-034 protection).""" + slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1)) + s = _svc(repo, service, broker, watchdog, _cfg(watchdog_grace_period_sec=3600)) + assert s.check_forced_close(_NOW) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + +# ------------------------------------------------ REQ-038 stop-loss / take-profit +class TestPriceTriggers: + def test_stop_loss_triggers(self, repo, service, broker, watchdog): + """AC-1: price <= stop_loss → sell.""" + slot = _make_holding(service, repo, stop_loss_price=Decimal("50000")) + broker.prices["005930"] = Decimal("49000") + s = _svc(repo, service, broker, watchdog, _cfg()) + fired = s.check_prices(_NOW) + assert slot.slot_id in fired + assert repo.fetch_slot(slot.slot_id).status is SlotState.PENDING_SELL + + def test_take_profit_triggers(self, repo, service, broker, watchdog): + """AC-2: price >= take_profit → sell.""" + slot = _make_holding(service, repo, take_profit_price=Decimal("80000")) + broker.prices["005930"] = Decimal("81000") + s = _svc(repo, service, broker, watchdog, _cfg()) + assert slot.slot_id in s.check_prices(_NOW) + assert repo.fetch_slot(slot.slot_id).status is SlotState.PENDING_SELL + + def test_neither_condition_no_sell(self, repo, service, broker, watchdog): + """AC-3: price between thresholds → no sell.""" + slot = _make_holding(service, repo, stop_loss_price=Decimal("50000"), + take_profit_price=Decimal("80000")) + broker.prices["005930"] = Decimal("65000") + s = _svc(repo, service, broker, watchdog, _cfg()) + assert s.check_prices(_NOW) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_watchdog_active_no_kis_call(self, repo, service, broker, watchdog): + """AC-4: watchdog active → no KIS price call at all.""" + slot = _make_holding(service, repo, stop_loss_price=Decimal("50000")) + watchdog.update(str(slot.slot_id)) + s = _svc(repo, service, broker, watchdog, _cfg()) + s.check_prices(_NOW) + assert broker.calls == [] # never polled + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_no_thresholds_excluded(self, repo, service, broker, watchdog): + """AC-5: both thresholds None → excluded (no poll).""" + _make_holding(service, repo) + s = _svc(repo, service, broker, watchdog, _cfg()) + assert s.check_prices(_NOW) == [] + assert broker.calls == [] + + def test_idempotent_on_pending_sell(self, repo, service, broker, watchdog): + """AC-6: second pass adds no second transition.""" + slot = _make_holding(service, repo, stop_loss_price=Decimal("50000")) + broker.prices["005930"] = Decimal("49000") + s = _svc(repo, service, broker, watchdog, _cfg()) + s.check_prices(_NOW) + s.check_prices(_NOW) + log = repo.transition_log_for(slot.slot_id) + assert len([e for e in log if e.to_state is SlotState.PENDING_SELL]) == 1 + + def test_quota_estimate(self, repo, service, broker, watchdog): + """AC-8: hourly call upper bound = N * (3600/interval); interval=30 → N*120.""" + s = _svc(repo, service, broker, watchdog, _cfg(stop_loss_poll_interval_sec=30)) + assert s.estimated_hourly_kis_calls(3) == 360 # 3 * 120 + + def test_only_eligible_slots_polled(self, repo, service, broker, watchdog): + """AC-8: only watchdog-expired threshold slots are polled.""" + active = _make_holding(service, repo, symbol="A", stop_loss_price=Decimal("1")) + _make_holding(service, repo, symbol="B", stop_loss_price=Decimal("1")) # expired + _make_holding(service, repo, symbol="C") # no thresholds + watchdog.update(str(active.slot_id)) # A active + broker.prices.update({"A": Decimal("100"), "B": Decimal("100"), "C": Decimal("100")}) + s = _svc(repo, service, broker, watchdog, _cfg()) + s.check_prices(_NOW) + assert broker.calls == ["B"] # only the expired threshold slot + + def test_price_unavailable_skips(self, repo, service, broker, watchdog): + """price None (KIS error) → no false trigger.""" + slot = _make_holding(service, repo, stop_loss_price=Decimal("50000")) + # broker.prices has no entry → returns None + s = _svc(repo, service, broker, watchdog, _cfg()) + assert s.check_prices(_NOW) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + +# ------------------------------------------------ REQ-039 market-close liquidation +class TestMarketClose: + def test_time_reached_liquidates_all(self, repo, service, broker, watchdog): + """AC-1: at the configured KST time, all holding slots are sold.""" + s1 = _make_holding(service, repo, symbol="A") + s2 = _make_holding(service, repo, symbol="B") + now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc) # 15:10 KST + s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10")) + fired = s.check_market_close(now) + assert set(fired) == {s1.slot_id, s2.slot_id} + assert repo.fetch_slot(s1.slot_id).status is SlotState.PENDING_SELL + assert repo.fetch_slot(s2.slot_id).status is SlotState.PENDING_SELL + + def test_watchdog_active_included(self, repo, service, broker, watchdog): + """AC-2: watchdog-active slots are liquidated too (watchdog-independent).""" + slot = _make_holding(service, repo) + watchdog.update(str(slot.slot_id)) # active — but market-close ignores this + now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc) + s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10")) + assert slot.slot_id in s.check_market_close(now) + + def test_not_configured_no_sell(self, repo, service, broker, watchdog): + """AC-3: unset → never liquidates.""" + slot = _make_holding(service, repo) + now = datetime(2026, 6, 7, 8, 0, tzinfo=timezone.utc) + s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time=None)) + assert s.check_market_close(now) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_before_time_no_sell(self, repo, service, broker, watchdog): + slot = _make_holding(service, repo) + now = datetime(2026, 6, 7, 6, 0, tzinfo=timezone.utc) # 15:00 < 15:10 + s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10")) + assert s.check_market_close(now) == [] + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_once_per_day_guard(self, repo, service, broker, watchdog): + """AC-4: second evaluation on the same trading day does not re-fire.""" + _make_holding(service, repo, symbol="A") + now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc) + s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10")) + s.check_market_close(now) + # new holding appears after liquidation — must NOT be re-liquidated same day + s2 = _make_holding(service, repo, symbol="B") + assert s.check_market_close(now + timedelta(minutes=5)) == [] + assert repo.fetch_slot(s2.slot_id).status is SlotState.HOLDING + + def test_grace_period_does_not_block(self, repo, service, broker, watchdog): + """AC-6: market-close fires even during the grace period.""" + slot = _make_holding(service, repo) + now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc) + s = _svc(repo, service, broker, watchdog, + _cfg(market_close_liquidation_time="15:10", watchdog_grace_period_sec=3600)) + assert slot.slot_id in s.check_market_close(now) + + +# ----------------------------------------------------------------- run_once cadence +class TestRunOnce: + def test_run_once_fires_all_checks_first_tick(self, repo, service, broker, watchdog): + """First run_once runs forced_close + prices (last-run None).""" + fc = _make_holding(service, repo, symbol="A", forced_close_at=_NOW - timedelta(seconds=1)) + sl = _make_holding(service, repo, symbol="B", stop_loss_price=Decimal("50000")) + broker.prices["B"] = Decimal("49000") + s = _svc(repo, service, broker, watchdog, _cfg()) + s.run_once(_NOW) + assert repo.fetch_slot(fc.slot_id).status is SlotState.PENDING_SELL + assert repo.fetch_slot(sl.slot_id).status is SlotState.PENDING_SELL + + def test_base_tick_is_finest_interval(self, repo, service, broker, watchdog): + s = _svc(repo, service, broker, watchdog, + _cfg(forced_close_check_interval_sec=60, stop_loss_poll_interval_sec=30)) + assert s.base_tick_sec == 30.0 diff --git a/tests/application/test_snapshot_rpc_watchdog.py b/tests/application/test_snapshot_rpc_watchdog.py new file mode 100644 index 0000000..c54c5b6 --- /dev/null +++ b/tests/application/test_snapshot_rpc_watchdog.py @@ -0,0 +1,96 @@ +"""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"): + """Preallocate + buy one slot into HOLDING state.""" + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + slot.slot_id, SlotState.IDLE, 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 — idle slots are ignored.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + watchdog = WatchdogStore() + handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog) + handler(_make_req(trading_day)) + assert str(idle.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.""" + service.preallocate_daily_slots(trading_day, 3) + slots = [] + for sym in ("A", "B", "C"): + slot = repo.find_idle_slot(trading_day) + p = service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol=sym, + ) + h = service.transit_slot( + p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=100.0, qty=1.0, + ) + slots.append(h) + watchdog = WatchdogStore() + handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog) + handler(_make_req(trading_day)) + for s in slots: + assert str(s.slot_id) in watchdog._store, f"{s.slot_id} not in watchdog" diff --git a/tests/application/test_update_thresholds_handler.py b/tests/application/test_update_thresholds_handler.py new file mode 100644 index 0000000..c471f59 --- /dev/null +++ b/tests/application/test_update_thresholds_handler.py @@ -0,0 +1,135 @@ +"""update_thresholds intent RPC handler tests (REQ-040 / BAT-011).""" +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal + +import pytest + +from trex_core.application.intents import IntentService +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import make_intent_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 + +_DAY = date(2026, 6, 7) + + +@pytest.fixture +def repo(): + return InMemorySlotRepository() + + +@pytest.fixture +def service(repo): + svc = SlotService(repo) + svc.preallocate_daily_slots(_DAY, 3) + return svc + + +@pytest.fixture +def watchdog(): + return WatchdogStore() + + +@pytest.fixture +def handler(repo, service, watchdog): + intent_service = IntentService(service, repo, repo._intent_store if hasattr(repo, "_intent_store") else _StubIntentStore()) + return make_intent_rpc_handler(intent_service, slot_service=service, watchdog_store=watchdog) + + +class _StubIntentStore: + def try_insert(self, *a, **k): + return True + + +def _holding(service, repo, *, symbol="005930", stop_loss_price=None, + take_profit_price=None, forced_close_at=None): + idle = repo.find_idle_slot(_DAY) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol=symbol, + stop_loss_price=stop_loss_price, take_profit_price=take_profit_price, + forced_close_at=forced_close_at, + ) + return service.transit_slot( + pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=50000.0, qty=1.0, + ) + + +def _req(**body): + return RpcRequest(payload={"action": "update_thresholds", **body}, correlation_id="c1") + + +def test_updates_stop_loss(handler, service, repo): + """AC-1: stop_loss_price is updated in the slot.""" + slot = _holding(service, repo) + resp = handler(_req(slot_id=str(slot.slot_id), stop_loss_price=50000)) + assert resp.payload["status"] == "updated" + assert repo.fetch_slot(slot.slot_id).stop_loss_price == Decimal("50000") + + +def test_updates_take_profit_and_forced_close(handler, service, repo): + """AC-2: take_profit_price and forced_close_at updated together.""" + slot = _holding(service, repo) + resp = handler(_req( + slot_id=str(slot.slot_id), + take_profit_price=80000, + forced_close_at="2026-06-07T15:10:00+09:00", + )) + assert resp.payload["status"] == "updated" + updated = repo.fetch_slot(slot.slot_id) + assert updated.take_profit_price == Decimal("80000") + assert updated.forced_close_at == datetime.fromisoformat("2026-06-07T15:10:00+09:00") + + +def test_resets_watchdog_last_seen(handler, service, repo, watchdog): + """AC-3: update_thresholds resets the watchdog last_seen_at.""" + slot = _holding(service, repo) + assert str(slot.slot_id) not in watchdog._store + handler(_req(slot_id=str(slot.slot_id), stop_loss_price=50000)) + assert str(slot.slot_id) in watchdog._store + + +def test_partial_update_keeps_other_fields(handler, service, repo): + """AC-4: omitted fields keep their current value.""" + slot = _holding(service, repo, take_profit_price=Decimal("90000")) + handler(_req(slot_id=str(slot.slot_id), stop_loss_price=50000)) + updated = repo.fetch_slot(slot.slot_id) + assert updated.stop_loss_price == Decimal("50000") + assert updated.take_profit_price == Decimal("90000") # untouched + + +def test_explicit_null_clears(handler, service, repo): + """AC-5: explicit null clears the threshold.""" + slot = _holding(service, repo, stop_loss_price=Decimal("50000")) + handler(_req(slot_id=str(slot.slot_id), stop_loss_price=None)) + assert repo.fetch_slot(slot.slot_id).stop_loss_price is None + + +def test_non_holding_ignored(handler, service, repo, watchdog): + """AC-6: non-holding slot → ignored + not_holding reason, no watchdog reset.""" + service.preallocate_daily_slots(_DAY, 3) + idle = repo.find_idle_slot(_DAY) + resp = handler(_req(slot_id=str(idle.slot_id), stop_loss_price=50000)) + assert resp.payload["status"] == "ignored" + assert resp.payload["reason"] == "not_holding" + assert str(idle.slot_id) not in watchdog._store + + +def test_reply_updated_status(handler, service, repo): + """AC-7: success returns {status: updated, slot_id}.""" + slot = _holding(service, repo) + resp = handler(_req(slot_id=str(slot.slot_id), forced_close_at="2026-06-07T15:10:00+09:00")) + assert resp.payload == {"status": "updated", "slot_id": str(slot.slot_id)} + + +def test_unknown_slot_ignored(handler): + """Unknown slot → ignored + slot_not_found reason.""" + from uuid import uuid4 + sid = uuid4() + resp = handler(_req(slot_id=str(sid), stop_loss_price=50000)) + assert resp.payload["status"] == "ignored" + assert resp.payload["reason"] == "slot_not_found" diff --git a/tests/application/test_watchdog.py b/tests/application/test_watchdog.py new file mode 100644 index 0000000..ceaa188 --- /dev/null +++ b/tests/application/test_watchdog.py @@ -0,0 +1,103 @@ +"""WatchdogStore unit tests (REQ-034 / BAT-010).""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + + +from trex_core.application.watchdog import WatchdogStore + + +def _now(): + return datetime.now(tz=timezone.utc) + + +class TestWatchdogUpdate: + def test_update_marks_slot_as_seen(self): + """AC-1: update(slot_id) records current time in store.""" + store = WatchdogStore() + before = _now() + store.update("slot-A") + after = _now() + last = store._store.get("slot-A") + assert last is not None + assert before <= last <= after + + def test_update_overwrites_previous_timestamp(self): + """update() on already-seen slot refreshes the timestamp.""" + store = WatchdogStore() + store.update("slot-A") + first = store._store["slot-A"] + store.update("slot-A") + second = store._store["slot-A"] + assert second >= first + + +class TestIsExpired: + def test_never_seen_slot_is_expired(self): + """AC-2: slot never updated → is_expired returns True.""" + store = WatchdogStore() + assert store.is_expired("slot-X", timeout_sec=120) is True + + def test_recently_updated_slot_not_expired(self): + """AC-3: slot updated now → not expired with generous timeout.""" + store = WatchdogStore() + store.update("slot-A") + assert store.is_expired("slot-A", timeout_sec=120) is False + + def test_old_update_is_expired(self): + """AC-2: slot last updated 200s ago is expired with 120s timeout.""" + store = WatchdogStore() + old_time = _now() - timedelta(seconds=200) + store._store["slot-A"] = old_time + assert store.is_expired("slot-A", timeout_sec=120) is True + + def test_just_within_timeout_not_expired(self): + """Slot updated 100s ago is NOT expired with 120s timeout.""" + store = WatchdogStore() + recent = _now() - timedelta(seconds=100) + store._store["slot-A"] = recent + assert store.is_expired("slot-A", timeout_sec=120) is False + + +class TestGracePeriod: + def test_in_grace_period_immediately_after_init(self): + """AC-4: just-created store is in grace period for 60s.""" + store = WatchdogStore() + assert store.in_grace_period(grace_sec=60) is True + + def test_not_in_grace_period_after_expiry(self): + """AC-4: store started >60s ago is no longer in grace period.""" + store = WatchdogStore() + store._started_at = _now() - timedelta(seconds=61) + assert store.in_grace_period(grace_sec=60) is False + + def test_grace_period_exactly_at_boundary(self): + """Elapsed == grace_sec is NOT in grace (strictly less than).""" + store = WatchdogStore() + store._started_at = _now() - timedelta(seconds=60) + assert store.in_grace_period(grace_sec=60) is False + + +class TestGracePeriodDefer: + def test_grace_period_defers_expiry_check(self): + """AC-4: callers skip is_expired during grace period (combined scenario). + + The REQ states that during grace period no slot should be treated as + expired. This test verifies the pattern: in_grace_period() → True means + callers must not act on is_expired() results. + """ + store = WatchdogStore() + # Store is brand-new → in grace period + assert store.in_grace_period(grace_sec=60) is True + # Even though slot is never seen (would be expired), grace is active + assert store.is_expired("slot-never-seen", timeout_sec=120) is True + # Caller should defer: grace overrides expiry judgement. + effective_expired = store.is_expired("slot-never-seen", timeout_sec=120) and not store.in_grace_period(grace_sec=60) + assert effective_expired is False + + +class TestStoreReset: + def test_new_store_is_empty(self): + """AC-5: new WatchdogStore has no entries (simulates daemon restart).""" + store = WatchdogStore() + assert len(store._store) == 0 diff --git a/tests/domain/slot/test_price_threshold_fields.py b/tests/domain/slot/test_price_threshold_fields.py new file mode 100644 index 0000000..186d6d9 --- /dev/null +++ b/tests/domain/slot/test_price_threshold_fields.py @@ -0,0 +1,107 @@ +"""Price threshold field tests (REQ-035 / BAT-010).""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest + +import dataclasses + +from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot, 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) + + +class TestSlotModelPriceFields: + def test_price_fields_default_to_none(self): + """AC-1: stop_loss_price and take_profit_price default to None.""" + from uuid import uuid4 + slot = Slot(slot_id=uuid4(), trading_date=date(2026, 6, 7), status=SlotState.IDLE) + assert slot.stop_loss_price is None + assert slot.take_profit_price is None + + def test_price_fields_in_expected_contract(self): + """REQ-035 fields are declared in EXPECTED_SLOT_FIELDS.""" + assert "stop_loss_price" in EXPECTED_SLOT_FIELDS + assert "take_profit_price" in EXPECTED_SLOT_FIELDS + + def test_price_fields_are_in_dataclass(self): + """Slot dataclass actually has both fields.""" + field_names = {f.name for f in dataclasses.fields(Slot)} + assert "stop_loss_price" in field_names + assert "take_profit_price" in field_names + + +class TestTransitSlotPreservesPriceFields: + def test_price_fields_set_on_pending_buy(self, service, repo, trading_day): + """AC-2/AC-4: price threshold fields are stored in the slot on IDLE→PENDING_BUY.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930", + stop_loss_price=Decimal("48000"), + take_profit_price=Decimal("62000"), + ) + assert pending.stop_loss_price == Decimal("48000") + assert pending.take_profit_price == Decimal("62000") + + def test_price_fields_preserved_through_holding(self, service, repo, trading_day): + """Price fields survive PENDING_BUY→HOLDING fill transition.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930", + stop_loss_price=Decimal("48000"), + take_profit_price=Decimal("62000"), + ) + holding = service.transit_slot( + pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=50000.0, qty=1.0, + ) + assert holding.stop_loss_price == Decimal("48000") + assert holding.take_profit_price == Decimal("62000") + + def test_idle_reset_clears_price_fields(self, service, repo, trading_day): + """PENDING_BUY→IDLE rollback clears stop_loss_price and take_profit_price.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930", + stop_loss_price=Decimal("48000"), + take_profit_price=Decimal("62000"), + ) + rolled_back = service.transit_slot( + pending.slot_id, SlotState.PENDING_BUY, SlotState.IDLE, + reason="rejected", actor="test", + ) + assert rolled_back.stop_loss_price is None + assert rolled_back.take_profit_price is None + + def test_no_price_fields_sets_none(self, service, repo, trading_day): + """AC-5: transit without price fields leaves them as None.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930", + ) + assert pending.stop_loss_price is None + assert pending.take_profit_price is None diff --git a/tests/domain/slot/test_service_sell.py b/tests/domain/slot/test_service_sell.py new file mode 100644 index 0000000..f0df5b0 --- /dev/null +++ b/tests/domain/slot/test_service_sell.py @@ -0,0 +1,111 @@ +"""SlotService.sell() unit tests (REQ-036 / BAT-010).""" +from __future__ import annotations + +from datetime import date + +import pytest + +from trex_core.domain.slot import ( + SlotNotFoundError, + SlotService, + SlotState, + SlotTransitionError, +) +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 _holding_slot(service, repo, trading_day): + """Create one slot in HOLDING state.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930", + ) + return service.transit_slot( + pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=50000.0, qty=1.0, + ) + + +class TestSell: + def test_sell_holding_transitions_to_pending_sell(self, service, repo, trading_day): + """AC-1: sell() on a holding slot transitions it to pending_sell.""" + holding = _holding_slot(service, repo, trading_day) + service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss") + updated = repo.fetch_slot(holding.slot_id) + assert updated.status is SlotState.PENDING_SELL + + def test_sell_records_transition_log(self, service, repo, trading_day): + """sell() appends a transition log entry.""" + holding = _holding_slot(service, repo, trading_day) + service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss") + log = repo.transition_log_for(holding.slot_id) + sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL] + assert len(sell_entries) == 1 + assert sell_entries[0].actor == "safety-sell" + assert sell_entries[0].reason == "stop_loss" + + def test_sell_idempotent_on_pending_sell(self, service, repo, trading_day): + """AC-2: sell() on already-pending_sell slot returns silently, no second transition.""" + holding = _holding_slot(service, repo, trading_day) + service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss") + # Call again — must not raise and must not add another transition + service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss") + log = repo.transition_log_for(holding.slot_id) + sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL] + assert len(sell_entries) == 1 # exactly one, not two + + def test_sell_idle_slot_raises_transition_error(self, service, repo, trading_day): + """AC-3: sell() on an idle slot raises SlotTransitionError.""" + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + with pytest.raises(SlotTransitionError): + service.sell(idle.slot_id, actor="safety-sell", reason="stop_loss") + # Slot state unchanged + assert repo.fetch_slot(idle.slot_id).status is SlotState.IDLE + + def test_sell_unknown_slot_raises_not_found(self, service, trading_day): + """sell() with unknown slot_id raises SlotNotFoundError.""" + from uuid import uuid4 + with pytest.raises(SlotNotFoundError): + service.sell(uuid4(), actor="safety-sell", reason="stop_loss") + + +class TestSellPreservesPriceFields: + def test_sell_preserves_stop_loss_price(self, service, repo, trading_day): + """Price threshold fields survive the sell transition.""" + from decimal import Decimal + service.preallocate_daily_slots(trading_day, 1) + idle = repo.find_idle_slot(trading_day) + pending = service.transit_slot( + idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="buy", actor="test", symbol="005930", + stop_loss_price=Decimal("45000"), + take_profit_price=Decimal("60000"), + ) + holding = service.transit_slot( + pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="test", avg_price=50000.0, qty=1.0, + ) + assert holding.stop_loss_price == Decimal("45000") + assert holding.take_profit_price == Decimal("60000") + service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss") + updated = repo.fetch_slot(holding.slot_id) + assert updated.stop_loss_price == Decimal("45000") + assert updated.take_profit_price == Decimal("60000") diff --git a/tests/runtime/test_safety_sell_config.py b/tests/runtime/test_safety_sell_config.py new file mode 100644 index 0000000..dd86ad5 --- /dev/null +++ b/tests/runtime/test_safety_sell_config.py @@ -0,0 +1,69 @@ +"""SafetySellConfig env-var parsing tests (REQ-041 / BAT-010).""" +from __future__ import annotations + +import pytest + +from trex_core.runtime.config import DaemonConfig, SafetySellConfig + +_SAFETY_KEYS = ( + "TREX_WATCHDOG_TIMEOUT", + "TREX_WATCHDOG_GRACE_PERIOD", + "TREX_FORCED_CLOSE_CHECK_INTERVAL", + "TREX_STOP_LOSS_POLL_INTERVAL", + "TREX_MARKET_CLOSE_LIQUIDATION_TIME", +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for k in _SAFETY_KEYS: + monkeypatch.delenv(k, raising=False) + + +@pytest.fixture +def _base_env(monkeypatch): + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "dbname=x") + + +def test_safety_sell_config_defaults(tmp_path, monkeypatch, _base_env): + """AC-1: unset env vars produce documented defaults.""" + monkeypatch.chdir(tmp_path) + cfg = DaemonConfig.from_env() + ss = cfg.safety_sell + assert ss.watchdog_timeout_sec == 120 + assert ss.watchdog_grace_period_sec == 60 + assert ss.forced_close_check_interval_sec == 60 + assert ss.stop_loss_poll_interval_sec == 30 + assert ss.market_close_liquidation_time is None + + +def test_safety_sell_config_env_tuning(tmp_path, monkeypatch, _base_env): + """AC-2~5: all five env vars are parsed correctly.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("TREX_WATCHDOG_TIMEOUT", "180") + monkeypatch.setenv("TREX_WATCHDOG_GRACE_PERIOD", "90") + monkeypatch.setenv("TREX_FORCED_CLOSE_CHECK_INTERVAL", "45") + monkeypatch.setenv("TREX_STOP_LOSS_POLL_INTERVAL", "15") + monkeypatch.setenv("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "15:30") + cfg = DaemonConfig.from_env() + ss = cfg.safety_sell + assert ss.watchdog_timeout_sec == 180 + assert ss.watchdog_grace_period_sec == 90 + assert ss.forced_close_check_interval_sec == 45 + assert ss.stop_loss_poll_interval_sec == 15 + assert ss.market_close_liquidation_time == "15:30" + + +def test_market_close_liquidation_time_empty_string_becomes_none(tmp_path, monkeypatch, _base_env): + """Empty string for optional field collapses to None.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "") + cfg = DaemonConfig.from_env() + assert cfg.safety_sell.market_close_liquidation_time is None + + +def test_safety_sell_config_is_frozen(): + """SafetySellConfig is a frozen dataclass.""" + ss = SafetySellConfig() + with pytest.raises((AttributeError, TypeError)): + ss.watchdog_timeout_sec = 999 # type: ignore[misc] diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index a947a0b..0746242 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -11,6 +11,7 @@ Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash from __future__ import annotations from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation from typing import Optional import httpx @@ -119,6 +120,10 @@ class KISBroker: return "VTTC0012U" if side == "buy" else "VTTC0011U" return "TTTC0012U" if side == "buy" else "TTTC0011U" + def _tr_price(self) -> str: + """tr_id for current-price inquiry (REQ-038 AC-7).""" + return "VTTC8397R" if self._mock else "TTTC8397R" + # --- BrokerPort --- def submit_order(self, req: OrderRequest) -> OrderAck: cano, product = self._account() @@ -234,6 +239,33 @@ class KISBroker: out.append(BalanceItem(symbol=symbol, hldg_qty=qty)) return out + def get_current_price(self, symbol: str) -> Optional[Decimal]: + """Current traded price `stck_prpr` via KIS inquire-price (REQ-038). + + tr_id: `TTTC8397R` (live) / `VTTC8397R` (mock) per REQ-038 AC-7. + Returns None on network/parse error or missing price so the + safety-sell worker skips the slot this tick (no false trigger). + """ + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": symbol} + try: + resp = httpx.get( + f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price", + headers=self._headers(self._tr_price()), params=params, timeout=self._timeout, + ) + payload = resp.json() + except (httpx.HTTPError, ValueError): + return None + if str(payload.get("rt_cd")) != "0": + return None + out = payload.get("output") or {} + raw = out.get("stck_prpr") + if raw is None or str(raw).strip() == "": + return None + try: + return Decimal(str(raw).strip()) + except (InvalidOperation, ValueError): + return None + def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool: cano, product = self._account() parts = broker_order_id.split(":") diff --git a/trex_core/adapters/broker/paper.py b/trex_core/adapters/broker/paper.py index 2e46930..8b48224 100644 --- a/trex_core/adapters/broker/paper.py +++ b/trex_core/adapters/broker/paper.py @@ -8,6 +8,7 @@ execution code path that production (KIS) takes. from __future__ import annotations import threading +from decimal import Decimal from typing import Optional from trex_core.application.broker_port import ( @@ -52,6 +53,8 @@ class PaperBroker: self._orders: dict[str, _Order] = {} self._seq = 0 self._lock = threading.Lock() + # REQ-038: programmable current price for safety-sell scenario tests. + self._prices: dict[str, Decimal] = {} def name(self) -> str: return "paper" @@ -111,3 +114,14 @@ class PaperBroker: balances[o.symbol] = balances.get(o.symbol, 0) + sign * int(o.filled) return [BalanceItem(symbol=sym, hldg_qty=qty) for sym, qty in balances.items() if qty != 0] + + # --- REQ-038: programmable current price (safety-sell scenario double) --- + def set_current_price(self, symbol: str, price: Decimal) -> None: + """Program the current price returned by get_current_price(symbol).""" + with self._lock: + self._prices[symbol] = Decimal(str(price)) + + def get_current_price(self, symbol: str) -> Optional[Decimal]: + """Return the programmed current price for symbol, or None if unset.""" + with self._lock: + return self._prices.get(symbol) diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index 27f7f85..b783d0d 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -29,7 +29,8 @@ from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransi _SLOT_COLUMNS = ( "slot_id, trading_date, status, symbol, qty, avg_price, entry_price, " "allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, " - "sell_strategy_id, forced_close_at, created_at, updated_at" + "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " + "created_at, updated_at" ) @@ -122,12 +123,14 @@ class PostgreSQLSlotRepository: "UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, " "entry_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, " - "forced_close_at=%s, updated_at=%s WHERE slot_id=%s", + "forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, " + "updated_at=%s WHERE slot_id=%s", ( slot.status.value, slot.symbol, slot.qty, slot.avg_price, slot.entry_price, slot.allocated_capital, slot.order_id, slot.strategy_id, slot.broker_order_id, slot.portfolio_id, slot.sell_strategy_id, slot.forced_close_at, + slot.stop_loss_price, slot.take_profit_price, slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, ), ) @@ -229,7 +232,14 @@ class PostgreSQLSlotRepository: @staticmethod def _row_to_slot(row: tuple) -> Slot: + from decimal import Decimal f = lambda v: float(v) if v is not None else None # noqa: E731 + d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731 + # Column order: slot_id(0) trading_date(1) status(2) symbol(3) qty(4) + # avg_price(5) entry_price(6) allocated_capital(7) order_id(8) + # strategy_id(9) broker_order_id(10) portfolio_id(11) sell_strategy_id(12) + # forced_close_at(13) stop_loss_price(14) take_profit_price(15) + # created_at(16) updated_at(17) return Slot( slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), trading_date=row[1], @@ -245,8 +255,10 @@ class PostgreSQLSlotRepository: portfolio_id=row[11], sell_strategy_id=row[12], forced_close_at=row[13], - created_at=row[14], - updated_at=row[15], + stop_loss_price=d(row[14]), + take_profit_price=d(row[15]), + created_at=row[16], + updated_at=row[17], ) diff --git a/trex_core/adapters/persistence/sql/004_add_price_thresholds.sql b/trex_core/adapters/persistence/sql/004_add_price_thresholds.sql new file mode 100644 index 0000000..098f036 --- /dev/null +++ b/trex_core/adapters/persistence/sql/004_add_price_thresholds.sql @@ -0,0 +1,4 @@ +-- BAT-010 / REQ-035: additive price threshold columns for stop-loss / take-profit. +-- Existing rows get NULL, which is the correct default (no threshold set). +ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS stop_loss_price NUMERIC; +ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS take_profit_price NUMERIC; diff --git a/trex_core/application/broker_port.py b/trex_core/application/broker_port.py index cea4171..b4f9d4e 100644 --- a/trex_core/application/broker_port.py +++ b/trex_core/application/broker_port.py @@ -8,6 +8,7 @@ trex_core.adapters.broker. Fills are observed asynchronously via poll_fill from __future__ import annotations from dataclasses import dataclass, field +from decimal import Decimal from typing import Any, Optional, Protocol @@ -90,6 +91,15 @@ class BrokerPort(Protocol): """ ... + def get_current_price(self, symbol: str) -> Optional[Decimal]: + """Return the current traded price (`stck_prpr`) for a symbol (REQ-038). + + Used by the safety-sell worker to evaluate stop-loss/take-profit + thresholds. Returns None when the price is unavailable (network error, + unknown symbol, or — for the paper double — no programmed price). + """ + ... + # --- shared fee model (REQ-018, orbit calculate_fee pattern) ----------------- # Conservative KR equities approximation (bps). Brokers can override, but we diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index b04b56e..e8760f8 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import date, datetime +from decimal import Decimal from typing import Any, Optional from uuid import UUID @@ -54,6 +55,8 @@ class BuyIntent: sell_strategy_id: Optional[str] = None portfolio_id: Optional[str] = None forced_close_at: Optional[datetime] = None + stop_loss_price: Optional[Decimal] = None # REQ-035 + take_profit_price: Optional[Decimal] = None # REQ-035 extra: dict[str, Any] = field(default_factory=dict) @@ -157,6 +160,8 @@ class IntentService: sell_strategy_id=intent.sell_strategy_id, portfolio_id=intent.portfolio_id, forced_close_at=intent.forced_close_at, + stop_loss_price=intent.stop_loss_price, + take_profit_price=intent.take_profit_price, extra={"intent_id": intent.intent_id, "symbol": intent.symbol}, ) except SlotStateMismatchError: diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index df938be..66b4583 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -20,14 +20,25 @@ from trex_core.application.portfolio import ( View, build_snapshot_envelope, ) -from trex_core.domain.slot import SlotRepository, SlotService +from trex_core.application.watchdog import WatchdogStore +from trex_core.domain.slot import ( + SlotNotFoundError, + SlotRepository, + SlotService, + SlotState, +) _log = logging.getLogger(__name__) # ---- intent RPC handler ----------------------------------------------------- -def make_intent_rpc_handler(intent_service: IntentService): +def make_intent_rpc_handler( + intent_service: IntentService, + *, + slot_service: Optional[SlotService] = None, + watchdog_store: Optional[WatchdogStore] = None, +): """Build the RPC handler for `trex.intent.req`. Payload shape: @@ -36,9 +47,14 @@ def make_intent_rpc_handler(intent_service: IntentService): "intent_id": "", ... fields per BuyIntent / SellIntent ... } + or (REQ-040): + {"action": "update_thresholds", "slot_id": ..., + "stop_loss_price"?, "take_profit_price"?, "forced_close_at"?} Response shape: {"intent_id": str, "status": "admitted"|"rejected", "slot_id": str|None, "reason": str|None} + or for update_thresholds: + {"status": "updated"|"ignored"|"error", "slot_id": str, "reason"?: str} Dedup uses `intent_log.try_insert(intent_id, ...)` (REQ-007 gate). The request's `correlation_id` is reused as `intent_id` so the messaging @@ -48,6 +64,9 @@ def make_intent_rpc_handler(intent_service: IntentService): def handler(req: RpcRequest) -> RpcResponse: body = req.payload or {} + # REQ-040: update_thresholds action (separate from buy/sell intents). + if str(body.get("action") or "").lower() == "update_thresholds": + return _handle_update_thresholds(body, slot_service, watchdog_store) intent_id = str(body.get("intent_id") or req.correlation_id) intent_type = str(body.get("intent_type") or "").lower() try: @@ -63,6 +82,8 @@ def make_intent_rpc_handler(intent_service: IntentService): sell_strategy_id=_opt_str(body.get("sell_strategy_id")), portfolio_id=_opt_str(body.get("portfolio_id")), forced_close_at=_opt_dt(body.get("forced_close_at")), + stop_loss_price=_opt_decimal(body.get("stop_loss_price")), + take_profit_price=_opt_decimal(body.get("take_profit_price")), ) d = intent_service.submit_buy(buy) elif intent_type == "sell": @@ -93,6 +114,46 @@ def make_intent_rpc_handler(intent_service: IntentService): return handler +def _handle_update_thresholds( + body: dict, + slot_service: Optional[SlotService], + watchdog_store: Optional[WatchdogStore], +) -> RpcResponse: + """REQ-040: update stop_loss/take_profit/forced_close_at on a holding slot. + + Only the keys actually present in the payload are forwarded, so an absent + field keeps its value while an explicit ``null`` clears it (AC-4 vs AC-5). + On success the slot's watchdog last_seen_at is reset (AC-3) — receiving an + update_thresholds is itself a strategy-module liveness signal. + """ + if slot_service is None: + return RpcResponse(payload={"status": "error", "reason": "update_thresholds_unsupported"}) + try: + slot_id = UUID(str(body["slot_id"])) + except (KeyError, ValueError) as exc: + return RpcResponse(payload={"status": "error", "reason": f"bad_request:{exc}"}) + kwargs: dict = {} + if "stop_loss_price" in body: + kwargs["stop_loss_price"] = _opt_decimal(body["stop_loss_price"]) + if "take_profit_price" in body: + kwargs["take_profit_price"] = _opt_decimal(body["take_profit_price"]) + if "forced_close_at" in body: + kwargs["forced_close_at"] = _opt_dt(body["forced_close_at"]) + actor = str(body.get("actor") or "strategy") + try: + status = slot_service.update_thresholds(slot_id, actor=actor, **kwargs) + except SlotNotFoundError: + return RpcResponse(payload={ + "status": "ignored", "slot_id": str(slot_id), "reason": "slot_not_found", + }) + if status == "updated" and watchdog_store is not None: + watchdog_store.update(str(slot_id)) # AC-3: liveness signal + payload = {"status": status, "slot_id": str(slot_id)} + if status == "ignored": + payload["reason"] = "not_holding" # AC-6 + return RpcResponse(payload=payload) + + # ---- snapshot RPC handler --------------------------------------------------- def make_snapshot_rpc_handler( @@ -102,6 +163,7 @@ def make_snapshot_rpc_handler( snapshot_store: Optional[SnapshotStore] = None, pause_store: Optional[PauseStore] = None, default_total_slots: int = 10, + watchdog_store: Optional[WatchdogStore] = None, ): """Build the RPC handler for `trex.snapshot.req` (REQ-028). @@ -126,6 +188,12 @@ 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: + for s in slot_repo.list_slots(day): + if s.status is SlotState.HOLDING: + 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() @@ -244,6 +312,13 @@ def _opt_str(v) -> Optional[str]: return str(v) +def _opt_decimal(v): + if v is None: + return None + from decimal import Decimal + return Decimal(str(v)) + + def _opt_float(v) -> Optional[float]: if v is None: return None diff --git a/trex_core/application/safety_sell.py b/trex_core/application/safety_sell.py new file mode 100644 index 0000000..cb09269 --- /dev/null +++ b/trex_core/application/safety_sell.py @@ -0,0 +1,190 @@ +"""Safety-sell trigger service (REQ-037, REQ-038, REQ-039 / BAT-011). + +A single service that the `trex-safety-sell` daemon worker drives once per +tick. It evaluates three autonomous sell triggers on `holding` slots: + +- forced_close_at expiry (REQ-037) — watchdog-gated +- stop-loss / take-profit price (REQ-038) — watchdog-gated, KIS polling +- market-close liquidation (REQ-039) — watchdog-INDEPENDENT, once/day + +Watchdog gating: forced_close and price checks only act on slots whose +watchdog has expired (strategy module is absent). During the grace period +no expiry-gated trigger fires (REQ-034). Market-close liquidation ignores +the watchdog and the grace period entirely (REQ-039 AC-2/AC-6). + +All sells go through `SlotService.sell()` (REQ-036), which is idempotent on +`pending_sell` — so concurrent/duplicate triggers never double-submit. +Each trigger is exception-isolated: a single slot's failure (KIS error, +transition race) is logged and the loop continues (REQ-024 degrade policy). +""" +from __future__ import annotations + +import logging +from datetime import date as date_cls +from datetime import datetime, time as time_cls, timedelta, timezone + +from trex_core.application.broker_port import BrokerPort +from trex_core.application.watchdog import WatchdogStore +from trex_core.domain.slot import SlotRepository, SlotService, SlotState +from trex_core.domain.slot.models import Slot +from trex_core.runtime.config import SafetySellConfig + +_log = logging.getLogger(__name__) +_KST = timezone(timedelta(hours=9)) + +# Conservative KIS current-price quota guard (REQ-038 AC-8). When the +# estimated hourly polling volume crosses this, we log a warning so the +# operator can widen TREX_STOP_LOSS_POLL_INTERVAL. +_KIS_PRICE_QUOTA_PER_HOUR = 1000 + +_ACTOR = "trex-safety-sell" + + +def _parse_kst_time(spec: str) -> time_cls: + parts = spec.split(":") + return time_cls(hour=int(parts[0]), minute=int(parts[1]) if len(parts) > 1 else 0) + + +class SafetySellService: + """Evaluates safety-sell triggers for one daemon tick (REQ-037/038/039).""" + + def __init__( + self, + *, + slot_repo: SlotRepository, + slot_service: SlotService, + broker: BrokerPort, + watchdog: WatchdogStore, + config: SafetySellConfig, + ) -> None: + self._repo = slot_repo + self._svc = slot_service + self._broker = broker + self._watchdog = watchdog + self._cfg = config + self._last_liquidation_date: date_cls | None = None + self._last_forced_close_at: datetime | None = None + self._last_price_check_at: datetime | None = None + + @property + def base_tick_sec(self) -> float: + """Worker loop sleep — the finest of the configured check cadences.""" + return float(max( + 1, + min(self._cfg.forced_close_check_interval_sec, + self._cfg.stop_loss_poll_interval_sec), + )) + + # ---------------------------------------------------------------- helpers + @staticmethod + def _trading_date(now: datetime) -> date_cls: + return now.astimezone(timezone.utc).date() + + def _holding(self, now: datetime) -> list[Slot]: + return [s for s in self._repo.list_slots(self._trading_date(now)) + if s.status is SlotState.HOLDING] + + def _watchdog_expired(self, slot: Slot) -> bool: + return self._watchdog.is_expired(str(slot.slot_id), self._cfg.watchdog_timeout_sec) + + def _in_grace(self) -> bool: + return self._watchdog.in_grace_period(self._cfg.watchdog_grace_period_sec) + + def _safe_sell(self, slot: Slot, reason: str) -> bool: + try: + self._svc.sell(slot.slot_id, actor=_ACTOR, reason=reason) + return True + except Exception: # noqa: BLE001 - one slot's failure must not stop the loop + _log.warning("safety-sell failed (slot=%s reason=%s)", + slot.slot_id, reason, exc_info=True) + return False + + def estimated_hourly_kis_calls(self, n_slots: int) -> int: + """REQ-038 AC-8: worst-case current-price polls per hour for n_slots.""" + interval = max(1, self._cfg.stop_loss_poll_interval_sec) + return n_slots * (3600 // interval) + + # ---------------------------------------------------- REQ-037 forced_close + def check_forced_close(self, now: datetime) -> list: + """Sell holding+watchdog-expired slots whose forced_close_at has passed.""" + if self._in_grace(): + return [] + fired = [] + for s in self._holding(now): + if s.forced_close_at is None: + continue + if not self._watchdog_expired(s): + continue # strategy module active — do not interfere (AC-3) + if s.forced_close_at <= now and self._safe_sell(s, "forced_close_at"): + fired.append(s.slot_id) + return fired + + # ----------------------------------------------- REQ-038 stop-loss/take-profit + def check_prices(self, now: datetime) -> list: + """Poll KIS current price for eligible slots and trigger SL/TP sells.""" + if self._in_grace(): + return [] + candidates = [ + s for s in self._holding(now) + if (s.stop_loss_price is not None or s.take_profit_price is not None) + and self._watchdog_expired(s) # no KIS call for watchdog-active (AC-4) + ] + if self.estimated_hourly_kis_calls(len(candidates)) > _KIS_PRICE_QUOTA_PER_HOUR: + _log.warning( + "safety-sell KIS price polling may exceed quota: ~%d calls/hour " + "(%d slots, interval=%ds). Consider widening TREX_STOP_LOSS_POLL_INTERVAL.", + self.estimated_hourly_kis_calls(len(candidates)), + len(candidates), self._cfg.stop_loss_poll_interval_sec, + ) + fired = [] + for s in candidates: + price = self._broker.get_current_price(s.symbol) + if price is None: + continue # unavailable — skip, retry next tick (no false trigger) + if s.stop_loss_price is not None and price <= s.stop_loss_price: + if self._safe_sell(s, "stop_loss"): + fired.append(s.slot_id) + elif s.take_profit_price is not None and price >= s.take_profit_price: + if self._safe_sell(s, "take_profit"): + fired.append(s.slot_id) + return fired + + # ------------------------------------------------ REQ-039 market-close liquidation + def check_market_close(self, now: datetime) -> list: + """Liquidate ALL holding slots at the configured KST time, once per day.""" + spec = self._cfg.market_close_liquidation_time + if not spec: + return [] # feature disabled (AC-3) + now_kst = now.astimezone(_KST) + today = now_kst.date() + if self._last_liquidation_date == today: + return [] # already fired this trading day (AC-4) + if now_kst.time() < _parse_kst_time(spec): + return [] # not yet + fired = [] + for s in self._holding(now): # watchdog/grace ignored (AC-2/AC-6) + if self._safe_sell(s, "market_close_liquidation"): + fired.append(s.slot_id) + self._last_liquidation_date = today + return fired + + # ----------------------------------------------------------------- run_once + def run_once(self, now: datetime | None = None) -> None: + """One worker tick — market-close, then cadence-gated forced_close + prices.""" + now = now or datetime.now(tz=timezone.utc) + try: + self.check_market_close(now) + except Exception: # noqa: BLE001 + _log.warning("market-close liquidation tick failed", exc_info=True) + if self._due(self._last_forced_close_at, now, self._cfg.forced_close_check_interval_sec): + self._last_forced_close_at = now + self.check_forced_close(now) + if self._due(self._last_price_check_at, now, self._cfg.stop_loss_poll_interval_sec): + self._last_price_check_at = now + self.check_prices(now) + + @staticmethod + def _due(last: datetime | None, now: datetime, interval_sec: int) -> bool: + if last is None: + return True + return (now - last).total_seconds() >= interval_sec diff --git a/trex_core/application/watchdog.py b/trex_core/application/watchdog.py new file mode 100644 index 0000000..fcf9605 --- /dev/null +++ b/trex_core/application/watchdog.py @@ -0,0 +1,49 @@ +"""Per-slot watchdog store for safety-sell (REQ-034 / BAT-010). + +WatchdogStore tracks the last time a snapshot RPC response included each +holding slot. BAT-011 trigger loops use is_expired() to decide whether a +slot is unmonitored (strategy module absent) and therefore eligible for +autonomous safety-sell actions. + +Thread-safety: dict reads and writes in CPython are GIL-atomic for simple +assignments, which is sufficient here — one writer (snapshot-rpc thread) +and one or more readers (BAT-011 trigger-loop threads). No explicit lock +is needed. +""" +from __future__ import annotations + +from datetime import datetime, timezone + + +class WatchdogStore: + """In-memory per-slot last_seen_at tracker (REQ-034).""" + + def __init__(self) -> None: + self._store: dict[str, datetime] = {} + self._started_at: datetime = datetime.now(tz=timezone.utc) + + def update(self, slot_id: str) -> None: + """Record that slot_id appeared in a snapshot RPC response.""" + self._store[slot_id] = datetime.now(tz=timezone.utc) + + def is_expired(self, slot_id: str, timeout_sec: int) -> bool: + """Return True if slot_id has not been seen for longer than timeout_sec. + + A slot that has never been seen is treated as expired (returns True) + unless in_grace_period() is True — callers must check grace period + before calling this method when they want to suppress false positives. + """ + last = self._store.get(slot_id) + if last is None: + return True + elapsed = (datetime.now(tz=timezone.utc) - last).total_seconds() + return elapsed > timeout_sec + + def in_grace_period(self, grace_sec: int) -> bool: + """Return True if the daemon started less than grace_sec seconds ago. + + During the grace period ALL expiry judgements must be deferred — + no slot should be treated as watchdog-expired (REQ-034 AC-4). + """ + elapsed = (datetime.now(tz=timezone.utc) - self._started_at).total_seconds() + return elapsed < grace_sec diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py index 61ebb1f..450a03a 100644 --- a/trex_core/domain/slot/models.py +++ b/trex_core/domain/slot/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import date, datetime +from decimal import Decimal from typing import Optional from uuid import UUID @@ -32,6 +33,8 @@ class Slot: portfolio_id: Optional[str] = None # BAT-006 / REQ-025 (trex-orbit lineage) sell_strategy_id: Optional[str] = None # BAT-008 / REQ-047 (trex-orbit lineage) forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage) + stop_loss_price: Optional[Decimal] = None # REQ-035 / BAT-010 + take_profit_price: Optional[Decimal] = None # REQ-035 / BAT-010 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -55,6 +58,8 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ "portfolio_id", "sell_strategy_id", "forced_close_at", + "stop_loss_price", # REQ-035 + "take_profit_price", # REQ-035 "created_at", "updated_at", }) diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py index 0ad98ad..4ef4efc 100644 --- a/trex_core/domain/slot/service.py +++ b/trex_core/domain/slot/service.py @@ -12,6 +12,7 @@ from __future__ import annotations from dataclasses import replace from datetime import date, datetime, timezone +from decimal import Decimal from typing import Any, Optional from uuid import UUID @@ -24,6 +25,10 @@ from trex_core.domain.slot.models import Slot, SlotCapacity, SlotTransitionRecor from trex_core.domain.slot.repository import SlotRepository from trex_core.domain.slot.state import SLOT_ADVISORY_LOCK_ID, SlotState, is_allowed_transition +# Sentinel distinguishing "field omitted (keep current)" from "field set to +# None (clear threshold)" in update_thresholds (REQ-040 AC-4 vs AC-5). +_UNSET: Any = object() + class SlotService: """Slot lifecycle service. Race-free transitions via advisory lock + transaction.""" @@ -69,6 +74,8 @@ class SlotService: portfolio_id: Optional[str] = None, sell_strategy_id: Optional[str] = None, forced_close_at: Optional[datetime] = None, + stop_loss_price: Optional[Decimal] = None, + take_profit_price: Optional[Decimal] = None, extra: Optional[dict[str, Any]] = None, ) -> Slot: """Transition slot from one state to another atomically. @@ -115,6 +122,8 @@ class SlotService: portfolio_id=portfolio_id, sell_strategy_id=sell_strategy_id, forced_close_at=forced_close_at, + stop_loss_price=stop_loss_price, + take_profit_price=take_profit_price, ) self._repo.update_slot(updated) @@ -155,6 +164,8 @@ class SlotService: portfolio_id: Optional[str] = None, sell_strategy_id: Optional[str] = None, forced_close_at: Optional[datetime] = None, + stop_loss_price: Optional[Decimal] = None, + take_profit_price: Optional[Decimal] = None, ) -> Slot: """Compute the post-transition Slot row. @@ -181,6 +192,8 @@ class SlotService: portfolio_id=None, sell_strategy_id=None, forced_close_at=None, + stop_loss_price=None, + take_profit_price=None, updated_at=datetime.now(tz=timezone.utc), ) @@ -211,9 +224,86 @@ class SlotService: forced_close_at=( forced_close_at if forced_close_at is not None else slot.forced_close_at ), + stop_loss_price=( + stop_loss_price if stop_loss_price is not None else slot.stop_loss_price + ), + take_profit_price=( + take_profit_price if take_profit_price is not None else slot.take_profit_price + ), updated_at=datetime.now(tz=timezone.utc), ) + # ------------------------------------------------------------ + # sell (REQ-036 / BAT-010) + # ------------------------------------------------------------ + def sell(self, slot_id: UUID, actor: str, reason: str) -> None: + """Trigger an autonomous safety-sell for a holding slot (REQ-036). + + Idempotent: silently returns if the slot is already pending_sell. + Raises SlotNotFoundError if slot_id is unknown. + Raises SlotTransitionError if the slot is neither holding nor pending_sell. + """ + slot = self._repo.fetch_slot(slot_id) + if slot is None: + raise SlotNotFoundError(f"slot not found: {slot_id}") + if slot.status is SlotState.PENDING_SELL: + return # already in progress — idempotent (REQ-036 AC-2) + if slot.status is not SlotState.HOLDING: + raise SlotTransitionError( + f"sell() requires holding state, got: {slot.status.value}" + ) + self.transit_slot( + slot_id, + SlotState.HOLDING, + SlotState.PENDING_SELL, + reason=reason, + actor=actor, + ) + + # ------------------------------------------------------------ + # update_thresholds (REQ-040 / BAT-011) + # ------------------------------------------------------------ + def update_thresholds( + self, + slot_id: UUID, + *, + stop_loss_price: Any = _UNSET, + take_profit_price: Any = _UNSET, + forced_close_at: Any = _UNSET, + actor: str = "strategy", + ) -> str: + """Partially update a holding slot's safety-sell thresholds (REQ-040). + + Each of the three fields is independent: pass `_UNSET` (the default — + i.e. omit the kwarg) to keep the current value, or pass an explicit + value (including ``None``) to set/clear it (AC-4 vs AC-5). + + Returns "updated" on success, or "ignored" when the slot is not in + the holding state (AC-6). Raises SlotNotFoundError for unknown slots. + The caller (RPC handler) resets the watchdog last_seen_at (AC-3). + """ + with self._repo.transactional_advisory_lock(SLOT_ADVISORY_LOCK_ID): + slot = self._repo.fetch_slot(slot_id) + if slot is None: + raise SlotNotFoundError(f"slot not found: {slot_id}") + if slot.status is not SlotState.HOLDING: + return "ignored" # not_holding (AC-6) + updated = replace( + slot, + stop_loss_price=( + slot.stop_loss_price if stop_loss_price is _UNSET else stop_loss_price + ), + take_profit_price=( + slot.take_profit_price if take_profit_price is _UNSET else take_profit_price + ), + forced_close_at=( + slot.forced_close_at if forced_close_at is _UNSET else forced_close_at + ), + updated_at=datetime.now(tz=timezone.utc), + ) + self._repo.update_slot(updated) + return "updated" + # ------------------------------------------------------------ # get_today_entry_count # ------------------------------------------------------------ diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 423bab6..88b444c 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -69,6 +69,22 @@ class SnapshotScheduleConfig: tick_sec: float = 60.0 +@dataclass(frozen=True) +class SafetySellConfig: + """Safety-sell feature tuning (REQ-041 / BAT-010). + + All five env vars are parsed here even though only WATCHDOG_TIMEOUT and + WATCHDOG_GRACE_PERIOD are consumed in BAT-010; the remaining three are + used by BAT-011 trigger loops once they land. + """ + + watchdog_timeout_sec: int = 120 + watchdog_grace_period_sec: int = 60 + forced_close_check_interval_sec: int = 60 + stop_loss_poll_interval_sec: int = 30 + market_close_liquidation_time: Optional[str] = None # "HH:MM" KST, optional + + @dataclass(frozen=True) class DaemonConfig: dsn: str @@ -87,6 +103,8 @@ class DaemonConfig: rabbitmq: Optional[RabbitMQConfig] = None # REQ-030 (BAT-008): daily snapshot refresh. snapshot_schedule: SnapshotScheduleConfig = SnapshotScheduleConfig() + # REQ-041 (BAT-010): safety-sell feature config. + safety_sell: SafetySellConfig = SafetySellConfig() @staticmethod def from_env() -> "DaemonConfig": @@ -136,6 +154,18 @@ class DaemonConfig: kst_time=os.environ.get("TREX_SNAPSHOT_SCHEDULE_KST", "07:30").strip() or "07:30", tick_sec=float(os.environ.get("TREX_SNAPSHOT_TICK_SEC", "60")), ) + _mc_time = os.environ.get("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "").strip() or None + safety_sell = SafetySellConfig( + watchdog_timeout_sec=int(os.environ.get("TREX_WATCHDOG_TIMEOUT", "120")), + watchdog_grace_period_sec=int(os.environ.get("TREX_WATCHDOG_GRACE_PERIOD", "60")), + forced_close_check_interval_sec=int( + os.environ.get("TREX_FORCED_CLOSE_CHECK_INTERVAL", "60") + ), + stop_loss_poll_interval_sec=int( + os.environ.get("TREX_STOP_LOSS_POLL_INTERVAL", "30") + ), + market_close_liquidation_time=_mc_time, + ) return DaemonConfig( dsn=dsn, total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")), @@ -147,4 +177,5 @@ class DaemonConfig: ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")), rabbitmq=rabbitmq, snapshot_schedule=snapshot_schedule, + safety_sell=safety_sell, ) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index d8fb38a..6695292 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -5,9 +5,12 @@ strategy-facing communication runs over RabbitMQ (REQ-021/022/023). Threads (started in `run()`): - intent RPC consumer — `trex.intent.req`, prefetch=1, FIFO, dedup via intent_log - - snapshot RPC consumer — `trex.snapshot.req` + - snapshot RPC consumer — `trex.snapshot.req` (also refreshes the watchdog) + - pause RPC consumer — `trex.pause.req` (REQ-032) - execution loop — broker dispatch + poll + slot transitions + event emit - ledger writer — ledger outbox drain (REQ-019) + - snapshot scheduler — daily portfolio-hub snapshot refresh (REQ-030) + - safety-sell worker — forced_close / stop-loss·take-profit / market-close (REQ-037/038/039) Each consumer/publisher uses its OWN RabbitMQTransport instance because pika BlockingConnection is not thread-safe across channels. @@ -56,7 +59,9 @@ from trex_core.application.portfolio import ( PortfolioHubReadPort, SnapshotStore, ) +from trex_core.application.safety_sell import SafetySellService from trex_core.application.snapshot_scheduler import SnapshotScheduler +from trex_core.application.watchdog import WatchdogStore from trex_core.domain.slot import SlotService from trex_core.runtime.config import DaemonConfig @@ -140,6 +145,8 @@ class Daemon: # BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler. self.snapshot_store = SnapshotStore() + # BAT-010/011: per-slot watchdog (snapshot RPC writer, safety-sell reader). + self.watchdog = WatchdogStore() self.pause_store = PostgreSQLPauseStore(config.dsn) self.portfolio_read = _build_portfolio_read(config) self.snapshot_scheduler = SnapshotScheduler( @@ -160,6 +167,15 @@ class Daemon: event_emitter=self._make_event_emitter(), ) + # BAT-011: safety-sell trigger service (forced_close / SL-TP / market-close). + self.safety_sell = SafetySellService( + slot_repo=self.slot_repo, + slot_service=self.slot_service, + broker=self.broker, + watchdog=self.watchdog, + config=config.safety_sell, + ) + # ------------------------------------------------------------ event hook def _make_event_emitter(self): def emit(event_type: str, slot) -> None: @@ -195,7 +211,11 @@ class Daemon: transport.declare_topology() except Exception: # noqa: BLE001 pass - handler = make_intent_rpc_handler(self.intent_service) + handler = make_intent_rpc_handler( + self.intent_service, + slot_service=self.slot_service, + watchdog_store=self.watchdog, + ) transport.consume_rpc_forever(topology.INTENT_REQ_QUEUE, handler, self.stop) def _snapshot_rpc_loop(self) -> None: @@ -210,6 +230,7 @@ class Daemon: snapshot_store=self.snapshot_store, pause_store=self.pause_store, default_total_slots=self.config.total_slots, + watchdog_store=self.watchdog, ) transport.consume_rpc_forever(topology.SNAPSHOT_REQ_QUEUE, handler, self.stop) @@ -223,6 +244,14 @@ class Daemon: handler = make_pause_rpc_handler(self.pause_store) transport.consume_rpc_forever(topology.PAUSE_REQ_QUEUE, handler, self.stop) + def _safety_sell_loop(self) -> None: + # REQ-037/038/039: forced_close / stop-loss·take-profit / market-close. + while not self.stop.wait(self.safety_sell.base_tick_sec): + try: + self.safety_sell.run_once() + except Exception: # noqa: BLE001 - loop must survive transient errors + continue + def _snapshot_scheduler_loop(self) -> None: try: self.snapshot_scheduler.run_forever( @@ -251,6 +280,7 @@ class Daemon: ("trex-snapshot-rpc", self._snapshot_rpc_loop), ("trex-pause-rpc", self._pause_rpc_loop), ("trex-snapshot-scheduler", self._snapshot_scheduler_loop), + ("trex-safety-sell", self._safety_sell_loop), ] for name, target in workers: t = threading.Thread(target=target, name=name, daemon=True)