- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
127 lines
5.9 KiB
Python
127 lines
5.9 KiB
Python
"""KIS 스트림 무수신 경보: 등록 0건(대시보드 미접속)은 정상 — 2026-10-12 운영 오경보 3건."""
|
|
import app.main as m
|
|
|
|
|
|
class _T:
|
|
def __init__(self, done):
|
|
self._d = done
|
|
|
|
def done(self):
|
|
return self._d
|
|
|
|
|
|
class _App:
|
|
class state:
|
|
pass
|
|
|
|
|
|
def _app(done=False):
|
|
a = _App()
|
|
a.state = type("S", (), {})()
|
|
a.state.kis_stream_task = _T(done)
|
|
return a
|
|
|
|
|
|
def test_zero_registrations_is_not_silent(monkeypatch):
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
r = m._kis_stream_health_v3(_app(), now=100.0 + limit + 1, market_open=True, registrations=0)
|
|
assert r["silent"] is False and r["registrations"] == 0
|
|
|
|
|
|
def test_dead_task_is_silent_even_with_zero_registrations(monkeypatch):
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
r = m._kis_stream_health_v3(_app(done=True), now=101.0, market_open=True, registrations=0)
|
|
assert r["silent"] is True
|
|
|
|
|
|
def test_resubscribe_uses_registration_time_as_reference(monkeypatch):
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
t0 = 100.0 + limit + 500 # 옛 메시지 이후 오래 지나 재구독
|
|
assert m._kis_stream_health_v3(_app(), now=t0, market_open=True, registrations=8)["silent"] is False
|
|
assert m._kis_stream_health_v3(_app(), now=t0 + limit - 1, market_open=True, registrations=8)["silent"] is False
|
|
assert m._kis_stream_health_v3(_app(), now=t0 + limit + 1, market_open=True, registrations=8)["silent"] is True
|
|
|
|
|
|
def test_registered_and_receiving_is_not_silent(monkeypatch):
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", 0.0)
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 1000.0)
|
|
assert m._kis_stream_health_v3(_app(), now=1000.0 + limit - 5, market_open=True, registrations=8)["silent"] is False
|
|
|
|
|
|
def test_unknown_registrations_falls_back_to_v2(monkeypatch):
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
assert m._kis_stream_health_v3(_app(), now=100.0 + limit + 1, market_open=True, registrations=None)["silent"] is True
|
|
|
|
|
|
# ── R9 E-1: 희망 구독 수 기준 v4 ─────────────────────────────────────────
|
|
def test_v4_desired_subscribers_zero_regs_silent(monkeypatch):
|
|
"""구독자는 있는데 등록 0(연결 단절) + 수신 이력 있고 임계 초과 → silent."""
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", 5.0)
|
|
r = m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True, registrations=0, desired=3)
|
|
assert r["silent"] is True and r["desired"] == 3
|
|
assert m._kis_stream_regs_since is None
|
|
|
|
|
|
def test_v4_no_desired_subscribers_is_normal(monkeypatch):
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
r = m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True, registrations=0, desired=0)
|
|
assert r["silent"] is False
|
|
|
|
|
|
def test_v4_regs_transition_resets_regs_since(monkeypatch):
|
|
"""regs>0 -> 0 -> >0: 0 구간에서 기준점이 리셋되고 재등록 시각이 새 기준점이 된다."""
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
t0 = 100.0 + limit + 10
|
|
m._kis_stream_health_v4(_app(), now=t0, market_open=True, registrations=5, desired=5)
|
|
assert m._kis_stream_regs_since == t0
|
|
m._kis_stream_health_v4(_app(), now=t0 + 1, market_open=True, registrations=0, desired=0)
|
|
assert m._kis_stream_regs_since is None
|
|
t1 = t0 + 50
|
|
m._kis_stream_health_v4(_app(), now=t1, market_open=True, registrations=5, desired=5)
|
|
assert m._kis_stream_regs_since == t1
|
|
|
|
|
|
def test_v4_sentinel_reads_live_sources(monkeypatch):
|
|
"""registrations/desired 기본 센티널(-1) 이면 실제 조회 함수를 쓴다."""
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
monkeypatch.setattr(m, "_kis_stream_registrations_v2", lambda: 0)
|
|
monkeypatch.setattr(m, "_kis_stream_desired_subscriptions_v2", lambda: 2)
|
|
assert m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True)["silent"] is True
|
|
monkeypatch.setattr(m, "_kis_stream_desired_subscriptions_v2", lambda: 0)
|
|
assert m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True)["silent"] is False
|
|
|
|
|
|
def test_v4_unknown_desired_falls_back_to_v3(monkeypatch):
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
monkeypatch.setattr(m, "_kis_stream_regs_since", None)
|
|
r = m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True, registrations=0, desired=None)
|
|
assert r["silent"] is False
|
|
|
|
|
|
def test_v4_dead_task_none_stream_and_off_hours(monkeypatch):
|
|
monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0)
|
|
assert m._kis_stream_health_v4(_app(done=True), now=101.0, market_open=True, registrations=0, desired=0)["silent"] is True
|
|
|
|
class _NoTask:
|
|
state = type("S", (), {})()
|
|
assert m._kis_stream_health_v4(_NoTask(), now=101.0, market_open=True, registrations=0, desired=3) is None
|
|
limit = m._HB_KIS_STREAM_SILENT_SECONDS
|
|
assert m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=False, registrations=0, desired=3)["silent"] is False
|