Files
auto-stock/backend/app/services/day_trading_strategy.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

668 lines
26 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 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 아님.
"""
if len(candles) < period + 1:
return None
highs = [c.high for c in candles]
lows = [c.low for c in candles]
closes = [c.close for c in candles]
return calculate_atr(highs, lows, closes, period)
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
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.002 (0.2% 이상 돌파)
2. 최근 1분봉 거래량 ≥ ORB 분당평균 × 1.5 (거래량 폭발)
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
"""
if not orb or current_price <= 0:
return None
# 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분봉 거래량 체크
if not candles_1min:
return None
recent_vol = candles_1min[-1].volume
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}배"
),
confidence=round(confidence, 2),
)
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. 최근 봉 거래량 ≥ 직전 봉 × vol_mult
4. 최근 5분봉 양봉 전환 (close > open)
"""
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
if len(candles_5min) < 2:
return None
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 최근 봉 양봉 확인
last = candles_5min[-1]
if 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원 보장
target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
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}, "
f"현재={current_price:.0f}"
),
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. 직전 봉 대비 거래량 ≥ vol_mult배
4. 최근 봉 양봉 (음봉이면 스킵)
"""
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
if len(candles_5min) < 2:
return None
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
last = candles_5min[-1]
if 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}, "
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}"
),
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()