feat(safety-sell): dcy-007 core 자율 안전 매도 — BAT-010 기반 + BAT-011 트리거

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 08:32:30 +09:00
co-authored by Claude Opus 4.8
parent e4e5228972
commit a30efa8f3b
47 changed files with 3399 additions and 36 deletions
@@ -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() 내부 경로 반영)
@@ -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 설치 시 해소.
@@ -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 참조).
@@ -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()` 코드 경로를 최대한 재사용하고 신규 로직을 최소화한다.
@@ -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 영향 없음).
@@ -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)
@@ -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).
@@ -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).
@@ -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"}` 응답으로 이미 결정됨 — 구현은 이를 따른다.
@@ -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).
+2
View File
@@ -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/) |
@@ -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
+1 -1
View File
@@ -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 |
| dcy-007_20260606_core-autonomous-safety-sell | 20260606 | core 자율 안전 매도 — stop-loss·시간 만기 자동 트리거 | [Discovery](./dcy-007_20260606_core-autonomous-safety-sell.md) | confirmed |
+21 -3
View File
@@ -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)
요청:
+12 -8
View File
@@ -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).
+42 -5
View File
@@ -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)
@@ -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 배정
@@ -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 배정
@@ -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 배포 편성.
@@ -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 배포 편성.
@@ -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 배포 편성.
@@ -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 배포 편성.
@@ -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 배정
@@ -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 배정
@@ -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 배포 편성.
+14 -5
View File
@@ -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)
+100
View File
@@ -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")
@@ -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
@@ -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"
@@ -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"
+103
View File
@@ -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
@@ -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
+111
View File
@@ -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")
+69
View File
@@ -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]
+32
View File
@@ -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(":")
+14
View File
@@ -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)
+16 -4
View File
@@ -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],
)
@@ -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;
+10
View File
@@ -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
+5
View File
@@ -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:
+77 -2
View File
@@ -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": "<idempotency key, = correlation_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
+190
View File
@@ -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
+49
View File
@@ -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
+5
View File
@@ -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",
})
+90
View File
@@ -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
# ------------------------------------------------------------
+31
View File
@@ -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,
)
+32 -2
View File
@@ -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)