Files
auto-stock/backend/app/services/day_trading_strategy.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

908 lines
41 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
from app.core.log_throttle import warn_throttled # noqa: E402
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
# [2026-10-01] 시간손절 사유 접두어 — 사유 문자열 생성과 판별이 이 상수 하나를 공유한다.
# 시간손절은 "진입 후 N분간 목표 방향 진행 미미"를 정리하는 **기회비용 청산**이지
# 손절선 이탈이 아니다. 이것까지 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지)에
# 집계하면 소폭 손실 시간손절 3회만으로 그 종목의 스윙 진입까지 42일간 막힌다.
# 판별은 exit_service 가 청산 사유로 한다(_execute_exit 시그니처 불변 유지).
TIME_STOP_REASON_PREFIX = "시간 손절"
def is_time_stop_exit_reason(reason: Optional[str]) -> bool:
"""청산 사유가 DT 시간손절인지 판별 (check_day_trade_exit 가 만든 사유 기준)."""
return bool(reason) and str(reason).startswith(TIME_STOP_REASON_PREFIX)
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
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
# [M3 2026-10-02] 오버나이트 갭(전일 종가 대비)은 **세션을 자르기 전** 시계열에서만
# 계산된다. 플래그 OFF 면 계산하지 않는다(현행 판정 유지).
_max_gap = float(settings.DAY_TRADE_ORB_MAX_GAP_PCT)
_overnight_gap = (
_overnight_gap_pct(candles_1min) if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else None
)
# 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다
candles_1min = _last_session_candles(candles_1min)
# M4: Gap filter.
# ⚠️ 현행(기본) 판정은 **오버나이트 갭이 아니다** — |현재가 − 당일 09:00 시가| / 시가 로
# "시가 이후 장중 이동폭"을 본다(§7 문서의 '갭 4%↑ 거부'와 정의가 다름).
# DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED=True 면 §7 대로 전일 종가 대비 갭으로 판정하고,
# 전일 종가를 확보하지 못하면 이 현행 판정으로 폴백한다(warning).
if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED and _overnight_gap is not None:
if abs(_overnight_gap) / 100.0 > _max_gap:
logger.info(
"ORB skip %s: overnight gap %+.2f%% > %.2f%%",
stock_code, _overnight_gap, _max_gap * 100,
)
return None
else:
if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED:
warn_throttled(
logger, "dt_strategy:orb_overnight_gap_unavailable",
"[DayTrade] ORB 오버나이트 갭 산출 불가(전일 봉 없음) — 당일시가 대비 판정으로 폴백",
)
if orb.open_price > 0:
_gap_ratio = abs(current_price - orb.open_price) / orb.open_price
if _gap_ratio > _max_gap:
logger.debug(
"ORB skip %s: gap ratio %.2f%% > %.2f%%",
stock_code, _gap_ratio * 100, _max_gap * 100,
)
return None
# M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험
_min_range = float(settings.DAY_TRADE_ORB_MIN_RANGE_PCT)
if orb.low > 0:
_range_width = (orb.high - orb.low) / orb.low
if _range_width < _min_range:
logger.debug(
"ORB skip %s: range width %.3f%% < %.3f%%",
stock_code, _range_width * 100, _min_range * 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 * settings.DAY_TRADE_VWAP_TARGET_FALLBACK_MULT
)
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
# [L1 2026-10-02] 리터럴 1.5 → DAY_TRADE_TARGET_MULTIPLIER(1.5, 같은 값·같은 의미 "목표=진입+risk×R")
target = max(bb_middle, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER)
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:
warn_throttled(logger, "day_trading_strategy:732", 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. 시간 손절 — 진입 후 DAY_TRADE_TIME_STOP_MINUTES 경과 이후 수익률 < DAY_TRADE_TIME_STOP_MIN_GAIN 이면 청산
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"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.2f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.2f}%",
)
# 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 ≥ DAY_TRADE_RSI_EXIT_OVERBOUGHT, 기본 70)
rsi = _rsi(candles_5min, 14)
_rsi_ob = float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT)
if rsi is not None and rsi >= _rsi_ob:
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}≥{_rsi_ob:g}, 수익={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()