Files
auto-stock/backend/app/services/swing/scheduler.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

1439 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""스윙 트레이딩 독립 스케줄러
main.py의 lifespan에서 asyncio.create_task()로 시작되는
백그라운드 태스크. 기존 auto_trade_scheduler()를 대체.
60초 주기 실행 흐름:
1. 장 오픈 체크 (장외 시 30분 대기)
2. 스윙 포지션 손절/익절 체크 (check_and_execute_exit_v2)
3. MDD 체크 + 강제 축소 실행 (10% → 30% 축소, 20% → 50% 축소)
4. AUTO_TRADE_ENABLED 체크
5. Redis 캐시 또는 DB에서 추천 조회
6. MCP RISK_ASSESSMENT → 승인 시 스윙 매수 실행
MDD 축소 (CLAUDE.md 5.3):
- reduction_applied == "10pct": 30% 축소 (REDUCE_AND_PAUSE)
- reduction_applied == "20pct": 50% 축소 (FORCE_REDUCE, 레버리지 ETF 우선)
추천 데이터 소스 (우선순위):
1. Redis: "recommendations:latest" 키 (배치 분석 결과 캐시)
2. DB: get_rule_based_recommendations_from_db() (Redis 미스 시 폴백)
"""
import asyncio
import logging
import pytz
from datetime import timedelta as _td
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.market_schedule import is_market_open
from app.services.mdd_manager import mdd_manager
from app.services.swing.exit_service import check_and_execute_exit_v2
from app.services.swing.entry_service import execute_swing_entries, scan_dual_strategy_signals
logger = logging.getLogger(__name__)
_KST = pytz.timezone("Asia/Seoul")
# [M2] MDD 갱신 실패 감시 — 연속 실패가 이 횟수를 넘으면 error 로 승격
MDD_UPDATE_FAILURE_ALERT = 5
# MDD 갱신 상태 (모니터링/디버깅용 인메모리 상태)
_mdd_health: dict = {"consecutive_failures": 0, "last_error": None, "empty_account": False}
def get_mdd_update_health() -> dict:
"""MDD 갱신 상태 스냅샷 반환 (연속 실패 횟수/마지막 오류/진입 차단 여부).
swing_scheduler 의 MDD 갱신이 조용히 실패 중인지 외부에서 확인하기 위한 읽기 전용 헬퍼.
[항목7 2026-09-27] 헬스의 원본(source of truth)은 mdd_manager 로 이동했다
(can_open_new_position() 이 순환 import 없이 참조하려면 mdd_manager 가 소유해야 함).
이 함수는 시그니처를 유지한 읽기 전용 파사드이며, mdd_manager 스냅샷을 병합해
blind_block_active/threshold 까지 함께 노출한다.
"""
snapshot = dict(_mdd_health)
snapshot.update(mdd_manager.get_update_health())
return snapshot
# ─────────────────────────────────────────────────────────────────────────────
# [하드닝 R1 2026-10-09] 단계 타임아웃 · 청산 판정 하트비트 · 텔레그램 경보
#
# (1) 단계 상한: DT 의 _guard(2026-09-22 KIS 무응답 1시간 49분 정지 → EOD 청산 누락)와
# 같은 목적. 단, 취소 정책을 단계별로 나눈다(과다매도 방지가 우선).
# - 체결가 보정·잔고 조회(주문 없음): wait_for 로 **취소**한다.
# - 청산 판정·MDD 축소·진입(평균회귀+듀얼) — 주문 경로: **취소하지 않는다**.
# 상한 초과 시 루프만 풀어 다음 단계로 넘어가고, 그 작업이 끝날 때까지 같은 단계를
# 새로 시작하지 않는다. 청산은 내부 매도가 shield 라 취소해도 매도 자체는 계속되는데,
# 그 상태로 새 판정을 시작하면 같은 종목 분할 매도가 이중 실행될 수 있다. MDD 축소·
# 진입은 shield 가 없어 취소 시 "브로커 체결·장부 미반영" 후 재주문 위험이 있다.
# (2) 청산 판정 하트비트: 장중에 청산 판정이 마지막으로 정상 완료된 뒤
# SWING_EXIT_STALE_ALERT_SEC 를 넘기면 error + 텔레그램. 개장 직후는 개장 시각을 기준으로
# 계산해 밤사이 공백을 오경보하지 않는다. 사이클 하트비트(heartbeat.beat("swing"))도
# 매 반복 시작마다 찍어 main.py 감시 루프가 루프 정지를 잡게 한다.
# (3) 경보: MDD fail-safe 진입 차단 진입/해제, 청산 체크 연속 실패, 단계 타임아웃.
# 같은 key 는 SWING_ALERT_REPEAT_SEC 간격으로만 재전송한다. 전송은 백그라운드 태스크라
# 텔레그램 지연이 손절 루프를 막지 않는다. 텔레그램 미설정이면 기동 시 error 1회 +
# get_swing_scheduler_health_v2()["telegram_enabled"]=False.
# ─────────────────────────────────────────────────────────────────────────────
_DEFAULT_EXIT_STEP_TIMEOUT_SEC = 180.0
_DEFAULT_FILL_SWEEP_TIMEOUT_SEC = 60.0
_DEFAULT_BALANCE_TIMEOUT_SEC = 60.0
_DEFAULT_MDD_REDUCTION_TIMEOUT_SEC = 180.0
_DEFAULT_ENTRY_PHASE_TIMEOUT_SEC = 300.0
_DEFAULT_EXIT_FAIL_ALERT_COUNT = 3
_DEFAULT_EXIT_STALE_ALERT_SEC = 600.0
_DEFAULT_ALERT_REPEAT_SEC = 1800.0
_STEP_TIMED_OUT = object() # 상한 초과(작업은 취소 또는 백그라운드 계속)
_STEP_SKIPPED = object() # 이전 같은 단계 작업이 아직 진행 중이라 이번 사이클 생략
_exit_health: dict = {
"last_success_at": None, # monotonic — 청산 판정 정상 완료 시각
"market_open_since": None, # monotonic — 이번 장중 구간을 처음 관측한 시각
"consecutive_failures": 0, # 예외 + 타임아웃
"last_error": None,
"timeouts": 0,
}
_step_timeouts: dict = {} # step -> 누적 타임아웃 횟수
_inflight_steps: dict = {} # step -> asyncio.Task (취소하지 않는 단계의 진행 중 작업)
_alert_sent_at: dict = {} # key -> monotonic 마지막 전송 시각
_alert_tasks: set = set() # 텔레그램 백그라운드 전송 태스크 참조 유지
_blind_prev: dict = {"active": False}
_telegram_state: dict = {"enabled": None}
# [하드닝 R3 2026-10-09] 비취소형 단계 하드 데드라인 — 상한(timeout)의 N배를 넘겨도 끝나지 않는
# 작업은 critical 경보 + /health degraded. 주문이 shield 로 분리된 '청산 판정'만, 진행 중인
# shield 주문 태스크가 없을 때 강제 취소 후 다음 사이클에 재시작한다(아래 _handle_hard_deadline_v2).
_DEFAULT_STEP_HARD_DEADLINE_MULT = 5.0
_DEFAULT_STEP_CANCEL_GRACE_SEC = 10.0
_HARD_CANCEL_STEPS = frozenset({"청산 판정"})
# swing/exit_service 가 asyncio.shield 로 감싸는 "주문 전송 ~ 장부 반영" 코루틴 이름.
# 이 태스크가 살아 있으면 청산 판정을 재시작하지 않는다(같은 종목 분할매도 이중 실행 방지).
_SWING_SHIELDED_ORDER_CORO_NAMES = frozenset({"_sell_and_record_v2"})
_inflight_meta: dict = {} # step -> {"started": monotonic, "hard": 하드 데드라인(초)}
_hard_deadline_stats: dict = {"cancels": 0, "exceeded": {}} # exceeded: step -> 누적 초과 감지 횟수
# [하드닝 R3 2026-10-09] 텔레그램 경보 전송 결과 추적 — 실패를 '전송됨'으로 두지 않는다.
_DEFAULT_ALERT_SEND_RETRIES = 2 # 첫 시도 외 추가 시도 횟수(백그라운드)
_DEFAULT_ALERT_RETRY_BACKOFF_SEC = 5.0 # 시도 간 대기(선형 증가)
_DEFAULT_ALERT_FAIL_RETRY_SEC = 60.0 # 최종 실패 시 같은 key 재경보 허용까지의 간격
_telegram_send_health: dict = {
"sent": 0,
"failures": 0, # 재시도까지 모두 실패한 경보 수(누계)
"consecutive_failures": 0,
"last_success_at": None, # monotonic
"last_failure_at": None, # monotonic
"last_error": None,
}
# [하드닝 R3 2026-10-09] MDD 단계 임계 — mdd_manager.update() 가 실제로 전이하는 값과 같아야 한다.
_MDD_MANAGER_PAUSE_PCT = -10.0 # mdd_manager.update: `drawdown_pct <= -10` 리터럴
_MDD_MANAGER_FORCE_PCT = -20.0 # mdd_manager.update: `drawdown_pct <= -20` 리터럴
_mdd_knob_state: dict = {"mismatches": []}
# [하드닝 R4 2026-10-09] 스윙 튜닝 키(SWING_*_TIMEOUT_SEC, MDD_STAGE_CONFIRM_CYCLES 등)는
# config.Settings 에 선언돼 있지 않다. Settings 는 `extra="ignore"` 라 .env/compose 에 넣어도
# 속성이 생기지 않아 getattr 가 늘 모듈 기본값을 돌려줬다(운영자 오버라이드가 조용히 무시됨).
# 선언 전까지는 settings 속성 → 프로세스 환경변수 → Settings 의 env_file 순으로 직접 읽는다.
# config.py 에 선언되면 1순위(settings 속성)가 그대로 이긴다.
_env_file_cache: dict = {}
_undeclared_env_warned: set = set()
def _env_file_values_v2() -> dict:
"""Settings 가 읽는 env_file(.env) 의 키/값. 실패하면 빈 dict(경고 1회)."""
try:
cfg = getattr(type(settings), "model_config", None) or {}
path = cfg.get("env_file") if isinstance(cfg, dict) else None
except Exception as e:
logger.warning(f"[Swing] settings env_file 경로 확인 실패: {e}")
return {}
if not path or isinstance(path, (list, tuple)):
return {}
key = str(path)
if key in _env_file_cache:
return _env_file_cache[key]
vals: dict = {}
try:
import os as _os
if _os.path.isfile(key):
from dotenv import dotenv_values
vals = {k: v for k, v in dotenv_values(key).items() if v is not None}
except Exception as e:
logger.warning(f"[Swing] env_file({key}) 읽기 실패 — 스윙 튜닝 키는 기본값 사용: {e}")
vals = {}
_env_file_cache[key] = vals
return vals
def _raw_setting_v2(name: str):
"""튜닝 키 원시값. settings 속성 > os.environ > env_file. 없으면 None."""
raw = getattr(settings, name, None)
if raw is not None:
return raw
import os as _os
src = None
if name in _os.environ:
raw, src = _os.environ.get(name), "환경변수"
else:
file_vals = _env_file_values_v2()
if name in file_vals:
raw, src = file_vals[name], "env_file"
if src is not None and name not in _undeclared_env_warned:
_undeclared_env_warned.add(name)
logger.warning(
f"[Swing] 설정 {name}={raw!r} 는 config.Settings 에 미선언 — {src}에서 직접 적용"
f"(Settings 에 선언 필요)"
)
return raw
def _cfg_float_v2(name: str, default: float) -> float:
"""settings 값을 양수 float 로 읽는다. 누락·비정상 값이면 기본값(상한을 끄지 않는다)."""
raw = _raw_setting_v2(name)
try:
val = float(raw) if raw is not None else float(default)
except (TypeError, ValueError):
logger.warning(f"[Swing] 설정 {name}={raw!r} 비정상 — 기본값 {default} 사용")
return float(default)
if not val > 0:
logger.warning(f"[Swing] 설정 {name}={raw!r} 는 양수가 아님 — 기본값 {default} 사용")
return float(default)
return val
def _now_mono_v2() -> float:
import time as _time
return _time.monotonic()
def _telegram_enabled_v2() -> bool:
try:
from app.services.telegram_notifier import _is_enabled
return bool(_is_enabled())
except Exception as e:
logger.warning(f"[Swing] 텔레그램 설정 확인 실패: {type(e).__name__}: {e}")
return False
def _check_telegram_configured_v2() -> bool:
enabled = _telegram_enabled_v2()
_telegram_state["enabled"] = enabled
if not enabled:
logger.error(
"[Swing] 🚨 텔레그램 미설정(TELEGRAM_BOT_TOKEN/CHAT_ID) — 청산 정지·MDD 진입 차단·"
"단계 타임아웃 경보가 로그로만 남는다"
)
return enabled
async def _send_system_warning_checked_v2(category: str, message: str) -> "bool | None":
"""텔레그램 시스템 경고를 보내고 **전송 결과**를 돌려준다.
True=전송 성공, False=전송 실패(비200·전송 오류), None=텔레그램 미설정(전송 시도 안 함).
telegram_notifier.notify_system_warning 은 _send_message 의 bool 을 버리므로 같은 포맷으로
공개 API send_message(bool 반환)를 직접 호출한다.
"""
from app.services.telegram_notifier import telegram_notifier, _is_enabled, _escape_markdown_v2
if not _is_enabled():
return None
esc = _escape_markdown_v2
text = "\n".join([
f"\U0001F6A8 *{esc('시스템 경고')}*",
"",
f"\U0001F3F7 {esc('유형')}: {esc(category)}",
f"\U0001F4DD {esc(message)}",
])
return bool(await telegram_notifier.send_message(text))
def _note_telegram_send_result_v2(ok: bool, err: str | None = None) -> None:
h = _telegram_send_health
now = _now_mono_v2()
if ok:
h["sent"] += 1
h["consecutive_failures"] = 0
h["last_success_at"] = now
return
h["failures"] += 1
h["consecutive_failures"] += 1
h["last_failure_at"] = now
h["last_error"] = err
def _notify_throttled_v2(key: str, category: str, message: str, repeat_sec: float | None = None) -> bool:
"""같은 key 는 repeat_sec 간격으로만 텔레그램 시스템 경고를 보낸다(백그라운드, 비차단).
반환: 이번 호출에서 전송을 예약했으면 True. 로그는 호출부가 남긴다.
[하드닝 R3] 전송 결과를 확인한다. 실패(비200·예외)면 백그라운드에서 재시도하고, 끝내
실패하면 실패 누계를 올리고(/health) 이 key 의 억제 시각을 되돌려 짧은 간격
(SWING_ALERT_FAIL_RETRY_SEC) 뒤 다음 호출이 다시 보내게 한다 — 실패를 '전송됨'으로 두지 않는다.
"""
interval = repeat_sec if repeat_sec is not None else _cfg_float_v2(
"SWING_ALERT_REPEAT_SEC", _DEFAULT_ALERT_REPEAT_SEC
)
now = _now_mono_v2()
last = _alert_sent_at.get(key)
if last is not None and now - last < interval:
return False
_alert_sent_at[key] = now
stamp = now
try:
retries = _cfg_int_v2("SWING_ALERT_SEND_RETRIES", _DEFAULT_ALERT_SEND_RETRIES, minimum=0)
backoff = _cfg_float_v2("SWING_ALERT_RETRY_BACKOFF_SEC", _DEFAULT_ALERT_RETRY_BACKOFF_SEC)
fail_retry = _cfg_float_v2("SWING_ALERT_FAIL_RETRY_SEC", _DEFAULT_ALERT_FAIL_RETRY_SEC)
async def _send():
err = None
for attempt in range(retries + 1):
try:
ok = await _send_system_warning_checked_v2(category=category, message=message)
except Exception as e:
ok = False
err = f"{type(e).__name__}: {e}"
if ok is None:
return # 미설정 — 실패가 아니다(기동 시 error + /health telegram_enabled)
if ok:
_note_telegram_send_result_v2(True)
return
if err is None:
err = "sendMessage 실패(비200 응답 또는 전송 오류 — telegram_notifier warning 로그 참조)"
if attempt < retries:
await asyncio.sleep(backoff * (attempt + 1))
_note_telegram_send_result_v2(False, err)
# 억제 롤백 — 다른 전송이 그 사이 key 를 갱신하지 않았을 때만
if _alert_sent_at.get(key) == stamp:
_alert_sent_at[key] = stamp - max(0.0, interval - fail_retry)
logger.error(
f"[Swing] 🚨 텔레그램 경보 전송 실패({key}, {retries + 1}회 시도): {err} — "
f"{min(interval, fail_retry):.0f}초 뒤 재경보 허용. 원문: {message}"
)
task = asyncio.create_task(_send())
_alert_tasks.add(task)
task.add_done_callback(_alert_tasks.discard)
return True
except Exception as e:
if _alert_sent_at.get(key) == stamp:
if last is None:
_alert_sent_at.pop(key, None)
else:
_alert_sent_at[key] = last
logger.warning(f"[Swing] 텔레그램 경보 예약 실패({key}): {type(e).__name__}: {e}")
return False
def _record_step_timeout_v2(step: str, timeout: float, cancelled: bool) -> None:
_step_timeouts[step] = _step_timeouts.get(step, 0) + 1
how = "취소" if cancelled else "백그라운드 계속(취소 안 함 — 주문 중복 방지), 끝날 때까지 이 단계 생략"
msg = f"스윙 '{step}' {timeout:.0f}초 초과 — {how} (KIS 무응답·락 대기 가능성)"
logger.error(f"[Swing] ⏱️ {msg}")
_notify_throttled_v2(f"timeout:{step}", "SWING_SCHEDULER", msg)
async def _guard_cancel_v2(coro, step: str, timeout: float):
"""취소형 상한(DT _guard 패턴). 초과 시 작업을 취소하고 _STEP_TIMED_OUT 반환.
작업이 낸 예외는 그대로 전파한다(호출부의 기존 처리 유지).
"""
try:
return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
_record_step_timeout_v2(step, timeout, cancelled=True)
return _STEP_TIMED_OUT
def _log_detached_result_v2(step: str, task: "asyncio.Task") -> None:
if task.cancelled():
logger.warning(f"[Swing] 상한 초과 후 백그라운드 '{step}' 취소됨")
return
exc = task.exception()
if exc is not None:
logger.error(f"[Swing] 상한 초과 후 백그라운드 '{step}' 예외: {type(exc).__name__}: {exc}")
else:
logger.warning(f"[Swing] 상한 초과 후 백그라운드 '{step}' 완료")
def _hard_deadline_sec_v2(timeout: float) -> float:
"""비취소형 단계의 하드 데드라인(초) = 상한 × SWING_STEP_HARD_DEADLINE_MULT(최소 1배)."""
mult = _cfg_float_v2("SWING_STEP_HARD_DEADLINE_MULT", _DEFAULT_STEP_HARD_DEADLINE_MULT)
if mult < 1.0:
logger.warning(f"[Swing] SWING_STEP_HARD_DEADLINE_MULT={mult} < 1 — 기본값 {_DEFAULT_STEP_HARD_DEADLINE_MULT} 사용")
mult = _DEFAULT_STEP_HARD_DEADLINE_MULT
return float(timeout) * mult
def _pending_shielded_order_tasks_v2() -> list:
"""살아 있는 스윙 shield 주문 태스크(_sell_and_record_v2) — 있으면 청산 판정 재시작 금지."""
out = []
for t in asyncio.all_tasks():
if t.done():
continue
coro = t.get_coro()
if getattr(coro, "__name__", "") in _SWING_SHIELDED_ORDER_CORO_NAMES:
out.append(t)
return out
async def _handle_hard_deadline_v2(step: str, prev: "asyncio.Task") -> bool:
"""진행 중인 비취소형 단계가 하드 데드라인을 넘겼는지 판정·처리.
반환 True = 이전 작업을 취소·종료시켰으므로 이번 사이클에 새로 시작해도 된다.
반환 False = 그대로 생략(_STEP_SKIPPED).
정책(과다매도·손절 무력화 양쪽을 막는 쪽):
- 하드 데드라인 초과 시 모든 단계에 critical 로그 + 텔레그램 + /health degraded.
- 강제 취소는 _HARD_CANCEL_STEPS('청산 판정')만. 이 단계의 주문 전송~장부 반영은
swing/exit_service 가 asyncio.shield(_sell_and_record_v2) 로 분리해 두었으므로 바깥
작업을 취소해도 '전송됨·장부 미반영'이 생기지 않는다. 단 그 shield 태스크가 살아 있으면
(=주문 전송 구간 진행 중) 취소하지 않는다 — 재시작한 판정이 같은 종목을 이중 분할매도할 수 있다.
- 'MDD 축소'·'진입' 은 trade_executor/mdd_manager 주문이 shield 되어 있지 않아 취소 시
중복 주문 위험이 있으므로 경보만 한다(진입 정지는 fail-closed 방향).
"""
meta = _inflight_meta.get(step) or {}
started = meta.get("started")
hard = meta.get("hard")
if started is None or hard is None:
return False
age = _now_mono_v2() - float(started)
if age <= float(hard):
return False
_hard_deadline_stats["exceeded"][step] = _hard_deadline_stats["exceeded"].get(step, 0) + 1
can_cancel = step in _HARD_CANCEL_STEPS
shielded = _pending_shielded_order_tasks_v2() if can_cancel else []
if can_cancel and not shielded:
prev.cancel()
grace = _cfg_float_v2("SWING_STEP_CANCEL_GRACE_SEC", _DEFAULT_STEP_CANCEL_GRACE_SEC)
await asyncio.wait({prev}, timeout=grace)
if prev.done():
_inflight_steps.pop(step, None)
_inflight_meta.pop(step, None)
_hard_deadline_stats["cancels"] += 1
msg = (
f"스윙 '{step}' 이 하드 데드라인 {hard:.0f}초를 넘겨 {age:.0f}초 정체 — 강제 취소 후 "
f"이번 사이클에 재시작(진행 중 주문 없음 확인). 정체 원인(락·외부 호출) 확인 필요"
)
logger.critical(f"[Swing] 🚨 {msg}")
_notify_throttled_v2(f"hard_deadline:{step}", "SWING_SCHEDULER_STUCK", msg)
return True
how = f"취소 요청이 {grace:.0f}초 안에 처리되지 않음(취소 무시)"
elif can_cancel:
how = f"shield 주문 태스크 {len(shielded)}건 진행 중 — 이중 매도 방지로 취소하지 않음"
else:
how = "주문 경로(비 shield)라 취소하지 않음 — 중복 주문 방지"
msg = (
f"스윙 '{step}' 이 하드 데드라인 {hard:.0f}초를 넘겨 {age:.0f}초 정체 — {how}. "
f"재기동 등 수동 조치 필요"
)
logger.critical(f"[Swing] 🚨 {msg}")
_notify_throttled_v2(f"hard_deadline:{step}", "SWING_SCHEDULER_STUCK", msg)
return False
async def _guard_detached_v2(factory, step: str, timeout: float):
"""비취소형 상한 — 주문 경로(MDD 축소·진입)용.
- 같은 step 의 이전 작업이 진행 중이면 새로 시작하지 않고 _STEP_SKIPPED.
- 상한 안에 끝나면 결과 반환(예외는 전파).
- 초과하면 작업은 취소하지 않고 남겨 둔 채 _STEP_TIMED_OUT 반환 → 루프는 다음
사이클(청산 우선)로 진행한다. 주문 전송 중 취소로 "브로커 체결·장부 미반영"이
생겨 같은 물량을 다시 주문하는 것을 막기 위함이다.
- 스케줄러 자신이 취소되면(종료) 작업은 취소하지 않는다(shield 와 같은 의미).
"""
prev = _inflight_steps.get(step)
if prev is not None and not prev.done():
# [하드닝 R3] 하드 데드라인 판정은 이 분기 맨 앞, try 밖(가드)에서 한다.
if not await _handle_hard_deadline_v2(step, prev):
msg = f"스윙 '{step}' 이전 작업이 아직 진행 중 — 이번 사이클 생략"
logger.error(f"[Swing] ⏱️ {msg}")
_notify_throttled_v2(f"inflight:{step}", "SWING_SCHEDULER", msg)
return _STEP_SKIPPED
task = asyncio.ensure_future(factory())
_inflight_steps[step] = task
_inflight_meta[step] = {"started": _now_mono_v2(), "hard": _hard_deadline_sec_v2(timeout)}
done, _pending = await asyncio.wait({task}, timeout=timeout)
if task in done:
_inflight_steps.pop(step, None)
_inflight_meta.pop(step, None)
return task.result()
task.add_done_callback(lambda t, _s=step: _log_detached_result_v2(_s, t))
_record_step_timeout_v2(step, timeout, cancelled=False)
return _STEP_TIMED_OUT
def _note_market_open_v2(now: float) -> None:
if _exit_health["market_open_since"] is None:
_exit_health["market_open_since"] = now
def _note_market_closed_v2() -> None:
_exit_health["market_open_since"] = None
def _record_exit_success_v2(now: float) -> None:
if _exit_health["consecutive_failures"]:
logger.warning(
f"[Swing] 청산 판정 복구 (연속 실패 {_exit_health['consecutive_failures']}회 후 정상화)"
)
_had_fail_alert = _alert_sent_at.pop("exit_failures", None) is not None
_had_stale_alert = _alert_sent_at.pop("exit_stale", None) is not None
if _had_fail_alert or _had_stale_alert:
_notify_throttled_v2(
"exit_recovered", "SWING_EXIT",
f"스윙 청산 판정 복구 (연속 실패 {_exit_health['consecutive_failures']}회 후)",
repeat_sec=0.0,
)
_exit_health["last_success_at"] = now
_exit_health["consecutive_failures"] = 0
_exit_health["last_error"] = None
try:
from app.core import heartbeat as _hb
_hb.beat("swing_exit", "exit_ok")
except Exception as e:
logger.warning(f"[Swing] 청산 하트비트 기록 실패: {type(e).__name__}: {e}")
def _record_exit_failure_v2(err: str, timed_out: bool = False) -> None:
_exit_health["consecutive_failures"] += 1
_exit_health["last_error"] = err
if timed_out:
_exit_health["timeouts"] += 1
n = _exit_health["consecutive_failures"]
threshold = int(_cfg_float_v2("SWING_EXIT_FAIL_ALERT_COUNT", _DEFAULT_EXIT_FAIL_ALERT_COUNT))
if n >= threshold:
msg = f"스윙 청산 판정 연속 실패 {n}회 — 손절·익절이 멈춰 있을 수 있음: {err}"
logger.error(f"[Swing] 🚨 {msg}")
_notify_throttled_v2("exit_failures", "SWING_EXIT", msg)
def _check_exit_staleness_v2(now: float) -> bool:
"""장중 청산 판정 무완료 시간이 상한을 넘으면 error + 텔레그램. 넘었으면 True.
기준 시각 = max(마지막 정상 완료, 이번 장중 구간 첫 관측) — 밤사이 공백은 세지 않는다.
"""
open_since = _exit_health["market_open_since"]
if open_since is None:
return False
last_ok = _exit_health["last_success_at"]
ref = open_since if last_ok is None else max(last_ok, open_since)
age = now - ref
limit = _cfg_float_v2("SWING_EXIT_STALE_ALERT_SEC", _DEFAULT_EXIT_STALE_ALERT_SEC)
if age <= limit:
return False
msg = (
f"스윙 청산 판정이 장중 {age:.0f}초 동안 완료되지 않음(상한 {limit:.0f}초) — "
f"보유 종목 손절이 방치될 수 있음. 마지막 오류: {_exit_health['last_error']}"
)
logger.error(f"[Swing] 🚨 {msg}")
_notify_throttled_v2("exit_stale", "SWING_EXIT", msg)
return True
def _check_blind_block_transition_v2() -> None:
"""MDD fail-safe 진입 차단(blind_block_active) 진입·해제 시점에 경보."""
try:
h = mdd_manager.get_update_health() or {}
except Exception as e:
logger.warning(f"[Swing] MDD 헬스 조회 실패: {type(e).__name__}: {e}")
return
active = bool(h.get("blind_block_active"))
prev = _blind_prev["active"]
_blind_prev["active"] = active
if active and not prev:
msg = (
f"MDD 갱신 연속 실패 {h.get('consecutive_failures')}회 — 신규 매수 전면 차단(fail-safe). "
f"청산은 계속. 마지막 오류: {h.get('last_error')}"
)
logger.error(f"[Swing] 🚨 {msg}")
_notify_throttled_v2("mdd_blind_on", "MDD_FAILSAFE", msg)
elif prev and not active:
logger.warning("[Swing] MDD fail-safe 진입 차단 해제(갱신 정상화)")
_notify_throttled_v2("mdd_blind_off", "MDD_FAILSAFE", "MDD 갱신 정상화 — 신규 매수 차단 해제")
def get_swing_scheduler_health_v2() -> dict:
"""스윙 스케줄러 감시 상태(읽기 전용) — /health 노출용.
청산 판정 경과·연속 실패·단계 타임아웃·진행 중 단계·텔레그램 설정 여부.
"""
now = _now_mono_v2()
last_ok = _exit_health["last_success_at"]
open_since = _exit_health["market_open_since"]
ref = None
if open_since is not None:
ref = open_since if last_ok is None else max(last_ok, open_since)
stale_limit = _cfg_float_v2("SWING_EXIT_STALE_ALERT_SEC", _DEFAULT_EXIT_STALE_ALERT_SEC)
exit_age = None if ref is None else round(now - ref, 1)
inflight_ages: dict = {}
stuck: list = []
for k, t in _inflight_steps.items():
if t.done():
continue
meta = _inflight_meta.get(k) or {}
if meta.get("started") is None:
continue
a = now - float(meta["started"])
inflight_ages[k] = round(a, 1)
if meta.get("hard") is not None and a > float(meta["hard"]):
stuck.append(k)
stuck.sort()
tg = _telegram_send_health
reasons: list = []
if stuck:
reasons.append("stuck_steps")
if exit_age is not None and exit_age > stale_limit:
reasons.append("exit_stale")
if tg["consecutive_failures"] > 0:
reasons.append("telegram_send_failing")
if _mdd_knob_state["mismatches"]:
reasons.append("mdd_threshold_knob_mismatch")
# [하드닝 R4] 청산 판정 실행 실패·종목별 연속 예외·매도 거부(계좌 단위 포함)
try:
from app.services.swing.exit_service import get_swing_exit_health_v2
_exh = get_swing_exit_health_v2()
except Exception as e:
logger.warning(f"[Swing] 청산 감시 상태 조회 실패: {type(e).__name__}: {e}")
_exh = {"degraded_reasons": ["exit_health_unavailable"]}
for _r in _exh.get("degraded_reasons") or ():
if _r not in reasons:
reasons.append(_r)
return {
"exit_last_success_age": None if last_ok is None else round(now - last_ok, 1),
"exit_unjudged_market_sec": exit_age,
"exit_stale": bool(exit_age is not None and exit_age > stale_limit),
"exit_consecutive_failures": _exit_health["consecutive_failures"],
"exit_timeouts": _exit_health["timeouts"],
"exit_last_error": _exit_health["last_error"],
"step_timeouts": dict(_step_timeouts),
"inflight_steps": sorted(k for k, t in _inflight_steps.items() if not t.done()),
"inflight_step_ages": inflight_ages,
"stuck_steps": stuck,
"hard_deadline_cancels": _hard_deadline_stats["cancels"],
"hard_deadline_exceeded": dict(_hard_deadline_stats["exceeded"]),
"mdd_blind_block_active": _blind_prev["active"],
"mdd_threshold_knob_mismatch": list(_mdd_knob_state["mismatches"]),
"telegram_enabled": (
_telegram_state["enabled"] if _telegram_state["enabled"] is not None else _telegram_enabled_v2()
),
"telegram_sent": tg["sent"],
"telegram_send_failures": tg["failures"],
"telegram_consecutive_failures": tg["consecutive_failures"],
"telegram_last_success_age": (
None if tg["last_success_at"] is None else round(now - tg["last_success_at"], 1)
),
"telegram_last_error": tg["last_error"],
"exit_symbol_error_counts": _exh.get("symbol_error_counts", {}),
"exit_sell_reject_counts": _exh.get("sell_reject_counts", {}),
"exit_sell_reject_last": _exh.get("sell_reject_last", {}),
"exit_last_run": _exh.get("last_run"),
"status": "degraded" if reasons else "ok",
"degraded_reasons": reasons,
}
# ─────────────────────────────────────────────────────────────────────────────
# [하드닝 R2 2026-10-09] MDD 갱신 입력 가드 — 입출금·부분응답을 낙폭으로 오인하지 않게
#
# 결함: tot_evlu_amt 원값을 그대로 mdd_manager.update() 에 넣어, 출금(또는 KIS 가 일부 종목
# 평가를 0 으로 준 응답) 한 번으로 drawdown<=-10% → is_paused + 전 보유 30% 시장가 축소
# (되돌릴 수 없는 주문)가 실행됐다. 입금은 peak 를 부풀리거나 진행 중인 MDD 정지를 해제했다.
#
# (1) 현금흐름 보정: KIS 국내잔고 항등식 tot_evlu_amt = scts_evlu_amt + prvs_rcdl_excc_amt
# (portfolio.py 참조)에서 현금 성분 = tot − scts. 직전 관측 대비 현금 성분이 총자산의
# MDD_CASH_FLOW_MIN_RATIO(기본 2%) 이상 움직였는데 유가평가(scts)는 그 변화의
# MDD_CASH_FLOW_SCTS_TOLERANCE(기본 25%) 이내로만 움직였으면 외부 입출금으로 본다.
# 매수·매도는 현금↔유가가 같이 움직이고 시세 손실은 유가 쪽에서만 나므로 이 판정에
# 걸리지 않는다(실손실을 입출금으로 숨기는 방향의 오판을 구조적으로 피한다).
# 입출금이면 peak 를 (직전총액+흐름)/직전총액 비율로 스케일해 낙폭 %를 보존한다.
# (2) 단계 전이 확인: 이번 값이 새 MDD 단계(-10%/-20%)를 유발하면 즉시 반영하지 않는다.
# (a) 보유 종목 평가 교차검증 — hldg_qty>0 인데 evlu_amt/prpr 가 0 이하이거나, output1
# 평가합이 scts_evlu_amt 와 어긋나면 부분응답으로 보고 갱신 실패 처리(fail-safe 계수).
# MDD_STAGE_SUSPECT_MAX_HOLDS(기본 5)회 연속이면 그대로 반영한다 — 실제 낙폭을
# 무기한 억제하지 않기 위해서다(§5.3).
# (b) MDD_STAGE_CONFIRM_CYCLES(기본 2) 사이클 연속 전이 조건이어야 반영한다. 대기 중인
# 사이클은 신규 진입을 차단한다(실제 낙폭일 수 있으므로 보수적으로).
# 전이가 아닌 갱신(peak 갱신·단계 내 변동)은 이전과 동일하게 즉시 반영한다.
# 한계(추정): 입출금과 매매가 같은 사이클에 겹치면 (1)이 걸리지 않고 (2)만 적용된다.
# ─────────────────────────────────────────────────────────────────────────────
_DEFAULT_MDD_STAGE_CONFIRM_CYCLES = 2
_DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS = 5
_DEFAULT_MDD_CASH_FLOW_MIN_RATIO = 0.02
_DEFAULT_MDD_CASH_FLOW_SCTS_TOLERANCE = 0.25
_DEFAULT_MDD_EVAL_SUM_TOLERANCE = 0.05
_mdd_gate_state: dict = {
"prev": None, # 직전 정상 관측 {"total": float, "scts": float|None}
"pending": 0, # 단계 전이 조건 연속 관측 횟수
"suspect": 0, # 부분응답 의심으로 보류한 연속 횟수
"last_flow": None, # 마지막으로 보정한 외부 현금흐름(원)
}
def _cfg_int_v2(name: str, default: int, minimum: int = 1) -> int:
raw = _raw_setting_v2(name)
if isinstance(raw, str):
raw = raw.strip()
try:
val = int(raw) if raw is not None else int(default)
except (TypeError, ValueError):
logger.warning(f"[Swing] 설정 {name}={raw!r} 비정상 — 기본값 {default} 사용")
return int(default)
if val < minimum:
logger.warning(f"[Swing] 설정 {name}={raw!r} < {minimum} — 기본값 {default} 사용")
return int(default)
return val
def _num_or_none_v2(raw):
if raw is None or str(raw).strip() == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _mdd_stage_thresholds_v2() -> tuple:
"""(pause, force) 임계(음수 %) — mdd_manager.update() 가 **실제로 전이하는** 값.
[하드닝 R3 2026-10-09] 예전에는 settings.MDD_PAUSE/FORCE_THRESHOLD 중 얕은 쪽을 썼다. 그래서
설정이 -8 이면 -8%~-10% 구간에서 전이를 '예측'해 확인 사이클(신규 진입 차단)을 채운 뒤
update() 를 불러도 mdd_manager(-10 리터럴)는 전이하지 않고, pending 이 리셋됐다가 다시 쌓여
축소 없이 진입만 막혔다. 예측은 실제 전이와 같아야 하므로 mdd_manager 리터럴을 쓴다.
설정/DB 값이 이와 다르면 _check_mdd_threshold_knobs_v2 가 '효과 없는 손잡이'로 경보한다.
"""
return _MDD_MANAGER_PAUSE_PCT, _MDD_MANAGER_FORCE_PCT
def _mdd_threshold_mismatches_v2(sources: dict) -> list:
"""sources = {"settings": {key: raw}, "strategy_cfg": {key: raw}} → 실효 임계와 다른 항목 목록."""
effective = {"MDD_PAUSE_THRESHOLD": _MDD_MANAGER_PAUSE_PCT, "MDD_FORCE_THRESHOLD": _MDD_MANAGER_FORCE_PCT}
out = []
for src, vals in sources.items():
for key, eff in effective.items():
v = _num_or_none_v2((vals or {}).get(key))
if v is None:
continue
if abs(-abs(v) - eff) > 1e-9:
out.append({"source": src, "key": key, "value": v, "effective": eff})
return out
async def _check_mdd_threshold_knobs_v2() -> list:
"""MDD 임계 '조절 손잡이'(settings·strategy_configs DB)가 실효값과 다르면 error + 텔레그램.
mdd_manager.update() 는 -10/-20 리터럴로만 전이하므로 다른 값은 아무 효과가 없다
(운영자가 UI 에서 바꿔도 반영되지 않는다). 읽기 전용 — strategy_cfg 캐시 조회(SELECT)만.
"""
sources: dict = {"settings": {
"MDD_PAUSE_THRESHOLD": getattr(settings, "MDD_PAUSE_THRESHOLD", None),
"MDD_FORCE_THRESHOLD": getattr(settings, "MDD_FORCE_THRESHOLD", None),
}}
try:
from app.services.strategy_config_service import strategy_cfg
db_vals = {}
for key in ("MDD_PAUSE_THRESHOLD", "MDD_FORCE_THRESHOLD"):
db_vals[key] = await asyncio.wait_for(strategy_cfg.aget(key, None), timeout=10.0)
sources["strategy_cfg"] = db_vals
except Exception as e:
logger.warning(f"[Swing] MDD 임계 DB 파라미터 조회 실패(대조 생략): {type(e).__name__}: {e}")
mism = _mdd_threshold_mismatches_v2(sources)
_mdd_knob_state["mismatches"] = mism
if mism:
detail = ", ".join(f"{m['source']}.{m['key']}={m['value']}" for m in mism)
msg = (
f"MDD 임계 설정이 실효값(진입중단 {_MDD_MANAGER_PAUSE_PCT}% / 강제축소 {_MDD_MANAGER_FORCE_PCT}%)과 "
f"다름 — 효과 없음(mdd_manager 는 고정 임계로 전이): {detail}"
)
logger.error(f"[Swing] 🚨 {msg}")
_notify_throttled_v2("mdd_threshold_knob", "MDD_CONFIG", msg)
return mism
def _detect_cash_flow_v2(prev, total: float, scts) -> float | None:
"""직전 관측 대비 외부 입출금 추정액(원, 출금 음수). 판정 불가·해당 없음이면 None."""
if not prev or scts is None or prev.get("scts") is None:
return None
prev_total = float(prev.get("total") or 0.0)
if prev_total <= 0:
return None
d_cash = (total - scts) - (prev_total - float(prev["scts"]))
d_scts = scts - float(prev["scts"])
min_ratio = _cfg_float_v2("MDD_CASH_FLOW_MIN_RATIO", _DEFAULT_MDD_CASH_FLOW_MIN_RATIO)
tol = _cfg_float_v2("MDD_CASH_FLOW_SCTS_TOLERANCE", _DEFAULT_MDD_CASH_FLOW_SCTS_TOLERANCE)
if abs(d_cash) < prev_total * min_ratio:
return None
if abs(d_scts) > abs(d_cash) * tol:
return None # 매매(현금↔유가 동반 이동) 또는 시세 변동 — 입출금으로 보지 않음
return d_cash
def _holdings_eval_suspect_v2(balance_resp, scts) -> str | None:
"""보유 평가 교차검증 — 부분응답 의심 사유 문자열, 정상/판정불가면 None.
필드가 아예 없는 경우는 의심으로 보지 않는다(필드명 불일치로 실제 MDD 를 막지 않게).
"""
holdings = (balance_resp or {}).get("output1")
if not isinstance(holdings, list):
return None
evals = []
for h in holdings:
if not isinstance(h, dict):
continue
qty = _num_or_none_v2(h.get("hldg_qty"))
if not qty or qty <= 0:
continue
ev = _num_or_none_v2(h.get("evlu_amt"))
px = _num_or_none_v2(h.get("prpr"))
code = h.get("pdno") or "?"
if (ev is not None and ev <= 0) or (px is not None and px <= 0):
return f"보유 {code} 평가 0 (evlu_amt={h.get('evlu_amt')!r}, prpr={h.get('prpr')!r})"
evals.append(ev)
if scts is None or not evals or any(e is None for e in evals):
return None
tol = _cfg_float_v2("MDD_EVAL_SUM_TOLERANCE", _DEFAULT_MDD_EVAL_SUM_TOLERANCE)
s = sum(evals)
if s > scts * (1 + tol) + 1:
return f"보유 평가합 {s:,.0f} > 유가평가 {scts:,.0f}"
from app.services.mdd_manager import _balance_page_full_v2
if not _balance_page_full_v2(holdings) and s < scts * (1 - tol) - 1:
return f"보유 평가합 {s:,.0f} < 유가평가 {scts:,.0f} (페이지 미만)"
return None
def _predict_mdd_transition_v2(total: float) -> str | None:
"""이 값으로 update() 하면 새 단계로 전이하는지 (I/O 없음, 판정만)."""
peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0)
if getattr(mdd_manager, "_rebase_peak_pending", False) or peak <= 0 or total >= peak:
return None
dd = (total - peak) / peak * 100.0
pause, force = _mdd_stage_thresholds_v2()
applied = getattr(mdd_manager, "reduction_applied", None)
if dd <= force and applied != "20pct":
return "20pct"
if dd <= pause and applied is None:
return "10pct"
return None
async def _rescale_peak_for_cash_flow_v2(prev_total: float, flow: float) -> None:
"""외부 입출금만큼 peak 를 비율 스케일(낙폭 % 보존) + Redis 영속화(락 밖)."""
after = prev_total + flow
if prev_total <= 0 or after <= 0:
return
ratio = after / prev_total
async with mdd_manager._lock:
old = float(mdd_manager.peak_balance or 0.0)
if old <= 0:
return
mdd_manager.peak_balance = old * ratio
_mdd_gate_state["last_flow"] = flow
logger.warning(
f"[Swing] MDD 입출금 감지 추정 {flow:+,.0f}원 — peak {old:,.0f} → "
f"{old * ratio:,.0f} 보정(낙폭 % 보존). 수동 확인 권장"
)
_notify_throttled_v2(
"mdd_cash_flow", "MDD_CASH_FLOW",
f"계좌 입출금 추정 {flow:+,.0f}원 — MDD peak 를 비율 보정했습니다(낙폭 오인 방지). 수동 확인 권장",
)
await mdd_manager.save_to_redis()
def _mdd_has_known_balance_v2() -> bool:
"""[하드닝 R1-라운드1] MDD 관리자가 이미 양(+)의 자산을 관측했는가 (I/O 없음).
peak_balance 또는 마지막 갱신 잔고가 >0 이면 '운용 중 계좌'로 본다. 이 상태의
tot_evlu_amt<=0 응답은 빈 계좌가 아니라 부분/오류 응답으로 취급해야 한다.
값 해석이 불가하면 True(=빈 계좌로 인정하지 않음 — fail-safe 쪽).
"""
try:
peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0)
last = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
except (TypeError, ValueError) as e:
logger.warning(f"[Swing] MDD 기존 잔고 해석 실패 — 빈 계좌 판정 불허: {type(e).__name__}: {e}")
return True
return peak > 0 or last > 0
async def _mdd_update_guarded_v2(total: float, balance_resp) -> dict:
"""mdd_manager.update(total) 앞단 가드. 반환 {"status": "applied"|"pending", "result": dict|None}.
부분응답 의심은 ValueError 로 올려 호출부의 갱신 실패 경로(fail-safe 계수)를 탄다.
"""
st = _mdd_gate_state
out2 = ((balance_resp or {}).get("output2") or [{}])[0] or {}
scts = _num_or_none_v2(out2.get("scts_evlu_amt"))
prev = st["prev"]
flow = _detect_cash_flow_v2(prev, total, scts)
if flow is not None:
await _rescale_peak_for_cash_flow_v2(float(prev["total"]), flow)
stage = _predict_mdd_transition_v2(total)
if stage is None:
st["pending"] = 0
st["suspect"] = 0
st["prev"] = {"total": total, "scts": scts}
return {"status": "applied", "result": await mdd_manager.update(total)}
suspect = _holdings_eval_suspect_v2(balance_resp, scts)
if suspect:
st["suspect"] += 1
max_holds = _cfg_int_v2("MDD_STAGE_SUSPECT_MAX_HOLDS", _DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS)
if st["suspect"] < max_holds:
# 직전 정상 관측(prev)은 유지 — 부분응답을 흐름 판정 기준으로 쓰지 않는다
raise ValueError(
f"MDD {stage} 전이 보류 — 잔고 부분응답 의심 {st['suspect']}/{max_holds}: {suspect}"
)
logger.error(
f"[Swing] 🚨 MDD {stage} 전이 — 부분응답 의심이 {st['suspect']}회 연속이라 그대로 반영: {suspect}"
)
st["suspect"] = 0
st["prev"] = {"total": total, "scts": scts}
st["pending"] += 1
need = _cfg_int_v2("MDD_STAGE_CONFIRM_CYCLES", _DEFAULT_MDD_STAGE_CONFIRM_CYCLES)
if st["pending"] < need:
logger.warning(
f"[Swing] MDD {stage} 전이 조건 관측 {st['pending']}/{need} — 확인 대기"
f"(총평가 {total:,.0f}, peak {mdd_manager.peak_balance:,.0f}). 이번 사이클 신규 진입 차단"
)
return {"status": "pending", "result": None}
st["pending"] = 0
return {"status": "applied", "result": await mdd_manager.update(total)}
async def _run_exit_phase_v2():
"""청산 판정 1회. 청산 판정 성공/실패를 직접 기록한다.
비취소형 상한(_guard_detached_v2) 아래에서 돌기 때문에 세션을 스스로 연다 — 상한 초과로
루프가 먼저 진행해도 세션이 작업보다 먼저 닫히지 않게 하기 위함이다.
[하드닝 R8 2026-10-10] 체결가 후속 보정(sweep)은 여기서 분리해 _run_fill_sweep_phase_v2
(취소형 별도 단계)로 옮겼다. 이전엔 sweep(최대 SWING_FILL_SWEEP_TIMEOUT_SEC)이 청산 판정과
같은 비취소형 상한(SWING_EXIT_STEP_TIMEOUT_SEC)을 공유해, 청산이 끝났는데도 상한 초과로
'청산 실패' 집계·다음 사이클 _STEP_SKIPPED·가짜 연속 실패 경보가 날 수 있었다.
반환: check_and_execute_exit_v2 결과(list) 또는 예외 시 None.
"""
exits = None
# DB-READ-EXCEPTION: 청산 판정용 SELECT 전용 세션(commit 없음, 쓰기는 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT, 시간손절 TradeHistory SELECT 포함
async with AsyncSessionLocal() as exit_session:
try:
# [하드닝 R4 2026-10-09] check_and_execute_exit_v2 는 '전체 오류'·'보유 종목 전부 예외'를
# 삼키고 정상 반환한다. 실행 일련번호로 이번 실행분 결과를 확인해 실패로 집계한다
# (이전엔 무조건 성공 기록 → 연속 실패·무완료 경보 우회).
from app.services.swing import exit_service as _es_run
_seq0 = _es_run.get_last_exit_run_status_v2()["seq"]
exits = await check_and_execute_exit_v2(exit_session)
_run_st = _es_run.get_last_exit_run_status_v2()
if _run_st["seq"] != _seq0 and not _run_st["ok"]:
logger.error(f"[Swing] 청산 판정 실패(예외 삼킴 경로): {_run_st['error']}")
_record_exit_failure_v2(str(_run_st["error"]))
else:
_record_exit_success_v2(_now_mono_v2())
if exits:
logger.info(f"[Swing] 청산 실행: {len(exits)}건")
except Exception as e:
logger.error(f"[Swing] 청산 체크 오류: {e}", exc_info=True)
_record_exit_failure_v2(f"{type(e).__name__}: {e}")
return exits
async def _run_fill_sweep_phase_v2():
"""체결가 미확인 TradeHistory 후속 보정(swing/fill_sweep) — 청산 판정과 분리된 취소형 단계.
주문이 없는 조회·보정 경로라 취소형 상한(SWING_FILL_SWEEP_TIMEOUT_SEC)을 쓴다.
갱신은 db_task_queue 경유(swing/fill_sweep). 청산 판정의 성공/실패 집계에 관여하지 않는다.
청산 판정 작업이 아직 살아 있으면(상한 초과 후 백그라운드 계속) 이번 사이클은 생략한다 —
진행 중인 매도와 같은 행을 동시에 보정하지 않기 위함(이전 동작: sweep 은 청산 판정 뒤에만 실행).
반환: 보정 건수, _STEP_TIMED_OUT, 또는 생략·예외 시 None.
"""
_prev_exit = _inflight_steps.get("청산 판정")
if _prev_exit is not None and not _prev_exit.done():
logger.warning("[Swing] 청산 판정 진행 중 — 체결가 후속 보정 이번 사이클 생략")
return None
try:
from app.services.swing.fill_sweep import sweep_unconfirmed_fills_v2
# DB-READ-EXCEPTION: 체결가 후속 보정 대상 SELECT 전용 세션(commit 없음, 갱신은 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT
async with AsyncSessionLocal() as sweep_session:
_swept = await _guard_cancel_v2(
sweep_unconfirmed_fills_v2(sweep_session), "체결가 후속 보정",
_cfg_float_v2("SWING_FILL_SWEEP_TIMEOUT_SEC", _DEFAULT_FILL_SWEEP_TIMEOUT_SEC),
)
if _swept and _swept is not _STEP_TIMED_OUT:
logger.info(f"[Swing] 체결가 후속 보정: {_swept}건")
return _swept
except Exception as e:
logger.warning(f"[Swing] 체결가 후속 보정 실패: {type(e).__name__}: {e}")
return None
async def _run_entry_phase_v2(
get_json, get_rule_based_recommendations_from_db, request_mcp_risk_assessment,
) -> None:
"""평균회귀 진입 → 듀얼 스캔(순서 유지, M-1). 하나의 비취소형 단계로 묶어 상한을 건다.
평균회귀 단계 예외는 듀얼 스캔을 막지 않는다(H5).
"""
try:
await _run_recommendation_entry_v2(
get_json,
get_rule_based_recommendations_from_db,
request_mcp_risk_assessment,
)
except Exception as e:
logger.error(f"[Swing] 평균회귀 진입 단계 오류 — 듀얼 스캔은 계속: {e}", exc_info=True)
# 듀얼 전략(모멘텀 돌파·눌림목) — MCP 평가 대상 아님(2026-10-02 사용자 결정)
await _run_dual_strategy_scan_v2()
def _normalize_recommendations(rec: dict) -> dict:
"""recommendations_by_rule 키 정규화.
Redis 캐시에 "recommendations" 키로 저장된 경우
"recommendations_by_rule"로 변환하여 execute_swing_entries()와 호환.
Args:
rec: 추천 딕셔너리
Returns:
dict: 정규화된 추천 딕셔너리
"""
if not isinstance(rec, dict):
return rec
if "recommendations_by_rule" in rec:
return rec
if "recommendations" in rec:
rec["recommendations_by_rule"] = rec["recommendations"]
return rec
def _filter_unsuitable_stocks(rec: dict, unsuitable_codes: set) -> None:
"""장 전 AI 분석에서 부적합 판정된 종목을 추천 목록에서 제거."""
groups = rec.get("recommendations_by_rule", [])
if isinstance(groups, list):
for group in groups:
if isinstance(group, dict) and "stocks" in group:
group["stocks"] = [
s for s in group["stocks"]
if isinstance(s, dict) and s.get("stock_code") not in unsuitable_codes
]
elif isinstance(groups, dict):
for key, stocks in groups.items():
if isinstance(stocks, list):
groups[key] = [
s for s in stocks
if isinstance(s, dict) and s.get("stock_code") not in unsuitable_codes
]
async def _run_dual_strategy_scan_v2() -> None:
"""듀얼 전략(모멘텀 돌파·눌림목) 스캔 1회 — 추천/MCP 경로와 독립 (H5 2026-10-02).
게이트(장 시간·BULL 전용·MDD·Crisis)는 scan_dual_strategy_signals 내부에 있고,
주문은 execute_auto_trade_background 의 일일 한도·리스크 가드를 그대로 탄다.
예외는 이 사이클의 평균회귀 경로를 막지 않도록 warning 으로 흡수한다.
[3회차] 평균회귀 진입 다음에 호출된다(M-1). 루프의 AUTO_TRADE_ENABLED 게이트 뒤에서만
불리지만 단독 호출에도 안전하도록 여기서 한 번 더 확인한다(try 밖). 플레이북·지수
서킷브레이커·일일 한도 선확인은 scan_dual_strategy_signals 내부(H-1/M-1).
MCP 리스크 평가 대상 아님(2026-10-02 사용자 결정, H-2).
"""
if not settings.AUTO_TRADE_ENABLED:
logger.info("[Swing] AUTO_TRADE_ENABLED=False — 듀얼 전략 스캔 스킵")
return
try:
# DB-READ-EXCEPTION: 듀얼 전략 후보 SELECT 전용(commit 없음) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT
async with AsyncSessionLocal() as dual_session:
dual_results = await scan_dual_strategy_signals(dual_session)
if dual_results:
# [2026-09-27] scan_dual_strategy_signals() 가 위임하는
# execute_auto_trade_background() 는 항상 list 를 반환하며,
# 그 list 에는 성공 BUY 뿐 아니라 주문 실패(success=False)한
# BUY/SELL, 청산 우선 로직으로 섞인 SELL 도 포함된다.
# 따라서 len(dual_results) 를 그대로 "진입 n건"으로 쓰지 않고
# action/success 기준으로 분리 집계한다(상세 집계는
# entry_service.scan_dual_strategy_signals() 내부 로그 참조).
buy_success = sum(
1 for r in dual_results
if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success")
)
sell_count = sum(
1 for r in dual_results
if isinstance(r, dict) and r.get("action") == "SELL"
)
fail_count = sum(
1 for r in dual_results
if isinstance(r, dict) and not r.get("success")
)
logger.info(
f"[Swing] 듀얼 전략 — 진입 {buy_success}건 / "
f"청산 {sell_count}건 / 주문실패 {fail_count}건"
)
except Exception as e:
logger.warning(f"[Swing] 듀얼 전략 스캔 오류: {e}")
async def _run_recommendation_entry_v2(
get_json, get_rule_based_recommendations_from_db, request_mcp_risk_assessment,
) -> None:
"""평균회귀(추천 기반) 진입 1회 — 추천 조회 → AI 장전 필터 → MCP 평가 → 진입.
[M-1 2026-10-02 3회차] swing_scheduler 루프 본문에서 분리했다. 조기 종료(추천 없음·
필터 후 0종목·MCP 거부)는 `continue` 가 아니라 `return` 이라, 호출부가 이어서
듀얼 스캔(_run_dual_strategy_scan_v2)을 항상 실행할 수 있다(H5 의도 유지).
인자는 순환 import 를 피하려고 루프에서 지연 import 한 함수들을 그대로 받는다.
"""
# ── Phase 3: 추천 조회 + 진입 실행 (별도 세션) ──
# 4. Redis 캐시 확인 → cache miss 시 DB 조회
rec = await get_json("recommendations:latest")
# [FIX] Redis 캐시가 존재해도 recommendations_by_rule이 비어있으면(배치가 빈
# 추천을 캐싱한 경우) 기존엔 '히트'로 처리해 DB 폴백을 건너뛰고 스킵했다.
# 빈 캐시가 30분 TTL 동안 DB의 유효 후보를 가려 매매를 막으므로, 비어있으면
# 캐시 미스와 동일하게 DB 조회로 폴백한다.
if rec and rec.get("recommendations_by_rule"):
rec = _normalize_recommendations(rec)
logger.info(f"[Swing] Redis 캐시 히트 (추천 {len(rec.get('recommendations_by_rule', []))}그룹)")
else:
logger.info("[Swing] Redis 캐시 미스/빈값 → DB 조회")
# DB-READ-EXCEPTION: 추천 목록 SELECT 전용(commit 없음) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT
async with AsyncSessionLocal() as rec_session:
rec = await get_rule_based_recommendations_from_db(rec_session)
if not rec or not rec.get("recommendations_by_rule"):
logger.info("[Swing] 추천 데이터 없음 — 스킵")
return
# 5. 장 전 AI 분석 결과 활용 + MCP 리스크 평가 (최적화)
# 장 전 Sonnet 배치 분석 결과가 Redis에 있으면 부적합 종목을 사전 필터링
# MCP RISK_ASSESSMENT는 실제 매수 직전에만 호출 (비용 절감)
_ai_filtered_codes = set()
try:
_ai_pre = await get_json("ai:pre_market:latest")
if _ai_pre and isinstance(_ai_pre, dict):
for s in _ai_pre.get("suitable_stocks", []):
if isinstance(s, dict) and not s.get("suitable", True):
_ai_filtered_codes.add(s.get("stock_code", ""))
if _ai_filtered_codes:
logger.info(f"[Swing] AI 장 전 분석 필터: {len(_ai_filtered_codes)}종목 제외")
except Exception as _swallowed:
logger.warning("[Swing] AI 장 전 분석 필터 적용 실패: %s", _swallowed)
# 추천 목록에서 AI 부적합 종목 제거
if _ai_filtered_codes:
_filter_unsuitable_stocks(rec, _ai_filtered_codes)
recommendations = rec.get("recommendations_by_rule", [])
if isinstance(recommendations, dict):
rec_count = sum(len(v) for v in recommendations.values())
top_stocks = [
item.get("stock_code", "")
for items in recommendations.values()
for item in (items[:3] if isinstance(items, list) else [])
][:10]
else:
rec_count = sum(
len(g.get("stocks", [])) for g in recommendations
if isinstance(g, dict)
)
top_stocks = [
s.get("stock_code", "")
for g in recommendations if isinstance(g, dict)
for s in g.get("stocks", [])[:3]
][:10]
if not top_stocks:
logger.info("[Swing] 필터링 후 추천 종목 없음 — 스킵")
return
# MCP 리스크 평가 (Haiku — 실제 진입 직전 1회)
trade_plan = {
"action": "BUY",
"strategy": "swing",
"total_recommendations": rec_count,
"top_stocks": top_stocks,
}
logger.info(f"[Swing] MCP 리스크 평가 요청: {len(top_stocks)}종목")
mcp_approved, mcp_reason = await request_mcp_risk_assessment(
trade_plan, {"strategy": "swing"}, {"top_stocks": top_stocks}
)
logger.info(f"[Swing] MCP 결과: approved={mcp_approved}, reason={mcp_reason}")
if not mcp_approved:
logger.warning(f"[Swing] MCP 거부: {mcp_reason}")
from app.services.db_worker import enqueue_db_write
await enqueue_db_write({
"type": "log_mcp_deny",
"reason": mcp_reason,
"trade_plan": trade_plan,
})
return
# 6. 스윙 진입 실행 (별도 세션)
# DB-READ-EXCEPTION: 진입 후보 조회 SELECT 전용(commit 없음, 주문 기록은 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT
async with AsyncSessionLocal() as entry_session:
logger.info(f"[Swing] 진입 실행 시작: {top_stocks[:5]}")
# execute_swing_entries() 내부에서 진입/청산/실패 건수를 이미
# 분리 집계해 로깅하므로(entry_service.py 참조), 여기서는 결과를
# 그대로 소비하지 않고 반환값(list, 2026-09-27 이후 항상 list)만
# 확인한다.
await execute_swing_entries(rec, entry_session)
async def swing_scheduler() -> None:
"""60초 주기 스윙 트레이딩 스케줄러.
무한 루프로 실행되며 asyncio.CancelledError로만 종료.
각 사이클에서 예외 발생 시 로그 기록 후 다음 사이클 계속.
DB 세션은 사이클마다 AsyncSessionLocal 로 새로 생성하여
장시간 세션 유지로 인한 커넥션 풀 고갈 방지.
"""
await asyncio.sleep(2)
from app.api.endpoints.recommendations import (
get_rule_based_recommendations_from_db,
)
from app.api.endpoints.mcp import request_mcp_risk_assessment
from app.core.redis import get_json
logger.info("📈 [Swing] Scheduler started (every 60s)")
_check_telegram_configured_v2()
try:
await _check_mdd_threshold_knobs_v2()
except Exception as e:
logger.warning(f"[Swing] MDD 임계 설정 대조 실패: {type(e).__name__}: {e}")
_mdd_update_failures = 0
_mdd_last_error: str | None = None
# [M3] '정상 응답이지만 총평가금액 0'(빈/미입금 계좌) 누적 횟수 — 장애 카운터와 분리
_mdd_empty_account_seen = 0
try:
while True:
try:
logger.info("[Swing] ── 사이클 시작 ──")
# [하드닝 R1] 사이클 하트비트 — 반복 시작마다 찍는다(어느 단계에서 멈추든
# main.py 감시 루프가 장중 정체로 잡도록). 기록 실패가 루프를 막지 않게 흡수.
try:
from app.core import heartbeat as _hb
_hb.beat("swing", "cycle_start")
except Exception as _hb_e:
logger.warning(f"[Swing] 사이클 하트비트 기록 실패: {type(_hb_e).__name__}: {_hb_e}")
if not is_market_open():
_note_market_closed_v2()
logger.info("[Swing] 장 미오픈 — 개장까지 대기")
# 개장을 넘겨 자지 않도록 상한 (2026-09-23)
from app.core.market_schedule import seconds_until_market_open
_until_open = seconds_until_market_open()
_closed_sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))
# [하드닝 R4 2026-10-09] 종가 동시호가(15:20~15:30) 주문은 15:30 에 체결된다 —
# 장 마감 직후 창에서 체결가 후속 보정(swing/fill_sweep). 창 안이면 대기를 줄인다.
try:
from app.services.swing.fill_sweep import run_post_close_fill_sweep_v2
_pc_sleep = await run_post_close_fill_sweep_v2()
if _pc_sleep is not None:
_closed_sleep = min(_closed_sleep, float(_pc_sleep))
except Exception as _pc_e:
logger.warning(f"[Swing] 장 마감 후 체결가 보정 실패: {type(_pc_e).__name__}: {_pc_e}")
await asyncio.sleep(_closed_sleep)
continue
# ── Phase 1: 청산 체크 (별도 세션, _run_exit_phase_v2) ──
# [하드닝 R1] 비취소형 상한(SWING_EXIT_STEP_TIMEOUT_SEC). 초과 시 판정 작업은
# 백그라운드로 계속되고 루프는 MDD 단계로 진행한다. 끝날 때까지 새 청산 판정을
# 시작하지 않는다 — 취소 후 재시작하면 shield 로 계속 진행 중인 매도와 새 판정의
# 매도가 같은 종목에 겹쳐 분할 매도가 이중 실행될 수 있다(과다매도).
_note_market_open_v2(_now_mono_v2())
_exit_timeout = _cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", _DEFAULT_EXIT_STEP_TIMEOUT_SEC)
try:
_exit_res = await _guard_detached_v2(_run_exit_phase_v2, "청산 판정", _exit_timeout)
if _exit_res is _STEP_TIMED_OUT:
_record_exit_failure_v2(f"청산 판정 {_exit_timeout:.0f}초 초과", timed_out=True)
elif _exit_res is _STEP_SKIPPED:
_record_exit_failure_v2("이전 청산 판정이 아직 진행 중(상한 초과 상태)")
except Exception as e:
logger.error(f"[Swing] 청산 단계 오류: {e}", exc_info=True)
_record_exit_failure_v2(f"{type(e).__name__}: {e}")
try:
from app.core import heartbeat as _hb
_hb.beat("swing", "exit_phase_done")
except Exception as _hb_e:
logger.warning(f"[Swing] 사이클 하트비트 기록 실패: {type(_hb_e).__name__}: {_hb_e}")
# 청산 판정 무완료 감시(장중, 개장 시각 기준) — try 밖(가드)
_check_exit_staleness_v2(_now_mono_v2())
# [하드닝 R8] 체결가 후속 보정 — 청산 판정 상한과 분리된 취소형 단계(내부에서 예외 흡수)
await _run_fill_sweep_phase_v2()
# ── Phase 2: MDD 업데이트 + 체크 + 강제 축소 ──
# [FIX] mdd_manager.update()를 매 사이클 호출하여 MDD 감지 활성화
# [M2 2026-09-27] 여기가 mdd_manager.update() 의 유일한 실효 호출부다.
# 실패를 debug 로 삼키면 KIS 장애 시 peak/drawdown 이 갱신되지 않아
# -10%/-20% 게이트가 무증상으로 영구 미발동한다 → 경고 + 연속 실패 카운트.
# [M3 2026-09-27] "응답 없음/파싱 실패"(진짜 장애)와 "정상 응답 + 총평가금액
# 0"(빈/미입금 계좌)을 구분한다. 후자는 정상 응답이므로 실패로 계수하지 않고
# MDD 갱신만 스킵한다 — 그러지 않으면 신규 모의계좌에서 매 사이클 실패가
# 누적돼 🚨 error 로그가 계속 찍혀 실제 KIS 장애와 구분되지 않는다.
_empty_account = False
_mdd_stage_pending = False
try:
from app.services.korea_investment_api import korea_investment_api
# [하드닝 R1] 읽기 전용 → 취소형 상한. 초과는 TimeoutError 로 아래 실패 경로를 탄다.
balance_resp = await asyncio.wait_for(
korea_investment_api.get_balance(),
timeout=_cfg_float_v2("SWING_BALANCE_TIMEOUT_SEC", _DEFAULT_BALANCE_TIMEOUT_SEC),
)
if not balance_resp:
raise ValueError("잔고 조회 응답 없음 (resp=없음)")
_out2 = balance_resp.get("output2")
if not isinstance(_out2, list) or not _out2:
raise ValueError("잔고 응답 파싱 실패 (output2 누락/형식 이상)")
_raw_total = (_out2[0] or {}).get("tot_evlu_amt")
if _raw_total is None or str(_raw_total).strip() == "":
raise ValueError("잔고 응답 파싱 실패 (tot_evlu_amt 누락)")
try:
_total = float(_raw_total)
except (TypeError, ValueError):
raise ValueError(f"잔고 응답 파싱 실패 (tot_evlu_amt={_raw_total!r})")
if _total <= 0 and _mdd_has_known_balance_v2():
# [하드닝 R1-라운드1] 이미 자산을 관측한(peak/last>0) 운용 계좌에서 0 은
# '빈 계좌'가 아니라 부분/오류 응답으로 본다 → 실패 경로(fail-safe 계수).
# 빈 계좌로 분류하면 blind 차단·staleness 가 모두 해제돼 실제 -10%/-20%
# 구간에서도 신규 진입이 열린다. (실제 전액 출금이면 자산 0 이라 매수
# 자체가 불가하므로 차단 비용이 없다.)
raise ValueError(
f"잔고 응답 이상 (tot_evlu_amt={_total:,.0f} 이나 기존 peak="
f"{float(getattr(mdd_manager, 'peak_balance', 0.0) or 0.0):,.0f} — "
f"빈 계좌로 보지 않고 갱신 실패 처리)"
)
if _total <= 0:
# 정상 응답이지만 자산 0 — 빈 계좌/미입금(자산 관측 이력 없음). 장애 아님.
_empty_account = True
_mdd_empty_account_seen += 1
if _mdd_empty_account_seen == 1 or _mdd_empty_account_seen % 60 == 0:
logger.info(
f"[Swing] 잔고 총평가금액 0 (빈/미입금 계좌) — MDD 갱신 스킵 "
f"({_mdd_empty_account_seen}회 누적)"
)
else:
_mdd_empty_account_seen = 0
if _empty_account:
# 정상 응답(장애 아님) — 실패 카운터 리셋 + 빈 계좌 표시.
# 빈 계좌는 진입 차단 사유가 아니다(항목7).
await mdd_manager.record_update_success(empty_account=True)
if _total > 0:
# [하드닝 R2] 입출금 보정 + 단계 전이 확인(부분응답 의심은 ValueError → 실패 경로)
_guarded = await _mdd_update_guarded_v2(_total, balance_resp)
_mdd_stage_pending = _guarded["status"] == "pending"
mdd_result = _guarded["result"] or {"action": "PENDING_CONFIRM"}
# [항목7] 갱신 성공 → fail-safe 차단 해제
await mdd_manager.record_update_success(empty_account=False)
if _mdd_update_failures:
logger.warning(
f"[Swing] MDD 갱신 복구 (연속 실패 {_mdd_update_failures}회 후 정상화)"
)
_mdd_update_failures = 0
_mdd_last_error = None
if mdd_result.get("action") != "NONE":
logger.warning(f"[Swing] MDD 상태 변경: {mdd_result}")
except Exception as e:
_mdd_update_failures += 1
_mdd_last_error = str(e)
# [항목7] 실패를 mdd_manager 에 보고 → 임계 도달 시
# can_open_new_position() 이 모든 진입 경로를 차단한다(청산은 무영향).
await mdd_manager.record_update_failure(_mdd_last_error)
if _mdd_update_failures >= MDD_UPDATE_FAILURE_ALERT:
logger.error(
f"[Swing] 🚨 MDD 갱신 연속 실패 {_mdd_update_failures}회 — "
f"peak/drawdown 미갱신 상태, -10%/-20% 게이트가 작동하지 않을 수 있음: {e}",
exc_info=True,
)
else:
logger.warning(
f"[Swing] MDD 갱신 실패 ({_mdd_update_failures}회 연속): {e}"
)
# 상태 노출용 (모니터링/헬스체크에서 참조 가능)
_mdd_health["consecutive_failures"] = _mdd_update_failures
_mdd_health["last_error"] = _mdd_last_error
else:
_mdd_health["consecutive_failures"] = _mdd_update_failures
_mdd_health["last_error"] = _mdd_last_error
_mdd_health["empty_account"] = _empty_account
can_open_new = await mdd_manager.can_open_new_position()
# [하드닝 R2] MDD 단계 전이 확인 대기 중엔 신규 진입 차단 (try 밖 — 가드)
if _mdd_stage_pending:
can_open_new = False
logger.info(f"[Swing] MDD 체크: can_open={can_open_new}")
# [하드닝 R1] fail-safe 진입 차단 진입/해제 경보 (판정 자체는 위 메서드가 소유)
_check_blind_block_transition_v2()
if not can_open_new:
if mdd_manager.get_update_health().get("blind_block_active"):
logger.error(
f"[Swing] 🚨 MDD 갱신 장애로 신규 진입 차단(fail-safe, 연속 실패 "
f"{_mdd_update_failures}회, 마지막 오류: {_mdd_last_error}) — "
f"청산/축소는 계속 수행"
)
else:
logger.warning("[Swing] MDD 초과 — 신규 진입 차단")
if mdd_manager.reduction_applied:
try:
reduce_pct = 0.50 if mdd_manager.reduction_applied == "20pct" else 0.30
mdd_action = {
"action": "FORCE_REDUCE" if mdd_manager.reduction_applied == "20pct" else "REDUCE_AND_PAUSE",
"reduce_pct": reduce_pct,
"prioritize_leverage": mdd_manager.reduction_applied == "20pct",
}
# [하드닝 R1] 주문 경로라 비취소형 상한 — 초과 시 백그라운드로 남기고
# 끝날 때까지 새 축소를 시작하지 않는다(이중 축소 방지).
results = await _guard_detached_v2(
lambda: mdd_manager.execute_reduction(mdd_action), "MDD 축소",
_cfg_float_v2("SWING_MDD_REDUCTION_TIMEOUT_SEC", _DEFAULT_MDD_REDUCTION_TIMEOUT_SEC),
)
if results is _STEP_TIMED_OUT or results is _STEP_SKIPPED:
results = None
if results:
logger.warning(f"[Swing] MDD 강제 축소 완료: {len(results)}종목")
except Exception as e:
logger.error(f"[Swing] MDD 강제 축소 실패: {e}", exc_info=True)
await asyncio.sleep(60)
continue
# 3. 자동매매 활성화 여부
if not settings.AUTO_TRADE_ENABLED:
logger.info("[Swing] AUTO_TRADE_ENABLED=False — 스킵")
await asyncio.sleep(60)
continue
# ── Phase 3: 평균회귀(추천 기반) 진입 → 듀얼 전략 스캔 ──
# [M-1 2026-10-02 3회차] 듀얼 스캔을 평균회귀 진입 **다음**으로 옮겼다.
# 일일 매수 한도(MAX_DAILY_TRADES)를 두 경로가 공유하므로, 듀얼이 먼저 돌면
# 평균회귀 후보가 슬롯을 잃었다. 평균회귀 단계의 조기 종료(추천 없음·필터 후
# 0종목·MCP 거부)·예외는 듀얼을 건너뛰지 않는다(H5 의도 유지) — 헬퍼가
# continue 대신 return 하고, 예외는 여기서 흡수한다. 한도가 이미 찼으면
# scan_dual_strategy_signals 가 일봉 조회 전에 생략한다.
# [하드닝 R1] 진입(평균회귀→듀얼)은 주문 경로라 비취소형 상한. 초과 시 루프는
# 다음 사이클(청산 우선)로 진행하고, 끝날 때까지 새 진입을 시작하지 않는다.
await _guard_detached_v2(
lambda: _run_entry_phase_v2(
get_json,
get_rule_based_recommendations_from_db,
request_mcp_risk_assessment,
),
"진입",
_cfg_float_v2("SWING_ENTRY_PHASE_TIMEOUT_SEC", _DEFAULT_ENTRY_PHASE_TIMEOUT_SEC),
)
logger.info("[Swing] ── 사이클 완료 ──")
except Exception as e:
logger.error(f"[Swing] 스케줄러 사이클 오류: {e}", exc_info=True)
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("[Swing] Scheduler stopped")
raise