"""데이트레이딩 진입 서비스 ORB / VWAP / RSI+BB 신호 기반 당일 매매 진입 처리. day_trading_strategy.py의 신호 함수를 재활용하고 인메모리 상태(DayTradingState)를 업데이트. 진입 기준 (CLAUDE.md 섹션 7.3): - 감시 종목 대상 (watchlist) - ORB: 개장 범위 돌파 신호 (09:30 이후) - VWAP: VWAP 반등 신호 - RSI+BB: 5분봉 RSI < 30, BB 하단 근처 - 포지션 최대 3종목, 종목당 총자본 4% (CLAUDE.md 5.1) - MDD -10% 이하 시 비활성화 - 14:30 이후 신규 진입 마감 (CLAUDE.md 7.3) - 점심시간(11:30~13:00) VWAP/RSI_BB 진입 제한 진입 흐름: 1. MDD / 적응형 전략 / 플레이북 / 일일 손실 / 쿨다운 체크 2. 시장 국면 체크 (SIDEWAYS 차단) 3. 시간대 체크 (14:30 이후 마감) 4. 포지션 한도 체크 (최대 3종목) 5. 감시 종목 순회 → 5분봉 데이터 조회 → 신호 체크 6. 포지션 사이징 (confidence 기반 동적 조정) 7. MCP 리스크 평가 (선택적) 8. KIS API 매수 주문 실행 9. 인메모리 상태 + DB 큐 기록 아키텍처 규칙: - DB 직접 접근 금지 (CLAUDE.md 3.1) - 체결 기록은 db_worker 큐 경유 - 매수 주문은 safe_place_order() 사용 (세마포어+분산락 보호) """ import asyncio import logging import time from datetime import datetime, time as dtime import pytz from app.core.config import settings from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( parse_minute_candles, aggregate_to_5min, calculate_vwap, calculate_opening_range, check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, calculate_day_trade_position_size, is_daily_loss_exceeded, MAX_POSITIONS, # 진단 전용(읽기 전용 순수 함수). 게이트에 쓰이지 않는다 — # `_log_rsi_gap_diagnostics()` 가 탈락 후보의 분포를 남기는 데만 쓴다. _rsi as _rsi5, _overnight_gap_pct, ) from app.services.daytrading.position_manager import day_trading_state from app.services.db_worker import enqueue_trade_history, enqueue_active_trade_upsert from app.services.mdd_manager import mdd_manager from app.services.market_analyzer import check_market_regime from app.services.loss_tracker import loss_tracker from app.core.market_schedule import is_opening_range_period, is_day_trade_entry_window from app.services.market_playbook import ( is_daytrading_allowed_by_playbook, is_vi_active, ) logger = logging.getLogger(__name__) # 한국 표준시(KST) 타임존 — 장 시간/시간대 체크에 사용 _KST = pytz.timezone("Asia/Seoul") # 감시 종목 딕셔너리 (code → name) # day_trading.py의 _watchlist와 동기화되며, POST /api/daytrading/watchlist 또는 # populate_daytrading_watchlist()로 설정됨 _watchlist: dict[str, str] = {} # True = populate_daytrading_watchlist()로 자동 구성된 목록 (스케줄러가 30분마다 갱신 가능) # False = API로 수동 설정된 목록 (자동 갱신으로 덮어쓰지 않음) _watchlist_auto: bool = False # ── [F6 2026-09-28] 스캔 처리량 ─────────────────────────────────────────────── # 증상: 감시종목 20개인데 매 사이클 앞쪽 5~6종목만 검사되고 뒤쪽은 영구히 기회를 # 못 얻었다. 스케줄러의 _guard(scan_and_enter(), "진입 스캔") 가 180초에서 코루틴을 # 취소하는데, 종목별 분봉 조회가 순차라 20종목이 예산에 들어오지 못했다. # # 대책 3종: # 1) 분봉 조회를 제한 동시성으로 **선조회**(읽기 전용)해 지배적 지연을 제거한다. # 주문은 그대로 순차 실행 → MAX_POSITIONS·예산 클램프 순서가 바뀌지 않는다. # 2) 순차 실행 구간에 시간 예산을 둬 **잘린 종목 수를 warning 으로 남긴다** # (기존에는 _guard 가 조용히 취소했다). # 3) 라운드로빈 시작 오프셋 — 그래도 잘리면 다음 사이클은 다른 종목부터 본다. # # 동시성은 보수적으로 잡는다. KIS 호출은 korea_investment_api 내부에서 # semaphore(API_MAX_CONCURRENT_REQUESTS=8) + _throttle_lock(min_request_interval) # 으로 이미 직렬화·쓰로틀되므로 여기서 병렬도를 올려도 레이트리밋을 넘지 않는다. # [2026-09-30] 4 → 8. 실제 병목은 전역 쓰로틀이 아니라 **이 동시성**이었다. # 종목당 분봉 페이지 1장의 체인 비용은 약 3.2초(운영 실측 요청 3.0초 + in-loop # sleep 0.2초)이므로 동시성 4 에서는 4/3.35 ≈ 1.2 req/s 밖에 나오지 않는다 — # 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL = 6.67 req/s)의 5분의 1 이다. # 8 로 올려도 8/3.35 ≈ 2.4 req/s 로 쓰로틀 상한에 닿지 않고, 오후(종목당 10~12장) # 구간의 선조회 시간이 절반으로 줄어 스캔 포화가 풀린다. # ⚠️ 상한은 8 이다: korea_investment_api 의 `semaphore(API_MAX_CONCURRENT_REQUESTS=8)` # 와 같은 값이라 이 이상 올려도 실효가 없고, 세마포어를 완전히 점유해 다른 경로 # (스윙 청산·주문)의 대기만 늘린다. 세마포어는 FIFO 이므로 대기는 요청 1건 # (약 3초) 으로 유계다. _SCAN_PREFETCH_CONCURRENCY: int = 8 # 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다. # [2026-09-30] 20 → 45. 종목당 페이지 수는 시간대에 비례해 늘어난다 # (당일 1분봉 ÷ 30봉): 09:30 4장 → 12:00 6장 → 14:00 10장 → 15:00 12장. # 12장 × 3.2초 ≈ 38초이므로 20초에서는 **12:00 이후 전 종목이 타임아웃**했고, # 그러면 순차 루프가 종목별 재조회를 떠안아(타임아웃 없음·직렬) 오히려 느려졌다. # VWAP 이 세션 앵커 누적이라 페이지 수를 줄이는 방식은 지표 정의를 바꾸게 되므로 # 택하지 않았다. 대신 아래 **단계 전체 데드라인**으로 예산을 보호한다. _SCAN_PREFETCH_TIMEOUT: float = 45.0 # 선조회 **단계 전체** 데드라인(초). per-stock 타임아웃만 올리면 최악의 경우 # ceil(N/동시성) 웨이브 × 45초가 스케줄러 _guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가 # 조용히 취소된다(진입 0건 + 로그 없음). 단계 자체에 상한을 둬서 **순차 진입 구간이 # 항상 실행되게** 한다. 이미 캐시에 담긴 종목은 취소돼도 남는다(_one 이 완료 즉시 저장). # _SCAN_TIME_BUDGET(150초)에서 순차 구간·주문 몫 30초를 남긴 값이다. _SCAN_PREFETCH_TOTAL_BUDGET: float = 120.0 # 순차 진입 구간 시간 예산(초). 스케줄러 _guard(STEP_TIMEOUT=180) 보다 작게 잡아 # 취소가 아니라 '남은 종목 수 로그'로 끝나게 한다. _SCAN_TIME_BUDGET: float = 150.0 # 선조회 분봉 캐시 {code: (저장시각, candles)} — 한 사이클 안에서만 유효 _candle_cache: dict[str, tuple[float, list]] = {} # [F11 2026-09-28] 수명은 '시간'이 아니라 '한 사이클'이다 — scan_and_enter() 가 진입 # 시점에 캐시를 비운다. TTL 만 180초로 두면 30초 주기 스케줄러에서 같은 스냅샷이 5개 # 사이클을 덮어, 세 신호 함수가 (candles, orb, price) 의 순수 함수인 탓에 t=0 에 # 신호가 없으면 t=150 까지 무조건 no-signal 이 되고 진입가·손절·수량이 최대 3분 묵는다. # [M3 2026-09-28] TTL 은 **한 사이클 상한(_SCAN_TIME_BUDGET)보다 커야** 한다. # 이전 값 25초는 선조회 자체보다 짧았다: 모든 KIS 요청이 싱글톤 _throttle_lock 안에서 # min_request_interval(0.15초) 만큼 sleep 하므로 실효 처리율은 ≈6.7 req/s 로 전역 # 고정이고(semaphore(8) 은 상한만 제공), 20종목 × 종목당 다회 호출이면 선조회가 수십 # 초를 쓴다 → 앞쪽 항목이 순차 루프 시작 전에 만료돼 재조회됐다(종목당 호출 2배). _CANDLE_CACHE_TTL: float = 180.0 # 라운드로빈 시작 오프셋 _scan_offset: int = 0 # ── RSI·갭 진단 로깅 (2026-09-30) ───────────────────────────────────────────── # 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가 # 차분 한 개로 RSI 에 들어간다(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). 사용자 # 결정은 **유지 + 관찰**인데, 관찰 수단이 없었다: `trade_history.entry_reason` 의 # `RSI=` 는 **게이트를 통과한 진입에만** 존재해 분포가 임계값 바로 아래에 고인다 # (실측 56건 29.3~44.9, 임계 45 절단). 기저 분포를 보려면 **탈락 후보**의 값이 필요하다. # # 볼륨 억제: 종목마다 한 줄이면 30종목 × 30초 주기 × 5.5시간 ≈ 2만 줄/일이고, # `docker logs` 가 이미 수 분씩 걸린다. 그래서 **사이클당 1줄**로 집계하고, 다시 # N 사이클마다 1회만 남긴다(market_analyzer._ML_SKIP_LOG_EVERY 와 같은 패턴). # 10 사이클 = 약 5분 → 하루 약 60줄. _RSI_DIAG_LOG_EVERY: int = 10 _rsi_diag_cycles: int = 0 # 한 줄에 싣는 최저 RSI 종목 수 (전량 싣으면 줄이 길어져 그렙이 어려워진다) _RSI_DIAG_TOP_N: int = 8 def _log_rsi_gap_diagnostics(candidates: list[tuple[str, str]]) -> None: """후보 전체의 (RSI, 갭) 분포를 **한 줄로** 남긴다 — 읽기 전용 진단. 진입 판정에 개입하지 않는다. 이미 선조회된 `_candle_cache` 만 읽으므로 **추가 KIS 호출·DB I/O 가 없다**(§3.1). 통과 여부와 무관하게 전 후보의 값을 남기므로 `entry_reason` 이 가진 **절단 편향이 없는 기저 분포**가 쌓인다. 로깅 위치를 전략 모듈이 아니라 **호출부**로 잡은 이유: - `day_trading_strategy` 의 신호 함수는 `(candles, vwap, price)` 의 순수 함수다. 거기서 남기면 종목마다 한 줄이 되어 볼륨을 억제할 방법이 없고, 억제 카운터를 두면 모듈에 가변 상태가 생겨 순수성·테스트 순서 독립성이 깨진다(§4.1). - 호출부는 사이클 경계를 알기 때문에 집계가 자연스럽다. - 필요한 값(RSI·갭)은 `_rsi5`/`_overnight_gap_pct` 로 **같은 입력에서 재계산**된다. 둘 다 순수 함수이므로 신호 함수가 본 값과 동일하다(5분봉 45개 내외 연산). """ global _rsi_diag_cycles _rsi_diag_cycles += 1 if _rsi_diag_cycles != 1 and _rsi_diag_cycles % _RSI_DIAG_LOG_EVERY != 0: return rows: list[tuple[float, float | None, str]] = [] for code, _ in candidates: cached = _candle_cache.get(code) if not cached: continue c5 = aggregate_to_5min(cached[1]) rsi = _rsi5(c5, 14) if rsi is None: continue rows.append((rsi, _overnight_gap_pct(c5), code)) if not rows: logger.info( f"[DayEntry] RSI 진단: 후보 {len(candidates)}종목 중 RSI 산출 0건 " f"(5분봉 부족 — 워밍업 미달)" ) return rows.sort() _rsis = [r[0] for r in rows] _gaps = [r[1] for r in rows if r[1] is not None] _vwap_th = settings.DAY_TRADE_VWAP_RSI_THRESHOLD _bb_th = settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD _pass_vwap = sum(1 for v in _rsis if v <= _vwap_th) _pass_bb = sum(1 for v in _rsis if v <= _bb_th) _gap_down = sum(1 for g in _gaps if g <= -1.0) _top = ", ".join( f"{c}:{r:.1f}" + (f"/{g:+.1f}%" if g is not None else "") for r, g, c in rows[:_RSI_DIAG_TOP_N] ) logger.info( f"[DayEntry] RSI 진단(사이클 {_rsi_diag_cycles}): n={len(rows)} " f"평균={sum(_rsis) / len(_rsis):.1f} 최저={_rsis[0]:.1f} 최고={_rsis[-1]:.1f} | " f"RSI≤{_vwap_th:.0f} {_pass_vwap}종목, RSI≤{_bb_th:.0f} {_pass_bb}종목 | " f"갭 산출 {len(_gaps)}종목, 그중 갭≤-1% {_gap_down}종목 | " f"최저 RSI(종목:RSI/갭): {_top}" ) def _clamp_day_trade_budget( budget: float, total_assets: float, available_cash: float, current_day_trade_amount: float, ) -> float: """[H3] 데이트레이딩 주문 예산을 세 가지 한도로 재클램프. calculate_day_trade_position_size()와 동일한 한도를 적용한다 (종목당 DAY_TRADE_MAX_CAPITAL_RATIO / DT 전체 DAY_TRADE_MAX_RATIO_DT 잔여 / 보유 현금). confidence 배율처럼 클램프 이후 예산을 변형하는 로직 뒤에 반드시 호출해야 한다. calculate_day_trade_position_size() 자체는 변경하지 않는다(시그니처 보존). Args: budget: 클램프 대상 예산 (원) total_assets: 총자산 available_cash: 주문 가능 현금 current_day_trade_amount: 현재 데이트레이딩 투입 금액 Returns: float: 세 한도를 모두 만족하는 예산 (0 이상) """ per_position_cap = total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO remaining_dt_cap = total_assets * settings.DAY_TRADE_MAX_RATIO_DT - current_day_trade_amount return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash)) def set_watchlist(stocks: list[dict]) -> None: """감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외). Args: stocks: 종목 목록. 각 항목은 {"code": "005930", "name": "삼성전자"} 형태 """ global _watchlist, _watchlist_auto _watchlist = {s["code"]: s.get("name", s["code"]) for s in stocks} _watchlist_auto = False logger.info(f"[DayEntry] 감시 종목 설정: {len(_watchlist)}개") def is_watchlist_auto() -> bool: """현재 감시 목록이 자동 구성(populate)된 것인지 여부.""" return _watchlist_auto def get_watchlist() -> dict[str, str]: """현재 감시 종목 딕셔너리 복사본 반환. Returns: dict[str, str]: {종목코드: 종목명} 딕셔너리 """ return dict(_watchlist) async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | None = None) -> int: """stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정. 배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을 자동으로 데이트레이딩 감시 대상으로 설정. 필터 조건: - 오늘 날짜 분석 결과만 사용 - score >= min_score (기본 1점 이상) - RSI 값이 존재 (기술적 분석 완료 종목만) - 현재가 > 5,000원 (CLAUDE.md 5.4 가격 필터) Args: min_score: 최소 점수 기준 (기본 1) max_stocks: 최대 감시 종목 수. ``None`` 이면 ``settings.DAY_TRADE_WATCHLIST_SIZE`` (하드코딩 상한 없음). Returns: int: 설정된 감시 종목 수 Note: 이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당. populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신. [2026-09-30] 상한이 20 으로 하드코딩돼 있어 ORB 후보 풀을 넓힐 방법이 없었다. ``settings.DAY_TRADE_WATCHLIST_SIZE`` 로 이관했다 — 상한 근거·경합 계산은 config.py 의 해당 키 주석 참조. 기존 시그니처는 보존한다(§4): 인자를 명시한 호출부는 그대로 동작하고, 인자를 생략한 호출부만 settings 를 따른다. """ from app.core.database import AsyncSessionLocal from app.models.models import StockAnalysis from sqlalchemy import select, func # [§4] 한도 결정은 try 밖에서 — 예외로 조용히 우회되면 상한이 사라진다. if max_stocks is None: max_stocks = settings.DAY_TRADE_WATCHLIST_SIZE today = datetime.now(_KST).date() # [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유] # 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다. # 세션 수명은 아래 블록으로 최소화하고, 실패 시 warning 으로 운영에 노출한다. latest_date = None rows: list = [] try: async with AsyncSessionLocal() as session: # [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 → # ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근 # 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨. latest_date = ( await session.execute( select(func.max(StockAnalysis.analysis_date)).where( StockAnalysis.analysis_date <= today ) ) ).scalar_one_or_none() if latest_date is None: logger.info("[DayEntry] stock_analysis 데이터 없음") return 0 result = await session.execute( select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) .where( StockAnalysis.analysis_date == latest_date, StockAnalysis.score >= min_score, StockAnalysis.rsi.isnot(None), StockAnalysis.current_price > 5000, ) .order_by(StockAnalysis.score.desc()) .limit(max_stocks) ) rows = result.all() except Exception as e: logger.warning(f"[DayEntry] watchlist 자동 구성용 분석 조회 실패(읽기 전용): {e}") return 0 if latest_date != today: logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성") if not rows: logger.info(f"[DayEntry] stock_analysis({latest_date})에 조건 충족 종목 없음") return 0 stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows] # [F8 2026-09-28] ORB 를 이미 수집한 종목은 교체에서 살린다. # set_watchlist() 는 목록을 **통째로 재대입**하고, 스케줄러의 30분 갱신은 # _last_wl_sync=0.0 초기값 때문에 09:30 첫 사이클에서 즉시 발동한다. # 09:00~09:29 에 ORB 를 수집한 종목이 여기서 빠지면(당일 배치가 다른 종목을 # 상위로 올리면 실제로 빠진다) 스캔 루프가 _watchlist 만 순회하므로 # 그 ORB 는 버려지고 당일 ORB 진입이 0건이 된다. _kept = [ {"code": c, "name": _watchlist.get(c, c)} for c in _watchlist if day_trading_state.has_orb(c) ] if _kept: _kept_codes = {x["code"] for x in _kept} merged = _kept + [x for x in stocks if x["code"] not in _kept_codes] dropped = len(merged) - max_stocks merged = merged[:max_stocks] logger.info( f"[DayEntry] watchlist 갱신 시 ORB 보유 {len(_kept)}종목 유지" + (f" (신규 후보 {dropped}종목은 상한 {max_stocks}개로 탈락)" if dropped > 0 else "") ) stocks = merged set_watchlist(stocks) global _watchlist_auto _watchlist_auto = True logger.info(f"[DayEntry] 자동 watchlist 설정: {len(stocks)}개 (score>={min_score})") return len(stocks) async def _fetch_candles_impl(stock_code: str) -> list: """분봉 조회 + 파싱. 실패 시 빈 리스트. ⚠️ **전일 봉이 섞인 시계열을 반환한다** — 진입 판정 경로 전용이다. `get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **RSI(14)/ BB(20) 판정 게이트의 워밍업**이다: 5분봉 RSI(14)=15봉·BB(20)=20봉 요건 때문에 당일 봉만으로는 VWAP 이 약 10:15, RSI_BB 가 약 10:40 이전에 구조적으로 발화하지 못했다 (`indicators_core.rsi_wilder` 는 봉 부족 시 `None`). 원장 실측으로 필터 도입(1b9257c, 2026-09-28) 이전에는 09:30·10:02 진입이 실재했음이 확인됐다. ⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30 정정). 같은 시계열이 `_atr` 로도 흘러가 **당일 첫 봉의 TR = 오버나이트 갭**을 윈도에 넣어 진입 손절·목표가를 부풀렸고, 청산 트레일링(당일 전용 분봉)과 기준이 엇갈렸다. 지금은 `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊는다 — 진입/청산 ATR 이 같은 기준이다. **봉 간 차분을 쓰는 지표를 새로 추가할 때 이 시계열을 그대로 먹이지 말 것.** ⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.** 조회 계층이 전일로 거슬러 가려면 미래 `ref_time` 에 KIS 가 전일 데이터를 준다는 전제가 필요한데 그 전제는 확인되지 않았다(`get_minute_chart_full_day_v2` docstring). 전일 봉이 경계 페이지 1장만 들어오면 발화 시각이 소폭 앞당겨지는 데 그친다. 실제 확보량은 조회 계층의 `[분봉] ... 워밍업 5분봉 N봉` 로그로 확인할 것. ORB 수집(`collect_opening_ranges`)·DT 청산(`daytrading/exit_service`)·레거시 엔드포인트는 **기존 메서드(`get_minute_chart_full_day`)를 그대로 쓴다** — 당일 봉만 받는다. ⚠️ 호출부(`_try_enter`)는 용도를 반드시 분리해야 한다: - 워밍업 허용(전일 포함): `aggregate_to_5min(candles)` → RSI/BB - 당일 봉만: `calculate_vwap`, `check_orb_signal`, 현재가, 갭·당일 거래량 → `filter_today_candles()` 로 분리 (CLAUDE.md §7 마지막 항목) 세션 경계 비교는 순수 계산 모듈 쪽에서 막는다 — `day_trading_strategy.volume_surge_ratio()` 와 `check_orb_signal()` 의 거래량 게이트는 **마지막 봉과 같은 영업일자의 봉끼리만** 비교한다(`_last_session_candles`). """ raw = await korea_investment_api.get_minute_chart_full_day_v2( stock_code, include_prev_day=True ) return parse_minute_candles(raw) async def _prefetch_minute_charts(codes: list[str]) -> int: """[F6] 후보 종목의 분봉을 제한 동시성으로 선조회해 캐시에 담는다. 읽기 전용이며 주문·상태 변경을 하지 않는다. 실패·타임아웃 종목은 캐시에 담기지 않고, 뒤이은 순차 스캔이 개별적으로 재시도한다(기존 동작과 동일). Returns: int: 캐시에 담긴 종목 수 """ if not codes: return 0 sem = asyncio.Semaphore(_SCAN_PREFETCH_CONCURRENCY) now = time.monotonic() async def _one(code: str) -> None: cached = _candle_cache.get(code) if cached and (now - cached[0]) < _CANDLE_CACHE_TTL: return async with sem: try: candles = await asyncio.wait_for( _fetch_candles_impl(code), timeout=_SCAN_PREFETCH_TIMEOUT ) if candles: _candle_cache[code] = (time.monotonic(), candles) except asyncio.TimeoutError: logger.warning( f"[DayEntry] 분봉 선조회 {_SCAN_PREFETCH_TIMEOUT:.0f}초 초과: {code} — 이번 사이클 스킵" ) except Exception as e: logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}") # 단계 전체 데드라인 — 초과분은 포기하고 순차 진입 구간으로 넘어간다(§8.1-11: # 청산·진입 판정이 선조회 때문에 통째로 취소되는 것이 최악이다). _gather = asyncio.gather(*(_one(c) for c in codes), return_exceptions=True) try: await asyncio.wait_for(_gather, timeout=_SCAN_PREFETCH_TOTAL_BUDGET) except asyncio.TimeoutError: logger.warning( f"[DayEntry] ⏱️ 분봉 선조회 단계 예산 {_SCAN_PREFETCH_TOTAL_BUDGET:.0f}초 초과 " f"— {sum(1 for c in codes if c in _candle_cache)}/{len(codes)}종목만 선조회됨. " f"나머지는 순차 루프가 개별 조회한다(시간 예산에 걸리면 다음 사이클로 미룸)" ) # 오래된 항목 정리 (감시목록 교체로 무한 증식 방지) stale = [c for c, (ts, _) in _candle_cache.items() if (time.monotonic() - ts) > _CANDLE_CACHE_TTL * 2] for c in stale: _candle_cache.pop(c, None) return sum(1 for c in codes if c in _candle_cache) async def _get_candles_cached(stock_code: str) -> list: """[F6] 선조회 캐시를 우선 사용하고, 없으면 그 자리에서 조회한다. ``_try_enter()`` 의 시그니처를 바꾸지 않기 위해 인자 전달 대신 모듈 캐시를 쓴다 (CLAUDE.md §4: 기존 시그니처 변경 금지 / §4.1: 목킹 지점이 빗나가지 않게 유지). """ cached = _candle_cache.get(stock_code) if cached and (time.monotonic() - cached[0]) < _CANDLE_CACHE_TTL: return cached[1] candles = await _fetch_candles_impl(stock_code) if candles: _candle_cache[stock_code] = (time.monotonic(), candles) return candles def filter_today_candles(candles: list) -> list: """당일(KST) 캔들만 남긴다. [CLAUDE.md §7 마지막 항목] ``get_minute_chart_full_day`` 는 ``FID_PW_DATA_INCU_YN="Y"`` 로 과거 데이터를 포함해 요청하므로 **전일 캔들이 섞인다.** ``calculate_opening_range`` 는 시각(090000~092959)만 보고 날짜를 보지 않기 때문에, 필터 없이 넘기면 전일 09:00~09:29 봉이 ORB 고저·거래량에 합산되어 레인지가 오염된다(레인지 과대 → 돌파 미발생, 평균분당거래량 과대 → 거래량 게이트 통과 불가). [M2 2026-09-28] "날짜 필드가 없다"와 "날짜가 다 있는데 전부 당일이 아니다"를 **구분한다**. 전자는 목/레거시 응답 방어를 위해 원본을 통과시키지만, 후자는 §7 이 경고하는 오염 그 자체이므로 ``[]`` 를 반환하고 ``warning`` 을 남긴다. 통과시키면 전일 ORB 고저·평균분당거래량이 당일 ORB 로 Redis(TTL 24시간)에 저장돼 하루 남는다. (`market_schedule` 에 휴장일 판정이 없어 휴장일에 당일 봉이 0건인 경로가 실재한다.) """ today_str = datetime.now(_KST).strftime("%Y%m%d") todays = [c for c in candles if getattr(c, "date", "") == today_str] if todays: return todays if not candles: return candles dated = [c for c in candles if getattr(c, "date", "")] if len(dated) == len(candles): # 전 캔들에 날짜가 있는데 당일이 0건 → 전일 데이터만 돌아온 것(오염) _dates = sorted({getattr(c, "date", "") for c in candles}) logger.warning( f"[DayEntry] ⚠️ 당일({today_str}) 분봉 0건 — 응답 날짜 {_dates[:3]} " f"(총 {len(candles)}봉). 전일 캔들로 ORB 를 계산하면 고저·거래량이 오염되므로 " f"폐기한다(휴장일 또는 분봉 조회 이상)." ) return [] # 날짜 필드가 비어 있는 응답(목/레거시) — 기존 동작 유지 return candles async def collect_opening_ranges() -> None: """09:00~09:30 ORB(Opening Range Breakout) 데이터 수집. 감시 종목의 시초가 레인지(고가/저가)를 수집하여 ORB 돌파 전략의 기준값으로 사용. 호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출. 장중 재기동으로 09:30 을 지나 기동한 경우는 backfill_opening_ranges() 가 같은 로직을 소급 호출한다(당일 분봉 전체 조회이므로 시각 제약 없음). KIS API 호출 간격: 0.3초 (레이트 리밋 준수) """ day_trading_state.reset_if_new_day() # 재기동으로 인메모리가 비었을 수 있으므로 먼저 Redis 캐시를 복원한다 await day_trading_state.load_orb_cache() _collected_before = len(day_trading_state.orb_cache) for stock_code in list(_watchlist.keys()): # 이미 ORB가 수집된 종목은 건너뜀 if day_trading_state.has_orb(stock_code): continue try: # 당일 분봉 전체 조회 → 시초가 레인지 계산 raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = filter_today_candles(parse_minute_candles(raw)) if candles: orb = calculate_opening_range(candles) if orb: day_trading_state.set_orb(stock_code, orb) # [2026-09-30] debug → info. **레인지가 몇 개 봉으로 확정됐는지**가 # 로그에 남아야 한다: `calculate_opening_range` 는 봉 5개면 값을 # 확정하고 이 루프는 `has_orb()` 로 단락하므로, 실제로는 09:00~09:29 # 전체가 아니라 **첫 성공 시점까지의 레인지**로 하루가 고정될 수 있다 # (의도된 창은 09:00~09:29 — `is_day_trade_entry_window()` 가 09:30 # 부터라 조기 확정에 실익이 없다). 봉 수는 저장 필드에서 정확히 # 복원된다: total_volume / avg_vol_per_min = 봉 수. _bars = ( round(orb.total_volume / orb.avg_vol_per_min) if orb.avg_vol_per_min > 0 else 0 ) _width_pct = ( (orb.high - orb.low) / orb.low * 100 if orb.low > 0 else 0.0 ) logger.info( f"[DayEntry] ORB 수집: {stock_code} {_bars}봉 " f"고={orb.high:,.0f} 저={orb.low:,.0f} 폭={_width_pct:.2f}% " f"분당평균거래량={orb.avg_vol_per_min:,.0f}" ) # KIS API 레이트 리밋 방지 (초당 약 3회) await asyncio.sleep(0.3) except Exception as e: logger.debug(f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}") if len(day_trading_state.orb_cache) != _collected_before: await day_trading_state.save_orb_cache() async def backfill_opening_ranges() -> int: """장중 재기동 시 당일 ORB 를 소급 수집. 수집된 종목 수 반환. [2026-09-28 재기동 내구성 수정] ``collect_opening_ranges()`` 의 유일한 호출부가 스케줄러의 ``is_opening_range_period()``(09:00~09:29) 분기였다. 09:30 이후에 기동하면 재수집 경로가 없어 **당일 ORB 전략이 영구 0건**이 됐다(Redis 캐시가 살아 있으면 복원되지만, 캐시가 없거나 09:30 전에 한 번도 수집되지 않았으면 불가). ``get_minute_chart_full_day`` 는 당일 전체 분봉을 주므로 09:30 이후에도 09:00~09:29 레인지를 정확히 소급 계산할 수 있다. 전일 캔들 혼입은 ``filter_today_candles()`` 가 차단한다. 진입 게이트는 건드리지 않는다 — 수집만 수행한다. Returns: int: 소급 수집 후 orb_cache 에 존재하는 종목 수 """ if not _watchlist: try: count = await populate_daytrading_watchlist() if count: logger.info(f"[DayEntry] ORB 소급수집용 watchlist 구성: {count}개") except Exception as e: logger.warning(f"[DayEntry] ORB 소급수집용 watchlist 구성 실패: {e}") if not _watchlist: logger.warning( "[DayEntry] ⚠️ 장중 기동 ORB 소급수집 불가 — watchlist 가 비어 있다 " "(당일 stock_analysis 부재 가능). 당일 ORB 전략 0건이 된다." ) return len(day_trading_state.orb_cache) before = len(day_trading_state.orb_cache) await collect_opening_ranges() after = len(day_trading_state.orb_cache) if after: logger.info( f"[DayEntry] 장중 기동 ORB 소급수집: {before}개 → {after}개 " f"(watchlist {len(_watchlist)}개)" ) else: logger.warning( f"[DayEntry] ⚠️ 장중 기동 ORB 소급수집 0건 (watchlist {len(_watchlist)}개) — " "당일 ORB 전략 불가. 분봉 조회 실패 또는 당일 09:00~09:29 봉 부족." ) return after async def scan_and_enter() -> list[dict]: """감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행. 09:30~14:30 구간에서 30초 주기로 호출 (14:30 이후 진입 마감). 진입 전 체크 순서 (P0 = 최우선, P1 = 중요): [P0] MDD -10% 체크 → 단기 전략 비활성화 [P0] MDD 공통 진입 게이트 can_open_new_position() (is_paused + 갱신 장애 fail-safe) [P0] 플레이북 체크 → FOMC/옵션만기 등 이벤트 당일 차단 [P0] 일일 손실 한도 -3% 체크 [P1] 3연패 쿨다운 체크 [P0] 시장 국면 체크 → SIDEWAYS/CRASH 등 차단 [P1] 14:30 이후 신규 진입 마감 Returns: list[dict]: 진입 실행 결과 목록. 각 항목: - stock_code, stock_name, quantity, price, strategy, reason """ day_trading_state.reset_if_new_day() entries = [] # MDD 체크 ① — 단기/데이트레이딩 전략 비활성화 여부 (MDD -10% 시 pause) short_term_ok = await mdd_manager.is_short_term_strategy_enabled() if not short_term_ok: logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화") return entries # MDD 체크 ② — 공통 신규 진입 게이트 (CLAUDE.md §5.3) # [항목7 2026-09-27] DT 진입 경로는 is_short_term_strategy_enabled() 만 봤기 때문에 # can_open_new_position() 의 fail-safe(MDD 갱신 연속 실패 = peak/drawdown 정지로 # -10%/-20% 게이트 무력화)와 is_paused 가 적용되지 않았다. 두 게이트를 모두 # 통과해야 진입한다. 이 블록은 try 밖에 두어 예외로 우회되지 않게 하고, # 종목 루프/분봉 API 호출 전에 배치해 불필요한 KIS 호출을 없앤다. # 청산 경로(exit_service)에는 영향 없음. if not await mdd_manager.can_open_new_position(): _health = mdd_manager.get_update_health() if _health.get("blind_block_active"): logger.error( "[DayEntry] ⛔ MDD 갱신 장애(fail-safe) — 데이트레이딩 신규 진입 차단 " f"(연속 실패 {_health.get('consecutive_failures')}회 / 임계 " f"{_health.get('blind_block_threshold')}, 마지막 오류: {_health.get('last_error')})" ) else: logger.warning("[DayEntry] ⛔ MDD 임계 초과(is_paused) — 데이트레이딩 신규 진입 차단") return entries # 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화 try: from app.services.adaptive_strategy import get_current_adaptive_params _adp_dt = get_current_adaptive_params() if not _adp_dt.fallback_used and not _adp_dt.daytrading_enabled: logger.info("[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화", _adp_dt.trading_mode.upper()) return entries except Exception: pass # adaptive 모듈 실패 시 기존 로직 유지 # [P0] 플레이북 체크 — FOMC/옵션만기/서킷브레이커 등 이벤트 당일 차단 if not is_daytrading_allowed_by_playbook(): logger.info("[DayEntry] ⛔ 플레이북 차단 — 데이트레이딩 비활성화 (이벤트 대응)") return entries # [P0] 일일 손실 한도 체크 — 청산 후 즉시 재진입 방지 if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl): logger.info("[DayEntry] ⛔ 일일 손실 한도(-3%) 초과 — 신규 진입 차단") return entries # [P1] 쿨다운 — 3연패 시 당일 진입 중단 if day_trading_state.is_cooldown_active(): logger.info("[DayEntry] ⛔ 3연패 쿨다운 — 신규 진입 차단") return entries # [P0] 시장 국면 체크 — CRASH/HALTED에서만 진입 차단 # 데이트레이딩은 분봉 기반이라 일봉 SIDEWAYS/strong_bearish와 무관하게 # 장중 변동성이 있으면 진입 가능 (CLAUDE.md 7.3 전략 독립성) _BLOCK_REGIMES_DT = {"crash", "halted"} try: regime_info = await check_market_regime() regime = regime_info.get("regime", "neutral") if regime in _BLOCK_REGIMES_DT: logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단") return entries logger.info(f"[DayEntry] 시장 국면: {regime}") except Exception as e: logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {e}") # [시간대 제한 완화 2026-09-22] 점심시간(11:30~13:00) VWAP/RSI_BB 제한 제거. # 신규 진입 마감만 유지 — settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN (기본 15:00, # EOD 15:20 강제청산 전 최소 20분 확보). 하드코딩 14:30 → 설정값. now_time = datetime.now(_KST).time() _is_lunch_break = False _cutoff = dtime(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN) if now_time >= _cutoff: logger.info(f"[DayEntry] ⛔ {_cutoff.strftime('%H:%M')} 이후 — 신규 진입 마감") return entries # 포지션 한도 체크 if day_trading_state.position_count() >= MAX_POSITIONS: logger.info(f"[DayEntry] ⛔ 포지션 한도 ({MAX_POSITIONS}) 도달") return entries # 포트폴리오 총자산 조회 (캐시 활용) try: balance = await korea_investment_api.get_balance() # output2가 빈 리스트([])로 오는 경우까지 방어 (get의 default는 키가 없을 때만 적용됨) summary = (balance.get("output2") or [{}])[0] total_assets = float(summary.get("tot_evlu_amt", 0) or 0) available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0) # 당일 매도대금 재사용 가능 — 주문가능현금이 조회되면 우선 사용 _buyable = await korea_investment_api.get_buyable_cash() if _buyable > 0: available_cash = _buyable day_trading_state.last_total_assets = total_assets except Exception as e: # [§4 2026-09-30] debug → warning. 잔고 조회가 실패하면 available_cash=0 이 # 되어 **모든 진입이 조용히 차단**된다(사이징이 0 → quantity 0). 원인 추적이 # 불가능한 무증상 정지의 전형이라 반드시 노출한다. logger.warning( f"[DayEntry] ⚠️ 잔고 조회 실패 — 가용현금 0 으로 진행(신규 진입 사실상 차단): {e}" ) total_assets = day_trading_state.last_total_assets available_cash = 0.0 if total_assets <= 0: return entries # [BUG FIX] 글로벌 자본 사용률 가드 — 전체 포트폴리오 투자액이 MAX_TOTAL_CAPITAL_USAGE 초과 시 진입 차단 total_invested = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) for pos in day_trading_state.positions.values() ) # 스윙 포트폴리오 투자액도 합산 (잔고 조회에서 available_cash로 간접 계산) swing_invested = total_assets - available_cash - total_invested all_invested = max(0, total_invested + swing_invested) if total_assets > 0 and all_invested / total_assets > settings.MAX_TOTAL_CAPITAL_USAGE: logger.warning( f"[DayEntry] 전체 자본 사용률 초과: {all_invested/total_assets:.1%} > " f"{settings.MAX_TOTAL_CAPITAL_USAGE:.0%} — 신규 진입 차단" ) return entries scanned = 0 skipped_held = 0 skipped_closed = 0 no_signal = 0 truncated = 0 # [F6] 후보 선별 → 라운드로빈 회전 → 분봉 선조회 → 순차 진입 candidates: list[tuple[str, str]] = [] for stock_code, stock_name in list(_watchlist.items()): # 이미 보유 중이거나 오늘 이미 청산한 종목 제외 if stock_code in day_trading_state.positions: skipped_held += 1 continue if day_trading_state.is_closed_today(stock_code): skipped_closed += 1 continue candidates.append((stock_code, stock_name)) # 라운드로빈: 시간 예산에 걸려 잘려도 다음 사이클은 다른 종목부터 본다 global _scan_offset if candidates: _scan_offset %= len(candidates) candidates = candidates[_scan_offset:] + candidates[:_scan_offset] # [F11] 사이클 경계 — 이전 사이클 스냅샷을 버려 매 사이클 새 데이터로 재평가한다. # 재조회 비용은 get_minute_chart_full_day 의 Redis 1분 버킷 캐시가 대부분 흡수한다. _candle_cache.clear() # 선조회(읽기 전용, 제한 동시성) — 순차 조회로 예산을 다 쓰던 지배 원인 제거 _scan_started = time.monotonic() prefetched = await _prefetch_minute_charts([c for c, _ in candidates]) _prefetch_sec = time.monotonic() - _scan_started for idx, (stock_code, stock_name) in enumerate(candidates): if day_trading_state.position_count() >= MAX_POSITIONS: break _elapsed = time.monotonic() - _scan_started if _elapsed > _SCAN_TIME_BUDGET: # 기존에는 스케줄러 _guard(180초)가 코루틴을 조용히 취소해 '뒤쪽 종목이 # 통째로 검사되지 않는다'는 사실이 로그에 전혀 남지 않았다. truncated = len(candidates) - idx logger.warning( f"[DayEntry] ⏱️ 스캔 시간 예산 {_SCAN_TIME_BUDGET:.0f}초 초과 " f"({_elapsed:.0f}초 경과) — 남은 {truncated}종목 미스캔. " f"다음 사이클은 오프셋 {(_scan_offset + idx) % max(1, len(candidates))} 부터 시작" ) _scan_offset = (_scan_offset + idx) % len(candidates) break scanned += 1 try: entry = await _try_enter( stock_code, stock_name, total_assets, available_cash, is_lunch_break=_is_lunch_break, ) if entry: entries.append(entry) await asyncio.sleep(0.5) # KIS API 레이트 리밋 else: no_signal += 1 except Exception as e: logger.error(f"[DayEntry] 진입 시도 오류 ({stock_code}): {e}", exc_info=True) else: # 전 종목을 소화했으면 오프셋을 리셋해 다음 사이클도 앞에서부터 본다 _scan_offset = 0 logger.info( f"[DayEntry] 스캔 완료: 후보 {len(candidates)}종목 중 {scanned}종목 스캔" f"{f' (미스캔 {truncated})' if truncated else ''}, {len(entries)}진입, " f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료 " f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, " f"총 {time.monotonic() - _scan_started:.1f}초)" ) # RSI·갭 기저 분포 진단 — 읽기 전용, 사이클당 1줄(N 사이클마다). 진단 실패가 # 스캔 결과를 잃게 만들면 안 되므로 여기서만 흡수하고 warning 으로 드러낸다 # (§4: logger.debug 금지). 리스크 가드가 아니므로 try 안에 두어도 무해하다. try: _log_rsi_gap_diagnostics(candidates) except Exception as e: logger.warning(f"[DayEntry] RSI 진단 로깅 실패(진단 전용, 매매 영향 없음): {e}") return entries async def _try_enter( stock_code: str, stock_name: str, total_assets: float, available_cash: float, is_lunch_break: bool = False, ) -> dict | None: """단일 종목에 대해 진입 신호 체크 및 매수 주문 실행. 처리 흐름: 1. VI 쿨다운 체크 (변동성 완화장치 발동 후 10분간 차단) 2. 5분봉 데이터 조회 및 VWAP/ORB 계산 3. 신호 체크 (ORB → VWAP → RSI_BB 순서, 점심시간 VWAP/RSI_BB 제한) 4. confidence 기반 포지션 사이징 5. MCP 리스크 평가 (선택적, 실패 시 진행) 6. safe_place_order()로 시장가 매수 (세마포어+분산락 보호) 7. 인메모리 상태 업데이트 + DB 큐 기록 Args: stock_code: 종목 코드 stock_name: 종목명 total_assets: 총자산 (포지션 사이징 기준) available_cash: 가용 현금 is_lunch_break: 점심시간 여부 (11:30~13:00) Returns: dict | None: 진입 실행 결과 또는 None (신호 없음/실패) """ try: # loss_tracker ban 체크 — 3연패 종목 42일간 진입 차단 (CLAUDE.md 5.5) if await loss_tracker.is_banned_safe(stock_code): logger.debug(f"[DayEntry] loss_tracker ban — {stock_code} 진입 스킵") return None # VI 쿨다운 체크 — VI 발동 후 10분간 진입 차단 if is_vi_active(stock_code): logger.debug(f"[DayEntry] VI 쿨다운 — {stock_code} 진입 스킵") return None # [F6] 선조회 캐시 사용 (miss 면 이 자리에서 조회). # candles 에는 **전일 워밍업 봉이 섞여 있다**(_fetch_candles_impl docstring). candles = await _get_candles_cached(stock_code) if not candles: return None # 세션 앵커 지표(VWAP)·당일 거래량·갭·현재가는 '당일 봉'이라는 보장이 필요하다. candles_today = filter_today_candles(candles) if not candles_today: # 당일 봉이 0건 = 휴장일·조회 이상. 현재가·VWAP·거래량 기준을 만들 수 없다. # (filter_today_candles 가 날짜 필드가 채워진 경우 warning 을 남긴다) return None # 5분봉 집계 — 버킷 키에 날짜가 포함돼 서로 다른 날의 1분봉이 한 봉으로 # 합쳐지지 않는다. 전일 봉이 앞쪽에 붙어 RSI(14)/BB(20) 워밍업을 **부분적으로든 # 전부든** 채운다 → 채워진 만큼만 판정이 가능해진다(확보량은 조회 계층 로그 참조. # 개장 직후 발화를 단언할 수 없다 — _fetch_candles_impl docstring 의 미검증 전제). # 세션 경계 비교(거래량 게이트)는 day_trading_strategy 쪽에서 막는다. candles_5m = aggregate_to_5min(candles) if len(candles_5m) < 5: return None # VWAP: 당일 누적 (세션 앵커) vwap = calculate_vwap(candles_today) orb = day_trading_state.get_orb(stock_code) # 현재가: 당일 마지막 1분봉 종가 (형성 중 5분봉 종가보다 최신) current_price = float(candles_today[-1].close) if current_price <= 0: return None # 신호 체크 (ORB → VWAP → RSI+BB 순) signal = None if orb: # ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다 signal = check_orb_signal(stock_code, stock_name, candles_today, orb, current_price) # [P1] 점심시간(11:30~13:00)에는 VWAP/RSI_BB 진입 제한 — 유동성 저하 if not is_lunch_break: if not signal and vwap: signal = check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, current_price) if not signal: signal = check_rsi_bb_signal(stock_code, stock_name, candles_5m, current_price) if not signal: return None # [P1] confidence 기반 포지션 사이징 — 신호 품질에 따라 예산 조정 current_day_trade_amount = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) for pos in day_trading_state.positions.values() ) budget = calculate_day_trade_position_size( total_assets=total_assets, available_cash=available_cash, current_day_trade_amount=current_day_trade_amount, ) # confidence 스케일링: 기준 0.7 대비 비례 조정 (0.6→86%, 0.7→100%, 0.85→121%) # [H3 fix 2026-09-27] 예전 코드는 calculate_day_trade_position_size()가 이미 # min(종목당 6%, DT 잔여 30%, 보유현금)로 클램프한 값에 최대 1.2배를 곱해 # 세 한도를 모두 최대 20% 초과했다(ORB confidence 상한 0.85 → 실제로 1.2배 발동). # → 배율 적용 후 동일한 세 한도로 재클램프한다. 감액(<1.0)은 그대로 적용된다. confidence_scale = min(1.2, signal.confidence / 0.7) budget = _clamp_day_trade_budget( budget * confidence_scale, total_assets=total_assets, available_cash=available_cash, current_day_trade_amount=current_day_trade_amount, ) quantity = int(budget // current_price) if current_price > 0 else 0 if quantity <= 0: return None # MCP RISK_ASSESSMENT (선택적) try: from app.api.endpoints.mcp import request_mcp_risk_assessment trade_plan = { "action": "BUY", "strategy": "daytrading", "stock_code": stock_code, "price": current_price, "quantity": quantity, "signal": signal.strategy, } mcp_approved, mcp_reason = await request_mcp_risk_assessment( trade_plan, {"strategy": "daytrading"}, {"stock_code": stock_code} ) if not mcp_approved: logger.debug(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}") return None except Exception: pass # MCP 실패 시 진입 계속 # KIS API 매수 주문 (시장가) — 분산락+세마포어 보호 from app.services.trade_executor import safe_place_order order_resp = await safe_place_order( stock_code=stock_code, order_type="BUY", quantity=quantity, price=None, # 시장가 ) if not order_resp or order_resp.get("rt_cd") != "0": err = order_resp.get("msg1", "unknown") if order_resp else "no response" logger.error(f"[DayEntry] 매수 주문 실패 ({stock_code}): {err}") return None order_id = order_resp.get("output", {}).get("ODNO", "") # 인메모리 포지션 추가 (DB 직접 접근 금지) async with day_trading_state.trade_lock: day_trading_state.add_position(stock_code, { "stock_name": stock_name, "strategy": signal.strategy, "entry_price": current_price, "stop_loss": signal.stop_loss, "target_price": signal.target_price, "quantity": quantity, "highest_price": current_price, "entry_time": datetime.now(_KST).replace(tzinfo=None), "reason": signal.reason, "order_id": order_id, }) # DB 기록 (큐 경유) await enqueue_trade_history({ "stock_code": stock_code, "stock_name": stock_name, "order_type": "BUY", "order_price": current_price, "order_quantity": quantity, "executed_price": current_price, "executed_quantity": quantity, "total_amount": current_price * quantity, "status": "COMPLETED", "order_id": order_id, "entry_reason": signal.reason, "strategy_type": "daytrading", }) # 포트폴리오 화면 즉시 반영 (strategy_type='daytrading' 태깅 → 스윙 청산 로직 대상에서 제외) from app.services.portfolio_cache_bridge import reflect_buy await reflect_buy(stock_code, stock_name, quantity, current_price, strategy_type="daytrading") await enqueue_active_trade_upsert({ "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "entry_price": current_price, "strategy_name": "day_trade", "entry_reason": signal.reason, "stop_loss": signal.stop_loss, "take_profit": signal.target_price, }) # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는 # 신호 시점 1분봉 종가다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고, # 실패하면 신호가 기록이 그대로 남는다(warning). _schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity) logger.info( f"[DayEntry] ✅ 진입: {stock_code} {stock_name} " f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}" ) return { "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "price": current_price, "strategy": signal.strategy, "reason": signal.reason, } except Exception as e: logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True) return None def _schedule_buy_fill_reconcile( stock_code: str, order_id: str, signal_price: float, quantity: int, ): """[2026-10-01] 매수 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단). 확인되면: - TradeHistory(order_id) executed_price·total_amount·slippage(%) 갱신 (db_worker 큐) - 인메모리 포지션 entry_price 를 체결가로 교체(같은 주문번호의 포지션이 남아 있을 때만). 손절가·목표가는 신호 시점 절대가격 그대로 둔다(전략 파라미터 불변). - ActiveTrade(day_trade) entry_price 갱신 — 재기동 복원도 체결가 기준이 된다. 이미 일부/전량 청산된 뒤에 확인되면 그 청산 손익은 신호가 기준으로 남는다(보고된 잔여 위험). """ from app.services.daytrading import fill_reconciler from app.core.timeutil import now_kst as _now_kst _order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회 async def _on_fill(filled_qty: int, avg_price: float) -> None: if filled_qty < quantity: logger.warning( f"[DayEntry] 매수 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 " f"order_id={order_id} — 인메모리 수량은 주문 수량 기준(수동 확인 필요)" ) slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0 from decimal import Decimal from app.services.db_worker import enqueue_update_trade_history_by_order_id await enqueue_update_trade_history_by_order_id(order_id, { # 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다 "order_date": _order_date, "order_type": "BUY", "strategy_type": "daytrading", "executed_price": Decimal(str(round(avg_price, 2))), "total_amount": Decimal(str(round(avg_price * quantity, 2))), "slippage": Decimal(str(round(slippage_pct, 4))), }) updated = False async with day_trading_state.trade_lock: pos = day_trading_state.positions.get(stock_code) if pos is not None and str(pos.get("order_id", "")) == str(order_id): pos["entry_price"] = float(avg_price) updated = True if updated: await enqueue_active_trade_upsert({ "stock_code": stock_code, "strategy_name": "day_trade", "entry_price": float(avg_price), }) logger.info( f"[DayEntry] 매수 체결가 확인: {stock_code} 신호가={signal_price:,.0f} " f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%)" + ("" if updated else " — 포지션 이미 청산/교체됨, 원장만 보정") ) return fill_reconciler.schedule(stock_code, order_id, "BUY", _on_fill)