docs(discovery): dcy-010 safety-sell 영속 거부 재시도 백오프 — KIS 주문 spam 방지 슬롯별 쿨다운 정책

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:54:30 +09:00
co-authored by Claude Sonnet 4.6
parent 278076a376
commit 3a2020012d
2 changed files with 221 additions and 1 deletions
@@ -0,0 +1,219 @@
# 0. 문서 상태
- 상태: draft
- 문서 ID: dcy-010_20260607_safety-sell-persistent-rejection-backoff
- 이슈명: safety-sell 영속 거부 재시도 백오프 — 장 휴장·잔고 부족·규제 등 지속 거부 상황에서 KIS 주문 spam 방지를 위한 슬롯별 재시도 backoff/cooldown 정책
- 작성 시각(KST): 2026-06-07
- 마지막 갱신 시각(KST): 2026-06-07
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 없음
# 1. 계획 상태 요약
- 연계 문서 현황:
- 관련 참조 파일: trex_core/application/safety_sell.py, trex_core/application/execution.py, trex_core/domain/slot/service.py
- 관련 TODO/메모: REL-003 Feedback Handoff — Change Request Input "safety-sell 영속 거부 시 재시도 백오프/쿨다운(신규, 우선순위 ↑)"로 기록됨. 2026-06-07 실 KIS LIVE SL/TP e2e에서 직접 확인된 운영 위험.
- 해석:
- `SafetySellService`가 매도 시도(`HOLDING → PENDING_SELL`)에 성공해도, 브로커 거부로 인한 `PENDING_SELL → HOLDING` 롤백은 별도 `trex-execution` 스레드에서 일어나며 safety-sell이 이를 인지할 수 없다. 결과적으로 트리거 조건이 해소되지 않는 한 동일 슬롯에 매도를 무한 재제출한다.
- 이번 Discovery는 슬롯별 마지막 시도 시각을 `SafetySellService` 인메모리로 추적하는 단순 쿨다운을 도입해, 추가 스레드·DB 스키마 변경 없이 KIS 주문 spam을 방지하는 방법을 정의한다.
# 2. 요구사항 판정 결과
- 판정: 신규
- 근거
- dcy-007(core 자율 안전 매도, confirmed + Implemented)은 watchdog 기반 triggered sell 메커니즘을 정의했으나, 브로커 거부 시 재시도 제한·쿨다운·백오프 정책은 전혀 포함하지 않는다.
- 현재 어떤 Discovery/REQ도 `SafetySellService`의 per-slot 재시도 상태 추적, 최소 재시도 간격, 영속 거부 운영자 경고를 다루지 않는다.
# 3. 문제점의 요약
- 현재 상태
- `trex_core/application/safety_sell.py`: `SafetySellService._safe_sell()`이 `SlotService.sell()` 호출 성공(HOLDING → PENDING_SELL) 시 즉시 `True`를 반환한다. 브로커가 해당 주문을 거부하는지는 알 수 없다.
- `trex_core/application/execution.py`: `_rollback()`이 PENDING_SELL 거부 슬롯을 `PENDING_SELL → HOLDING`으로 전환하고 `clear_dispatch()`한다. 이 사실이 `SafetySellService`에 전달될 피드백 채널이 없다.
- `SafetySellService`에는 per-slot 마지막 시도 시각, 시도 횟수, 쿨다운 만료 시각 등 재시도 상태가 전혀 없다.
- 기본 cadence: stop-loss/take-profit은 30s마다, forced_close는 60s마다 트리거를 재평가한다. 장 휴장(9시간)에서 stop-loss 트리거가 지속되면 최대 9×3600÷30 = 1,080회 KIS 매도 주문이 제출·거부된다.
- paper 브로커는 `reject_symbols` 설정 없이는 주문을 거부하지 않으므로 테스트 환경에서 이 문제가 재현되지 않았다.
- 기대 상태
- `SafetySellService`가 슬롯별 마지막 매도 시도 시각을 인메모리로 추적한다.
- 이전 시도로부터 `min_retry_interval_sec` 이내인 슬롯에는 재시도를 건너뛴다.
- `min_retry_interval_sec` 경과 후 트리거 조건이 여전히 충족되면 자동으로 재시도한다.
- 일정 횟수 이상 재시도가 발생한 슬롯에 대해 운영자 경고 로그를 발행한다.
- DB 스키마 변경 없이 인메모리 상태만으로 구현한다.
- 이해관계자
- 운영자(Justin Ko): KIS 쿼터 보호 및 휴장/규제 상황에서의 안전 운용 책임
- trex-core 데몬 개발자: `SafetySellService` + `SafetySellConfig` 수정 범위 판단
- KIS API: 분당/시간당 쿼터 제한 존재. 영속 거부 시 재제출이 쿼터를 소모해 정상 거래일 운용에 영향 가능.
- 제약사항
- `SafetySellService`와 `ExecutionService`는 별도 스레드이며 공유 데이터 구조에 직접 접근하지 않는다. 피드백 채널 신설은 스레드 간 결합을 증가시키므로 본 Discovery 범위 밖.
- 쿨다운 상태는 데몬 재시작 시 초기화된다. 재시작 직후 1회 재시도는 허용 범위로 본다.
- `SafetySellService`는 단일 스레드에서 호출되므로 쿨다운 `dict` 접근에 별도 락 불필요.
- 브로커 거부 원인 코드(`ack.reason`)를 `SafetySellService`가 직접 수신하는 설계는 이번 Discovery 밖.
## 주요 사용자 시나리오
- (휴장) KIS 장 휴장일에 stop-loss 임계값이 설정된 holding 슬롯이 있을 때 — 쿨다운 없이 30s마다 KIS에 매도 제출·거부가 반복됨. 쿨다운 도입 후: 첫 거부 후 `min_retry_interval_sec` 동안 재시도 없음, 장 재개 후 트리거 조건 재평가.
- (잔고 부족) 계좌 잔고 부족으로 KIS가 매도를 거부하는 경우 — 동일 패턴으로 반복 거부. 쿨다운 중 운영자 경고 로그가 발행되어 수동 조치 가능.
- (규제) 특정 종목 매도 규제가 일시적으로 적용된 경우 — 쿨다운이 규제 해제 시점까지 재시도를 막고, 이후 자동 재평가.
- (정상 거래일) 워치독 만료 슬롯에 stop-loss 트리거 발동 → KIS 매도 접수·체결 → 슬롯 CLOSED → `_holding()`에서 제외. 쿨다운 미발동, 동작 변화 없음.
# 4. 이번에 정의할 변경
- **`SafetySellService` per-slot 쿨다운 추적 도입**: `_last_attempt: dict[UUID, datetime]`와 `_attempt_count: dict[UUID, int]`를 추가한다. `_safe_sell()` 호출 직전 쿨다운 체크를 수행하고, 시도 후 `last_attempt`와 `attempt_count`를 갱신한다. 슬롯이 CLOSED 또는 IDLE로 전환되면(= `_holding()`에서 사라지면) 해당 슬롯의 쿨다운 상태를 자동 정리한다.
- **`SafetySellConfig` 확장**: `min_retry_interval_sec`(기본값 TBD), `max_retry_warn_count`(기본값 TBD) 두 env var을 추가한다. 기존 5종 env var(`TREX_WATCHDOG_TIMEOUT` 등)과 같은 방식으로 optional 파싱한다.
- **운영자 경고 로그**: `attempt_count >= max_retry_warn_count`인 슬롯에 대해 `_log.warning("safety-sell repeated rejection: slot=%s count=%d reason=persistent_rejection", ...)` 경고를 발행한다. 이 경고는 쿨다운 체크 통과 후 재시도 직전에 발행한다.
## 영향 범위
- 코드 영향:
- `trex_core/application/safety_sell.py`: `SafetySellService.__init__` 인스턴스 변수 추가(`_last_attempt`, `_attempt_count`). `_safe_sell()` 호출 전후에 쿨다운 check/update 로직 추가. 슬롯 추적 정리 로직 추가.
- `trex_core/runtime/config.py`: `SafetySellConfig`에 `min_retry_interval_sec: int`, `max_retry_warn_count: int` 필드 추가. `from_env()`에서 `TREX_SAFETY_SELL_MIN_RETRY_INTERVAL`, `TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT` 파싱 추가.
- 문서 영향:
- `docs/runtime-flows.md`: safety-sell worker tick 흐름에 "쿨다운 체크 → 건너뜀 or 재시도" 단계 추가
- 운영 영향:
- `TREX_SAFETY_SELL_MIN_RETRY_INTERVAL`, `TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT` 두 env var 신설(전부 optional, 기본값 있음). 기존 `.env` 구조 호환.
- 데몬 재시작 시 쿨다운 상태 초기화. 재시작이 의도적이라면 허용 범위.
# 5. 요구사항 목록
## 기능 요구사항
- FR-1: `SafetySellService`는 슬롯별 마지막 매도 시도 시각(`last_attempt_at`)과 연속 시도 횟수(`attempt_count`)를 인메모리로 추적한다. 각 트리거(`forced_close`, `stop_loss`, `take_profit`, `market_close`)가 `_safe_sell()`을 호출하기 전에 해당 슬롯의 쿨다운 상태를 확인하고, `now - last_attempt_at < min_retry_interval_sec`이면 해당 슬롯을 건너뛴다.
- FR-2: 쿨다운 만료(`now - last_attempt_at >= min_retry_interval_sec`) 후 트리거 조건이 여전히 충족되면 재시도한다. 재시도 성공 여부와 무관하게 `last_attempt_at`을 갱신한다.
- FR-3: `attempt_count >= max_retry_warn_count`인 슬롯에 대해 재시도 직전에 `WARNING` 로그를 발행한다. 로그에는 `slot_id`, `attempt_count`, `reason`, `last_attempt_at`을 포함한다.
- FR-4: 슬롯이 HOLDING 상태를 벗어나 `_holding()` 목록에서 사라지면(CLOSED, IDLE 등) 해당 슬롯의 `last_attempt_at`과 `attempt_count` 항목을 제거한다.
- FR-5: `SafetySellConfig`에 `min_retry_interval_sec`(`TREX_SAFETY_SELL_MIN_RETRY_INTERVAL`, optional)과 `max_retry_warn_count`(`TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT`, optional) 두 env var를 추가한다. 미설정 시 기본값으로 동작한다.
- FR-6: `docs/runtime-flows.md`를 갱신해 safety-sell tick 흐름에 "쿨다운 체크" 단계를 반영한다.
## 비기능 요구사항
- NFR-1: 쿨다운 상태는 `SafetySellService` 인메모리 `dict`로만 유지한다. DB 스키마 변경(마이그레이션) 없음.
- NFR-2: `SafetySellService`는 단일 스레드에서 호출되므로 쿨다운 `dict` 접근에 별도 락을 두지 않는다. 다중 스레드 접근이 발생하는 구조 변경은 이번 범위 밖.
- NFR-3: `instance_key = "default"` 환경에서 기존 pytest 스위트(단위 + 회귀) 전체 통과 — 동작 회귀 없음.
- NFR-4: paper 브로커 `reject_symbols` 설정으로 거부를 시뮬레이션했을 때 쿨다운이 발동하고, 쿨다운 만료 후 재시도함을 단위 테스트로 검증한다.
## 범위 경계
- In Scope
- `SafetySellService` 인메모리 per-slot 쿨다운 추적
- `SafetySellConfig` 두 env var 확장(`min_retry_interval_sec`, `max_retry_warn_count`)
- 영속 거부 시 운영자 WARNING 로그
- `docs/runtime-flows.md` safety-sell 흐름 갱신(FR-6)
- Out of Scope
- `ExecutionService` → `SafetySellService` 거부 사유 피드백 채널 신설
- 브로커 거부 원인별(휴장/잔고/규제) 차등 쿨다운 정책
- 쿨다운 상태의 DB 영속화(재시작 후 쿨다운 복원)
- 수동 쿨다운 해제 API(`resume_kill_switch` 유사)
- market_close 트리거에 대한 별도 쿨다운 분리 — market_close는 하루 1회 발동(already guarded by `_last_liquidation_date`)
# 6. 리스크/가정 목록
## 리스크
- R-1 (Medium): 쿨다운 중 트리거 조건이 실제로 해소됐을 때(예: 슬롯 수동 청산, 가격 회복) 불필요한 경고 로그가 계속 발행될 수 있다.
- 완화 방안: 슬롯이 `_holding()`에서 사라지면 쿨다운 상태를 즉시 정리한다(FR-4). 가격 회복으로 트리거 조건 자체가 해소되면 `check_prices()`에서 `_safe_sell()`을 호출하지 않으므로 `attempt_count` 증가도 없다.
- R-2 (Low): 데몬 재시작 시 쿨다운 초기화 → 재시작 직후 영속 거부 슬롯에 1회 즉시 재시도 발생.
- 완화 방안: 재시작은 의도적 행위이므로 1회 재시도는 허용. 재시작 후 재시도가 또 거부되면 쿨다운에 즉시 재진입. 문서에 운영 주의사항으로 명시.
- R-3 (Low): paper 브로커(`reject_symbols` 미설정)에서는 쿨다운이 발동하지 않아 단위 테스트 범위가 제한될 수 있다.
- 완화 방안: paper 브로커 `reject_symbols`에 테스트 대상 종목을 설정해 거부를 시뮬레이션하는 단위 테스트를 추가(NFR-4).
## 가정
- A-1: 영속 거부의 주요 원인(휴장, 잔고 부족, 규제)은 수십 분~수 시간 지속된다. 따라서 `min_retry_interval_sec` 기본값은 분 단위(예: 300s~1800s)가 적합하다.
- A-2: 슬롯이 쿨다운 중 HOLDING으로 돌아온 것은 이전 매도 시도가 브로커에서 거부됐음을 의미한다. 별도 피드백 채널 없이도 `_holding()` 재등장으로 암묵적 거부를 감지할 수 있다.
- A-3: market_close 트리거(`check_market_close`)는 하루 1회 `_last_liquidation_date`로 이미 중복 방지가 되어 있어 쿨다운 적용 대상에서 제외한다.
- A-4: `SafetySellService.run_once()`는 `trex-safety-sell` 단일 스레드에서만 호출되므로 쿨다운 `dict` 동시성 문제 없음.
## 오픈 질문
- OQ-1: `min_retry_interval_sec` 기본값 — 어느 정도가 적합한가? (예: 300s / 600s / 1800s)
- 상태: Open
- 처리 방안: Justin Ko가 실 KIS 운용 경험(휴장 시 평균 거부 지속 시간)을 기준으로 결정.
- 종료 조건: confirm-discovery 또는 REQ 단계에서 Justin Ko가 값을 확정하면 Answered.
- OQ-2: `max_retry_warn_count` 기본값 및 경고 이후 동작 — 경고 로그만? 아니면 N회 초과 시 쿨다운 무기한 전환 + 수동 해제?
- 상태: Open
- 처리 방안: Justin Ko가 운용 정책(로그만으로 충분한지, 수동 해제 필요한지)을 결정.
- 종료 조건: confirm-discovery에서 확정.
- OQ-3: 트리거 종류별(`forced_close` vs `stop_loss/take_profit`) 쿨다운 분리 여부 — 단일 per-slot 쿨다운으로 통합할지, 트리거별로 독립 쿨다운을 유지할지?
- 상태: Open
- 처리 방안: 단일 쿨다운(단순)과 트리거별 분리(세밀 제어) 트레이드오프를 Justin Ko와 협의.
- 종료 조건: confirm-discovery에서 확정.
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
# 7. 초기 성공 기준
- S-1: paper 브로커 `reject_symbols`에 테스트 종목을 설정한 상태에서 stop-loss 트리거 발동 후 `min_retry_interval_sec` 이내에 동일 슬롯 재시도가 없음을 확인한다.
- S-2: 쿨다운 만료 후 트리거 조건이 여전히 충족되면 재시도 1회 실행 및 `last_attempt_at` 갱신 확인.
- S-3: `attempt_count >= max_retry_warn_count` 도달 시 `WARNING` 로그가 발행되고 `slot_id`, `attempt_count`, `reason`이 포함됨을 확인.
- S-4: 슬롯이 CLOSED 전환 후 `_last_attempt`와 `_attempt_count`에서 해당 슬롯 항목이 제거됨을 단위 테스트로 확인.
- S-5: `instance_key = "default"` 환경에서 기존 pytest 스위트 전체 통과 — 동작 회귀 없음.
## 측정 방식 및 데이터 출처
- S-1 측정 방식: `reject_symbols` 설정 + 단위 테스트에서 `run_once()` N회 호출 후 `_safe_sell` 호출 횟수 assert / 데이터 출처: pytest 단위 테스트
- S-2 측정 방식: `last_attempt_at`을 과거 시각으로 설정 후 `run_once()` 호출 → `_safe_sell` 재호출 여부 + `last_attempt_at` 갱신 확인 / 데이터 출처: pytest 단위 테스트
- S-3 측정 방식: `attempt_count`를 `max_retry_warn_count - 1`로 설정 후 `run_once()` → `caplog`에서 WARNING 로그 포함 여부 / 데이터 출처: pytest + `caplog`
- S-4 측정 방식: slot CLOSED 전환 후 `run_once()` → `_last_attempt[slot_id]` KeyError 또는 absent 확인 / 데이터 출처: pytest 단위 테스트
- S-5 측정 방식: `pytest` 전체 실행 → 0 failures / 데이터 출처: pytest 출력
# 8. REQ로 넘기기 전 확인 체크
- [ ] 범위(In Scope/Out of Scope) 확정
- 기준: OQ-1(min_retry_interval_sec 기본값), OQ-2(max_retry_warn_count 및 경고 후 동작), OQ-3(쿨다운 분리 여부) 모두 Answered 또는 Deferred
- [ ] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-5 각각 pytest 단위 테스트 또는 manual e2e 근거 확정
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1~R-3 완화 방안 REQ 단계 인계 여부 확인 (현재 Medium/Low — High 없음)
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1, OQ-2, OQ-3 모두 Answered 또는 Deferred
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: Justin Ko 확인
# 9. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-010_20260607_safety-sell-persistent-rejection-backoff.md
- 참조 문서:
- trex_core/application/safety_sell.py (SafetySellService — 변경 대상)
- trex_core/runtime/config.py (SafetySellConfig — env var 확장 대상)
- trex_core/application/execution.py (_rollback 로직 — 참조 기준, 직접 변경 없음)
- docs/runtime-flows.md (baseline 문서 — safety-sell tick 흐름 갱신 대상, FR-6)
- docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md (원래 safety-sell Discovery — 참조 기준)
- docs/releases/rel-003_20260607_core-autonomous-safety-sell.md (REL-003 Feedback Handoff — 발견 근거)
# 10. 사용자 결정 필요 항목 요약
## DECIDE
- {{DECIDE_MIN_RETRY_INTERVAL}}: `min_retry_interval_sec` 기본값 결정 — 300s / 600s / 1800s 중 선택, 또는 다른 값. 실 KIS 운용에서 휴장·잔고 부족 등 영속 거부의 평균 지속 시간 기준으로 판단(OQ-1).
- {{DECIDE_MAX_RETRY_WARN_AND_ACTION}}: `max_retry_warn_count` 기본값 및 경고 이후 동작 결정 — 로그만 발행할지, N회 초과 시 쿨다운 무기한 전환 + 수동 해제 방식으로 전환할지(OQ-2).
- {{DECIDE_COOLDOWN_GRANULARITY}}: 단일 per-slot 쿨다운(simple) vs 트리거 종류별(`forced_close`/`stop_loss`/`take_profit`) 독립 쿨다운(granular) 결정(OQ-3).
## CONFIRM
- {{CONFIRM_MARKET_CLOSE_OUT_OF_SCOPE}}: `check_market_close` 트리거는 `_last_liquidation_date`로 하루 1회 이미 보호되어 별도 쿨다운 적용 대상 밖으로 확정하는지 확인.
## DATA
- {{DATA_REJECTION_DURATION_STATS}}: 실 KIS 운용에서 관찰된 영속 거부 지속 시간 데이터(있다면) — `min_retry_interval_sec` 기본값 결정에 필요.
# 11. Discovery Freeze
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 보류
- Handoff Rationale: Safety-sell 쿨다운 메커니즘 설계 완료. OQ-1(min_retry_interval_sec 기본값), OQ-2(경고 후 동작), OQ-3(쿨다운 분리 여부) 세 항목이 Open 상태로 confirm-discovery 전 Justin Ko 결정 필요.
- Ready for REQ Drafting: false
- Confirmed By: TBD
- Confirmed At (KST): TBD
+2 -1
View File
@@ -14,4 +14,5 @@
| 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) | confirmed |
| dcy-008_20260607_emergency-stop-kill-switch | 20260607 | 비상 정지/강제 청산(kill-switch) — portfolio/전역 슬롯 청산 + 당일 전략 제외 | [Discovery](./dcy-008_20260607_emergency-stop-kill-switch.md) | confirmed |
| dcy-009_20260607_multi-instance-rabbitmq-topology | 20260607 | 인스턴스별 독립 RabbitMQ 채널 토폴로지 — trex-core 다중 인스턴스 운영을 위한 Topic Exchange + Fanout Exchange per-instance 큐 분리 | [Discovery](./dcy-009_20260607_multi-instance-rabbitmq-topology.md) | confirmed |
| dcy-009_20260607_multi-instance-rabbitmq-topology | 20260607 | 인스턴스별 독립 RabbitMQ 채널 토폴로지 — trex-core 다중 인스턴스 운영을 위한 Topic Exchange + Fanout Exchange per-instance 큐 분리 | [Discovery](./dcy-009_20260607_multi-instance-rabbitmq-topology.md) | confirmed |
| dcy-010_20260607_safety-sell-persistent-rejection-backoff | 20260607 | safety-sell 영속 거부 재시도 백오프 — 장 휴장·잔고 부족·규제 등 지속 거부 상황에서 KIS 주문 spam 방지를 위한 슬롯별 재시도 backoff/cooldown 정책 | [Discovery](./dcy-010_20260607_safety-sell-persistent-rejection-backoff.md) | draft |