수정본
This commit is contained in:
472
backend/app/services/day_trading_strategy.py
Normal file
472
backend/app/services/day_trading_strategy.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
데이트레이딩 전략 (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종목, 종목당 최대 10% 자본
|
||||
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2)
|
||||
- 수수료+슬리피지 포함 기준: 0.5% (매수 0.015% + 매도 0.3% + 슬리피지 0.2%)
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 장 시간 상수 (KST) ──────────────────────────────────────────────────────
|
||||
MARKET_OPEN = time(9, 0)
|
||||
ORB_END = time(9, 30) # Opening Range 집계 종료
|
||||
ENTRY_CUTOFF = time(15, 0) # 신규 진입 마감 (이후는 청산만)
|
||||
EOD_FLAT = time(15, 20) # EOD 강제 청산 시작
|
||||
MARKET_CLOSE = time(15, 30)
|
||||
|
||||
# ── 리스크 파라미터 ───────────────────────────────────────────────────────────
|
||||
MAX_CAPITAL_RATIO = 0.10 # 종목당 최대 10%
|
||||
MAX_DAY_TRADE_RATIO = 0.25 # 전체 데이트레이딩 최대 25%
|
||||
MAX_POSITIONS = 3 # 동시 최대 3종목
|
||||
DAILY_LOSS_LIMIT = -0.03 # 일일 손실 한도 -3%
|
||||
STOP_LOSS_PCT = 0.02 # 종목 손절 -2%
|
||||
TRAILING_TRIGGER = 0.015 # 트레일링 발동 +1.5%
|
||||
TRAILING_ATR_MULT = 1.0 # 트레일링 ATR 배수
|
||||
ORB_BREAKOUT_MARGIN = 0.002 # ORB 돌파 최소 여유 +0.2%
|
||||
COST_PCT = 0.005 # 수수료+슬리피지 합산 (매수+매도)
|
||||
|
||||
|
||||
# ── 데이터 클래스 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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[int, List[MinuteCandle]] = {}
|
||||
for c in candles:
|
||||
if not c.time:
|
||||
continue
|
||||
h = int(c.time[:2])
|
||||
m = int(c.time[2:4])
|
||||
key = (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 기준 누적)"""
|
||||
if not candles:
|
||||
return None
|
||||
last = candles[-1]
|
||||
if last.cum_volume <= 0:
|
||||
return None
|
||||
return last.cum_amount / last.cum_volume
|
||||
|
||||
|
||||
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]:
|
||||
closes = [c.close for c in candles]
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
d = closes[i] - closes[i - 1]
|
||||
gains.append(max(d, 0))
|
||||
losses.append(max(-d, 0))
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
return 100 - (100 / (1 + avg_gain / avg_loss))
|
||||
|
||||
|
||||
def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]:
|
||||
closes = [c.close for c in candles]
|
||||
if len(closes) < period:
|
||||
return None
|
||||
window = closes[-period:]
|
||||
mid = sum(window) / period
|
||||
std = (sum((p - mid) ** 2 for p in window) / period) ** 0.5
|
||||
return (mid + 2 * std, mid, mid - 2 * std)
|
||||
|
||||
|
||||
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||||
if len(candles) < period + 1:
|
||||
return None
|
||||
trs = []
|
||||
for i in range(1, len(candles)):
|
||||
h, l, cp = candles[i].high, candles[i].low, candles[i - 1].close
|
||||
trs.append(max(h - l, abs(h - cp), abs(l - cp)))
|
||||
if len(trs) < period:
|
||||
return None
|
||||
return sum(trs[-period:]) / 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 + 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 * 1.5:
|
||||
return None
|
||||
|
||||
# 직전 5분봉 양봉 확인
|
||||
candles_5m = aggregate_to_5min(candles_1min)
|
||||
if len(candles_5m) >= 2 and candles_5m[-2].close < candles_5m[-2].open:
|
||||
return None # 직전 5분봉 음봉 → 스킵
|
||||
|
||||
stop_loss = max(orb.low, current_price * (1 - STOP_LOSS_PCT))
|
||||
risk = current_price - stop_loss
|
||||
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
|
||||
target = current_price + max(risk * 2.0, current_price * 0.03)
|
||||
|
||||
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) ≤ 40
|
||||
3. 최근 봉 거래량 ≥ 직전 봉 × 1.3
|
||||
4. 최근 5분봉 양봉 전환 (close > open)
|
||||
"""
|
||||
if not candles_5min or vwap <= 0 or current_price <= 0:
|
||||
return None
|
||||
|
||||
if not (vwap * 0.99 <= current_price <= vwap * 1.005):
|
||||
return None
|
||||
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is None or rsi > 40:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * 1.3:
|
||||
return None
|
||||
|
||||
# 최근 봉 양봉 확인
|
||||
last = candles_5min[-1]
|
||||
if last.close <= last.open:
|
||||
return None
|
||||
|
||||
atr = _atr(candles_5min, 14)
|
||||
stop_loss = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT)
|
||||
target = (current_price + 2.0 * 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) ≤ 30 (강한 과매도)
|
||||
2. 종가 ≤ BB 하단
|
||||
3. 직전 봉 대비 거래량 ≥ 1.3배
|
||||
4. 최근 봉 양봉 (음봉이면 스킵)
|
||||
"""
|
||||
if not candles_5min or current_price <= 0:
|
||||
return None
|
||||
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is None or rsi > 30:
|
||||
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 * 1.01:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * 1.3:
|
||||
return None
|
||||
|
||||
last = candles_5min[-1]
|
||||
if last.close <= last.open:
|
||||
return None
|
||||
|
||||
atr = _atr(candles_5min, 14)
|
||||
stop_loss = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT)
|
||||
target = bb_middle # BB 중앙선까지 반등 목표
|
||||
|
||||
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,
|
||||
) -> Optional[DayTradeExit]:
|
||||
"""데이트레이딩 청산 조건 체크 (우선순위 순)
|
||||
|
||||
1. EOD 강제 청산 (15:20 이후)
|
||||
2. 손절 (현재가 ≤ stop_loss)
|
||||
3. 목표가 달성 (현재가 ≥ target_price)
|
||||
4. 트레일링 스탑 (1.5% 수익 후 ATR 트레일)
|
||||
5. 5분봉 RSI ≥ 70 (과매수 청산)
|
||||
"""
|
||||
# 1. EOD 강제 청산
|
||||
if current_time >= EOD_FLAT:
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ 15:20)",
|
||||
)
|
||||
|
||||
# 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. 목표가 달성
|
||||
if current_price >= target_price:
|
||||
gain_pct = (current_price - entry_price) / entry_price * 100
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"목표가 달성 +{gain_pct:.1f}% (현재={current_price:.0f} ≥ 목표={target_price:.0f})",
|
||||
)
|
||||
|
||||
# 4. 트레일링 스탑 (1.5% 이상 수익 시 발동)
|
||||
profit_pct = (highest_since_entry - entry_price) / entry_price
|
||||
if profit_pct >= TRAILING_TRIGGER:
|
||||
atr = _atr(candles_5min, 14)
|
||||
if atr:
|
||||
trailing_stop = highest_since_entry - TRAILING_ATR_MULT * atr
|
||||
if current_price <= trailing_stop:
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=(
|
||||
f"트레일링 스탑: 최고={highest_since_entry:.0f}, "
|
||||
f"트레일={trailing_stop:.0f}, 현재={current_price:.0f}"
|
||||
),
|
||||
)
|
||||
|
||||
# 5. 과매수 청산 (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 > 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:
|
||||
"""데이트레이딩 포지션 크기 계산
|
||||
|
||||
- 종목당 최대: 총자산 × 10%
|
||||
- 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2)
|
||||
- 동시 최대 3종목 (MAX_POSITIONS)
|
||||
"""
|
||||
max_total = total_assets * MAX_DAY_TRADE_RATIO
|
||||
remaining_budget = max_total - current_day_trade_amount
|
||||
per_position_max = total_assets * MAX_CAPITAL_RATIO
|
||||
return max(0.0, min(per_position_max, remaining_budget, available_cash))
|
||||
|
||||
|
||||
def is_daily_loss_exceeded(
|
||||
total_assets: float,
|
||||
realized_pnl_today: float,
|
||||
) -> bool:
|
||||
"""일일 손실 한도 초과 여부 (전체 자본 -3%)"""
|
||||
if total_assets <= 0:
|
||||
return False
|
||||
return (realized_pnl_today / total_assets) <= DAILY_LOSS_LIMIT
|
||||
Reference in New Issue
Block a user