Files
auto-stock/backend/app/services/day_trading_strategy.py
shuim 9de0715165 fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전
사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
  자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
  system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
  ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
    _delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
  _sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
  == 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
  승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
  직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
  PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
  (build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
  SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다

## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
  (120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
  타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가

## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
  오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
  클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
  → 진입 ATR == 청산 트레일링 ATR 보장
  ※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
  R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일

## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
  충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
  격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다

## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
  + 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)

테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 16:50:54 +09:00

859 lines
39 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.
"""
데이트레이딩 전략 (CLAUDE.md 섹션 8.2 확장)
전략 3종:
1. ORB — Opening Range Breakout (09:00~09:30 고점 돌파)
2. VWAP — VWAP 지지 + 과매도 반등
3. RSI_BB — 5분봉 RSI+볼린저 과매도 반등
공통 규칙:
- EOD 강제 청산: 15:20 이후 전량 시장가 매도
- 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단
- 동시 최대 3종목, 종목당 최대 4% 자본 (CLAUDE.md 5.1)
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2)
[.env 설정]
DAY_TRADE_MAX_CAPITAL_RATIO
DAY_TRADE_MAX_RATIO_DT
DAY_TRADE_MAX_POSITIONS (== MAX_DAY_TRADE_POSITIONS)
DAY_TRADE_DAILY_LOSS_LIMIT
DAY_TRADE_ORB_STOP_LOSS_PCT
DAY_TRADE_REVERSION_STOP_LOSS_PCT
DAY_TRADE_TRAILING_TRIGGER
DAY_TRADE_TRAILING_ATR_MULT
DAY_TRADE_ORB_BREAKOUT_MARGIN
DAY_TRADE_COST_PCT
DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN
DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN
"""
import logging
from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass
from datetime import time
from app.core.config import settings
from app.services.mean_reversion_strategy import calculate_rsi, calculate_atr, calculate_bollinger
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈)
from app.services.sizing_core import budget_by_ratios
logger = logging.getLogger(__name__)
# ── 장 시간 상수 (KST) ──────────────────────────────────────────────────────
MARKET_OPEN = time(9, 0)
ORB_END = time(9, 30) # Opening Range 집계 종료
# 동적 시간 설정 (settings에서 읽음)
def _get_entry_cutoff() -> time:
"""신규 진입 마감 시간 (.env: DAY_TRADE_ENTRY_CUTOFF_HOUR, DAY_TRADE_ENTRY_CUTOFF_MIN)"""
return time(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN)
def _get_eod_flat() -> time:
"""EOD 강제 청산 시작 시간 (.env: DAY_TRADE_EOD_HOUR, DAY_TRADE_EOD_MIN)"""
return time(settings.DAY_TRADE_EOD_HOUR, settings.DAY_TRADE_EOD_MIN)
MARKET_CLOSE = time(15, 30)
# ── 동적 설정 접근자 (이슈6 수정) ──────────────────────────────────────────────
# [이슈6 수정] 모듈 로드 시점에 settings 값을 상수로 고정하면, 런타임에 .env가
# 변경되거나 테스트에서 settings를 오버라이드해도 반영되지 않음.
# 접근자 함수로 래핑하여 호출 시점에 settings를 읽도록 변경.
# 기존 모듈 레벨 이름(ENTRY_CUTOFF, EOD_FLAT 등)은 하위 호환을 위해 유지하되
# 실제 사용부(calculate_day_trade_position_size, is_daily_loss_exceeded)는
# 아래 _get_* 함수를 통해 동적으로 읽음.
def _get_max_positions() -> int:
return settings.DAY_TRADE_MAX_POSITIONS
def _get_max_day_trade_ratio() -> float:
return settings.DAY_TRADE_MAX_CAPITAL_RATIO
def _get_max_capital_ratio() -> float:
return settings.DAY_TRADE_MAX_RATIO_DT
def _get_daily_loss_limit() -> float:
return settings.DAY_TRADE_DAILY_LOSS_LIMIT
# 모듈 레벨 이름 — 하위 호환 유지 (import하는 코드가 있을 경우 그대로 동작)
ENTRY_CUTOFF = _get_entry_cutoff()
EOD_FLAT = _get_eod_flat()
MAX_POSITIONS = settings.DAY_TRADE_MAX_POSITIONS
MAX_DAY_TRADE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
MAX_CAPITAL_RATIO = settings.DAY_TRADE_MAX_RATIO_DT
DAILY_LOSS_LIMIT = settings.DAY_TRADE_DAILY_LOSS_LIMIT # 이미 비율 (-0.03 = -3%)
@dataclass
class MinuteCandle:
"""KIS API output2 데이터 정규화 결과"""
date: str # YYYYMMDD
time: str # HHMMSS
open: float
high: float
low: float
close: float
volume: int
cum_volume: int # 누적 거래량 (acml_vol)
cum_amount: float # 누적 거래대금 원 (acml_tr_pbmn)
@dataclass
class OpeningRange:
high: float
low: float
total_volume: int
avg_vol_per_min: float # 분당 평균 거래량 (ORB 기간)
open_price: float = 0.0 # M4: 시가 (첫 ORB 봉의 open, gap 필터용)
@dataclass
class DayTradeSignal:
stock_code: str
stock_name: str
strategy: str # "ORB" | "VWAP_BOUNCE" | "RSI_BB"
entry_price: float
stop_loss: float
target_price: float
reason: str
confidence: float # 0.0 ~ 1.0
def __post_init__(self) -> None:
"""Validate DayTradeSignal field constraints.
Issues warnings for validation failures but does not raise exceptions
to avoid breaking existing code.
"""
# Validate confidence: 0.0 to 1.0
if not (0.0 <= self.confidence <= 1.0):
logger.warning(
f"[DayTradeSignal] {self.stock_code}: confidence={self.confidence} not in [0.0, 1.0]"
)
# Validate entry_price > 0
if self.entry_price <= 0:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: entry_price={self.entry_price} must be > 0"
)
# Validate stop_loss > 0 and < entry_price
if self.stop_loss <= 0:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: stop_loss={self.stop_loss} must be > 0"
)
elif self.stop_loss >= self.entry_price:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: stop_loss={self.stop_loss} must be < entry_price={self.entry_price}"
)
# Validate target_price > entry_price
if self.target_price <= self.entry_price:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: target_price={self.target_price} must be > entry_price={self.entry_price}"
)
# Validate strategy is one of allowed values
allowed_strategies = {"ORB", "VWAP_BOUNCE", "RSI_BB"}
if self.strategy not in allowed_strategies:
logger.warning(
f"[DayTradeSignal] {self.stock_code}: strategy='{self.strategy}' not in {allowed_strategies}"
)
@dataclass
class DayTradeExit:
action: str # "SELL_ALL"
reason: str
sell_pct: float = 1.0
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]:
"""KIS API output2 리스트를 MinuteCandle 리스트로 변환
KIS 응답 필드:
stck_bsop_date / stck_cntg_hour / stck_oprc / stck_hgpr /
stck_lwpr / stck_prpr (종가) / cntg_vol / acml_vol / acml_tr_pbmn
"""
candles: List[MinuteCandle] = []
for row in raw_output2:
try:
candles.append(MinuteCandle(
date=row.get("stck_bsop_date", ""),
time=row.get("stck_cntg_hour", ""),
open=float(row.get("stck_oprc", 0) or 0),
high=float(row.get("stck_hgpr", 0) or 0),
low=float(row.get("stck_lwpr", 0) or 0),
close=float(row.get("stck_prpr", 0) or 0),
volume=int(row.get("cntg_vol", 0) or 0),
cum_volume=int(row.get("acml_vol", 0) or 0),
cum_amount=float(row.get("acml_tr_pbmn", 0) or 0),
))
except (ValueError, TypeError):
continue
return candles
def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
"""1분봉 → 5분봉 집계 (09:00 기준 5분 버킷)"""
if not candles:
return []
buckets: Dict[tuple, List[MinuteCandle]] = {}
for c in candles:
if not c.time:
continue
h = int(c.time[:2])
m = int(c.time[2:4])
key = (c.date, (h * 60 + m) // 5) # 날짜 포함 → 다른 날 캔들 혼합 방지
buckets.setdefault(key, []).append(c)
result: List[MinuteCandle] = []
for key in sorted(buckets):
bkt = buckets[key]
result.append(MinuteCandle(
date=bkt[0].date,
time=bkt[0].time,
open=bkt[0].open,
high=max(b.high for b in bkt),
low=min(b.low for b in bkt),
close=bkt[-1].close,
volume=sum(b.volume for b in bkt),
cum_volume=bkt[-1].cum_volume,
cum_amount=bkt[-1].cum_amount,
))
return result
def _last_session_candles(candles: List[MinuteCandle]) -> List[MinuteCandle]:
"""마지막 봉과 **같은 영업일자**의 봉만 남긴다 (세션 경계 차단, 순수 함수).
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있다
(`korea_investment_api.get_minute_chart_full_day_v2(include_prev_day=True)`).
RSI·BB 처럼 연속 시계열이 필요한 지표는 그대로 써도 되지만, **거래량 비교는
같은 세션 안에서만 의미가 있다.** 예컨대 09:30 에 5분봉 `[-3]` 이 전일 마지막
5분봉(15:15~15:19, 종가 동시호가 물량)이면 당일 개장봉과의 비교가 등가가 아니고,
거래량 게이트가 개장 직후 자동 통과하거나 반대로 영구 탈락한다.
영업일자 필드가 비어 있는 응답(목/레거시)은 기존 동작을 유지하기 위해
그대로 통과시킨다.
"""
if not candles:
return []
last_date = getattr(candles[-1], "date", "") or ""
if not last_date:
return list(candles)
return [c for c in candles if (getattr(c, "date", "") or "") == last_date]
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
"""VWAP = 누적거래대금 / 누적거래량 (당일 09:00 기준 누적)
cum_volume이 0인 경우 (KIS API 분봉에 acml_vol 미제공 시)
개별 캔들의 typical_price * volume 합산으로 계산.
"""
if not candles:
return None
# 방법 1: 누적 필드 사용
last = candles[-1]
if last.cum_volume > 0:
return last.cum_amount / last.cum_volume
# 방법 2: 개별 캔들에서 합산 (acml_vol 미제공 시 폴백)
total_pv = 0.0
total_vol = 0
for c in candles:
if c.volume > 0:
typical = (c.high + c.low + c.close) / 3.0
total_pv += typical * c.volume
total_vol += c.volume
if total_vol <= 0:
return None
return total_pv / total_vol
def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[OpeningRange]:
"""09:00~09:29 분봉으로 Opening Range (H/L) 계산"""
orb = [c for c in candles_1min if "090000" <= c.time <= "092959"]
if len(orb) < 5:
return None
total_vol = sum(c.volume for c in orb)
# M4: 시가 = ORB 기간 첫 봉의 open (gap 필터용)
sorted_orb = sorted(orb, key=lambda c: c.time)
return OpeningRange(
high=max(c.high for c in orb),
low=min(c.low for c in orb),
total_volume=total_vol,
avg_vol_per_min=total_vol / len(orb),
open_price=sorted_orb[0].open,
)
def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: MinuteCandle → closes 추출 후 canonical calculate_rsi 위임.
알고리즘: Wilder 평활 RSI (indicators_core.rsi_wilder) — 5분봉도 일봉과 동일 방식.
"""
closes = [c.close for c in candles]
return calculate_rsi(closes, period)
def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]:
"""Wrapper: MinuteCandle → closes 추출 후 canonical calculate_bollinger 위임.
알고리즘: SMA 중앙선 + 모표준편차 2σ (indicators_core.bollinger_sma, num_std=2.0).
"""
closes = [c.close for c in candles]
return calculate_bollinger(closes, period)
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임.
알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder ATR 아님.
⚠️ **세션 경계를 먼저 끊는다** (`_last_session_candles`) — 2026-09-30 수정.
진입 판정 경로의 시계열에는 지표 워밍업용 **전일 봉**이 섞여 있다
(`get_minute_chart_full_day_v2(include_prev_day=True)`). TR 은
`max(H-L, |H-C_prev|, |L-C_prev|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**이고,
`atr_sma` 가 마지막 `period` 개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기
전(대략 10:10 이전)에는 갭 TR + 전일 오후 TR 이 윈도에 남는다. 그 구간이 바로
DT 진입이 가장 많이 나는 구간이다.
영향은 임계값이 아니라 **산출값**이었다:
- `check_vwap_bounce_signal` / `check_rsi_bb_signal` 의
`target = 현재가 + DAY_TRADE_TARGET_MULTIPLIER × ATR` 은 **클램프가 없어**
부푼 ATR 이 1.5R 목표를 그대로 멀리 밀었다.
- `raw_stop = 현재가 − ATR_MULT × ATR` 은 보통 최소손절폭(-2%)이 이기지만
고변동 종목에서는 -2% 를 뚫고 손절이 깊어졌다.
- 청산 경로(`daytrading/exit_service`)는 당일 전용 분봉을 쓰므로 트레일링 ATR 은
깨끗했다 → **진입은 갭 ATR, 청산은 당일 ATR** 로 기준이 엇갈렸다.
여기서 한 번 끊으면 네 호출부(ORB 손절·VWAP·RSI_BB·트레일링) 전부가 같은 기준을
쓴다. 당일 봉이 `period+1` 개 미만이면 `None` 이고, 호출부는 기존 고정 폴백
(`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 등)을 쓴다 — 워밍업 도입 이전과 같은 동작이다.
**임계값은 하나도 바뀌지 않는다.** RSI(14)/BB(20) 는 연속 시계열이 필요한 판정
게이트이므로 워밍업을 그대로 받는다(`_rsi`/`_bollinger` 는 손대지 않았다).
"""
session = _last_session_candles(candles)
if len(session) < period + 1:
return None
highs = [c.high for c in session]
lows = [c.low for c in session]
closes = [c.close for c in session]
return calculate_atr(highs, lows, closes, period)
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
def _overnight_gap_pct(candles: List[MinuteCandle]) -> Optional[float]:
"""오버나이트 갭률(%) = (당일 첫 봉 시가 − 전일 마지막 봉 종가) / 전일 종가 × 100.
**진단용 순수 함수다 — 어떤 게이트도 이 값을 쓰지 않는다.**
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있으므로
(`get_minute_chart_full_day_v2(include_prev_day=True)`) 벽시계나 별도 조회 없이
데이터만으로 계산된다. 전일 봉이 없거나 날짜 필드가 비면 `None`.
용도(2026-09-30): 장중 5분봉 RSI 는 워밍업 시계열을 쓰므로 **오버나이트 갭이
종가 차분 한 개로 RSI 에 들어간다**(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3).
이는 차트 플랫폼 표준 동작이고 평균회귀가 과매도를 사는 의도와 방향이 맞아
**그대로 유지하기로 결정**됐으나, 의도한 설계가 아니었으므로 관찰 대상이다.
진입 `reason` 에 이 값을 남겨 두면 나중에 원장(`trade_history.entry_reason`)만으로
"갭 하락 종목에 쏠렸는가"를 집계할 수 있다.
"""
if not candles:
return None
today = _last_session_candles(candles)
if not today:
return None
today_date = getattr(today[0], "date", "") or ""
if not today_date or len(today) == len(candles):
return None # 전일 봉이 없다 / 날짜 필드 없음
prev = [c for c in candles if (getattr(c, "date", "") or "") != today_date]
if not prev:
return None
prev_close = prev[-1].close
first_open = today[0].open
if prev_close <= 0 or first_open <= 0:
return None
return (first_open - prev_close) / prev_close * 100.0
def _diag_suffix(candles: List[MinuteCandle], atr: Optional[float]) -> str:
"""진입 `reason` 뒤에 붙는 **진단 정보** 문자열 (게이트 아님).
- `갭=` : `_overnight_gap_pct` (RSI 갭 섭취 관찰용, 없으면 생략)
- `ATR=` : 손절·목표 산출에 실제로 쓰인 ATR. `None` 이면 폴백 손절이 쓰였음을
명시한다 — 09:30~10:10 구간은 당일 5분봉 15개 미만이라 ATR 이 없고
`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 폴백이 적용된다(CLAUDE.md §7 예외).
값만 넣는다. 임계값을 문자열에 하드코딩하지 않는다(§4).
"""
parts = ["ATR=없음→폴백손절" if atr is None else f"ATR={atr:.0f}"]
gap = _overnight_gap_pct(candles)
if gap is not None:
parts.append(f"갭={gap:+.2f}%")
return " [진단 " + ", ".join(parts) + "]"
def check_orb_signal(
stock_code: str,
stock_name: str,
candles_1min: List[MinuteCandle],
orb: OpeningRange,
current_price: float,
) -> Optional[DayTradeSignal]:
"""ORB 상향 돌파 매수 신호
조건:
1. 현재가 > ORB 고점 × (1 + DAY_TRADE_ORB_BREAKOUT_MARGIN)
2. **마지막 완성 1분봉** 거래량 ≥ ORB 분당평균 × DAY_TRADE_ORB_BREAKOUT_VOL_MULT
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
[2026-09-29] 2번은 `candles_1min[-1]`(형성 중 봉)을 쓰고 있었다. 30초 주기 스캔의
사이클 대부분에서 마지막 1분봉은 절반만 채워져 있어 **실효 요구가 임계값의 약 2배**가
됐다(volume_surge_ratio 의 F7 과 같은 클래스의 버그). 비교 대상만 마지막 **완성봉**
으로 고치고 임계값은 건드리지 않는다. `orb.avg_vol_per_min` 이 당일 기준이므로
분자도 당일이어야 한다 — `_last_session_candles()` 로 세션을 고정한다.
"""
if not orb or current_price <= 0:
return None
# 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다
candles_1min = _last_session_candles(candles_1min)
# M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부
# prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용
# orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주
if orb.open_price > 0:
_gap_ratio = abs(current_price - orb.open_price) / orb.open_price
if _gap_ratio > 0.04:
logger.debug("ORB skip %s: gap ratio %.2f%% > 4%%", stock_code, _gap_ratio * 100)
return None
# M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험
if orb.low > 0:
_range_width = (orb.high - orb.low) / orb.low
if _range_width < 0.003: # 최소 0.3% 범위
logger.debug("ORB skip %s: range width %.3f%% < 0.3%%", stock_code, _range_width * 100)
return None
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
return None
# 거래량 체크 — 마지막 **완성** 1분봉([-2]) 기준. 형성 중 봉([-1])은 부분 거래량이라
# 임계값을 실질적으로 부풀린다. 완성봉이 없으면(당일 봉 2개 미만) 게이트 미통과.
if len(candles_1min) < 2:
return None
recent_vol = candles_1min[-2].volume
if orb.avg_vol_per_min <= 0:
return None
if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT:
return None
# 직전 5분봉 양봉 확인 (5분봉 2개 이상 없으면 보수적으로 차단)
candles_5m = aggregate_to_5min(candles_1min)
if len(candles_5m) < 2:
return None # 데이터 부족 시 진입 차단
if candles_5m[-2].close < candles_5m[-2].open:
return None # 직전 5분봉 음봉 → 스킵
# [P1] ORB 손절: ATR 하한 + 하드 스탑 상한 클램프
atr_orb = _atr(aggregate_to_5min(candles_1min), 14)
orb_stop = max(orb.low, current_price * (1 - settings.DAY_TRADE_ORB_STOP_LOSS_PCT))
# ATR 기반 하한: 최소 ATR 손절폭 확보 (수수료 커버)
if atr_orb:
atr_floor = current_price - settings.DAY_TRADE_ORB_ATR_FLOOR_MULT * atr_orb
orb_stop = max(orb_stop, atr_floor)
# 최소 손절폭 보장: 노이즈 손절 방지
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
stop_loss = min(orb_stop, min_stop)
risk = current_price - stop_loss
if risk <= 0:
return None # 손절폭이 0 이하면 진입 무의미
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
target = current_price + max(risk * settings.DAY_TRADE_TARGET_MULTIPLIER, current_price * settings.DAY_TRADE_ORB_MARGIN_CHECK)
vol_ratio = recent_vol / orb.avg_vol_per_min
confidence = min(0.85, 0.65 + vol_ratio * 0.05)
return DayTradeSignal(
stock_code=stock_code,
stock_name=stock_name,
strategy="ORB",
entry_price=current_price,
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)"
# ORB 경로의 시계열은 당일 전용이라 오버나이트 갭을 계산할 수 없다
# (_diag_suffix 가 갭을 생략한다). ATR 만 남는다.
+ _diag_suffix(candles_1min, atr_orb)
),
confidence=round(confidence, 2),
)
def _last_completed_candle(candles_5min: List[MinuteCandle]) -> Optional[MinuteCandle]:
"""**당일** 마지막 완성 5분봉. 없으면 None (순수 함수).
[2026-09-30] `aggregate_to_5min` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.**
따라서 `candles_5min[-1]` 로 양봉/음봉을 판정하면 30초 주기 스캔에서 같은 버킷을
약 10회 보며 **같은 시장 상태에 사이클마다 다른 답**이 나온다(버킷 초반에 음봉이면
차단, 후반에 양봉이 되면 통과). 재현성이 없는 게이트다.
F7(2026-09-29)이 거래량 게이트를 완성봉으로 고칠 때 **양봉 판정은 빠졌다.**
`volume_surge_ratio()` 가 비교하는 봉과 **같은 봉**(`session[-2]`)을 돌려주므로
"거래량이 튄 그 봉이 양봉인가"라는 원래 의도가 성립한다 —
`check_orb_signal` 은 이미 `candles_5m[-2]` 로 그렇게 하고 있었다(`:407`).
세션 경계도 함께 끊는다(`_last_session_candles`): 진입 경로의 시계열에는 지표
워밍업용 전일 봉이 섞여 있어(`include_prev_day=True`) 개장 직후에는 마지막 완성봉이
**전일 종가 동시호가 봉**이 된다. 그걸로 양봉 판정을 하면 게이트가 전일 결과로
열린다(C1 과 같은 함정).
완성봉이 없으면(당일 봉 2개 미만) `None` — 호출부는 **미통과**로 닫는다.
데이터 부족을 통과 쪽으로 열지 않는다.
"""
session = _last_session_candles(candles_5min)
if len(session) < 2:
return None
return session[-2]
def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]:
"""직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값.
[F7 2026-09-28] 기존 게이트는 ``candles_5min[-1].volume >= candles_5min[-2].volume
* DAY_TRADE_VOLUME_MULTIPLIER`` 였다. 그런데 ``aggregate_to_5min`` 은 **형성 중인
5분 버킷도 하나의 봉으로 만든다.** 즉 좌변은 "아직 채워지는 중인 봉의 부분
거래량"이고 우변은 "완성된 봉의 전체 거래량"이라, 실제로 거래량이 1.3배로 늘어난
구간에서도 **버킷의 마지막 1분 남짓에만** 조건이 성립했다. 30초 주기 스캔에서
5분 버킷은 약 10사이클에 걸치므로 ~80% 사이클이 구조적으로 전멸했다.
비교 대상만 고친다(임계값 1.3 은 그대로): 마지막 **완성봉**([-2])을 그 직전
완성봉([-3])과 비교한다. 두 항이 모두 전체 5분치라 비교가 등가이고, 벽시계에
의존하지 않아 순수 함수로 남는다.
[2026-09-29] 진입 경로의 시계열에 전일 워밍업 봉이 포함되므로, 비교는
`_last_session_candles()` 로 **마지막 봉과 같은 세션**에 한정한다. 세션을 넘겨
비교하면 전일 종가 동시호가 물량과 당일 개장봉을 견주게 되어 게이트가 무의미해진다.
Returns:
float | None: 배율. **당일** 완성봉이 2개 미만이거나 분모가 0이면 None
(= 게이트 미통과. 데이터 부족은 통과 쪽으로 열지 않는다)
"""
session = _last_session_candles(candles_5min)
if len(session) < 3:
return None
prev_completed = session[-3].volume
last_completed = session[-2].volume
if prev_completed <= 0:
return None
return last_completed / prev_completed
def check_vwap_bounce_signal(
stock_code: str,
stock_name: str,
candles_5min: List[MinuteCandle],
vwap: float,
current_price: float,
) -> Optional[DayTradeSignal]:
"""VWAP 지지 반등 매수 신호
조건:
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
2. 5분봉 RSI(14) ≤ threshold
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. **그 완성봉이 양봉** (close > open) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
"""
if not candles_5min or vwap <= 0 or current_price <= 0:
return None
if not (vwap * settings.DAY_TRADE_VWAP_PRICE_LOWER <= current_price <= vwap * settings.DAY_TRADE_VWAP_PRICE_UPPER):
return None
rsi = _rsi(candles_5min, 14)
if rsi is None or rsi > settings.DAY_TRADE_VWAP_RSI_THRESHOLD:
return None
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
vol_ratio = volume_surge_ratio(candles_5min)
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 양봉 확인 — **당일 마지막 완성봉**(거래량 게이트가 본 그 봉). 형성 중 버킷을
# 보면 사이클마다 답이 바뀐다(_last_completed_candle docstring). 임계값·조건 정의는
# 그대로이고 '어느 봉을 보는가'만 고친다.
last = _last_completed_candle(candles_5min)
if last is None or last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
raw_stop = (current_price - settings.DAY_TRADE_VWAP_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
# 최소 손절폭 보장: 노이즈 손절 방지
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
stop_loss = min(raw_stop, min_stop)
stop_loss = max(stop_loss, 1.0) # 최소 1원 보장
# [M-1 2026-09-30] **1.5R 하한 보장** — RSI_BB 경로와 같은 방식(`max(기존목표, 진입가
# + risk × TARGET_MULTIPLIER)`)으로 맞춘다. 새 임계값은 없다: 두 값 중 큰 쪽을 고를 뿐이다.
#
# 고치지 않으면 `target_price` 가 위험보다 작아진다 — 이 값은
# `daytrading/exit_service` 를 통해 **1.5R 분할매도 트리거로 실제 쓰인다**(§7).
# · ATR 폴백 구간(09:30~약 10:10, 당일 5분봉 15개 미만): 손절 -3.5%(폴백) vs
# 목표 `vwap×1.03`(≈+3%) → **위험 > 보상**
# · ATR 이 있는 구간도 마찬가지다: `risk = max(1.5×ATR, 2%)` 인데(최소 손절폭 보장의
# `min()` 때문에 손절이 -2% 쪽으로 느슨해진다) 목표는 `1.5×ATR` 이라
# **1.5×ATR < 2% 인 저변동 종목에서는 R:R 이 0.2~0.5** 로 떨어진다.
# 실측: 진입 10,120 / ATR 29 → 손절 9,918(-2.0%) / 목표 10,164(+0.43%) = **0.21R**.
# §7 은 "1.5R 도달 시 50% 분할"을 규약으로 못박고 있으므로 이는 규약 복귀다.
_target_base = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
risk = current_price - stop_loss
target = max(_target_base, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER)
return DayTradeSignal(
stock_code=stock_code,
stock_name=stock_name,
strategy="VWAP_BOUNCE",
entry_price=current_price,
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}(≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}), "
f"현재={current_price:.0f}, 거래량={vol_ratio:.2f}배"
f"(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
+ _diag_suffix(candles_5min, atr)
),
confidence=0.65,
)
def check_rsi_bb_signal(
stock_code: str,
stock_name: str,
candles_5min: List[MinuteCandle],
current_price: float,
) -> Optional[DayTradeSignal]:
"""5분봉 RSI + 볼린저 과매도 반등 신호
조건:
1. RSI(14) ≤ threshold (강한 과매도)
2. 종가 ≤ BB 하단 + buffer
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. **그 완성봉이 양봉** (음봉이면 스킵) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
"""
if not candles_5min or current_price <= 0:
return None
rsi = _rsi(candles_5min, 14)
if rsi is None or rsi > settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:
return None
bb = _bollinger(candles_5min, 20)
if bb is None:
return None
bb_upper, bb_middle, bb_lower = bb
if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER:
return None
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
vol_ratio = volume_surge_ratio(candles_5min)
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 양봉 확인 — VWAP 경로와 동일하게 당일 마지막 완성봉 기준
last = _last_completed_candle(candles_5min)
if last is None or last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
raw_stop = (current_price - settings.DAY_TRADE_RSI_BB_ATR_MULT * atr) if atr else current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
# 최소 손절폭 보장: 노이즈 손절 방지
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
stop_loss = min(raw_stop, min_stop)
stop_loss = max(stop_loss, 1.0) # 최소 1원 보장
# 최소 1.5R 보장: bb_middle이 너무 가까우면 R:R 확보를 위해 목표 상향
risk = current_price - stop_loss
target = max(bb_middle, current_price + risk * 1.5) # 1.5R 보장
if target <= current_price: # 방어 로직 (stop_loss ≥ current_price 극단 케이스)
return None
return DayTradeSignal(
stock_code=stock_code,
stock_name=stock_name,
strategy="RSI_BB",
entry_price=current_price,
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"5분봉 과매도 반등: RSI={rsi:.1f}(≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}), "
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}, "
f"거래량={vol_ratio:.2f}배(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
+ _diag_suffix(candles_5min, atr)
),
confidence=0.60,
)
# ── 청산 신호 ─────────────────────────────────────────────────────────────────
def _elapsed_minutes(entry_time) -> Optional[float]:
"""진입 후 경과 시간(분). 값이 신뢰할 수 없으면 None.
[2026-09-23] DB에서 복원한 포지션의 entry_time 이 UTC(예전 기본값)면 KST now 와 비교 시
경과가 +540분으로 부풀어, 재배포 직후 '시간 손절'이 즉시 발동해 포지션이 몇 분 만에
청산됐다(산일전기 565분·미스토홀딩스 568분 = 실제 25·28분 + 9시간).
음수이거나 정규장 길이(09:00~15:30 = 390분)를 넘는 값은 시각 기준 불일치로 보고 무시한다.
"""
if entry_time is None:
return None
try:
from app.core.timeutil import now_kst as _now_kst
now_kst = _now_kst()
elapsed = (now_kst - entry_time).total_seconds() / 60
except Exception as e:
logger.debug(f"[DayTradeExit] 경과 시간 계산 실패: {e}")
return None
if elapsed < 0 or elapsed > 390:
logger.warning(
f"[DayTradeExit] entry_time 기준 불일치 추정 — 경과 {elapsed:.0f}분 무시 "
f"(entry_time={entry_time})"
)
return None
return elapsed
def check_day_trade_exit(
entry_price: float,
current_price: float,
stop_loss: float,
target_price: float,
highest_since_entry: float,
candles_5min: List[MinuteCandle],
current_time: time,
entry_time: Optional["datetime"] = None,
) -> Optional[DayTradeExit]:
"""데이트레이딩 청산 조건 체크 (우선순위 순)
1. EOD 강제 청산 (15:20 이후)
2. 손절 (현재가 ≤ stop_loss)
3. 시간 손절 — 진입 후 30분간 +0.3% 미달 시 청산
4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2)
5. 트레일링 스탑 (1.5% 수익 후 ATR 트레일)
6. 5분봉 RSI ≥ 70 (과매수 청산)
"""
from datetime import datetime as _dt
import pytz
# 1. EOD 강제 청산
if current_time >= _get_eod_flat():
return DayTradeExit(
action="SELL_ALL",
reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ {_get_eod_flat().strftime('%H:%M')})",
)
# 2. 손절
if current_price <= stop_loss:
loss_pct = (current_price - entry_price) / entry_price * 100
return DayTradeExit(
action="SELL_ALL",
reason=f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f})",
)
# 3. [P2] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산
elapsed_min = _elapsed_minutes(entry_time)
if elapsed_min is not None and elapsed_min >= settings.DAY_TRADE_TIME_STOP_MINUTES:
gain_pct = (current_price - entry_price) / entry_price
if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함)
return DayTradeExit(
action="SELL_ALL",
reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
)
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
# [FIX] target_price<=0 방어 — 0이면 즉시 조건 충족되어 오매도되는 것을 방지
if target_price > 0 and current_price >= target_price:
gain_pct = (current_price - entry_price) / entry_price * 100
return DayTradeExit(
action="SELL_PARTIAL",
reason=f"목표가 달성 +{gain_pct:.1f}% → 50% 분할 매도",
sell_pct=0.50,
)
# 5. 트레일링 스탑 (최소 보유 시간 경과 + 최소 수익률 이상 시 발동)
# [whipsaw 방지] 진입 직후 순간 상승→되돌림에 트레일링이 걸리는 것을 방지
_trailing_allowed = True
if elapsed_min is not None and elapsed_min * 60 < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC:
_trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용
if _trailing_allowed:
profit_pct = (highest_since_entry - entry_price) / entry_price
if profit_pct >= settings.DAY_TRADE_TRAILING_TRIGGER:
atr_val = _atr(candles_5min, 14)
if atr_val:
trailing_stop = highest_since_entry - settings.DAY_TRADE_TRAILING_ATR_MULT * atr_val
else:
trailing_stop = highest_since_entry * (1 - settings.DAY_TRADE_TRAILING_DEFAULT)
if current_price <= trailing_stop:
return DayTradeExit(
action="SELL_ALL",
reason=(
f"트레일링 스탑: 최고={highest_since_entry:.0f}, "
f"트레일={trailing_stop:.0f}, 현재={current_price:.0f}"
),
)
# 6. 과매수 청산 (RSI ≥ 70)
rsi = _rsi(candles_5min, 14)
if rsi is not None and rsi >= 70:
gain_pct = (current_price - entry_price) / entry_price * 100
if gain_pct > settings.DAY_TRADE_COST_PCT * 100:
return DayTradeExit(
action="SELL_ALL",
reason=f"과매수 청산: RSI={rsi:.1f}≥70, 수익={gain_pct:.1f}%",
)
return None
# ── 포지션 사이징 ─────────────────────────────────────────────────────────────
def calculate_day_trade_position_size(
total_assets: float,
available_cash: float,
current_day_trade_amount: float,
) -> float:
"""데이트레이딩 포지션 크기 계산
[2026-03-15 CLAUDE.md 정합성 수정]
- 종목당 최대: 총자산 × 4% (CLAUDE.md 5.1)
- 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2)
- 동시 최대 3종목 (MAX_POSITIONS)
"""
# [이슈6 수정] 모듈 레벨 상수 대신 동적 접근자 사용 (한도 값의 출처는 settings)
# [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임.
# 비율 자체는 여전히 settings 소유(_get_* 접근자) — 수식만 공유한다.
return budget_by_ratios(
total_assets=total_assets,
available_cash=available_cash,
current_allocation=current_day_trade_amount,
max_per_position=_get_max_day_trade_ratio(), # 종목당 한도 (DAY_TRADE_MAX_CAPITAL_RATIO)
max_strategy_ratio=_get_max_capital_ratio(), # 전체 DT 한도 (DAY_TRADE_MAX_RATIO_DT)
)
def is_daily_loss_exceeded(
total_assets: float,
realized_pnl_today: float,
) -> bool:
if total_assets <= 0:
return False
# realized_pnl_today가 0이면 손실 없음 → 한도 미초과
if realized_pnl_today >= 0:
return False
# [이슈6 수정] 동적 접근자로 런타임 설정값 반영
return (realized_pnl_today / total_assets) <= _get_daily_loss_limit()