Files
auto-stock/backend/app/services/schedulers.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

1568 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.
"""
배치 분석, 장 종료 후 분석, KRX 데이터 수집 스케줄러 모듈
- batch_analysis_scheduler(): 10분 간격 배치 분석 (stock_analysis DB 업데이트)
- post_market_analyzer_scheduler(): 장 종료 후 매매일지 생성 + 전략 최적화
- krx_data_collector_scheduler(): 장 종료 후 일봉 데이터 수집
- _normalize_recommendations(): 추천 결과 포맷 정규화 헬퍼
"""
import asyncio
import logging
import pytz
from datetime import datetime as _dt, date as _date, timedelta as _td
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.timeutil import now_kst
from app.core.market_schedule import (
is_market_open, seconds_until_market_open, is_pre_market_window,
)
logger = logging.getLogger(__name__)
_KST_TZ = pytz.timezone("Asia/Seoul")
# 16:00 배치의 "오늘 실행했는가" 마커. 인메모리 `_last_run` 만 쓰면 재기동마다
# 리셋돼 같은 날 두 번 돈다(2026-09-29 15:5x 배포가 정확히 이 상황이었다).
# DT 의 `daytrading:daily:{date}` 와 같은 패턴. TTL 48h — 날짜 비교를 별도로 하므로
# 값이 하루 넘게 남아도 무해하고, 무한정 남지 않게만 제한한다.
_POST_MARKET_LAST_RUN_KEY = "scheduler:post_market:last_run"
_POST_MARKET_LAST_RUN_TTL = 172_800
def _post_market_decide(now: _dt, last_run: "_date | None") -> tuple[str, float]:
"""16:00 배치 루프의 **순수** 결정 함수 — ("run"|"sleep", 초).
루프 본문에서 분리한 이유: "16:00 직후에 기동하면 당일을 건너뛰는가?" 를
테스트로 확정할 수 있어야 한다. 의미는 기존 인라인 로직과 동일하다.
- `last_run == today` → 익일 16:00 까지 대기 (최소 60초)
- 16:00 이전 → 16:00 까지 대기 (한 번에 최대 1800초 — 시계 점프 대비 재평가)
- 주말 → 1시간 대기 (`last_run` 을 남기지 않는다)
- 그 외(= 평일 16:00 이후이고 아직 미실행) → **즉시 실행**.
`hour >= 16` 이면 `tomorrow_16` 분기로 새지 않으므로 **건너뜀 경로는 없다.**
"""
today = now.date()
if last_run == today:
tomorrow_16 = _KST_TZ.localize(
_dt(today.year, today.month, today.day, 16, 0, 0)
) + _td(days=1)
return "sleep", max((tomorrow_16 - now).total_seconds(), 60)
if (now.hour, now.minute) < (16, 0):
target = _KST_TZ.localize(_dt(today.year, today.month, today.day, 16, 0, 0))
sleep_sec = (target - now).total_seconds()
if sleep_sec > 0:
return "sleep", min(sleep_sec, 1800)
if today.weekday() >= 5:
return "sleep", 3600.0
return "run", 0.0
async def _load_post_market_last_run() -> "_date | None":
"""Redis 에서 마지막 16:00 배치 실행일 복원. 실패·부재 시 None(= 인메모리 동작)."""
try:
from app.core.redis import get_json
data = await get_json(_POST_MARKET_LAST_RUN_KEY)
if not data:
return None
return _date.fromisoformat(str(data.get("date") or ""))
except Exception as e:
logger.warning(
f"[PostMarket] 마지막 실행일 복원 실패 — 재기동 당일 중복 실행 가능: {e}"
)
return None
async def _save_post_market_last_run(day: _date) -> None:
"""16:00 배치 실행일을 Redis 에 기록(재기동 후 중복 실행 방지)."""
try:
from app.core.redis import setex_json
ok = await setex_json(
_POST_MARKET_LAST_RUN_KEY, _POST_MARKET_LAST_RUN_TTL, {"date": day.isoformat()}
)
if not ok:
logger.warning(
"[PostMarket] ⚠️ 마지막 실행일 저장 실패(Redis) — 재기동 시 같은 날 "
"배치가 다시 돈다(일지는 uncovered 판정으로 멱등, 텔레그램 리포트는 중복)"
)
except Exception as e:
logger.warning(f"[PostMarket] ⚠️ 마지막 실행일 저장 실패: {e}")
# ── [하드닝 R1] 장 마감 후 확정 종가 반영 사이클 ───────────────────────────────
# 15:30 이후 배치가 곧바로 익일까지 자면 stock_analysis 가 마지막 장중 스냅샷
# (종가 12~53분 전)으로 남는다. 15:35 이후 1회(실패 시 18:00 까지 재시도) 더 돌려
# 종가 단일가 매매 이후의 값으로 덮어쓴다.
_BATCH_FINAL_START = (15, 35)
_BATCH_FINAL_END = (18, 0)
def _batch_final_cycle_decide_v2(now: _dt, last_final: "_date | None") -> tuple[str, float]:
"""15:30 이후 배치 루프의 **순수** 결정 — ("final"|"wait"|"sleep_tomorrow", 초)."""
from app.core.market_schedule import is_krx_holiday
today = now.date()
if today.weekday() >= 5 or is_krx_holiday(today):
return "sleep_tomorrow", 0.0
if last_final == today or (now.hour, now.minute) >= _BATCH_FINAL_END:
return "sleep_tomorrow", 0.0
if (now.hour, now.minute) < _BATCH_FINAL_START:
target = _KST_TZ.localize(_dt(today.year, today.month, today.day, *_BATCH_FINAL_START))
return "wait", max((target - now).total_seconds(), 15.0)
return "final", 0.0
# ── [하드닝 R1] KRX 일봉 DB 완전성/신선도 점검 ─────────────────────────────────
_KRX_COMPLETE_WINDOW = 60 # 점검 대상 최근 거래일 수(중앙값 산출 + 부분 수집 판정 전체)
_KRX_CHECK_RECENT = 60 # 부분 수집 판정 대상(최근 N 거래일) — 창 전체. 5 로 두면 오래된 부분일이 경보에서 사라졌다
_KRX_MORNING_LAG = 2 # 아침 점검 기대일: T+1 발행 전이라 '직전 거래일의 전 거래일'
_KRX_RECONCILE_DAYS = 5 # 종가 보정 대상: 최근 N KRX 거래일(멱등)
_KRX_RESCAN_DAYS = 400 # 주 1회 장기 재검사 달력일 범위(collect_krx_data --rescan-days)
_KRX_RESCAN_WEEKDAY = 0 # 월요일 아침
_KRX_COMPLETE_RATIO = 0.90 # 중앙값 대비 최소 비율
_KRX_EVENING_GIVEUP = (18, 0) # 이 시각 이후엔 재시도 없이 경보 후 종료
_KRX_DATA_HEALTH: dict = {
"checked_at": None, "ok": None, "issues": [], "last_trade_date": None,
"reconcile_unmatched_no_krx": None, # 최근 보정에서 KRX 행이 없어 보정 불가였던 분석 행 수(ETF/ETN 등)
}
_KRX_STALE_ALERT_STATE: dict = {"day": None} # 과거 부분일 경보 마지막 발송일
def get_krx_data_health_v2() -> dict:
"""/health 노출용 최근 KRX 일봉 점검 결과 사본 (main.py 에서 호출 가능)."""
return dict(_KRX_DATA_HEALTH)
def _expected_last_trade_date_v2(today: _date) -> _date:
"""today 이전의 가장 최근 KRX 거래일 (data.go.kr T+1 발행이므로 최소 기대치)."""
from app.core.market_schedule import is_krx_holiday
d = today - _td(days=1)
for _ in range(20):
if d.weekday() < 5 and not is_krx_holiday(d):
return d
d -= _td(days=1)
return d
def _expected_trade_date_lag_v2(today: _date, lag: int = 1) -> _date:
"""today 이전 lag 번째 KRX 거래일 (lag=1 이면 _expected_last_trade_date_v2 와 동일)."""
d = today
for _ in range(max(1, int(lag))):
d = _expected_last_trade_date_v2(d)
return d
_RECONCILE_COLS = (
"stock_code", "stock_name", "analysis_date", "current_price", "change_rate", "volume",
"rsi", "ma5", "ma20", "ma60", "stochastic_k", "stochastic_d",
"bollinger_upper", "bollinger_lower", "bollinger_middle",
"signals", "score", "ml_score", "ml_confidence", "ml_signal", "atr_14", "adx_14",
)
def _build_close_reconcile_rows_v2(analysis_rows: "list[dict]", krx: "dict[str, tuple]") -> "list[dict]":
"""stock_analysis 행의 current_price/change_rate/volume 을 KRX 확정 종가로 맞춘 upsert 행 목록 — 순수 함수.
krx: {종목코드: (close, change_rate(퍼센트 단위)|None, volume|None)}. 종가가 없거나 0 이하이거나 이미 같으면 제외.
지표(RSI/MA/BB/ATR)는 재계산하지 않는다(종가 1틱 차이 영향 — 한계는 호출부 로그 참고).
"""
out: list[dict] = []
stamp = now_kst()
for r in analysis_rows:
k = krx.get(r.get("stock_code"))
if not k or k[0] is None or int(k[0]) <= 0:
continue
if int(r.get("current_price") or 0) == int(k[0]):
continue
row = {c: r.get(c) for c in _RECONCILE_COLS}
row["current_price"] = int(k[0])
if len(k) > 1 and k[1] is not None:
row["change_rate"] = k[1]
if len(k) > 2 and k[2] is not None:
row["volume"] = int(k[2])
row["updated_at"] = stamp
out.append(row)
return out
def _close_reconcile_row_matches_v3(r: dict, k: tuple) -> bool:
"""가격·거래량·등락률이 모두 KRX 확정치와 같은가 (KRX 가 값을 주지 않는 항목은 비교 제외)."""
if int(r.get("current_price") or 0) != int(k[0]):
return False
if len(k) > 2 and k[2] is not None and int(r.get("volume") or 0) != int(k[2]):
return False
if len(k) > 1 and k[1] is not None:
cur = r.get("change_rate")
if cur is None or abs(float(cur) - float(k[1])) >= 0.005:
return False
return True
def _build_close_reconcile_rows_v3(analysis_rows: "list[dict]", krx: "dict[str, tuple]") -> "list[dict]":
"""v2 와 동일하되 '가격이 같으면 건너뜀' 을 '가격·거래량·등락률 모두 같을 때만 건너뜀' 으로 교체.
v2 는 가격만 우연히 같은 행의 장중 부분 거래량·틀린 등락률을 영구히 남겼다. 가격만 같으면
volume/change_rate 만 KRX 값으로 갱신한 행이 나온다. 지표는 재계산하지 않는다.
"""
out: list[dict] = []
stamp = now_kst()
for r in analysis_rows:
k = krx.get(r.get("stock_code"))
if not k or k[0] is None or int(k[0]) <= 0:
continue
if _close_reconcile_row_matches_v3(r, k):
continue
row = {c: r.get(c) for c in _RECONCILE_COLS}
row["current_price"] = int(k[0])
if len(k) > 1 and k[1] is not None:
row["change_rate"] = k[1]
if len(k) > 2 and k[2] is not None:
row["volume"] = int(k[2])
row["updated_at"] = stamp
out.append(row)
return out
async def _reconcile_prev_analysis_close_v2(today: _date) -> int:
"""KRX 아침 수집 이후, 직전 거래일 stock_analysis 종가를 stock_daily_prices(KRX) 로 보정.
SELECT 전용 + 쓰기는 db_task_queue(bulk_upsert_stock_analysis) 경유. 보정 건수 반환.
네이버 종가가 KRX 종가와 다른 문제(예: LS 10/7 281,000 vs 280,000)를 T+1 에 치유한다.
"""
from sqlalchemy import select, func
from app.models.models import StockAnalysis, StockDailyPrice
from app.services.db_worker import enqueue_bulk_upsert_analysis
async with AsyncSessionLocal() as db: # DB-READ-EXCEPTION: SELECT 전용, 배치 스케줄러
last = (await db.execute(
select(func.max(StockDailyPrice.trade_date)).where(StockDailyPrice.trade_date < today)
)).scalar()
if last is None:
return 0
px = (await db.execute(
select(StockDailyPrice.stock_code, StockDailyPrice.close_price,
StockDailyPrice.change_rate, StockDailyPrice.volume)
.where(StockDailyPrice.trade_date == last)
)).all()
an = (await db.execute(select(StockAnalysis).where(StockAnalysis.analysis_date == last))).scalars().all()
analysis_rows = [{c: getattr(a, c, None) for c in _RECONCILE_COLS} for a in an]
# stock_daily_prices.change_rate 는 비율(fltRt/100), stock_analysis.change_rate 는 퍼센트 → ×100 변환.
krx = {c: (cl, (round(float(cr) * 100.0, 4) if cr is not None else None), v) for c, cl, cr, v in px}
rows = _build_close_reconcile_rows_v3(analysis_rows, krx)
if rows:
await enqueue_bulk_upsert_analysis(rows)
logger.info(f"[KRX] {last} stock_analysis 종가 KRX 보정: {len(rows)}/{len(analysis_rows)}건 (지표 미재계산)")
return len(rows)
async def _reconcile_recent_analysis_close_v3(today: _date, days: int | None = None) -> dict:
"""최근 N KRX 거래일(today 미만) stock_analysis 종가를 KRX 확정 종가로 보정 — 멱등.
v2 는 '최신 1일'만, 그것도 아침 1회만 봐서 T+1 발행 지연·수집 실패일을 영구히 건너뛰었다.
KRX 행이 없는 분석 행(ETF/ETN)은 보정 불가이므로 건수를 집계해 로그/health 에 남긴다.
SELECT 전용 + 쓰기는 db_task_queue 경유. 반환: {"updated","examined","no_krx","dates"}.
"""
from sqlalchemy import select
from app.models.models import StockAnalysis, StockDailyPrice
from app.services.db_worker import enqueue_bulk_upsert_analysis
n = _KRX_RECONCILE_DAYS if days is None else int(days)
rows: list[dict] = []
examined = 0
no_krx = 0
async with AsyncSessionLocal() as db: # DB-READ-EXCEPTION: SELECT 전용, 배치 스케줄러
dates = (await db.execute(
select(StockDailyPrice.trade_date).where(StockDailyPrice.trade_date < today)
.distinct().order_by(StockDailyPrice.trade_date.desc()).limit(n)
)).scalars().all()
for d in dates:
px = (await db.execute(
select(StockDailyPrice.stock_code, StockDailyPrice.close_price,
StockDailyPrice.change_rate, StockDailyPrice.volume)
.where(StockDailyPrice.trade_date == d)
)).all()
an = (await db.execute(select(StockAnalysis).where(StockAnalysis.analysis_date == d))).scalars().all()
analysis_rows = [{c: getattr(a, c, None) for c in _RECONCILE_COLS} for a in an]
krx = {c: (cl, (round(float(cr) * 100.0, 4) if cr is not None else None), v) for c, cl, cr, v in px}
no_krx += sum(1 for r in analysis_rows if r.get("stock_code") not in krx)
examined += len(analysis_rows)
rows.extend(_build_close_reconcile_rows_v3(analysis_rows, krx))
if rows:
await enqueue_bulk_upsert_analysis(rows)
_KRX_DATA_HEALTH["reconcile_unmatched_no_krx"] = no_krx
logger.info(
f"[KRX] 최근 {len(dates)}거래일 stock_analysis 종가 KRX 보정: {len(rows)}/{examined}건 "
f"(KRX 행 없음 {no_krx}건 — ETF/ETN 등 보정 불가, 지표 미재계산)"
)
return {"updated": len(rows), "examined": examined, "no_krx": no_krx, "dates": len(dates)}
def _krx_rescan_due_v2(today: _date, last_rescan: "_date | None") -> bool:
"""주 1회(월요일) 장기 부분일 재검사가 필요한가 — 순수 함수."""
return today.weekday() == _KRX_RESCAN_WEEKDAY and last_rescan != today
def _evaluate_krx_completeness_v2(
rows_by_date: "dict[_date, int]",
expected_date: _date,
ratio: float | None = None,
recent: int | None = None,
) -> dict:
"""날짜별 행 수로 완전성·신선도를 판정하는 **순수** 함수.
issues: 최신 거래일이 expected_date 보다 과거 / 최근 N 거래일 중 중앙값×ratio 미만 /
기간 내 거래일 누락(갭).
"""
from app.core.market_schedule import is_krx_holiday
ratio = _KRX_COMPLETE_RATIO if ratio is None else ratio
recent = _KRX_CHECK_RECENT if recent is None else recent
issues: list[str] = []
if not rows_by_date:
return {"ok": False, "issues": ["일봉 데이터 없음"], "last_trade_date": None}
dates = sorted(rows_by_date)
last = dates[-1]
if last < expected_date:
issues.append(f"최신 거래일 {last} < 기대 {expected_date}")
counts = sorted(rows_by_date.values())
median = counts[len(counts) // 2]
floor_n = median * ratio
stale_issues: list[str] = [] # 기대일보다 과거의 부분일/갭 — 재수집 루프로 낫지 않는 경우가 많다
for d in dates[-recent:]:
if rows_by_date[d] < floor_n:
msg = f"{d} 부분 수집 {rows_by_date[d]}행 < 중앙값 {median}×{ratio:.0%}"
issues.append(msg)
if d < expected_date:
stale_issues.append(msg)
present = set(dates)
d = dates[0]
while d < last:
d += _td(days=1)
if d.weekday() < 5 and not is_krx_holiday(d) and d not in present:
msg = f"{d} 거래일 누락"
issues.append(msg)
if d < expected_date:
stale_issues.append(msg)
fresh_issues = [i for i in issues if i not in stale_issues]
return {
"ok": not issues, "issues": issues, "last_trade_date": last, "median_rows": median,
"fresh_issues": fresh_issues, "stale_issues": stale_issues, "fresh_ok": not fresh_issues,
}
def _krx_behind_days_v2(last: "_date | None", today: _date) -> "int | None":
"""last 이후 today 미만의 KRX 거래일 수 (0 이면 직전 거래일까지 적재, 순수 함수)."""
from app.core.market_schedule import is_krx_holiday
if last is None:
return None
n = 0
d = last + _td(days=1)
while d < today:
if d.weekday() < 5 and not is_krx_holiday(d):
n += 1
d += _td(days=1)
return n
async def _fetch_krx_row_counts_v2(window: int | None = None) -> "dict[_date, int]":
"""stock_daily_prices 최근 N 거래일의 날짜별 행 수 (SELECT 전용)."""
from sqlalchemy import select, func
from app.models.models import StockDailyPrice
window = _KRX_COMPLETE_WINDOW if window is None else window
async with AsyncSessionLocal() as db: # DB-READ-EXCEPTION: SELECT 전용, 배치 스케줄러
res = await db.execute(
select(StockDailyPrice.trade_date, func.count(StockDailyPrice.id))
.group_by(StockDailyPrice.trade_date)
.order_by(StockDailyPrice.trade_date.desc())
.limit(window)
)
return {d: int(n) for d, n in res.all()}
async def _check_krx_data_v2(today: _date, alert: bool, lag: int = 1, alert_stale: bool = False) -> dict:
"""점검 실행 + 상태 기록 + 로그/텔레그램 경보. 점검 자체 실패는 not ok 로 취급.
lag: 기대 최신일 = today 이전 lag 번째 거래일(아침 점검은 T+1 발행 전이라 2).
"""
try:
counts = await _fetch_krx_row_counts_v2()
result = _evaluate_krx_completeness_v2(counts, _expected_trade_date_lag_v2(today, lag))
except Exception as e:
logger.warning(f"⚠️ [KRX] 완전성 점검 실패: {e}", exc_info=True)
result = {"ok": False, "issues": [f"점검 실패: {e}"], "last_trade_date": None}
result.setdefault("fresh_issues", list(result["issues"]))
result.setdefault("stale_issues", [])
result.setdefault("fresh_ok", not result["fresh_issues"])
try:
behind = _krx_behind_days_v2(result.get("last_trade_date"), today)
except Exception as e:
logger.warning(f"[KRX] 거래일 지연 계산 실패: {e}")
behind = None
_KRX_DATA_HEALTH.update(
checked_at=now_kst().isoformat(), ok=result["ok"], issues=list(result["issues"]),
last_trade_date=str(result.get("last_trade_date")) if result.get("last_trade_date") else None,
behind_trading_days=behind,
stale_warning=bool(behind is not None and behind >= 2),
)
if behind is not None and behind >= 2:
logger.warning(f"⚠️ [KRX] 일봉 DB 가 직전 거래일보다 {behind} 거래일 뒤처짐 (최신 {result.get('last_trade_date')})")
if result["ok"]:
logger.info(f"✅ [KRX] 일봉 완전성 정상 (최신 {result.get('last_trade_date')})")
elif alert or (alert_stale and not result["fresh_issues"]):
# 최신일 지연/부분은 매번, 과거 부분일만 남은 경우는 1일 1회만 (재수집으로 안 낫는 결측의 경보 반복 방지)
fresh_only_stale = not result["fresh_issues"]
if fresh_only_stale and _KRX_STALE_ALERT_STATE.get("day") == today:
logger.warning(f"⚠️ [KRX] 과거 부분일 잔존(오늘 경보 완료): {'; '.join(result['issues'][:3])}")
else:
if fresh_only_stale:
_KRX_STALE_ALERT_STATE["day"] = today
msg = "KRX 일봉 DB 불완전: " + "; ".join(result["issues"][:5])
logger.error(f"🚨 [KRX] {msg}")
try:
from app.services.telegram_notifier import telegram_notifier
await telegram_notifier.notify_system_warning("KRX_DATA", msg)
except Exception as e:
logger.warning(f"[KRX] 텔레그램 경보 전송 실패: {e}")
else:
logger.warning(f"⚠️ [KRX] 일봉 불완전(재시도 예정): {'; '.join(result['issues'][:5])}")
return result
def _krx_collector_decide_v2(
now: _dt, last_collected: "_date | None", last_morning: "_date | None"
) -> tuple[str, float]:
"""KRX 수집 루프의 **순수** 결정 — ("morning"|"evening"|"sleep", 초).
morning: 평일 09:00 이후 1회(T+1 발행 지연 흡수). evening: 15:40 이후.
"""
from app.core.market_schedule import is_krx_holiday
today = now.date()
if today.weekday() >= 5 or is_krx_holiday(today):
return "sleep", 3600.0
hm = (now.hour, now.minute)
def _until(h, m):
return (_KST_TZ.localize(_dt(today.year, today.month, today.day, h, m, 0)) - now).total_seconds()
if hm < (9, 0):
return "sleep", min(max(_until(9, 0), 15.0), 1800.0)
if hm < (15, 40):
if last_morning != today:
return "morning", 0.0
return "sleep", min(max(_until(15, 40), 15.0), 1800.0)
if last_collected == today:
nxt = _KST_TZ.localize(_dt(today.year, today.month, today.day, 9, 0, 0)) + _td(days=1)
return "sleep", max((nxt - now).total_seconds(), 60.0)
return "evening", 0.0
_KRX_OFFDAY_COLLECT_HM = (12, 0) # 주말·휴장일 증분 수집 시각(하루 1회)
def _krx_collector_decide_v3(
now: _dt, last_collected: "_date | None", last_morning: "_date | None",
last_offday: "_date | None" = None,
) -> tuple[str, float]:
"""v2 + 주말·휴장일 12:00 에 하루 1회 "offday" 증분 수집 (연휴 직전 거래일 일봉이 다음 거래일 저녁까지 없던 문제).
평일 동작은 v2 와 동일하다.
"""
from app.core.market_schedule import is_krx_holiday
today = now.date()
if today.weekday() >= 5 or is_krx_holiday(today):
hm = (now.hour, now.minute)
if hm < _KRX_OFFDAY_COLLECT_HM:
tgt = _KST_TZ.localize(_dt(today.year, today.month, today.day, *_KRX_OFFDAY_COLLECT_HM, 0))
return "sleep", min(max((tgt - now).total_seconds(), 15.0), 3600.0)
if last_offday != today:
return "offday", 0.0
return "sleep", 3600.0
return _krx_collector_decide_v2(now, last_collected, last_morning)
def _normalize_recommendations(rec: dict) -> dict:
"""서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화.
- get_rule_based_recommendations_realtime() → {"recommendations": [...], ...}
- get_rule_based_recommendations_from_db() → {"recommendations_by_rule": {...}, ...}
둘 다 recommendations_by_rule 키를 보장한 상태로 돌려준다.
Redis 캐시 데이터와 DB 데이터 모두 이 함수를 통해 정규화.
변환 실패 시 원본 rec를 그대로 반환 (런타임 에러 방지).
"""
if not rec:
return rec or {}
try:
# Case 1: Already normalized (dict)
if isinstance(rec.get("recommendations_by_rule"), dict):
return rec
# Determine the source of the recommendation list
raw_list = []
if "recommendations" in rec and isinstance(rec["recommendations"], list):
raw_list = rec["recommendations"]
elif "recommendations_by_rule" in rec and isinstance(rec["recommendations_by_rule"], list):
# This is the case that causes the bug
raw_list = rec["recommendations_by_rule"]
if not raw_list:
# if there is no list to process, make sure the output has a dict
rec["recommendations_by_rule"] = {}
return rec
# Group the list by rule
by_rule: dict = {}
for item in raw_list:
rule = (
item.get("rule_name")
or item.get("signal")
or item.get("strategy")
or "general"
)
by_rule.setdefault(rule, []).append(item)
# Return the original dict with the new/replaced 'recommendations_by_rule'
return {**rec, "recommendations_by_rule": by_rule}
except Exception as e:
logger.warning(f"⚠️ _normalize_recommendations 변환 실패, 원본 반환: {e}")
return rec
# ── [하드닝 R2] 배치 분석 헬스/타임아웃/경보 ────────────────────────────────────
# stock_analysis 는 듀얼 전략·DB 폴백 추천의 유일한 소스(§6.4)라 배치 정지가 '매수 0건'
# 으로만 드러났다. 1회 실행 상한(wait_for) + 최대 재시도 초과 시 텔레그램 + 헬스 노출.
_BATCH_HEALTH: dict = {
"last_ok_at": None, "last_ok_count": None, "last_error": None,
"consecutive_failed_cycles": 0, "last_failed_at": None,
}
_BATCH_ALERT_EVERY_N_CYCLES = 6 # 연속 실패 사이클 중 1, 7, 13... 번째만 경보(스팸 방지)
def get_batch_analysis_health_v2() -> dict:
"""/health 노출용 배치 분석 상태 사본 (main.py 에서 호출 가능)."""
return dict(_BATCH_HEALTH)
def _batch_timeout_sec_v2() -> float:
"""배치 1회 실행 상한(초). 설정이 없거나 비정상이면 interval 이상의 안전 기본값."""
raw = getattr(settings, "BATCH_ANALYSIS_TIMEOUT_SEC", None)
try:
v = float(raw)
if v > 0:
return v
except (TypeError, ValueError):
pass
return 900.0
async def _batch_fetch_v2(fetch, timeout: float | None = None):
"""배치 분석 1회 호출에 상한을 건다. 초과 시 TimeoutError(재시도 루프가 처리)."""
t = _batch_timeout_sec_v2() if timeout is None else timeout
return await asyncio.wait_for(fetch(), timeout=t)
def _batch_cycle_ok_v2(count: int) -> None:
_BATCH_HEALTH.update(
last_ok_at=now_kst().isoformat(), last_ok_count=count,
last_error=None, consecutive_failed_cycles=0,
)
try:
from app.core import heartbeat as _hb
_hb.beat("batch_analysis", f"{count} stocks")
except Exception as e:
logger.warning(f"[Batch] heartbeat 기록 실패: {e}")
async def _batch_cycle_failed_v2(exc: BaseException, attempts: int) -> None:
"""최대 재시도 초과 — 헬스 갱신 + 텔레그램 경보(연속 실패 시 N사이클마다)."""
n = int(_BATCH_HEALTH.get("consecutive_failed_cycles") or 0) + 1
_BATCH_HEALTH.update(
last_error=f"{type(exc).__name__}: {exc}"[:300],
consecutive_failed_cycles=n, last_failed_at=now_kst().isoformat(),
)
if (n - 1) % _BATCH_ALERT_EVERY_N_CYCLES != 0:
return
try:
from app.services.telegram_notifier import telegram_notifier
await telegram_notifier.notify_system_warning(
"BATCH_ANALYSIS",
f"배치 분석 {attempts}회 재시도 모두 실패(연속 {n}사이클): "
f"{type(exc).__name__}: {exc}"[:400]
+ " — stock_analysis 갱신 정지, 듀얼 전략·DB 폴백 추천 후보 고갈 위험",
)
except Exception as e:
logger.warning(f"[Batch] 실패 경보 전송 실패: {e}")
async def batch_analysis_scheduler():
"""10분 간격 배치 분석 스케줄러.
장중 10분마다 KIS API로 종목 분석 후 stock_analysis DB에 upsert.
분석 완료 후 Redis에 recommendations:latest 캐시를 저장해
auto_trade_scheduler가 DB 조회 없이 바로 매매 판단에 활용할 수 있도록 한다.
개선:
- 15:30 이후 장 종료 감지 → 익일 08:50까지 자동 중단
- 분석 실패 시 exponential backoff 재시도 (최대 3회: 10s/20s/40s)
"""
await asyncio.sleep(15) # 부팅 안정화 (auto_trade_scheduler보다 늦게 시작)
from app.api.endpoints.recommendations import get_rule_based_recommendations_realtime
from app.core.redis import setex_json, TTL_RECOMMENDATIONS, TTL_ANALYSIS
interval = int(getattr(settings, "BATCH_ANALYSIS_INTERVAL", 600)) # 기본 10분
_KST = pytz.timezone("Asia/Seoul")
logger.info(f"📊 Batch analysis scheduler started (every {interval}s)")
_last_final: _date | None = None
try:
while True:
_now = pytz.datetime.datetime.now(_KST)
# 15:30 이후 → 장 종료. 15:35 이후 확정 종가 반영 사이클을 1회 돌린 뒤
# 익일 08:50까지 대기 (하드닝 R1: 장중 스냅샷이 종가로 남던 문제)
_final_pass = False
if _now.hour > 15 or (_now.hour == 15 and _now.minute >= 30):
_fa, _fsec = _batch_final_cycle_decide_v2(_now, _last_final)
if _fa == "wait":
await asyncio.sleep(_fsec)
continue
if _fa == "final":
_final_pass = True
logger.info("🏁 [Batch] 장 마감 확정 종가 반영 사이클 시작")
else:
tomorrow_start = (
_KST.localize(
pytz.datetime.datetime(_now.year, _now.month, _now.day, 8, 50, 0)
) + _td(days=1)
)
sleep_sec = max((tomorrow_start - _now).total_seconds(), 3600)
logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개")
await asyncio.sleep(sleep_sec)
continue
# [FIX 2026-09-23] 개장 전 준비 구간(08:30~09:00)에는 분석을 미리 돌려
# stock_analysis·Redis 추천 캐시를 채워둔다 → 09:00에 추천 종목 즉시 표시.
# 그 외 마감 시간에는 개장을 넘겨 자지 않도록 sleep 상한을 둔다.
if not _final_pass and not is_market_open() and not is_pre_market_window():
_until_open = seconds_until_market_open()
await asyncio.sleep(1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open)))
continue
# ── exponential backoff 재시도 (최대 3회) ────────────────────────
max_retries = 3
_cycle_ok = False
for attempt in range(max_retries):
try:
suffix = f" (재시도 {attempt + 1}/{max_retries})" if attempt > 0 else ""
logger.info(f"🔄 [Batch] 10-min analysis cycle starting...{suffix}")
result = await _batch_fetch_v2(get_rule_based_recommendations_realtime)
count = len(result.get("recommendations", [])) if result else 0
logger.info(f"✅ [Batch] Done: {count} stocks saved/updated in stock_analysis DB")
# ── Redis 캐시 업데이트 ─────────────────────────────────────────
if result:
# 정규화: recommendations_by_rule 키 보장 (auto_trade_scheduler 호환)
normalized = _normalize_recommendations(result)
ok = await setex_json(
"recommendations:latest", TTL_RECOMMENDATIONS, normalized
)
if ok:
rec_by_rule = normalized.get('recommendations_by_rule', [])
rec_count = len(rec_by_rule) if isinstance(rec_by_rule, (list, dict)) else 0
logger.info(
f"💾 [Batch] Redis 캐시 갱신 완료: recommendations:latest "
f"(rule 수: {rec_count}, "
f"TTL: {TTL_RECOMMENDATIONS}s)"
)
# ===== 개별 종목별 캐시 (병렬 처리 적용) =====
raw_list = result.get("recommendations", [])
cache_tasks = []
for item in raw_list:
code = item.get("stock_code") or item.get("code")
if code:
cache_tasks.append(setex_json(f"analysis:{code}", TTL_ANALYSIS, item))
if cache_tasks:
await asyncio.gather(*cache_tasks, return_exceptions=True)
logger.info(f"💾 [Batch] 개별 종목 분석 데이터 캐시 갱신 완료: {len(cache_tasks)}건 (TTL: {TTL_ANALYSIS}s)")
# ===============================================
# ───────────────────────────────────────────────────────
_cycle_ok = True
_batch_cycle_ok_v2(count)
break # 성공 시 재시도 루프 탈출
except Exception as e:
wait = 10 * (2 ** attempt) # 10s → 20s → 40s
if attempt < max_retries - 1:
logger.warning(
f"⚠️ [Batch] 분석 실패 ({attempt + 1}/{max_retries}), "
f"{wait}초 후 재시도: {e}"
)
await asyncio.sleep(wait)
else:
logger.error(f"❌ Batch analysis error (최대 재시도 초과): {e}", exc_info=True)
await _batch_cycle_failed_v2(e, max_retries)
if _final_pass and _cycle_ok:
_last_final = _now.date()
logger.info("🏁 [Batch] 확정 종가 반영 사이클 완료")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("⏹️ Batch analysis scheduler stopped")
raise
# ── 매매일지 큐 등록 (16:00 배치·장중 주기 공용) ─────────────────────────────
# db_worker 큐는 비동기라 enqueue → INSERT 커밋 사이에 같은 uncovered 조회가 다시 돌면
# 같은 trade_id 를 또 큐에 넣는다. 프로세스 내 in-flight 집합으로 막는다
# (재시작 시에는 DB 의 trade_id 커버 판정이 단일 진실). 무한 증가 방지로 상한을 둔다.
_JOURNAL_ENQUEUED_IDS: dict[int, float] = {} # trade_id -> 등록 monotonic 시각(TTL 만료)
_JOURNAL_ENQUEUED_MAX = 5000
async def _enqueue_journals_v2(trades, perf=None) -> int:
"""uncovered SELL 거래를 일지로 만들어 db_worker 큐에 등록한다. 등록 건수 반환.
멱등: 이미 이번 프로세스에서 등록한 trade_id 는 건너뛴다. DB 쓰기는 큐 경유(§3.2).
"""
from app.services.trade_analyzer import generate_journal_for_trade
from app.services.db_worker import enqueue_trade_journal
import time as _time
_now_m = _time.monotonic()
_ttl = max(1, int(settings.JOURNAL_ENQUEUE_TTL_MIN)) * 60
for _k in [k for k, ts in _JOURNAL_ENQUEUED_IDS.items() if _now_m - ts > _ttl]:
del _JOURNAL_ENQUEUED_IDS[_k]
if len(_JOURNAL_ENQUEUED_IDS) > _JOURNAL_ENQUEUED_MAX:
_JOURNAL_ENQUEUED_IDS.clear()
count = 0
for trade in trades:
tid = getattr(trade, "id", None)
if tid is not None and tid in _JOURNAL_ENQUEUED_IDS:
continue
try:
content = generate_journal_for_trade(trade, perf)
await enqueue_trade_journal(trade, content)
if tid is not None:
_JOURNAL_ENQUEUED_IDS[tid] = _now_m
count += 1
except Exception as e:
logger.warning(f"일지 생성 실패 ({getattr(trade, 'stock_code', '?')}): {e}")
return count
async def journal_intraday_scheduler():
"""장중 N분 주기로 uncovered SELL 을 일지화한다 (16:00 배치의 장중 보충).
별도 배치 태스크라 실시간 계층(체결 콜백)에서 DB I/O 를 하지 않는다. 읽기만 하고
쓰기는 db_worker 큐 경유. 멱등성은 trade_id 커버 판정 + in-flight 집합이 보장한다.
"""
await asyncio.sleep(90) # 부팅 안정화
logger.info(
"📝 Intraday journal scheduler started (every %s min)",
settings.JOURNAL_INTRADAY_INTERVAL_MIN,
)
try:
while True:
interval = max(1, int(settings.JOURNAL_INTRADAY_INTERVAL_MIN)) * 60
if settings.JOURNAL_INTRADAY_ENABLED and is_market_open():
try:
from app.services.trade_analyzer import get_uncovered_trades_v2
async with AsyncSessionLocal() as db: # SELECT 전용, commit 없음
uncovered = await get_uncovered_trades_v2(
db,
days=settings.JOURNAL_LOOKBACK_DAYS,
min_age_minutes=settings.JOURNAL_INTRADAY_GRACE_MIN,
)
n = await _enqueue_journals_v2(uncovered, None)
if n:
logger.info("📝 [IntradayJournal] 일지 %d건 큐 등록", n)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("⚠️ [IntradayJournal] 실행 실패: %s", e, exc_info=True)
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("⏹️ Intraday journal scheduler stopped")
raise
async def post_market_analyzer_scheduler():
"""장 종료 후 16:00 KST 1회 실행 — 매매일지 자동 생성 + 전략 자동 최적화.
조건:
- 매일 16:00 KST 에 1회 실행 (이미 오늘 실행했으면 스킵)
- 최근 7일 내 SELL FILLED 거래 >= 5건이면 최적화 실행
- 마지막 최적화로부터 3일 이상 경과 시만 최적화 실행
실행 여부 판정은 순수 함수 `_post_market_decide()` 에 있다(테스트 대상).
"오늘 실행했는가" 마커는 Redis 에 영속화한다 — 인메모리만 쓰면 재기동마다
리셋돼 같은 날 두 번 돌고, 텔레그램 일일 리포트가 중복 발송된다.
"""
_KST = pytz.timezone("Asia/Seoul")
await asyncio.sleep(60) # 부팅 안정화
# 재기동 내구성: 인메모리 변수만 쓰면 리셋돼 같은 날 배치가 두 번 돈다.
_last_run: _date | None = await _load_post_market_last_run()
logger.info(
"📝 Post-market analyzer scheduler started (daily 16:00 KST, last_run=%s)",
_last_run,
)
try:
while True:
_now = _dt.now(_KST)
today = _now.date()
action, sleep_sec = _post_market_decide(_now, _last_run)
if action == "sleep":
await asyncio.sleep(sleep_sec)
continue
logger.info("📝 [PostMarket] 매매일지 자동 생성 + 전략 최적화 시작")
_last_run = today
await _save_post_market_last_run(today)
try:
from app.services.trade_analyzer import (
analyze_performance,
get_uncovered_trades,
generate_journal_for_trade,
create_journal_record,
)
from app.services.strategy_optimizer import (
run_optimization,
save_optimization_log,
get_last_optimization_date,
)
from app.models.models import TradeHistory
from sqlalchemy import select, func, and_
async with AsyncSessionLocal() as db:
uncovered = await get_uncovered_trades(db, days=settings.JOURNAL_LOOKBACK_DAYS)
perf = await analyze_performance(db, days=30)
journal_count = await _enqueue_journals_v2(uncovered, perf)
if journal_count > 0:
logger.info(f"📝 [PostMarket] 일지 {journal_count}건 큐 등록 완료")
# ── Telegram 일일 성과 리포트 ──────────────────────────────────
try:
from app.services.telegram_notifier import telegram_notifier
# 당일 SELL 체결 내역으로 PnL 계산
_today_start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0)
_today_sells = await db.execute(
select(TradeHistory).where(
and_(
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "FILLED"]),
TradeHistory.created_at >= _today_start,
)
)
)
_sells = _today_sells.scalars().all()
_total_pnl = sum(float(t.net_profit_loss or 0) for t in _sells)
_win = sum(1 for t in _sells if float(t.net_profit_loss or 0) > 0)
_loss = sum(1 for t in _sells if float(t.net_profit_loss or 0) <= 0)
# 포트폴리오 요약
_pf_value, _cash = 0.0, 0.0
try:
from app.api.endpoints.portfolio import portfolio_cache
_summary = portfolio_cache.get_summary_copy()
if _summary:
_pf_value = float(_summary.get("total_assets", 0))
_cash = float(_summary.get("available_cash", 0))
except Exception as _pf_err:
logger.warning(f"[PostMarket] 포트폴리오 요약 조회 실패: {_pf_err}")
await telegram_notifier.notify_daily_report(
date_str=str(today),
total_pnl=_total_pnl,
trade_count=len(_sells),
win_count=_win,
loss_count=_loss,
portfolio_value=_pf_value,
cash_balance=_cash,
)
except Exception as _tg_err:
# [2026-09-29] logger.debug 였다. now_kst 섀도잉으로 인한
# TypeError 가 여기서 조용히 삼켜져 일일 리포트가 무기한
# 미발송 상태였다 — CLAUDE.md §4: 예외 로그는 최소 warning.
logger.warning(
f"[PostMarket] Telegram 리포트 전송 실패: {_tg_err}", exc_info=True
)
# [하드닝 R4] BUY 건수 + 진입 게이트 차단 Top5 (SELL 만 집계되던 공백)
try:
_buys = await _count_today_buys_v2(db)
_line = _format_funnel_v2(_buys)
logger.info(f"[PostMarket] 진입 퍼널: {_line}")
await _notify_funnel_v2("DAILY_ENTRY_FUNNEL", _line)
except Exception as _fn_err:
logger.warning(f"[PostMarket] 진입 퍼널 집계 실패: {_fn_err}", exc_info=True)
since_7d = now_kst() - _td(days=7)
trade_count_result = await db.execute(
select(func.count(TradeHistory.id)).where(
and_(
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "FILLED"]),
TradeHistory.created_at >= since_7d,
)
)
)
recent_trade_count = trade_count_result.scalar() or 0
last_opt = await get_last_optimization_date(db)
days_since_opt = (today - last_opt).days if last_opt else 999
if recent_trade_count >= 5 and days_since_opt >= 3:
logger.info(f"🔧 [PostMarket] 전략 최적화 실행 (최근거래:{recent_trade_count}건, 마지막:{days_since_opt}일 전)")
result = await run_optimization(db, perf, trigger="auto", period_days=90)
await save_optimization_log(db, result, trigger="auto")
logger.info(f"✅ [PostMarket] 최적화 완료: applied={result.applied}, {result.apply_reason}")
else:
logger.info(f"⏭️ [PostMarket] 최적화 스킵 (거래:{recent_trade_count}건, 마지막최적화:{days_since_opt}일 전)")
except Exception as e:
logger.error(f"❌ [PostMarket] 분석 실패: {e}", exc_info=True)
await asyncio.sleep(3600)
except asyncio.CancelledError:
logger.info("⏹️ Post-market analyzer scheduler stopped")
raise
async def krx_data_collector_scheduler():
"""장 종료 후 KRX 일봉 데이터 자동 수집 스케줄러.
매일 15:40 KST 이후 collect_all(update_mode=True) 를 1회 실행.
이미 오늘 수집 완료된 경우 다음 날까지 스킵.
"""
_KST = pytz.timezone("Asia/Seoul")
_last_collected: _date | None = None
_last_morning: _date | None = None
_last_rescan: _date | None = None
_last_offday: _date | None = None
await asyncio.sleep(30) # 부팅 안정화
logger.info("📅 KRX data collector scheduler started (09:00 보충 + 15:40 이후 수집, 완전성 점검)")
try:
while True:
_now = _dt.now(_KST)
today = _now.date()
action, sleep_sec = _krx_collector_decide_v3(_now, _last_collected, _last_morning, _last_offday)
if action == "sleep":
await asyncio.sleep(sleep_sec)
continue
# 주말·휴장일 증분 수집(하루 1회, 실패해도 당일 재시도 안 함) — 경보 없음
if action == "offday":
_last_offday = today
try:
logger.info("📥 [KRX] 휴장일 증분 수집 시작 (직전 거래일 일봉 선적재)")
from collect_krx_data import collect_all
await collect_all(update_mode=True)
except Exception as e:
logger.warning(f"⚠️ [KRX] 휴장일 증분 수집 실패: {e}", exc_info=True)
try:
await _reconcile_recent_analysis_close_v3(today)
except Exception as e:
logger.warning(f"⚠️ [KRX] 휴장일 종가 보정 실패: {e}", exc_info=True)
await _check_krx_data_v2(today, alert=False, lag=_KRX_MORNING_LAG)
continue
# 아침 보충(data.go.kr T+1 발행 지연 흡수): 1회, 실패해도 당일 재시도 안 함
if action == "morning":
_last_morning = today
try:
logger.info("📥 [KRX] 아침 보충 수집 시작 (T+1 발행 지연 흡수)")
from collect_krx_data import collect_all
await collect_all(update_mode=True)
except Exception as e:
logger.error(f"❌ [KRX] 아침 보충 수집 실패: {e}", exc_info=True)
try:
await _reconcile_recent_analysis_close_v3(today)
except Exception as e:
logger.warning(f"⚠️ [KRX] 직전일 종가 보정 실패: {e}", exc_info=True)
# 주 1회 장기 재검사: 21일 창 밖 부분 수집일 치유
if _krx_rescan_due_v2(today, _last_rescan):
_last_rescan = today
try:
logger.info(f"📥 [KRX] 주간 장기 재검사 시작 (rescan {_KRX_RESCAN_DAYS}일)")
from collect_krx_data import collect_all_v2
await collect_all_v2(update_mode=True, rescan_days=_KRX_RESCAN_DAYS)
except Exception as e:
logger.error(f"❌ [KRX] 주간 장기 재검사 실패: {e}", exc_info=True)
# 아침은 T+1 발행 전일 수 있어 기대일을 한 거래일 더 늦춘다(거짓 경보 방지)
await _check_krx_data_v2(today, alert=True, lag=_KRX_MORNING_LAG)
continue
# 저녁 수집 (최대 3회 재시도, 30분 간격)
max_krx_retries = 3
for krx_attempt in range(max_krx_retries):
try:
suffix = f" (재시도 {krx_attempt + 1}/{max_krx_retries})" if krx_attempt > 0 else ""
logger.info(f"📥 [KRX] 일봉 데이터 증분 수집 시작 (update mode){suffix}...")
from collect_krx_data import collect_all
await collect_all(update_mode=True)
logger.info("✅ [KRX] 일봉 데이터 수집 호출 완료 — 완전성 점검")
# 불완전이면 18:00 까지 30분 간격 재시도, 그 후엔 경보하고 종료(아침 보충이 이어받음)
_giveup = (_dt.now(_KST).hour, _dt.now(_KST).minute) >= _KRX_EVENING_GIVEUP
res = await _check_krx_data_v2(today, alert=_giveup, alert_stale=True)
# 보정은 완전성 판정과 분리: 멱등이고 KRX 행이 있는 종목만 대상이라 항상 실행
try:
await _reconcile_recent_analysis_close_v3(today)
except Exception as e:
logger.warning(f"⚠️ [KRX] 저녁 종가 보정 실패: {e}", exc_info=True)
# 재수집 루프는 최신일 문제(fresh)에만 — 과거 부분일은 재수집으로 안 낫는 경우가 많다
if res.get("fresh_ok", res["ok"]) or _giveup:
_last_collected = today
else:
await asyncio.sleep(1800)
break
except Exception as e:
if krx_attempt < max_krx_retries - 1:
logger.warning(
f"⚠️ [KRX] 수집 실패 ({krx_attempt + 1}/{max_krx_retries}), "
f"30분 후 재시도: {e}"
)
await asyncio.sleep(1800)
else:
logger.error(f"❌ [KRX] 데이터 수집 최대 재시도 초과: {e}", exc_info=True)
_last_collected = today
except asyncio.CancelledError:
logger.info("⏹️ KRX data collector scheduler stopped")
raise
# ── [하드닝 R4] '매수 0건' 조기 탐지: 진입 게이트 차단 집계 + 0건 경보 ────────────
# 수개월 매수 0건 사고(ML HOLD 게이트·빈 추천 캐시)가 일 단위 이상 늦게 발견됐다.
# - 진입 경로는 record_entry_gate_block_v2("mdd") 처럼 차단 사유를 사이클마다 호출(인메모리, DB 없음).
# - zero_buy_watchdog_scheduler 가 장중 BULL 인데 BUY 0건이 N시간 이어지면 텔레그램 경보.
# - 15:40 일일 리포트에 BUY 건수와 차단 Top5 를 덧붙인다.
_ENTRY_FUNNEL: dict = {"day": None, "blocks": {}, "attempts": 0, "last_alert_day": None}
_ENTRY_FUNNEL_MAX_GATES = 50
_BUY_COUNT_STATUSES = ("COMPLETED", "FILLED", "PENDING")
def _funnel_roll_v2(today: "_date") -> None:
"""날짜가 바뀌면 일일 집계를 리셋한다(경보 발송 이력은 유지)."""
if _ENTRY_FUNNEL["day"] != today:
_ENTRY_FUNNEL["day"] = today
_ENTRY_FUNNEL["blocks"] = {}
_ENTRY_FUNNEL["attempts"] = 0
def record_entry_gate_block_v2(gate: str, n: int = 1) -> None:
"""진입 게이트 차단 1회 기록. 예외를 던지지 않는다(호출부 매매 흐름 보호)."""
try:
_funnel_roll_v2(now_kst().date())
blocks = _ENTRY_FUNNEL["blocks"]
key = str(gate)[:40]
if key not in blocks and len(blocks) >= _ENTRY_FUNNEL_MAX_GATES:
key = "other"
blocks[key] = blocks.get(key, 0) + max(0, int(n))
except Exception as e:
logger.warning(f"[Funnel] 게이트 차단 기록 실패: {e}")
def record_entry_attempt_v2(n: int = 1) -> None:
"""진입 시도(게이트 통과 후 주문 시도) 1회 기록."""
try:
_funnel_roll_v2(now_kst().date())
_ENTRY_FUNNEL["attempts"] += max(0, int(n))
except Exception as e:
logger.warning(f"[Funnel] 진입 시도 기록 실패: {e}")
def get_entry_funnel_v2(top: int = 5) -> dict:
"""/health·리포트 노출용. 오늘 집계가 아니면 빈 집계."""
today = now_kst().date()
if _ENTRY_FUNNEL["day"] != today:
return {"day": str(today), "attempts": 0, "blocks_top": []}
ranked = sorted(_ENTRY_FUNNEL["blocks"].items(), key=lambda kv: kv[1], reverse=True)
return {"day": str(today), "attempts": _ENTRY_FUNNEL["attempts"],
"blocks_top": ranked[: max(1, top)]}
def _zero_buy_alert_hours_v2() -> float:
raw = getattr(settings, "ZERO_BUY_ALERT_HOURS", None)
try:
v = float(raw)
if v > 0:
return v
except (TypeError, ValueError):
pass
return 3.0
def _zero_buy_decide_v2(now: _dt, buys_today: int, regime_200d: str | None,
last_alert_day: "_date | None", hours: float) -> bool:
"""순수 판정: 평일 장중, 개장 후 hours 경과, BULL, 오늘 BUY 0건, 오늘 미경보면 True."""
if now.weekday() >= 5 or buys_today > 0 or regime_200d != "bull":
return False
if last_alert_day == now.date():
return False
open_dt = now.replace(hour=9, minute=0, second=0, microsecond=0)
if now < open_dt + _td(hours=hours):
return False
close_dt = now.replace(hour=15, minute=20, second=0, microsecond=0)
return now <= close_dt
async def _count_today_buys_v2(db) -> "dict[str, int]":
"""오늘 BUY 건수(전략별). SELECT 전용."""
from app.models.models import TradeHistory
from sqlalchemy import select, func, and_
start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0)
res = await db.execute(
select(TradeHistory.strategy_type, func.count(TradeHistory.id)).where(
and_(
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(list(_BUY_COUNT_STATUSES)),
TradeHistory.created_at >= start,
)
).group_by(TradeHistory.strategy_type)
)
return {str(k or "swing"): int(c) for k, c in res.all()}
def _format_funnel_v2(buys: "dict[str, int]") -> str:
f = get_entry_funnel_v2(5)
total = sum(buys.values())
by = ", ".join(f"{k}:{v}" for k, v in sorted(buys.items())) or "-"
top = ", ".join(f"{g}x{c}" for g, c in f["blocks_top"]) or "집계 없음"
return f"BUY {total}건({by}) / 진입시도 {f['attempts']} / 차단Top5: {top}"
async def _notify_funnel_v2(category: str, text: str) -> None:
try:
from app.services.telegram_notifier import telegram_notifier
await telegram_notifier.notify_system_warning(category, text)
except Exception as e:
logger.warning(f"[Funnel] 텔레그램 전송 실패: {e}")
async def zero_buy_watchdog_scheduler():
"""장중 BULL 인데 BUY 0건이 이어지면 경보. main.py lifespan 에서 create_task 로 등록한다."""
await asyncio.sleep(30)
logger.info("🔎 Zero-buy watchdog started")
try:
while True:
try:
now = now_kst()
if is_market_open():
from app.services.market_analyzer import check_market_regime
regime = (await check_market_regime() or {}).get("regime_200d")
# DB-READ-EXCEPTION: SELECT 전용(commit/add 없음), 5분 주기 감시
async with AsyncSessionLocal() as db:
buys = await _count_today_buys_v2(db)
if _zero_buy_decide_v2(now, sum(buys.values()), regime,
_ENTRY_FUNNEL["last_alert_day"],
_zero_buy_alert_hours_v2()):
_ENTRY_FUNNEL["last_alert_day"] = now.date()
msg = (f"BULL 국면인데 오늘 BUY 0건 ({_zero_buy_alert_hours_v2():g}시간 경과). "
+ _format_funnel_v2(buys))
logger.error(f"[Funnel] 🚨 {msg}")
await _notify_funnel_v2("ZERO_BUY", msg)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[Funnel] watchdog 사이클 실패: {e}", exc_info=True)
await asyncio.sleep(300)
except asyncio.CancelledError:
logger.info("⏹️ Zero-buy watchdog stopped")
raise
# Deprecated schedulers (kept for backwards compatibility)
async def auto_trade_scheduler():
"""[DEPRECATED] 1분 간격 자동매매 스케줄러.
.. deprecated::
services/swing/scheduler.py의 swing_scheduler()로 대체됨.
main.py lifespan에서 더 이상 호출하지 않음.
하위 호환을 위해 함수는 유지.
"""
import warnings
warnings.warn(
"auto_trade_scheduler is deprecated. Use services/swing/scheduler.py instead.",
DeprecationWarning,
stacklevel=2,
)
# 원래 기능: Redis 캐시 확인 후 매매 실행 (swing_scheduler로 대체됨)
await asyncio.sleep(2)
from app.api.endpoints.recommendations import (
get_rule_based_recommendations_from_db,
execute_auto_trade_background,
)
from app.api.endpoints.portfolio import check_and_execute_exit_trades
from app.api.endpoints.mcp import request_mcp_risk_assessment
from app.core.redis import get_json
logger.info("⏱️ Auto-trade scheduler started (every 60s, Redis→DB fallback mode)")
try:
while True:
try:
if not is_market_open():
# 개장을 넘겨 자지 않도록 상한 (09:00부터 바로 매매 판단 시작)
_until_open = seconds_until_market_open()
_sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))
logger.info(f"💤 Market is closed. Next check in {_sleep / 60:.0f} minutes.")
await asyncio.sleep(_sleep)
continue
async with AsyncSessionLocal() as session:
# 1. 보유 종목 손절/익절 체크
logger.info("💰 Checking portfolio for stop-loss/take-profit...")
try:
await check_and_execute_exit_trades(session)
except Exception as e:
logger.error(f"Exit trade check error: {e}", exc_info=True)
# 2. MDD 체크 (CLAUDE.md 5.3) — 신규 진입 가능 여부
from app.services.mdd_manager import mdd_manager
can_open_new = await mdd_manager.can_open_new_position()
if not can_open_new:
logger.warning(
"⚠️ [Auto-Trade] MDD -10% 이상 도달 — 신규 진입 차단. "
"기존 포지션만 손절/익절 모니터링 중"
)
# 3. 자동매매: Redis 캐시 우선 → cache miss 시만 DB 조회
if settings.AUTO_TRADE_ENABLED and can_open_new:
# ── Redis 캐시 확인 (10분 배치 결과, TTL 30분) ────────────────
rec = await get_json("recommendations:latest")
if rec:
rec_by_rule = rec.get('recommendations_by_rule', [])
rec_count = len(rec_by_rule) if isinstance(rec_by_rule, (list, dict)) else 0
logger.info(
"⚡ Redis cache hit: recommendations:latest "
f"(rule 수: {rec_count}) "
"→ DB 조회 생략"
)
# 포맷 정규화 (다른 키 조합 대응)
rec = _normalize_recommendations(rec)
else:
logger.info("🔄 Redis cache miss → DB에서 추천 조회")
rec = await get_rule_based_recommendations_from_db(session)
# ──────────────────────────────────────────────────────
if rec and rec.get("recommendations_by_rule"):
# ── MCP RISK_ASSESSMENT (공통 함수, 이슈 4) ──────────
recommendations = rec.get("recommendations_by_rule", [])
# Handle both list and dict formats for backwards compatibility
if isinstance(recommendations, dict):
# Old format: {rule_name: [stocks]}
recommendation_count = sum(len(v) for v in recommendations.values())
rules_list = list(recommendations.keys())
top_stocks_list = [
item.get("stock_code") or item.get("code", "")
for rule_items in recommendations.values()
for item in (
rule_items[:3]
if isinstance(rule_items, list)
else []
)
][:10]
else:
# New format: [{rule_id, rule_name, stocks: [...]}]
recommendation_count = sum(
len(rule_group.get("stocks", []))
for rule_group in recommendations
if isinstance(rule_group, dict)
)
rules_list = [
rule_group.get("rule_name", rule_group.get("rule_id", ""))
for rule_group in recommendations
if isinstance(rule_group, dict)
]
top_stocks_list = [
item.get("stock_code") or item.get("code", "")
for rule_group in recommendations
if isinstance(rule_group, dict)
for item in rule_group.get("stocks", [])[:3]
][:10]
trade_plan = {
"action": "BUY",
"strategy": "rule_based_batch",
"recommendation_count": recommendation_count,
"rules": rules_list,
"top_stocks": top_stocks_list,
}
portfolio: dict = {}
signals = {
"source": "db_recommendations",
"rules_triggered": rules_list,
}
approve, reason = await request_mcp_risk_assessment(
trade_plan, portfolio, signals
)
# ─────────────────────────────────────────────────────
if not approve:
logger.warning(
f"🛑 MCP RISK_ASSESSMENT DENY → 주문 스킵. 사유: {reason}"
)
try:
from app.services.db_worker import enqueue_db_write
await enqueue_db_write({
"type": "log_mcp_deny",
"reason": reason,
"trade_plan": trade_plan,
})
except Exception as _swallowed:
logger.warning("[MCP] log_mcp_deny DB 큐 적재 실패: %s", _swallowed)
else:
if reason:
logger.info(
f"✅ MCP RISK_ASSESSMENT APPROVE. 참고: {reason}"
)
logger.info("📊 Found recommendations, executing auto-trade")
await execute_auto_trade_background(rec, session)
else:
logger.info("⏭️ Auto-trade skipped: no recommendations")
elif settings.AUTO_TRADE_ENABLED and not can_open_new:
logger.info("⏸️ Auto-trade disabled (MDD protection) - only monitoring stop-loss/take-profit")
else:
logger.info("⏸️ Auto-trade disabled - only monitoring stop-loss/take-profit")
except Exception as e:
logger.error(f"❌ Auto-trade scheduler error: {e}", exc_info=True)
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("⏹️ Auto-trade scheduler stopped")
raise
async def day_trade_scheduler():
"""[DEPRECATED] 30초 간격 데이트레이딩 스케줄러.
.. deprecated::
services/daytrading/scheduler.py의 daytrading_scheduler()로 대체됨.
main.py lifespan에서 더 이상 호출하지 않음.
하위 호환을 위해 함수는 유지.
"""
# 원래 기능: ORB 수집, 신호 스캔, EOD 청산 (daytrading_scheduler로 대체됨)
await asyncio.sleep(5)
from app.api.endpoints.day_trading import run_day_trading_cycle
from app.services.mdd_manager import mdd_manager
logger.info("📈 Day-trade scheduler started (every 30s)")
try:
while True:
if not is_market_open():
await asyncio.sleep(1800)
continue
if not settings.DAY_TRADE_ENABLED:
await asyncio.sleep(60)
continue
# MDD 체크: 단기 전략 활성화 여부 확인 (CLAUDE.md 5.3)
short_term_enabled = await mdd_manager.is_short_term_strategy_enabled()
if not short_term_enabled:
logger.info("⏸️ [DayTrade] MDD -10% 이상 — 단기 전략(데이트레이딩) 중단 (신규 진입 불가)")
await asyncio.sleep(60) # 1분 후 재확인
continue
try:
result = await run_day_trading_cycle()
phase = result.get("phase", "")
if result.get("entries"):
logger.info(f"📈 데이트레이딩 진입: {result['entries']}")
if result.get("exits"):
logger.info(f"📉 데이트레이딩 청산: {result['exits']}")
if phase not in ("market_closed", "outside_window"):
logger.debug(f"Day-trade cycle [{phase}]: {result.get('time')}")
except Exception as e:
logger.error(f"❌ Day-trade scheduler error: {e}", exc_info=True)
await asyncio.sleep(30)
except asyncio.CancelledError:
logger.info("⏹️ Day-trade scheduler stopped")
raise
async def news_collect_scheduler():
"""뉴스 수집 스케줄러 (NEWS_COLLECT_INTERVAL 간격, 기본 30분).
- 장중에만 수집 (is_market_open)
- 시장 뉴스 + 활성 포트폴리오 종목 뉴스 수집
- NEWS_ENABLED=False 시 비활성화
"""
await asyncio.sleep(20) # 부팅 안정화 대기
if not settings.NEWS_ENABLED:
logger.info("News collect scheduler disabled (NEWS_ENABLED=False)")
return
logger.info(
f"News collect scheduler started "
f"(every {settings.NEWS_COLLECT_INTERVAL}s)"
)
try:
while True:
if not is_market_open():
await asyncio.sleep(300) # 장 외 5분 대기
continue
try:
from app.services.news_sentiment import (
get_market_news_score,
get_stock_news_score,
)
# 1. 시장 뉴스 수집
market_score = await get_market_news_score()
logger.info(
f"[NEWS_SCHEDULER] market_score={market_score:.3f}"
)
# 2. 활성 포트폴리오 종목 뉴스 수집
try:
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.models import Portfolio
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Portfolio.stock_code).where(
Portfolio.quantity > 0
)
)
held_codes = [row[0] for row in result.fetchall()]
for code in held_codes[:20]: # 최대 20종목
try:
score = await get_stock_news_score(code)
if score <= settings.NEWS_REDUCE_SCORE:
logger.warning(
f"[NEWS_SCHEDULER] {code} 악재 감지: "
f"score={score:.3f}"
)
except Exception as e:
logger.warning(
f"[NEWS_SCHEDULER] {code} 뉴스 수집 오류: {e}"
)
await asyncio.sleep(1) # 크롤링 간격 (서버 부하 방지)
except Exception as e:
logger.warning(
f"[NEWS_SCHEDULER] 포트폴리오 종목 뉴스 수집 오류: {e}"
)
except Exception as e:
logger.error(
f"[NEWS_SCHEDULER] 뉴스 수집 사이클 오류: {e}",
exc_info=True,
)
await asyncio.sleep(settings.NEWS_COLLECT_INTERVAL)
except asyncio.CancelledError:
logger.info("News collect scheduler stopped")
raise
async def pre_market_us_scheduler():
"""07:00 KST 미장 심리 분석 스케줄러 (1회/일).
전날 밤 미국 시장(S&P500, NASDAQ, VIX) 종가를 기반으로
한국 장 개장 전 적응형 전략 모드를 보정한다.
[실행 시점] 매일 07:00 KST (미장 마감 06:00 이후, 한국 장 09:00 이전)
[소요 시간] 약 3~5초 (네이버 3페이지 + Yahoo 1건)
"""
_KST = pytz.timezone("Asia/Seoul")
_last_run: _date | None = None
await asyncio.sleep(10) # 부팅 안정화
logger.info("🌍 Pre-market US sentiment scheduler started (daily 07:00 KST)")
try:
while True:
_now = _dt.now(_KST)
today = _now.date()
# 오늘 이미 실행했으면 내일 07:00까지 대기
if _last_run == today:
tomorrow_07 = _KST.localize(
_dt(today.year, today.month, today.day, 7, 0, 0)
) + _td(days=1)
sleep_sec = max((tomorrow_07 - _now).total_seconds(), 3600)
await asyncio.sleep(sleep_sec)
continue
# 주말(토/일) 스킵
if today.weekday() >= 5:
await asyncio.sleep(3600)
continue
# 07:00 이전이면 대기
if _now.hour < 7:
target_07 = _KST.localize(
_dt(today.year, today.month, today.day, 7, 0, 0)
)
sleep_sec = (target_07 - _now).total_seconds()
if sleep_sec > 0:
await asyncio.sleep(min(sleep_sec, 1800))
continue
# 09:00 이후면 오늘은 스킵 (이미 장 시작)
if _now.hour >= 9:
_last_run = today
await asyncio.sleep(3600)
continue
# 실행
try:
from app.services.us_market_sentiment import run_pre_market_check
result = await run_pre_market_check()
_last_run = today
logger.info(
"✅ [PreMarket] 미장 심리 분석 완료: %s (override=%s)",
result.get("sentiment", "?"),
result.get("regime_override") or "없음",
)
except Exception as e:
logger.error("❌ [PreMarket] 미장 심리 분석 실패: %s", e, exc_info=True)
# 실패해도 하루 1회만 시도 (무한 재시도 방지)
_last_run = today
await asyncio.sleep(3600)
except asyncio.CancelledError:
logger.info("⏹️ Pre-market US sentiment scheduler stopped")
raise