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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user