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:
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
요청:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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(":")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user