fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강

- 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>
This commit is contained in:
2026-10-12 15:37:16 +09:00
parent 0feaa61bfc
commit d140055e64
60 changed files with 2853 additions and 50 deletions

View File

@@ -203,6 +203,42 @@ def _scheduler_health_v2(application, now: float | None = None, market_open: boo
return out
# 프로세스 기동 시각(monotonic). 첫 하트비트 유예 판정(HB_FIRST_BEAT_GRACE_SEC)의 기준.
_PROCESS_START_MONO: float = time.monotonic()
def _scheduler_health_v3(application, now: float | None = None, market_open: bool | None = None,
uptime_sec: float | None = None) -> dict:
"""v2 + '첫 하트비트를 못 찍은 살아 있는 스케줄러'도 정체로 판정한다(2026-10-12 R9).
장중 재기동 후 restore_from_db 등 기동 처리에서 멈추면 age=None·alive=True 라 v2 는
stale/dead_silent 둘 다 False 였다. 장중 + beat 없음 + 가동 시간이 HB_FIRST_BEAT_GRACE_SEC
(기본 600초) 초과면 stale 로 보고 ``no_first_beat=True`` 를 붙여 경보 문구에 쓴다.
"""
n = now if now is not None else time.monotonic()
out = _scheduler_health_v2(application, now=n, market_open=market_open)
if market_open is None:
try:
market_open = bool(is_market_open())
except Exception as _swallowed:
warn_throttled(logger, "main:hb_mkt3", "[health] 장 상태 확인 실패: %s", _swallowed)
market_open = False
up = uptime_sec if uptime_sec is not None else (n - _PROCESS_START_MONO)
try:
grace = float(getattr(settings, "HB_FIRST_BEAT_GRACE_SEC", 600.0))
except (TypeError, ValueError):
grace = 600.0
for name, info in out.items():
no_first = bool(
market_open and name in _HEARTBEAT_WATCHED
and info["last_cycle_age"] is None and info["alive"] and up > grace
)
info["no_first_beat"] = no_first
if no_first:
info["stale"] = True
return out
# ── KIS 실시간 스트림 건강 감시 (하드닝 R1-3) ────────────────────────────
_kis_stream_last_msg_mono: float | None = None
_kis_stream_probe_installed = False
@@ -308,6 +344,41 @@ def _kis_stream_health_v3(application, now: float | None = None, market_open: bo
return base
def _kis_stream_desired_subscriptions_v2() -> int | None:
"""대시보드가 원하는 구독 종목 수(연결 상태와 무관). 조회 실패면 None."""
try:
from app.services.websocket_manager import websocket_manager
return len(websocket_manager.get_all_subscribed_codes())
except Exception as _swallowed:
warn_throttled(logger, "main:ks_desired", "[health] 희망 구독 수 확인 실패: %s", _swallowed)
return None
def _kis_stream_health_v4(application, now: float | None = None, market_open: bool | None = None,
registrations: int | None = -1, desired: int | None = -1) -> dict | None:
"""v3 + 희망 구독 수 기준 보정(2026-10-12 R9).
v3 는 KIS 등록 0건이면 무수신을 정상으로 보지만, 등록은 연결 단절·ACK 실패로도 0이 된다.
대시보드 구독자가 있는데(희망>0) 등록이 0이면 연결 문제이므로 v2 규칙(수신 이력 있고 임계
초과면 silent)으로 되돌린다. 희망 구독도 0이면 v3 와 동일(정상). 희망 수를 모르면 v3.
"""
global _kis_stream_regs_since
regs = _kis_stream_registrations_v2() if registrations == -1 else registrations
want = _kis_stream_desired_subscriptions_v2() if desired == -1 else desired
if regs is not None and regs <= 0 and want is not None and want > 0:
base = _kis_stream_health_v2(application, now=now, market_open=market_open)
if base is None:
return None
_kis_stream_regs_since = None
base["registrations"] = regs
base["desired"] = want
return base
base = _kis_stream_health_v3(application, now=now, market_open=market_open, registrations=regs)
if base is not None:
base["desired"] = want
return base
def _db_worker_health_v2(application) -> dict:
task = getattr(application.state, "db_worker_task", None)
try:
@@ -376,7 +447,7 @@ async def _heartbeat_watchdog_v2(application):
while True:
await asyncio.sleep(interval)
try:
h = _scheduler_health_v2(application)
h = _scheduler_health_v3(application)
now = time.monotonic()
for name, info in h.items():
if not info["stale"]:
@@ -385,11 +456,17 @@ async def _heartbeat_watchdog_v2(application):
if now - _stale_alerted_at.get(name, -1e9) < realert:
continue
_stale_alerted_at[name] = now
if info.get("no_first_beat"):
logger.error(f"🚨 스케줄러 '{name}' 장중 정체: 첫 하트비트 없음(기동 후 유예 초과)")
_notify_system_warning_bg(
"SCHEDULER", f"'{name}' 첫 하트비트 없음 — 기동 처리 정체 의심 (alive={info['alive']})"
)
continue
logger.error(f"🚨 스케줄러 '{name}' 장중 정체: 마지막 사이클 {info['last_cycle_age']}초 전")
_notify_system_warning_bg(
"SCHEDULER", f"'{name}' 장중 사이클 정체 {info['last_cycle_age']}초 (alive={info['alive']})"
)
ks = _kis_stream_health_v3(application)
ks = _kis_stream_health_v4(application)
if ks is None or not ks["silent"]:
_stale_alerted_at.pop("kis_stream", None)
elif now - _stale_alerted_at.get("kis_stream", -1e9) >= realert:
@@ -1334,7 +1411,7 @@ def _runtime_health_v2(market_open: bool | None = None) -> tuple[dict, list]:
detail["swing"] = "unavailable"
reasons.append("swing_health_unavailable")
try:
ks = _kis_stream_health_v3(app, market_open=market_open)
ks = _kis_stream_health_v4(app, market_open=market_open)
if ks is not None:
detail["kis_stream"] = ks
if ks["silent"]:
@@ -1430,7 +1507,7 @@ async def _health_check_impl():
warn_throttled(logger, "main:626", "[health] Redis ping 실패: %s", _swallowed)
status = "healthy" if (db_ok and redis_ok) else "degraded"
sched_info = _scheduler_health_v2(app)
sched_info = _scheduler_health_v3(app)
dbw_info = _db_worker_health_v2(app)
if (not dbw_info["alive"]) or any(v["stale"] or not v["alive"] for v in sched_info.values()):
status = "degraded"