18 KiB
18 KiB
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 (OQ-1 Answered: min_retry_interval_sec = 600s, OQ-2 Answered: max_retry_warn_count = 3 로그만 발행, OQ-3 Answered: 단일 per-slot 쿨다운)
- 대체됨: 없음
- 후속 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. 이번에 정의할 변경
SafetySellServiceper-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(기본값600, OQ-1 Answered),max_retry_warn_count(기본값3, OQ-2 Answered) 두 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, 기본값600)과max_retry_warn_count(TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT, optional, 기본값3) 두 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.mdsafety-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).
- 완화 방안: paper 브로커
가정
- A-1: 영속 거부의 주요 원인(휴장, 잔고 부족, 규제)은 수십 분~수 시간 지속된다.
min_retry_interval_sec = 600s(기본값)는 일시 거부 원인이 해소되기를 기다리는 중간값으로 적합하다(OQ-1 Answered). - 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)- 상태: Answered
- 결정:
600s(10분). 대부분의 일시 거부 원인(잔고 부족, 일시 규제)이 해소되기를 기다리는 중간값. 휴장 9시간 기준 최대 54회 재시도로 쿼터 소모를 수용 가능한 수준으로 제한. - 반영 위치: FR-5 env var 기본값, A-1
- OQ-2:
max_retry_warn_count기본값 및 경고 이후 동작 — 경고 로그만? 아니면 N회 초과 시 쿨다운 무기한 전환 + 수동 해제?- 상태: Answered
- 결정:
max_retry_warn_count = 3, 경고 로그만 발행. 무기한 차단 없음. 3회(600s × 3 = 30분 지속 거부) 시점에서 WARNING 로그 발행으로 운영자 인지. 재시도는 쿨다운 만료 후 계속 허용. - 반영 위치: FR-3, FR-5 env var 기본값
- OQ-3: 트리거 종류별(
forced_closevsstop_loss/take_profit) 쿨다운 분리 여부 — 단일 per-slot 쿨다운으로 통합할지, 트리거별로 독립 쿨다운을 유지할지?- 상태: Answered
- 결정: 단일 per-slot 쿨다운. 거부 원인(휴장/잔고/규제)은 트리거 종류와 무관하게 슬롯·시장 상태에 종속적이므로 트리거별 분리는 복잡도만 늘린다.
_last_attempt: dict[UUID, datetime]단일 구조로 구현. - 반영 위치: FR-1, Section 4 쿨다운 추적 설계
오픈 질문 처리 기준
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 = 600s(OQ-1 Answered). - DECIDE_MAX_RETRY_WARN_AND_ACTION: 확정됨.
max_retry_warn_count = 3, 경고 로그만 발행 (OQ-2 Answered). - DECIDE_COOLDOWN_GRANULARITY: 확정됨. 단일 per-slot 쿨다운 (OQ-3 Answered).
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. 요구사항 목록 > 범위 경계
- 리스크/오픈 질문: # 6. 리스크/가정 목록
- 성공 기준: # 7. 초기 성공 기준
- 인계 전 확인: # 8. REQ로 넘기기 전 확인 체크
- 사용자 결정 필요: # 10. 사용자 결정 필요 항목 요약
Freeze 플래그
- Handoff Decision: 보류
- Handoff Rationale: Safety-sell 쿨다운 메커니즘 설계 완료. OQ-1(600s), OQ-2(3회 경고 로그), OQ-3(단일 per-slot) 모두 Answered. confirm-discovery 전 Justin Ko 최종 승인 필요.
- Ready for REQ Drafting: false
- Confirmed By: TBD
- Confirmed At (KST): TBD