- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를 실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응) - recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 + MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지) - manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker) - mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시 active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정 - config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL, SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가 - docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001) - frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선 - CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
636 lines
25 KiB
Python
636 lines
25 KiB
Python
"""
|
||
데이트레이딩 전략 (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 기간)
|
||
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: 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
|
||
|
||
# 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 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% 분할 매도)
|
||
# [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 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() |