Files
auto-stock/backend/app/services/daytrading/entry_service.py
shuim 7abbb3411c fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류
## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:25 +09:00

898 lines
42 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.
"""데이트레이딩 진입 서비스
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,
)
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)
# 으로 이미 직렬화·쓰로틀되므로 여기서 병렬도를 올려도 레이트리밋을 넘지 않는다.
_SCAN_PREFETCH_CONCURRENCY: int = 4
# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다
_SCAN_PREFETCH_TIMEOUT: float = 20.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
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 = 20) -> int:
"""stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정.
배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을
자동으로 데이트레이딩 감시 대상으로 설정.
필터 조건:
- 오늘 날짜 분석 결과만 사용
- score >= min_score (기본 1점 이상)
- RSI 값이 존재 (기술적 분석 완료 종목만)
- 현재가 > 5,000원 (CLAUDE.md 5.4 가격 필터)
Args:
min_score: 최소 점수 기준 (기본 1)
max_stocks: 최대 감시 종목 수 (기본 20)
Returns:
int: 설정된 감시 종목 수
Note:
이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당.
populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신.
"""
from app.core.database import AsyncSessionLocal
from app.models.models import StockAnalysis
from sqlalchemy import select, func
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)` 를 쓰는 이유는 **지표
워밍업 하나뿐**이다: 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 진입이 실재했음이 확인됐다.
⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.**
조회 계층이 전일로 거슬러 가려면 미래 `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}")
await asyncio.gather(*(_one(c) for c in codes), return_exceptions=True)
# 오래된 항목 정리 (감시목록 교체로 무한 증식 방지)
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)
logger.debug(f"[DayEntry] ORB 수집: {stock_code} {orb}")
# 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:
logger.debug(f"[DayEntry] 잔고 조회 실패: {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}초)"
)
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,
})
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