Files
auto-stock/backend/app/services/daytrading/entry_service.py
shuim 45677c8fbb fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor
- 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room)
- 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning
- 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed
- 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지)
- 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡

korea_investment_api
- get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld
  (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning

daytrading
- 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제)
- 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler)
- legacy _record_trade strategy_type=daytrading 명시

db_worker
- update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지)

alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함)
CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014

998 passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:37:29 +09:00

1116 lines
56 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,
# 진단 전용(읽기 전용 순수 함수). 게이트에 쓰이지 않는다 —
# `_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)