Files
trex-core/docs/discovery/dcy-010_20260607_safety-sell-persistent-rejection-backoff.md
T
genichinandClaude Opus 4.8 565af6792c feat(safety-sell): dcy-010 BAT-014 — 영속 거부 재시도 쿨다운 (REQ-052~054)
dcy-010(safety-sell 영속 거부 재시도 백오프) 전체 SDLC: draft-req → confirm-req →
draft-batch(BAT-014 batch-lite) → run-batch-delivery → confirm-req-implemented.
2026-06-07 실 KIS LIVE SL/TP e2e에서 발견한 KIS 주문 spam 위험(영속 거부 시
매도 무한 재제출) 차단.

- REQ-052 (Integration): SafetySellService per-slot 인메모리 쿨다운
  (_in_cooldown/_attempt_sell/_prune_cooldown) — forced_close·stop-loss·take-profit
  트리거에 min_retry_interval_sec(600s) 이내 재시도 skip(가격 poll 포함) +
  max_retry_warn_count(3) 도달 시 WARNING + holding 이탈 정리. market_close 제외.
- REQ-053 (Configuration): SafetySellConfig min_retry_interval_sec/max_retry_warn_count
  + TREX_SAFETY_SELL_MIN_RETRY_INTERVAL/MAX_RETRY_WARN_COUNT env (additive)
- REQ-054 (Documentation): runtime-flows 쿨다운 flow + 가격체크 tr_id(FHKST01010100) 정정

인메모리 only(DB/스레드/인터페이스 변경 없음), default 미설정 시 기존 동작 불변.
검증: 신규 8 단위(쿨다운 6 + config 2), 전체 278 passed, ruff clean.
REQ-052~054 Implemented, BAT-014 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:35:11 +09:00

18 KiB
Raw Blame History

0. 문서 상태

  • 상태: confirmed
  • 문서 ID: dcy-010_20260607_safety-sell-persistent-rejection-backoff
  • 이슈명: safety-sell 영속 거부 재시도 백오프 — 장 휴장·잔고 부족·규제 등 지속 거부 상황에서 KIS 주문 spam 방지를 위한 슬롯별 재시도 backoff/cooldown 정책
  • 작성 시각(KST): 2026-06-07
  • 마지막 갱신 시각(KST): 2026-06-08 (draft-req: REQ-052~054 생성)
  • 대체됨: 없음
  • 후속 Discovery 참조: 없음
  • 생성된 REQ 참조: REQ-052, REQ-053, REQ-054

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(기본값 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.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 = 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_close vs stop_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(safety_sell.py:160)로 하루 1회 이미 보호되어 있어 본 Discovery의 per-slot 쿨다운 적용 대상 밖으로 확정됨(A-3).

DATA

  • DATA_REJECTION_DURATION_STATS: 비차단 운영 데이터. OQ-1이 600s로 확정되어 이 수치 없이도 REQ 초안 작성 가능. 실제 운용 후 수집된 데이터가 있다면 다음 Discovery 또는 change-req 입력으로 반영.

11. Discovery Freeze

섹션 참조

Freeze 플래그

  • Handoff Decision: REQ Drafting 진행 가능
  • Handoff Rationale: Safety-sell 쿨다운 메커니즘 설계 완료. OQ-1(600s), OQ-2(3회 경고 로그), OQ-3(단일 per-slot) 모두 Answered. confirm-discovery 전 Justin Ko 최종 승인 필요.
  • Ready for REQ Drafting: true
  • Confirmed By: Justin Ko
  • Confirmed At (KST): 2026-06-07