Files
auto-stock/backend/app/services/day_trading_strategy.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

611 lines
23 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
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 기간)
@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)
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),
)
def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: extract closes from MinuteCandle list and delegate to canonical calculate_rsi"""
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: extract closes from MinuteCandle list and delegate to canonical calculate_bollinger"""
closes = [c.close for c in candles]
return calculate_bollinger(closes, period)
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: extract highs/lows/closes from MinuteCandle list and delegate to canonical calculate_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
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)
hard_stop = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT) # 상한 클램프
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)
# 상한 클램프: 일일 손실 한도 대비 종목당 최대 한도
stop_loss = max(orb_stop, hard_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_ORB_STOP_LOSS_PCT)
hard_stop_vwap = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
stop_loss = max(raw_stop, hard_stop_vwap, 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_ORB_STOP_LOSS_PCT)
hard_stop_rsi = current_price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT)
stop_loss = max(raw_stop, hard_stop_rsi, 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 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] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산
if entry_time is not None:
try:
now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
elapsed_min = (now_kst - entry_time).total_seconds() / 60
if 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}%",
)
except Exception:
pass
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
if 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 entry_time is not None:
try:
now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
elapsed_sec = (now_kst - entry_time).total_seconds()
if elapsed_sec < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC:
_trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용
except Exception:
pass
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 수정] 모듈 레벨 상수 대신 동적 접근자 사용
total_dt_budget = total_assets * _get_max_capital_ratio() # 전체 DT 한도 (25%)
remaining_dt = total_dt_budget - current_day_trade_amount # 남은 DT 한도
per_position_budget = total_assets * _get_max_day_trade_ratio() # 종목당 한도 (4%, CLAUDE.md 5.1)
return max(0.0, min(per_position_budget, remaining_dt, available_cash))
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()