수정본
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
|
||||
381
backend/app/services/feature_engineering.py
Normal file
381
backend/app/services/feature_engineering.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
피처 엔지니어링 모듈
|
||||
|
||||
KIS API 차트 데이터(OHLCV)에서 ML 모델용 피처를 추출합니다.
|
||||
- 기본 피처 12개: RSI, MACD, 스토캐스틱, 볼린저, MA비율, 거래량비율
|
||||
- 파생 피처 8개: 수익률, 변동성, 모멘텀, 크로스, ADX, 이격도
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
|
||||
def _ema(values: np.ndarray, period: int) -> np.ndarray:
|
||||
"""지수이동평균 계산"""
|
||||
result = np.full_like(values, np.nan, dtype=float)
|
||||
if len(values) < period:
|
||||
return result
|
||||
result[period - 1] = np.mean(values[:period])
|
||||
multiplier = 2.0 / (period + 1)
|
||||
for i in range(period, len(values)):
|
||||
result[i] = (values[i] - result[i - 1]) * multiplier + result[i - 1]
|
||||
return result
|
||||
|
||||
|
||||
def chart_data_to_dataframe(chart_data: list) -> pd.DataFrame:
|
||||
"""KIS API 차트 데이터(최신순)를 DataFrame(시간순)으로 변환"""
|
||||
rows = []
|
||||
for item in chart_data:
|
||||
rows.append({
|
||||
"date": item.get("stck_bsop_date", ""),
|
||||
"open": float(item.get("stck_oprc", 0) or 0),
|
||||
"high": float(item.get("stck_hgpr", 0) or 0),
|
||||
"low": float(item.get("stck_lwpr", 0) or 0),
|
||||
"close": float(item.get("stck_clpr", 0) or 0),
|
||||
"volume": float(item.get("acml_vol", 0) or 0),
|
||||
})
|
||||
df = pd.DataFrame(rows)
|
||||
# KIS API는 최신순 → 시간순으로 뒤집기
|
||||
df = df.iloc[::-1].reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def db_data_to_dataframe(rows: list) -> pd.DataFrame:
|
||||
"""DB StockDailyPrice 레코드를 DataFrame(시간순)으로 변환.
|
||||
|
||||
Args:
|
||||
rows: StockDailyPrice 객체 리스트 또는 dict 리스트
|
||||
(trade_date, open_price, high_price, low_price, close_price, volume)
|
||||
|
||||
Returns:
|
||||
chart_data_to_dataframe()과 동일한 형태의 DataFrame
|
||||
columns: date, open, high, low, close, volume
|
||||
"""
|
||||
records = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
records.append({
|
||||
"date": str(row["trade_date"]),
|
||||
"open": float(row["open_price"]),
|
||||
"high": float(row["high_price"]),
|
||||
"low": float(row["low_price"]),
|
||||
"close": float(row["close_price"]),
|
||||
"volume": float(row["volume"]),
|
||||
})
|
||||
else:
|
||||
records.append({
|
||||
"date": str(row.trade_date),
|
||||
"open": float(row.open_price),
|
||||
"high": float(row.high_price),
|
||||
"low": float(row.low_price),
|
||||
"close": float(row.close_price),
|
||||
"volume": float(row.volume),
|
||||
})
|
||||
df = pd.DataFrame(records)
|
||||
# trade_date 기준 오름차순 정렬
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def compute_rsi(close: np.ndarray, period: int = 14) -> np.ndarray:
|
||||
"""RSI 계산"""
|
||||
result = np.full_like(close, np.nan, dtype=float)
|
||||
if len(close) < period + 1:
|
||||
return result
|
||||
delta = np.diff(close)
|
||||
gain = np.where(delta > 0, delta, 0.0)
|
||||
loss = np.where(delta < 0, -delta, 0.0)
|
||||
|
||||
avg_gain = np.mean(gain[:period])
|
||||
avg_loss = np.mean(loss[:period])
|
||||
|
||||
for i in range(period, len(delta)):
|
||||
avg_gain = (avg_gain * (period - 1) + gain[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss[i]) / period
|
||||
if avg_loss == 0:
|
||||
result[i + 1] = 100.0
|
||||
else:
|
||||
rs = avg_gain / avg_loss
|
||||
result[i + 1] = 100.0 - (100.0 / (1.0 + rs))
|
||||
# 첫 번째 유효 값
|
||||
if avg_loss == 0:
|
||||
result[period] = 100.0
|
||||
else:
|
||||
result[period] = 100.0 - (100.0 / (1.0 + np.mean(gain[:period]) / max(np.mean(loss[:period]), 1e-10)))
|
||||
return result
|
||||
|
||||
|
||||
def extract_features(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""DataFrame(OHLCV 시간순)에서 전체 피처를 계산하여 반환.
|
||||
|
||||
반환되는 DataFrame은 입력과 같은 인덱스를 갖되, 각 행마다
|
||||
해당 시점까지의 정보만을 사용하여 계산된 20개 피처 컬럼을 포함합니다.
|
||||
초기 데이터 부족 구간은 NaN이 됩니다.
|
||||
"""
|
||||
close = df["close"].values.astype(float)
|
||||
high = df["high"].values.astype(float)
|
||||
low = df["low"].values.astype(float)
|
||||
volume = df["volume"].values.astype(float)
|
||||
n = len(close)
|
||||
|
||||
features: Dict[str, np.ndarray] = {}
|
||||
|
||||
# --- 기본 피처 (12개) ---
|
||||
|
||||
# 1-2. RSI (14일, 7일)
|
||||
features["rsi_14"] = compute_rsi(close, 14)
|
||||
features["rsi_7"] = compute_rsi(close, 7)
|
||||
|
||||
# 3-5. MACD (12, 26, signal=9)
|
||||
ema12 = _ema(close, 12)
|
||||
ema26 = _ema(close, 26)
|
||||
macd_line = ema12 - ema26
|
||||
macd_signal = _ema(macd_line[~np.isnan(macd_line)], 9)
|
||||
# macd_signal을 원래 길이에 맞추기
|
||||
full_signal = np.full(n, np.nan, dtype=float)
|
||||
valid_start = np.argmax(~np.isnan(macd_line))
|
||||
if len(macd_signal) > 0:
|
||||
full_signal[valid_start:valid_start + len(macd_signal)] = macd_signal
|
||||
features["macd"] = macd_line
|
||||
features["macd_signal"] = full_signal
|
||||
features["macd_hist"] = macd_line - full_signal
|
||||
|
||||
# 6-7. 스토캐스틱 %K, %D (14일)
|
||||
stoch_k = np.full(n, np.nan, dtype=float)
|
||||
stoch_d = np.full(n, np.nan, dtype=float)
|
||||
for i in range(13, n):
|
||||
h14 = np.max(high[i - 13:i + 1])
|
||||
l14 = np.min(low[i - 13:i + 1])
|
||||
if h14 != l14:
|
||||
stoch_k[i] = (close[i] - l14) / (h14 - l14) * 100.0
|
||||
# %D = %K의 3일 SMA
|
||||
for i in range(15, n):
|
||||
vals = stoch_k[i - 2:i + 1]
|
||||
if not np.any(np.isnan(vals)):
|
||||
stoch_d[i] = np.mean(vals)
|
||||
features["stochastic_k"] = stoch_k
|
||||
features["stochastic_d"] = stoch_d
|
||||
|
||||
# 8. 볼린저 밴드 위치 (0~1)
|
||||
bb_pos = np.full(n, np.nan, dtype=float)
|
||||
for i in range(19, n):
|
||||
window = close[i - 19:i + 1]
|
||||
mid = np.mean(window)
|
||||
std = np.std(window, ddof=0)
|
||||
if std > 0:
|
||||
upper = mid + 2 * std
|
||||
lower = mid - 2 * std
|
||||
bb_pos[i] = (close[i] - lower) / (upper - lower)
|
||||
features["bb_position"] = bb_pos
|
||||
|
||||
# 9-11. MA 비율 (현재가 / MA)
|
||||
for period, name in [(5, "ma5_ratio"), (20, "ma20_ratio"), (60, "ma60_ratio")]:
|
||||
ratio = np.full(n, np.nan, dtype=float)
|
||||
for i in range(period - 1, n):
|
||||
ma = np.mean(close[i - period + 1:i + 1])
|
||||
if ma > 0:
|
||||
ratio[i] = close[i] / ma
|
||||
features[name] = ratio
|
||||
|
||||
# 12. 거래량 비율 (현재 / 20일 평균)
|
||||
vol_ratio = np.full(n, np.nan, dtype=float)
|
||||
for i in range(19, n):
|
||||
avg_vol = np.mean(volume[i - 19:i + 1])
|
||||
if avg_vol > 0:
|
||||
vol_ratio[i] = volume[i] / avg_vol
|
||||
features["volume_ratio"] = vol_ratio
|
||||
|
||||
# --- 파생 피처 (8개) ---
|
||||
|
||||
# 13-15. 단기 수익률
|
||||
for days, name in [(1, "change_rate_1d"), (3, "change_rate_3d"), (5, "change_rate_5d")]:
|
||||
cr = np.full(n, np.nan, dtype=float)
|
||||
for i in range(days, n):
|
||||
if close[i - days] > 0:
|
||||
cr[i] = (close[i] - close[i - days]) / close[i - days]
|
||||
features[name] = cr
|
||||
|
||||
# 16. 20일 변동성 (수익률의 표준편차)
|
||||
vol20 = np.full(n, np.nan, dtype=float)
|
||||
returns = np.full(n, np.nan, dtype=float)
|
||||
for i in range(1, n):
|
||||
if close[i - 1] > 0:
|
||||
returns[i] = (close[i] - close[i - 1]) / close[i - 1]
|
||||
for i in range(20, n):
|
||||
window = returns[i - 19:i + 1]
|
||||
valid = window[~np.isnan(window)]
|
||||
if len(valid) >= 10:
|
||||
vol20[i] = np.std(valid, ddof=1)
|
||||
features["volatility_20d"] = vol20
|
||||
|
||||
# 17. 5일 모멘텀 (현재가 / 5일전가)
|
||||
mom = np.full(n, np.nan, dtype=float)
|
||||
for i in range(5, n):
|
||||
if close[i - 5] > 0:
|
||||
mom[i] = close[i] / close[i - 5]
|
||||
features["price_momentum"] = mom
|
||||
|
||||
# 18. MA 크로스 상태 (-1, 0, 1)
|
||||
ma_cross = np.full(n, 0.0, dtype=float)
|
||||
for i in range(59, n):
|
||||
ma5 = np.mean(close[i - 4:i + 1])
|
||||
ma20 = np.mean(close[i - 19:i + 1])
|
||||
ma60 = np.mean(close[i - 59:i + 1])
|
||||
if ma5 > ma20 > ma60:
|
||||
ma_cross[i] = 1.0 # 정배열
|
||||
elif ma5 < ma20 < ma60:
|
||||
ma_cross[i] = -1.0 # 역배열
|
||||
features["ma_cross"] = ma_cross
|
||||
|
||||
# 19. 추세 강도 (간이 ADX, True Range 기반)
|
||||
adx_val = np.full(n, np.nan, dtype=float)
|
||||
tr = np.zeros(n, dtype=float)
|
||||
for i in range(1, n):
|
||||
tr[i] = max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1]))
|
||||
for i in range(13, n):
|
||||
avg_tr = np.mean(tr[i - 13:i + 1])
|
||||
current_tr = tr[i]
|
||||
if avg_tr > 0:
|
||||
adx_val[i] = current_tr / avg_tr
|
||||
features["trend_strength"] = adx_val
|
||||
|
||||
# 20. 이격도 (현재가 / 20일 이평선 * 100)
|
||||
disp = np.full(n, np.nan, dtype=float)
|
||||
for i in range(19, n):
|
||||
ma20 = np.mean(close[i - 19:i + 1])
|
||||
if ma20 > 0:
|
||||
disp[i] = close[i] / ma20 * 100.0
|
||||
features["disparity_20"] = disp
|
||||
|
||||
feat_df = pd.DataFrame(features, index=df.index)
|
||||
return feat_df
|
||||
|
||||
|
||||
# 피처 컬럼 순서 (모델 학습/추론 시 일관성 유지)
|
||||
FEATURE_COLUMNS = [
|
||||
"rsi_14", "rsi_7",
|
||||
"macd", "macd_signal", "macd_hist",
|
||||
"stochastic_k", "stochastic_d",
|
||||
"bb_position",
|
||||
"ma5_ratio", "ma20_ratio", "ma60_ratio",
|
||||
"volume_ratio",
|
||||
"change_rate_1d", "change_rate_3d", "change_rate_5d",
|
||||
"volatility_20d",
|
||||
"price_momentum",
|
||||
"ma_cross",
|
||||
"trend_strength",
|
||||
"disparity_20",
|
||||
]
|
||||
|
||||
# LSTM 시퀀스에 사용할 피처 (5개)
|
||||
LSTM_SEQUENCE_FEATURES = ["close", "volume", "rsi_14", "ma20_ratio", "volatility_20d"]
|
||||
|
||||
|
||||
def prepare_training_data(
|
||||
df: pd.DataFrame,
|
||||
features_df: pd.DataFrame,
|
||||
forecast_days: int = 3,
|
||||
up_threshold: float = 0.015,
|
||||
down_threshold: float = -0.015,
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""학습 데이터 준비.
|
||||
|
||||
Returns:
|
||||
X_tabular: (N, 20) 탭뷸러 피처
|
||||
X_seq: (N, 60, 5) LSTM 시퀀스 피처
|
||||
y: (N,) 레이블 (0=DOWN, 1=HOLD, 2=UP)
|
||||
valid_indices: 유효한 행 인덱스
|
||||
"""
|
||||
close = df["close"].values.astype(float)
|
||||
n = len(close)
|
||||
|
||||
# 레이블 생성: forecast_days 후 수익률
|
||||
labels = np.full(n, np.nan, dtype=float)
|
||||
for i in range(n - forecast_days):
|
||||
if close[i] > 0:
|
||||
future_return = (close[i + forecast_days] - close[i]) / close[i]
|
||||
if future_return >= up_threshold:
|
||||
labels[i] = 2 # UP
|
||||
elif future_return <= down_threshold:
|
||||
labels[i] = 0 # DOWN
|
||||
else:
|
||||
labels[i] = 1 # HOLD
|
||||
|
||||
# 유효한 행 찾기 (피처와 레이블 모두 NaN 아닌 행)
|
||||
tabular = features_df[FEATURE_COLUMNS].values
|
||||
seq_window = 60
|
||||
|
||||
valid_mask = np.ones(n, dtype=bool)
|
||||
valid_mask[:seq_window] = False # 시퀀스 윈도우 확보
|
||||
valid_mask[np.isnan(labels)] = False
|
||||
# 탭뷸러 피처 NaN 체크
|
||||
for i in range(n):
|
||||
if valid_mask[i] and np.any(np.isnan(tabular[i])):
|
||||
valid_mask[i] = False
|
||||
|
||||
valid_indices = np.where(valid_mask)[0]
|
||||
|
||||
if len(valid_indices) == 0:
|
||||
return np.array([]), np.array([]), np.array([]), np.array([])
|
||||
|
||||
# 탭뷸러 피처
|
||||
X_tabular = tabular[valid_indices]
|
||||
|
||||
# 레이블
|
||||
y = labels[valid_indices].astype(int)
|
||||
|
||||
# LSTM 시퀀스 피처 준비
|
||||
# 시퀀스에 사용할 raw 피처 결합
|
||||
seq_cols = []
|
||||
for col in LSTM_SEQUENCE_FEATURES:
|
||||
if col in df.columns:
|
||||
seq_cols.append(df[col].values.astype(float))
|
||||
elif col in features_df.columns:
|
||||
seq_cols.append(features_df[col].values.astype(float))
|
||||
else:
|
||||
seq_cols.append(np.zeros(n, dtype=float))
|
||||
seq_raw = np.column_stack(seq_cols) # (n, 5)
|
||||
|
||||
X_seq = np.zeros((len(valid_indices), seq_window, len(LSTM_SEQUENCE_FEATURES)), dtype=float)
|
||||
for j, idx in enumerate(valid_indices):
|
||||
X_seq[j] = seq_raw[idx - seq_window:idx]
|
||||
|
||||
return X_tabular, X_seq, y, valid_indices
|
||||
|
||||
|
||||
def extract_single_features(chart_data: list) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
|
||||
"""단일 종목 차트 데이터에서 최신 시점의 피처를 추출.
|
||||
|
||||
Returns:
|
||||
tabular_features: (20,) 탭뷸러 피처 또는 None
|
||||
sequence_features: (60, 5) LSTM 시퀀스 또는 None
|
||||
"""
|
||||
if not chart_data or len(chart_data) < 61:
|
||||
return None, None
|
||||
|
||||
df = chart_data_to_dataframe(chart_data)
|
||||
features_df = extract_features(df)
|
||||
|
||||
# 마지막 행의 탭뷸러 피처
|
||||
last_row = features_df[FEATURE_COLUMNS].iloc[-1].values
|
||||
if np.any(np.isnan(last_row)):
|
||||
return None, None
|
||||
|
||||
# LSTM 시퀀스 (최근 60일)
|
||||
seq_cols = []
|
||||
for col in LSTM_SEQUENCE_FEATURES:
|
||||
if col in df.columns:
|
||||
seq_cols.append(df[col].values.astype(float))
|
||||
elif col in features_df.columns:
|
||||
seq_cols.append(features_df[col].values.astype(float))
|
||||
else:
|
||||
seq_cols.append(np.zeros(len(df), dtype=float))
|
||||
seq_raw = np.column_stack(seq_cols)
|
||||
|
||||
if len(seq_raw) < 60:
|
||||
return None, None
|
||||
|
||||
sequence = seq_raw[-60:] # (60, 5)
|
||||
|
||||
return last_row, sequence
|
||||
@@ -1,9 +1,12 @@
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, Deque
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -65,6 +68,9 @@ class APICallMonitor:
|
||||
|
||||
class KoreaInvestmentAPI:
|
||||
"""한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)"""
|
||||
|
||||
# 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약)
|
||||
TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json"
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
|
||||
@@ -85,42 +91,148 @@ class KoreaInvestmentAPI:
|
||||
# 동시 요청 제한 (세마포어)
|
||||
self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
# 토큰 발급 Lock (중복 발급 방지)
|
||||
self._token_lock = asyncio.Lock()
|
||||
|
||||
# 모니터링
|
||||
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
|
||||
# 시작 시 설정 로깅 (키 마스킹)
|
||||
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
|
||||
f"base_url={self.base_url}, "
|
||||
f"app_key={self.app_key[:6]}..., "
|
||||
f"account={self.account_number[:4]}****{self.account_number[-2:]}")
|
||||
|
||||
# 캐시된 토큰 로드
|
||||
self._load_token_from_cache()
|
||||
|
||||
def _load_token_from_cache(self):
|
||||
"""파일에서 토큰 로드 (앱 시작 시 동기 호출)"""
|
||||
try:
|
||||
cache_file = Path(self.TOKEN_CACHE_FILE)
|
||||
if cache_file.exists():
|
||||
with open(cache_file, 'r') as f:
|
||||
cache = json.load(f)
|
||||
self.access_token = cache.get('access_token')
|
||||
expires_str = cache.get('expires_at')
|
||||
if expires_str:
|
||||
self.token_expires_at = datetime.fromisoformat(expires_str)
|
||||
if self.access_token and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"💾 캐시 파일에서 토큰 로드 성공 (남은 시간: {remaining:.1f}시간)")
|
||||
else:
|
||||
logger.info("💾 캐시 파일의 토큰이 만료됨")
|
||||
self.access_token = None
|
||||
self.token_expires_at = None
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 토큰 캐시 파일 로드 실패: {e}")
|
||||
|
||||
def _write_token_file(self):
|
||||
"""토큰을 파일에 동기 저장 (asyncio.to_thread 내부에서 실행)"""
|
||||
cache_file = Path(self.TOKEN_CACHE_FILE)
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache = {
|
||||
'access_token': self.access_token,
|
||||
'expires_at': self.token_expires_at.isoformat() if self.token_expires_at else None,
|
||||
}
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(cache, f)
|
||||
# 소유자만 읽기/쓰기 가능하도록 퍼미션 설정 (보안)
|
||||
os.chmod(cache_file, 0o600)
|
||||
logger.debug("💾 토큰 캐시 파일 저장 완료")
|
||||
|
||||
async def _save_token_to_cache(self):
|
||||
"""토큰을 파일에 비동기 저장 (이벤트 루프 블로킹 방지)"""
|
||||
try:
|
||||
await asyncio.to_thread(self._write_token_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 토큰 캐시 파일 저장 실패: {e}")
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""접근 토큰 발급"""
|
||||
"""접근 토큰 발급 (Lock으로 중복 발급 방지)"""
|
||||
# 캐시된 토큰이 유효하면 재사용 (23시간 유효)
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"✅ 캐시된 토큰 재사용 (남은 시간: {remaining:.1f}시간)")
|
||||
return self.access_token
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
from datetime import timedelta
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
# 🔒 Lock 획득 - 동시 요청 시 하나만 토큰 발급
|
||||
async with self._token_lock:
|
||||
# Lock 대기 중 다른 요청이 토큰 발급했을 수 있으므로 재확인
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"✅ 대기 중 토큰 발급됨, 재사용 (남은 시간: {remaining:.1f}시간)")
|
||||
return self.access_token
|
||||
else:
|
||||
raise Exception(f"Failed to get access token: {response.text}")
|
||||
|
||||
# 토큰 발급 시도 전에 마지막 시도 시간 확인 (1분 제한 대응)
|
||||
if hasattr(self, '_last_token_request_time'):
|
||||
elapsed = (datetime.now() - self._last_token_request_time).total_seconds()
|
||||
if elapsed < 70: # 70초 대기 (여유있게)
|
||||
# 기존 토큰이 있으면 계속 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한 ({elapsed:.0f}초 경과) - 기존 토큰 재사용")
|
||||
# 만료 시간 연장 (추가 1시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
|
||||
wait_time = 70 - elapsed
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한: {wait_time:.1f}초 대기 중...")
|
||||
await asyncio.sleep(wait_time + 1)
|
||||
|
||||
logger.info("🔑 새로운 토큰 발급 요청...")
|
||||
self._last_token_request_time = datetime.now()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
|
||||
# 파일에 저장 (비동기)
|
||||
await self._save_token_to_cache()
|
||||
|
||||
logger.info(f"✅ 새 토큰 발급 완료 (만료: {self.token_expires_at.strftime('%Y-%m-%d %H:%M')})")
|
||||
return self.access_token
|
||||
else:
|
||||
error_msg = response.text
|
||||
logger.error(f"❌ 토큰 발급 실패: {response.status_code} - {error_msg}")
|
||||
|
||||
# 토큰 발급 실패 시에도 기존 토큰이 있다면 계속 사용 시도
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 발급 실패했지만 기존 토큰 재사용 시도")
|
||||
# 만료 시간 연장
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise Exception(f"Failed to get access token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 토큰 요청 중 오류: {e}")
|
||||
# 기존 토큰이 있으면 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 요청 실패, 기존 토큰 재사용")
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise
|
||||
|
||||
async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
|
||||
async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)"""
|
||||
|
||||
# 동시 요청 수 제한 (세마포어)
|
||||
async with self.semaphore:
|
||||
return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs)
|
||||
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
|
||||
|
||||
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
|
||||
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""재시도 로직을 포함한 실제 요청 실행"""
|
||||
start_time = time.time()
|
||||
retry_count = 0
|
||||
@@ -146,6 +258,8 @@ class KoreaInvestmentAPI:
|
||||
"appsecret": self.app_secret,
|
||||
"tr_id": tr_id,
|
||||
}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
# 타임아웃 설정
|
||||
timeout = httpx.Timeout(self.timeout, connect=10.0)
|
||||
@@ -170,7 +284,7 @@ class KoreaInvestmentAPI:
|
||||
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
return response.json(), response.headers
|
||||
|
||||
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
|
||||
elif response.status_code in [429, 500, 502, 503, 504]:
|
||||
@@ -199,8 +313,12 @@ class KoreaInvestmentAPI:
|
||||
|
||||
error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
|
||||
# 에러 응답도 json과 headers 튜플로 반환 시도
|
||||
try:
|
||||
return response.json(), response.headers
|
||||
except Exception:
|
||||
return {"rt_cd": str(response.status_code), "msg1": error_msg, "output": None}, None
|
||||
|
||||
# 기타 오류
|
||||
else:
|
||||
duration = time.time() - start_time
|
||||
@@ -242,7 +360,7 @@ class KoreaInvestmentAPI:
|
||||
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""현재가 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
@@ -256,39 +374,292 @@ class KoreaInvestmentAPI:
|
||||
logger.error(f"Error fetching price for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]:
|
||||
"""일봉/주봉/월봉 조회
|
||||
async def get_daily_chart(self, stock_code: str, period: str = "D", limit: int = 240) -> Dict[str, Any]:
|
||||
"""일봉/주봉/월봉 조회 (페이지네이션 지원으로 최대 240개까지 조회)"""
|
||||
all_output2 = []
|
||||
|
||||
KIS API는 기본적으로 최근 30~100개 데이터를 반환
|
||||
더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음)
|
||||
"""
|
||||
# 1. 첫 요청
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
|
||||
}
|
||||
try:
|
||||
result = await self._make_request(
|
||||
result, headers = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
params=params
|
||||
)
|
||||
|
||||
# FHKST01010400은 'output' (배열) 또는 'output2' (배열)로 데이터를 반환
|
||||
# 실전/모의투자 환경에 따라 키 이름이 다를 수 있음
|
||||
data_key = 'output2' if isinstance(result.get('output2'), list) and result.get('output2') else \
|
||||
'output' if isinstance(result.get('output'), list) and result.get('output') else None
|
||||
|
||||
if not result or result.get('rt_cd') != '0' or data_key is None:
|
||||
logger.warning(f"No chart data for {stock_code}: {result.get('msg1', 'Unknown')}")
|
||||
return result
|
||||
|
||||
all_output2.extend(result.get(data_key, []))
|
||||
tr_cont = headers.get('tr_cont')
|
||||
|
||||
# 2. 연속 조회 (필요 시)
|
||||
while tr_cont in ('F', 'M') and len(all_output2) < limit:
|
||||
await asyncio.sleep(self.min_request_interval + 0.1) # Rate limit
|
||||
extra_headers = {'tr_cont': 'Y'}
|
||||
|
||||
result_cont, headers_cont = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
params=params,
|
||||
extra_headers=extra_headers
|
||||
)
|
||||
|
||||
if result_cont and result_cont.get('rt_cd') == '0':
|
||||
cont_key = 'output2' if isinstance(result_cont.get('output2'), list) else 'output'
|
||||
all_output2.extend(result_cont.get(cont_key, []))
|
||||
|
||||
tr_cont = headers_cont.get('tr_cont') if headers_cont else None
|
||||
|
||||
# 3. 최종 결과 조합
|
||||
final_result = result # 첫 페이지 응답을 기반으로 조합
|
||||
final_result['output2'] = all_output2
|
||||
final_result['msg1'] = f"OK (fetched {len(all_output2)} data points)"
|
||||
|
||||
logger.info(f"✅ Fetched {len(all_output2)} chart data points for {stock_code}")
|
||||
return final_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching paginated chart for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD") -> Dict[str, Any]:
|
||||
"""해외주식 현재가 조회 (NASD, NYSE, AMEX)"""
|
||||
try:
|
||||
# 거래소 코드 보정
|
||||
exch_map = {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"}
|
||||
formatted_exchange = exch_map.get(exchange_code.upper(), exchange_code.upper())
|
||||
|
||||
# 모의투자 여부에 따라 TR_ID가 다를 수 있는지 확인 필요하나,
|
||||
# 시세 조회는 실전/모의 공통인 경우가 많음 (HHDFS00000300)
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/overseas-price/v1/quotations/price",
|
||||
"HHDFS00000300",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
|
||||
"AUTH": "",
|
||||
"EXCD": exchange_code.upper(), # NASD, NYSE, AMEX
|
||||
"SYMB": symbol.upper()
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching chart for {stock_code}: {e}")
|
||||
logger.error(f"Error fetching overseas price for {symbol}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_minute_chart(
|
||||
self,
|
||||
stock_code: str,
|
||||
ref_time: str = "",
|
||||
include_past: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""국내주식 분봉차트 조회 (FHKST03010200)
|
||||
|
||||
KIS API: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice
|
||||
한 번 호출에 최대 30개 분봉 반환. 전체 장 데이터는 여러 번 호출 필요.
|
||||
|
||||
Args:
|
||||
stock_code: 종목코드 (e.g. "005930")
|
||||
ref_time: 조회 기준 시간 (HHMMSS). 빈 문자열이면 현재 시간
|
||||
include_past: True = 과거 데이터 포함 (Y), False = 당일만 (N)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"output1": { 현재가 요약 },
|
||||
"output2": [
|
||||
{
|
||||
"stck_bsop_date": "20260222", # 날짜 YYYYMMDD
|
||||
"stck_cntg_hour": "091500", # 시간 HHMMSS
|
||||
"stck_prpr": "71800", # 종가(현재가)
|
||||
"stck_oprc": "71500", # 시가
|
||||
"stck_hgpr": "72000", # 고가
|
||||
"stck_lwpr": "71400", # 저가
|
||||
"cntg_vol": "12345", # 분봉 거래량
|
||||
"acml_vol": "987654", # 누적 거래량
|
||||
"acml_tr_pbmn": "70896123456", # 누적 거래대금(원)
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if not ref_time:
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
kst = pytz.timezone("Asia/Seoul")
|
||||
ref_time = datetime.now(kst).strftime("%H%M%S")
|
||||
|
||||
params = {
|
||||
"FID_ETC_CLS_CODE": "",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_INPUT_HOUR_1": ref_time,
|
||||
"FID_PW_DATA_INCU_YN": "Y" if include_past else "N",
|
||||
}
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
|
||||
"FHKST03010200",
|
||||
params=params,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching minute chart for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_minute_chart_full_day(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_open: str = "090000",
|
||||
) -> list:
|
||||
"""당일 전체 분봉 데이터 수집 (여러 번 호출 집계)
|
||||
|
||||
09:00부터 현재까지의 모든 1분봉을 순서대로 반환.
|
||||
한 번 호출에 30개씩 → 최대 ~13회 호출 필요 (390분/30).
|
||||
시간 역순 데이터를 정순으로 변환해서 반환.
|
||||
|
||||
Returns:
|
||||
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
|
||||
"""
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
kst = pytz.timezone("Asia/Seoul")
|
||||
now_str = datetime.now(kst).strftime("%H%M%S")
|
||||
|
||||
all_candles: list = []
|
||||
ref = now_str
|
||||
seen_times: set = set()
|
||||
|
||||
for _ in range(15): # 최대 15회 (450분, 여유 있게)
|
||||
try:
|
||||
result = await self.get_minute_chart(stock_code, ref_time=ref)
|
||||
except Exception as e:
|
||||
logger.warning(f"Minute chart fetch failed for {stock_code} at {ref}: {e}")
|
||||
break
|
||||
|
||||
candles = result.get("output2") if isinstance(result.get("output2"), list) else []
|
||||
if not candles:
|
||||
break
|
||||
|
||||
new_added = 0
|
||||
for c in candles:
|
||||
t = c.get("stck_cntg_hour", "")
|
||||
if t and t not in seen_times:
|
||||
seen_times.add(t)
|
||||
all_candles.append(c)
|
||||
new_added += 1
|
||||
|
||||
# 가장 오래된 시간 확인
|
||||
times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")]
|
||||
oldest = min(times) if times else "000000"
|
||||
|
||||
if oldest <= market_open or new_added == 0:
|
||||
break
|
||||
|
||||
ref = oldest
|
||||
await asyncio.sleep(self.min_request_interval + 0.05)
|
||||
|
||||
# 오래된 것 → 최신 순으로 정렬
|
||||
all_candles.sort(key=lambda c: c.get("stck_cntg_hour", ""))
|
||||
return all_candles
|
||||
|
||||
async def get_overseas_daily_chart(self, symbol: str, exchange_code: str = "NASD", period: str = "D") -> Dict[str, Any]:
|
||||
"""해외주식 차트 조회"""
|
||||
try:
|
||||
# 기간 코드: O(일), P(주), Q(월) - KIS 해외주식 문서 기준
|
||||
# 또는 0(일), 1(주), 2(월)
|
||||
gubn = "0"
|
||||
if period == "W": gubn = "1"
|
||||
elif period == "M": gubn = "2"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/overseas-price/v1/quotations/dailyprice",
|
||||
"HHDFS76240000",
|
||||
params={
|
||||
"AUTH": "",
|
||||
"EXCD": exchange_code.upper(),
|
||||
"SYMB": symbol.upper(),
|
||||
"GUBN": gubn,
|
||||
"BYMD": "",
|
||||
"MODP": "1"
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching overseas chart for {symbol}: {e}")
|
||||
raise
|
||||
|
||||
async def place_overseas_order(
|
||||
self,
|
||||
symbol: str,
|
||||
order_type: str, # "BUY" or "SELL"
|
||||
quantity: int,
|
||||
price: float, # 해외주식은 지정가(Limit)가 기본
|
||||
exchange_code: str = "NASD"
|
||||
) -> Dict[str, Any]:
|
||||
"""해외주식 주문 (미국주식 기준)"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
# 모의/실전 구분
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
|
||||
# TR_ID 설정 (미국 매수/매도)
|
||||
if order_type == "BUY":
|
||||
tr_id = "VTTT1002U" if is_virtual else "TTTT1002U" # 미국 매수
|
||||
else:
|
||||
tr_id = "VTTT1006U" if is_virtual else "TTTT1006U" # 미국 매도
|
||||
|
||||
# 해외주식 주문 (미국)
|
||||
# OVRS_EXCG_CD: NASD, NYSE, AMEX
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/overseas-stock/v1/trading/order",
|
||||
tr_id,
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"OVRS_EXCG_CD": exchange_code.upper(),
|
||||
"PDNO": symbol.upper(),
|
||||
"ORD_QTY": str(quantity),
|
||||
"OVRS_ORD_UNPR": str(price),
|
||||
"ORD_SVR_DVSN_CD": "0",
|
||||
"ORD_DVSN": "00" # 00: 지정가
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing overseas order: {e}")
|
||||
raise
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
"""계좌 잔고 조회"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
result = await self._make_request(
|
||||
# 모의투자 환경 확인 (URL에 'vts' 포함 여부로 판단)
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
tr_id = "VTTC8434R" if is_virtual else "TTTC8434R"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
"TTTC8434R",
|
||||
tr_id,
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
@@ -303,6 +674,10 @@ class KoreaInvestmentAPI:
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
)
|
||||
logger.info(f"📋 Balance API Full Response: {result}")
|
||||
logger.info(f"📋 Balance API Keys: {result.keys()}")
|
||||
if result.get('rt_cd') != '0':
|
||||
logger.error(f"❌ Balance API error - rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching balance: {e}")
|
||||
@@ -317,20 +692,35 @@ class KoreaInvestmentAPI:
|
||||
) -> Dict[str, Any]:
|
||||
"""주문 실행"""
|
||||
try:
|
||||
# 환경 설정 확인
|
||||
if not self.app_key or not self.app_secret or not self.account_number:
|
||||
error_msg = f"KIS API 설정 누락: app_key={bool(self.app_key)}, secret={bool(self.app_secret)}, account={bool(self.account_number)}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"rt_cd": "-1",
|
||||
"msg1": error_msg,
|
||||
"odno": None
|
||||
}
|
||||
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
# 매수/매도 구분
|
||||
# 모의투자 환경 확인
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
|
||||
# 매수/매도 구분 (모의투자는 V 접두사)
|
||||
if order_type == "BUY":
|
||||
tr_id = "TTTC0802U" # 현금 매수
|
||||
tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" # 현금 매수
|
||||
else:
|
||||
tr_id = "TTTC0801U" # 현금 매도
|
||||
tr_id = "VTTC0801U" if is_virtual else "TTTC0801U" # 현금 매도
|
||||
|
||||
# 주문 유형 (00: 지정가, 01: 시장가)
|
||||
order_dvsn = "01" if price is None else "00"
|
||||
order_price = "0" if price is None else str(int(price))
|
||||
|
||||
result = await self._make_request(
|
||||
logger.info(f"📤 주문 요청: {order_type} {stock_code} {quantity}주 @{order_price}원 (tr_id={tr_id}, is_virtual={is_virtual})")
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-cash",
|
||||
tr_id,
|
||||
@@ -343,10 +733,16 @@ class KoreaInvestmentAPI:
|
||||
"ORD_UNPR": order_price
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"📥 주문 응답: rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}, odno={result.get('odno')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing order: {e}")
|
||||
raise
|
||||
logger.error(f"❌ 주문 실행 오류: {e}", exc_info=True)
|
||||
return {
|
||||
"rt_cd": "-1",
|
||||
"msg1": str(e),
|
||||
"odno": None
|
||||
}
|
||||
|
||||
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
|
||||
"""주문 취소"""
|
||||
@@ -354,10 +750,14 @@ class KoreaInvestmentAPI:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
result = await self._make_request(
|
||||
# 모의투자 환경 확인
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
tr_id = "VTTC0803U" if is_virtual else "TTTC0803U"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||
"TTTC0803U",
|
||||
tr_id,
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
@@ -383,7 +783,7 @@ class KoreaInvestmentAPI:
|
||||
) -> Dict[str, Any]:
|
||||
"""조건 검색으로 종목 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-search",
|
||||
"HHKST03900300",
|
||||
@@ -410,7 +810,7 @@ class KoreaInvestmentAPI:
|
||||
"""전체 종목 리스트 조회"""
|
||||
try:
|
||||
# 거래대금 상위 종목 조회
|
||||
result = await self._make_request(
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/volume-rank",
|
||||
"FHPST01710000",
|
||||
@@ -437,6 +837,32 @@ class KoreaInvestmentAPI:
|
||||
"""거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)"""
|
||||
return await self.get_stock_list(market)
|
||||
|
||||
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""호가 정보 조회 (매수/매도 호가 10단계)"""
|
||||
try:
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code
|
||||
}
|
||||
)
|
||||
|
||||
# 디버깅용 로그
|
||||
logger.info(f"📊 Orderbook raw response for {stock_code}:")
|
||||
logger.info(f" rt_cd: {result.get('rt_cd')}, msg1: {result.get('msg1')}")
|
||||
if result.get('output1'):
|
||||
logger.info(f" output1 keys: {list(result['output1'].keys())[:10]}")
|
||||
if result.get('output2'):
|
||||
logger.info(f" output2 keys: {list(result['output2'].keys())[:10]}")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
def get_monitor_stats(self) -> Dict[str, Any]:
|
||||
"""API 호출 모니터링 통계 반환"""
|
||||
return self.monitor.get_stats()
|
||||
|
||||
150
backend/app/services/loss_tracker.py
Normal file
150
backend/app/services/loss_tracker.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
연속 손실 추적 - CLAUDE.md 섹션 7.4
|
||||
|
||||
동일 종목에서 3회 이상 연속 손실 시 30거래일 동안 신규 진입 금지.
|
||||
재시작 후에도 ban 상태 유지: DB(stock_bans 테이블) 영속화.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsecutiveLossTracker:
|
||||
"""종목별 연속 손실 추적 및 쿨링오프 관리"""
|
||||
|
||||
def __init__(self, ban_count: int = 3, ban_calendar_days: int = 42):
|
||||
"""
|
||||
Args:
|
||||
ban_count: 연속 손실 횟수 임계값 (기본 3)
|
||||
ban_calendar_days: 금지 기간 (30거래일 ≈ 42캘린더일)
|
||||
"""
|
||||
self.ban_count = ban_count
|
||||
self.ban_calendar_days = ban_calendar_days
|
||||
# {stock_code: {"consecutive_losses": int, "ban_until": datetime|None}}
|
||||
self._tracker: Dict[str, dict] = {}
|
||||
|
||||
def record_trade_result(self, stock_code: str, is_loss: bool):
|
||||
"""매매 결과 인메모리 기록 (DB 저장은 save_to_db 별도 호출)
|
||||
|
||||
Args:
|
||||
stock_code: 종목코드
|
||||
is_loss: 손실 여부
|
||||
"""
|
||||
if stock_code not in self._tracker:
|
||||
self._tracker[stock_code] = {"consecutive_losses": 0, "ban_until": None}
|
||||
|
||||
entry = self._tracker[stock_code]
|
||||
if is_loss:
|
||||
entry["consecutive_losses"] += 1
|
||||
if entry["consecutive_losses"] >= self.ban_count:
|
||||
entry["ban_until"] = datetime.now() + timedelta(days=self.ban_calendar_days)
|
||||
logger.warning(
|
||||
f"{stock_code}: {entry['consecutive_losses']}회 연속 손실 → "
|
||||
f"{entry['ban_until'].strftime('%Y-%m-%d')}까지 진입 금지"
|
||||
)
|
||||
else:
|
||||
entry["consecutive_losses"] = 0
|
||||
entry["ban_until"] = None
|
||||
|
||||
async def save_to_db(self, db, stock_code: str):
|
||||
"""인메모리 상태를 DB에 영속화 (record_trade_result 후 호출)"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import StockBan
|
||||
|
||||
entry = self._tracker.get(stock_code)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
result = await db.execute(select(StockBan).where(StockBan.stock_code == stock_code))
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
record = StockBan(
|
||||
stock_code=stock_code,
|
||||
consecutive_losses=entry["consecutive_losses"],
|
||||
ban_until=entry["ban_until"],
|
||||
)
|
||||
db.add(record)
|
||||
else:
|
||||
record.consecutive_losses = entry["consecutive_losses"]
|
||||
record.ban_until = entry["ban_until"]
|
||||
record.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.debug(f"loss_tracker DB 저장 완료: {stock_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker DB 저장 실패 ({stock_code}): {e}")
|
||||
|
||||
async def load_from_db(self, db):
|
||||
"""앱 시작 시 DB에서 ban 상태 복원"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import StockBan
|
||||
|
||||
result = await db.execute(select(StockBan))
|
||||
records = result.scalars().all()
|
||||
|
||||
now = datetime.now()
|
||||
loaded = 0
|
||||
for rec in records:
|
||||
# 만료된 ban은 초기화
|
||||
ban_until = rec.ban_until
|
||||
if ban_until and now >= ban_until:
|
||||
ban_until = None
|
||||
consecutive_losses = 0
|
||||
else:
|
||||
consecutive_losses = rec.consecutive_losses
|
||||
|
||||
self._tracker[rec.stock_code] = {
|
||||
"consecutive_losses": consecutive_losses,
|
||||
"ban_until": ban_until,
|
||||
}
|
||||
if ban_until:
|
||||
loaded += 1
|
||||
logger.info(
|
||||
f"ban 복원: {rec.stock_code} → {ban_until.strftime('%Y-%m-%d')}까지 진입 금지"
|
||||
)
|
||||
|
||||
logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker DB 로드 실패: {e}")
|
||||
|
||||
def is_banned(self, stock_code: str) -> bool:
|
||||
"""해당 종목이 진입 금지 상태인지 확인"""
|
||||
entry = self._tracker.get(stock_code)
|
||||
if not entry or not entry["ban_until"]:
|
||||
return False
|
||||
if datetime.now() >= entry["ban_until"]:
|
||||
# 금지 기간 만료 → 리셋
|
||||
entry["consecutive_losses"] = 0
|
||||
entry["ban_until"] = None
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_ban_info(self, stock_code: str) -> dict:
|
||||
"""종목의 연속 손실 및 금지 정보 조회"""
|
||||
entry = self._tracker.get(stock_code, {"consecutive_losses": 0, "ban_until": None})
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"consecutive_losses": entry["consecutive_losses"],
|
||||
"is_banned": self.is_banned(stock_code),
|
||||
"ban_until": entry["ban_until"].isoformat() if entry["ban_until"] else None,
|
||||
}
|
||||
|
||||
|
||||
# 싱글톤 인스턴스 — settings에서 ban_count 주입
|
||||
def _create_loss_tracker() -> ConsecutiveLossTracker:
|
||||
try:
|
||||
from app.core.config import settings
|
||||
return ConsecutiveLossTracker(
|
||||
ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT,
|
||||
ban_calendar_days=42, # 30거래일 ≈ 42캘린더일
|
||||
)
|
||||
except Exception:
|
||||
return ConsecutiveLossTracker()
|
||||
|
||||
|
||||
loss_tracker = _create_loss_tracker()
|
||||
80
backend/app/services/mdd_manager.py
Normal file
80
backend/app/services/mdd_manager.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
MDD(Maximum Drawdown) 관리 - CLAUDE.md 섹션 6.3
|
||||
|
||||
- 포트폴리오 최고점 대비 하락률 추적
|
||||
- -10% 돌파 시: 신규 진입 중단 + 기존 포지션 30% 축소
|
||||
- -20% 돌파 시: 전체 포지션 50% 강제 축소 (레버리지/인버스 우선)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MDDManager:
|
||||
"""포트폴리오 MDD 관리자 (싱글톤으로 사용)"""
|
||||
|
||||
def __init__(self):
|
||||
self.peak_balance: float = 0.0
|
||||
self.is_paused: bool = False
|
||||
self.reduction_applied: str = None # None, "10pct", "20pct"
|
||||
self._lock = asyncio.Lock() # 스케줄러-sync 동시 접근 방지
|
||||
|
||||
async def update(self, current_balance: float) -> Dict:
|
||||
"""MDD 업데이트 및 필요한 조치 반환 (thread-safe)
|
||||
|
||||
Returns:
|
||||
{"action": "NONE"/"REDUCE_AND_PAUSE"/"FORCE_REDUCE",
|
||||
"drawdown": float, ...}
|
||||
"""
|
||||
async with self._lock:
|
||||
if current_balance > self.peak_balance:
|
||||
self.peak_balance = current_balance
|
||||
self.is_paused = False
|
||||
self.reduction_applied = None
|
||||
|
||||
if self.peak_balance <= 0:
|
||||
return {"action": "NONE", "drawdown": 0.0}
|
||||
|
||||
drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100
|
||||
|
||||
# -20% 돌파: 50% 강제 축소
|
||||
if drawdown_pct <= -20 and self.reduction_applied != "20pct":
|
||||
self.is_paused = True
|
||||
self.reduction_applied = "20pct"
|
||||
logger.warning(f"MDD {drawdown_pct:.1f}% - 50% 강제 축소 + 신규진입 중단")
|
||||
return {
|
||||
"action": "FORCE_REDUCE",
|
||||
"drawdown": drawdown_pct,
|
||||
"reduce_pct": 0.50,
|
||||
"prioritize_leverage": True,
|
||||
}
|
||||
|
||||
# -10% 돌파: 30% 축소 + 신규진입 중단
|
||||
if drawdown_pct <= -10 and self.reduction_applied is None:
|
||||
self.is_paused = True
|
||||
self.reduction_applied = "10pct"
|
||||
logger.warning(f"MDD {drawdown_pct:.1f}% - 30% 축소 + 신규진입 중단")
|
||||
return {
|
||||
"action": "REDUCE_AND_PAUSE",
|
||||
"drawdown": drawdown_pct,
|
||||
"reduce_pct": 0.30,
|
||||
}
|
||||
|
||||
return {"action": "NONE", "drawdown": drawdown_pct}
|
||||
|
||||
async def can_open_new_position(self) -> bool:
|
||||
"""신규 포지션 진입 가능 여부 (thread-safe)"""
|
||||
async with self._lock:
|
||||
return not self.is_paused
|
||||
|
||||
def reset(self):
|
||||
"""MDD 상태 리셋 (수동 복구 시) — 동기 메서드, 단독 호출 시에만 사용"""
|
||||
self.is_paused = False
|
||||
self.reduction_applied = None
|
||||
logger.info("MDD 관리자 리셋")
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
mdd_manager = MDDManager()
|
||||
340
backend/app/services/mean_reversion_strategy.py
Normal file
340
backend/app/services/mean_reversion_strategy.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5)
|
||||
|
||||
일봉 기준, 전체 자본의 최대 30% 이내에서만 운용.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketRegime(Enum):
|
||||
BULL = "bull"
|
||||
BEAR = "bear"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitSignal:
|
||||
action: str # "SELL_PARTIAL", "SELL_ALL"
|
||||
reason: str
|
||||
sell_pct: float # 0.5 = 50%, 1.0 = 전량
|
||||
|
||||
|
||||
def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""ATR(Average True Range) 계산
|
||||
|
||||
TR = max(H-L, |H-Cprev|, |L-Cprev|)
|
||||
ATR = SMA(TR, period)
|
||||
"""
|
||||
if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
true_ranges = []
|
||||
for i in range(1, len(highs)):
|
||||
h, l, c_prev = highs[i], lows[i], closes[i - 1]
|
||||
tr = max(h - l, abs(h - c_prev), abs(l - c_prev))
|
||||
true_ranges.append(tr)
|
||||
|
||||
if len(true_ranges) < period:
|
||||
return None
|
||||
return sum(true_ranges[-period:]) / period
|
||||
|
||||
|
||||
def calculate_adx(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""Wilder's ADX(Average Directional Index) 계산 — 추세 강도 지표
|
||||
|
||||
알고리즘:
|
||||
1. TR = max(H-L, |H-Cprev|, |L-Cprev|)
|
||||
2. +DM = H-Hprev if (H-Hprev) > (Lprev-L) and > 0, else 0
|
||||
3. -DM = Lprev-L if (Lprev-L) > (H-Hprev) and > 0, else 0
|
||||
4. Wilder 평활: ATR14 = ATR14_prev - ATR14_prev/14 + TR (초기값은 14일 합산)
|
||||
5. +DI14 = 100 × (+DM14 / ATR14), -DI14 동일
|
||||
6. DX = 100 × |+DI14 - -DI14| / (+DI14 + -DI14)
|
||||
7. ADX = DX의 Wilder 평활
|
||||
|
||||
해석 (평균회귀 전략):
|
||||
- ADX < 20: 횡보장 → 평균회귀 유리
|
||||
- ADX 20~30: 중간 추세 → 조건부 허용
|
||||
- ADX > 30: 강한 추세 → 평균회귀 위험 (진입 차단)
|
||||
"""
|
||||
n = len(highs)
|
||||
# 최소 2*period + 1개 필요 (초기 평활 + ADX 평활)
|
||||
if n < period * 2 + 1:
|
||||
return None
|
||||
|
||||
trs: List[float] = []
|
||||
plus_dms: List[float] = []
|
||||
minus_dms: List[float] = []
|
||||
|
||||
for i in range(1, n):
|
||||
h, l, c_prev = highs[i], lows[i], closes[i - 1]
|
||||
h_prev, l_prev = highs[i - 1], lows[i - 1]
|
||||
|
||||
tr = max(h - l, abs(h - c_prev), abs(l - c_prev))
|
||||
up_move = h - h_prev
|
||||
down_move = l_prev - l
|
||||
|
||||
plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0
|
||||
minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0
|
||||
|
||||
trs.append(tr)
|
||||
plus_dms.append(plus_dm)
|
||||
minus_dms.append(minus_dm)
|
||||
|
||||
def _wilder_smooth(values: List[float]) -> List[float]:
|
||||
"""Wilder 평활: 초기값 = 첫 period개 합산, 이후 재귀 평활."""
|
||||
smoothed = [sum(values[:period])]
|
||||
for v in values[period:]:
|
||||
smoothed.append(smoothed[-1] - smoothed[-1] / period + v)
|
||||
return smoothed
|
||||
|
||||
atr_s = _wilder_smooth(trs)
|
||||
plus_dm_s = _wilder_smooth(plus_dms)
|
||||
minus_dm_s = _wilder_smooth(minus_dms)
|
||||
|
||||
dxs: List[float] = []
|
||||
for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s):
|
||||
if atr_v == 0:
|
||||
dxs.append(0.0)
|
||||
continue
|
||||
plus_di = 100.0 * p_dm / atr_v
|
||||
minus_di = 100.0 * m_dm / atr_v
|
||||
di_sum = plus_di + minus_di
|
||||
dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0)
|
||||
|
||||
if len(dxs) < period:
|
||||
return None
|
||||
|
||||
adx_s = _wilder_smooth(dxs)
|
||||
return round(adx_s[-1], 2)
|
||||
|
||||
|
||||
def detect_market_regime(index_closes: List[float]) -> MarketRegime:
|
||||
"""섹션 7.1: 200일 MA + 기울기로 상승장/하락장 판별
|
||||
|
||||
상승장(Bull): 지수 > MA200 AND MA200 기울기 양(+)
|
||||
하락장(Bear): 그 외
|
||||
"""
|
||||
if len(index_closes) < 205:
|
||||
return MarketRegime.BEAR # 데이터 부족 시 보수적
|
||||
|
||||
ma200_current = sum(index_closes[-200:]) / 200
|
||||
ma200_5d_ago = sum(index_closes[-205:-5]) / 200
|
||||
slope_positive = ma200_current > ma200_5d_ago
|
||||
|
||||
is_bull = index_closes[-1] > ma200_current and slope_positive
|
||||
return MarketRegime.BULL if is_bull else MarketRegime.BEAR
|
||||
|
||||
|
||||
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""RSI(Relative Strength Index) 계산"""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
gains.append(max(diff, 0))
|
||||
losses.append(max(-diff, 0))
|
||||
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]:
|
||||
"""볼린저 밴드 (20일, 2σ) 계산
|
||||
|
||||
Returns: (upper, middle, lower)
|
||||
"""
|
||||
if len(closes) < period:
|
||||
return None
|
||||
window = closes[-period:]
|
||||
middle = sum(window) / period
|
||||
variance = sum((p - middle) ** 2 for p in window) / period
|
||||
std = variance ** 0.5
|
||||
return (middle + 2 * std, middle, middle - 2 * std)
|
||||
|
||||
|
||||
def is_reversal_candle(opens: List[float], highs: List[float],
|
||||
lows: List[float], closes: List[float]) -> bool:
|
||||
"""하락장 매수 조건용 되돌림 캔들 감지 (섹션 7.2)
|
||||
|
||||
양봉 또는 긴 하단 꼬리(하단 그림자 > 2× 몸통)
|
||||
"""
|
||||
if not closes:
|
||||
return False
|
||||
o, h, l, c = opens[-1], highs[-1], lows[-1], closes[-1]
|
||||
body = abs(c - o)
|
||||
lower_shadow = min(o, c) - l
|
||||
is_bullish = c > o
|
||||
has_long_lower_shadow = lower_shadow > 2 * max(body, 0.01)
|
||||
return is_bullish or has_long_lower_shadow
|
||||
|
||||
|
||||
def check_buy_signal(
|
||||
regime: MarketRegime,
|
||||
rsi: float,
|
||||
close: float,
|
||||
bb_upper: float,
|
||||
bb_middle: float,
|
||||
bb_lower: float,
|
||||
avg_volume_20d: float,
|
||||
current_volume: int,
|
||||
prev_volume: int,
|
||||
is_reversal: bool = False,
|
||||
) -> Optional[Dict]:
|
||||
"""섹션 7.2: 매수 조건 판별
|
||||
|
||||
Bull: RSI(14) ≤ 25 (과매도) OR 거래량 ≥ 20일평균×200%, 종가가 BB 하단선 -3% ~ +1.5% 구간
|
||||
Bear: RSI(14) ≤ 35 (과매도), 종가 ≤ BB 하단선, 되돌림 캔들 출현, 전일 대비 거래량 증가
|
||||
|
||||
공통: 거래대금 ≥ 20일 평균
|
||||
"""
|
||||
if current_volume < avg_volume_20d:
|
||||
return None
|
||||
|
||||
if regime == MarketRegime.BULL:
|
||||
rsi_ok = rsi <= 25
|
||||
vol_ok = current_volume >= avg_volume_20d * 2 # 거래량 20일평균 200% 이상
|
||||
if rsi_ok or vol_ok:
|
||||
bb_low_minus = bb_lower * 0.97 # -3%
|
||||
bb_low_plus = bb_lower * 1.015 # +1.5%
|
||||
if bb_low_minus <= close <= bb_low_plus:
|
||||
trigger = f"RSI({rsi:.1f})<=25" if rsi_ok else f"거래량({current_volume / avg_volume_20d * 100:.0f}%>=200%)"
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strategy": "MEAN_REVERSION_BULL",
|
||||
"split_plan": [0.50, 0.50], # 2회 분할 매수
|
||||
"description": f"{trigger} + BB 하단 -3%~+1.5% (상승장 과매도)",
|
||||
}
|
||||
|
||||
elif regime == MarketRegime.BEAR:
|
||||
if rsi <= 35: # 40 → 35: 더 낮은 가격에서 진입
|
||||
# 하락장 진입 필터: 거래량 전일 대비 증가 확인 (매수세 유입)
|
||||
if close <= bb_lower and is_reversal and current_volume > prev_volume:
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strategy": "MEAN_REVERSION_BEAR",
|
||||
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수
|
||||
"description": f"RSI({rsi:.1f})<=35 + BB 하단 + 되돌림 + 거래량↑ (하락장 과매도)",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_exit_signal(
|
||||
regime: MarketRegime,
|
||||
rsi: float,
|
||||
close: float,
|
||||
bb_upper: float,
|
||||
bb_middle: float,
|
||||
bb_lower: float,
|
||||
entry_price: float,
|
||||
atr: float,
|
||||
lowest_since_entry: float,
|
||||
remaining_pct: float,
|
||||
) -> Optional[ExitSignal]:
|
||||
"""섹션 7.3 + 7.4: 청산 조건
|
||||
|
||||
손절: min(최저가 - 2.5×ATR, 진입가 - 8%) 중 높은 값(타이트한 것)
|
||||
1차 익절: Bull 30% (트렌드 팔로잉), Bear 50% (리스크 관리)
|
||||
2차 익절(전량): RSI ≥ 60 진입 + 종가 ≥ BB 상단선 근처
|
||||
"""
|
||||
# 손절 체크 (섹션 7.4)
|
||||
atr_stop = lowest_since_entry - 2.5 * atr
|
||||
pct_stop = entry_price * 0.92 # -8%
|
||||
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
|
||||
|
||||
if close <= stop_price:
|
||||
return ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR={atr:.0f})",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
# 1차 익절: 시장 상태별 비중 조정 (RSI 45~50 회복 + BB 중앙선 상회)
|
||||
if regime == MarketRegime.BULL:
|
||||
# 상승장: 30% 익절 (트렌드 팔로잉)
|
||||
if remaining_pct > 0.7 and 45 <= rsi <= 50 and close > bb_middle:
|
||||
return ExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"1차 익절(Bull 30%): RSI({rsi:.1f}) 45-50 회복 + BB중앙({bb_middle:.0f}) 상회",
|
||||
sell_pct=0.30,
|
||||
)
|
||||
else:
|
||||
# 하락장: 50% 익절 (리스크 관리)
|
||||
if remaining_pct > 0.5 and 45 <= rsi <= 50 and close > bb_middle:
|
||||
return ExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"1차 익절(Bear 50%): RSI({rsi:.1f}) 45-50 회복 + BB중앙({bb_middle:.0f}) 상회",
|
||||
sell_pct=0.50,
|
||||
)
|
||||
|
||||
# 2차 익절: 전량 매도 (RSI ≥ 60 진입 + BB 상단선 근처)
|
||||
threshold = 0.7 if regime == MarketRegime.BULL else 0.5
|
||||
if remaining_pct <= threshold and rsi >= 60:
|
||||
if close >= bb_upper * 0.99:
|
||||
return ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"2차 익절: RSI({rsi:.1f})>=60 진입 + BB상단({bb_upper:.0f}) 도달",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def calculate_position_size(
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_strategy_allocation: float,
|
||||
regime: MarketRegime,
|
||||
max_per_position: float = 0.04,
|
||||
max_strategy_capital_bull: float = 0.70,
|
||||
max_strategy_capital_bear: float = 0.30,
|
||||
) -> float:
|
||||
"""섹션 6.1 + 7.5: 포지션 사이징 (시장 상태별 동적 배분)
|
||||
|
||||
- 1종목당 최대 3-4% (max_per_position)
|
||||
- 상승장(Bull): 전략 전체 최대 70% 운용 (공격적)
|
||||
- 하락장(Bear): 전략 전체 최대 30% 운용 (보수적)
|
||||
"""
|
||||
# 시장 상태에 따라 최대 자본 비중 결정
|
||||
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
|
||||
|
||||
max_by_position = total_assets * max_per_position
|
||||
remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation
|
||||
return max(0, min(max_by_position, remaining_budget, available_cash))
|
||||
|
||||
|
||||
def check_h1_breakout_with_ma5(
|
||||
h1_closes: List[float],
|
||||
h1_current_close: float,
|
||||
) -> bool:
|
||||
"""섹션 7.5: 60분봉 5일선 돌파 확인 (가짜 반등 필터)
|
||||
|
||||
60분봉 기준 5일(120시간 = 5일×24시간) 이동평균선 상향 돌파 여부 확인
|
||||
실제로는 5일 분량의 60분봉 데이터가 필요: 5일 × 6.5시간(장 운영) = 약 30~40개 봉
|
||||
|
||||
Args:
|
||||
h1_closes: 60분봉 종가 리스트 (최소 5개 이상)
|
||||
h1_current_close: 현재 60분봉 종가
|
||||
|
||||
Returns:
|
||||
True: 5일선 상향 돌파, False: 미돌파
|
||||
|
||||
Note:
|
||||
- 60분봉 데이터가 충분하지 않으면 False 반환 (보수적)
|
||||
- 복합 타임프레임 전략에서 선택적으로 사용
|
||||
"""
|
||||
if len(h1_closes) < 5:
|
||||
return False # 데이터 부족 시 보수적 대응
|
||||
|
||||
ma5 = sum(h1_closes[-5:]) / 5
|
||||
return h1_current_close > ma5
|
||||
560
backend/app/services/ml_ensemble.py
Normal file
560
backend/app/services/ml_ensemble.py
Normal file
@@ -0,0 +1,560 @@
|
||||
"""
|
||||
ML 앙상블 모델 서비스
|
||||
|
||||
3개 모델을 결합하여 주식 매수/매도 신호를 예측합니다:
|
||||
- LightGBM (40%): 탭뷸러 피처 기반 분류
|
||||
- PyTorch LSTM (35%): 시계열 패턴 학습
|
||||
- Random Forest (25%): 피처 조합 기반 분류
|
||||
|
||||
앙상블 스코어: 0~100 (높을수록 매수 신호 강함)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import joblib
|
||||
|
||||
from app.services.feature_engineering import (
|
||||
FEATURE_COLUMNS,
|
||||
LSTM_SEQUENCE_FEATURES,
|
||||
extract_single_features,
|
||||
chart_data_to_dataframe,
|
||||
extract_features,
|
||||
prepare_training_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 모델 저장 디렉토리
|
||||
MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble"
|
||||
|
||||
|
||||
# ─── PyTorch LSTM 모델 정의 ───
|
||||
|
||||
def _get_torch():
|
||||
"""PyTorch lazy import"""
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
return torch, nn
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
|
||||
class StockLSTM:
|
||||
"""PyTorch LSTM 모델 래퍼"""
|
||||
|
||||
def __init__(self, input_size: int = 5, hidden_size: int = 64, num_layers: int = 2,
|
||||
num_classes: int = 3, dropout: float = 0.3):
|
||||
self.input_size = input_size
|
||||
self.hidden_size = hidden_size
|
||||
self.num_layers = num_layers
|
||||
self.num_classes = num_classes
|
||||
self.dropout = dropout
|
||||
self.model = None
|
||||
self.device = None
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
torch, nn = _get_torch()
|
||||
if torch is None:
|
||||
logger.warning("PyTorch not available, LSTM model disabled")
|
||||
return
|
||||
|
||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
class LSTMNet(nn.Module):
|
||||
def __init__(self_, input_size, hidden_size, num_layers, num_classes, dropout):
|
||||
super().__init__()
|
||||
self_.lstm = nn.LSTM(
|
||||
input_size=input_size,
|
||||
hidden_size=hidden_size,
|
||||
num_layers=num_layers,
|
||||
batch_first=True,
|
||||
dropout=dropout if num_layers > 1 else 0,
|
||||
)
|
||||
self_.fc1 = nn.Linear(hidden_size, 32)
|
||||
self_.relu = nn.ReLU()
|
||||
self_.drop = nn.Dropout(dropout)
|
||||
self_.fc2 = nn.Linear(32, num_classes)
|
||||
|
||||
def forward(self_, x):
|
||||
out, _ = self_.lstm(x)
|
||||
out = out[:, -1, :] # 마지막 타임스텝
|
||||
out = self_.drop(self_.relu(self_.fc1(out)))
|
||||
out = self_.fc2(out)
|
||||
return out
|
||||
|
||||
self.model = LSTMNet(
|
||||
self.input_size, self.hidden_size, self.num_layers,
|
||||
self.num_classes, self.dropout,
|
||||
).to(self.device)
|
||||
|
||||
def train_model(self, X_seq: np.ndarray, y: np.ndarray,
|
||||
epochs: int = 50, lr: float = 0.001, batch_size: int = 32) -> Dict[str, float]:
|
||||
"""LSTM 모델 학습"""
|
||||
torch, nn = _get_torch()
|
||||
if torch is None or self.model is None:
|
||||
return {"error": "PyTorch not available"}
|
||||
|
||||
from torch.utils.data import TensorDataset, DataLoader
|
||||
|
||||
# 시퀀스 피처 정규화 (채널별)
|
||||
self.seq_mean = X_seq.mean(axis=(0, 1))
|
||||
self.seq_std = X_seq.std(axis=(0, 1)) + 1e-8
|
||||
X_norm = (X_seq - self.seq_mean) / self.seq_std
|
||||
|
||||
X_tensor = torch.FloatTensor(X_norm).to(self.device)
|
||||
y_tensor = torch.LongTensor(y).to(self.device)
|
||||
|
||||
dataset = TensorDataset(X_tensor, y_tensor)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.Adam(self.model.parameters(), lr=lr)
|
||||
|
||||
self.model.train()
|
||||
best_loss = float("inf")
|
||||
|
||||
for epoch in range(epochs):
|
||||
total_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
for X_batch, y_batch in loader:
|
||||
optimizer.zero_grad()
|
||||
outputs = self.model(X_batch)
|
||||
loss = criterion(outputs, y_batch)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
total_loss += loss.item() * len(y_batch)
|
||||
_, predicted = torch.max(outputs, 1)
|
||||
correct += (predicted == y_batch).sum().item()
|
||||
total += len(y_batch)
|
||||
|
||||
avg_loss = total_loss / total
|
||||
accuracy = correct / total
|
||||
if avg_loss < best_loss:
|
||||
best_loss = avg_loss
|
||||
|
||||
if (epoch + 1) % 10 == 0:
|
||||
logger.info(f"LSTM Epoch [{epoch+1}/{epochs}] Loss: {avg_loss:.4f} Acc: {accuracy:.4f}")
|
||||
|
||||
return {"final_loss": best_loss, "final_accuracy": accuracy}
|
||||
|
||||
def predict_proba(self, X_seq: np.ndarray) -> np.ndarray:
|
||||
"""예측 확률 반환 (N, 3)"""
|
||||
torch, nn = _get_torch()
|
||||
if torch is None or self.model is None:
|
||||
return np.full((len(X_seq), 3), 1.0 / 3)
|
||||
|
||||
self.model.eval()
|
||||
X_norm = (X_seq - self.seq_mean) / self.seq_std
|
||||
X_tensor = torch.FloatTensor(X_norm).to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self.model(X_tensor)
|
||||
probs = torch.softmax(outputs, dim=1).cpu().numpy()
|
||||
return probs
|
||||
|
||||
def save(self, path: Path):
|
||||
torch, _ = _get_torch()
|
||||
if torch is None or self.model is None:
|
||||
return
|
||||
torch.save({
|
||||
"model_state": self.model.state_dict(),
|
||||
"seq_mean": self.seq_mean,
|
||||
"seq_std": self.seq_std,
|
||||
"config": {
|
||||
"input_size": self.input_size,
|
||||
"hidden_size": self.hidden_size,
|
||||
"num_layers": self.num_layers,
|
||||
"num_classes": self.num_classes,
|
||||
"dropout": self.dropout,
|
||||
},
|
||||
}, path)
|
||||
|
||||
def load(self, path: Path) -> bool:
|
||||
torch, _ = _get_torch()
|
||||
if torch is None:
|
||||
return False
|
||||
try:
|
||||
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
||||
self.model.load_state_dict(checkpoint["model_state"])
|
||||
self.seq_mean = checkpoint["seq_mean"]
|
||||
self.seq_std = checkpoint["seq_std"]
|
||||
self.model.eval()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load LSTM model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ─── 앙상블 모델 클래스 ───
|
||||
|
||||
class MLEnsemble:
|
||||
"""3개 모델(LightGBM + LSTM + RF)을 앙상블하여 주식 매수/매도 예측"""
|
||||
|
||||
def __init__(self, model_dir: Path = MODEL_DIR,
|
||||
weights: Tuple[float, float, float] = (0.40, 0.35, 0.25)):
|
||||
self.model_dir = model_dir
|
||||
self.weights = weights # (lgbm, lstm, rf)
|
||||
|
||||
self.lgbm_model = None
|
||||
self.lstm_model = None
|
||||
self.rf_model = None
|
||||
self.scaler = None # tabular feature scaler
|
||||
|
||||
self.is_trained = False
|
||||
self.training_metrics: Dict[str, Any] = {}
|
||||
|
||||
def _ensure_dir(self):
|
||||
self.model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def train(self, X_tabular: np.ndarray, X_seq: np.ndarray, y: np.ndarray) -> Dict[str, Any]:
|
||||
"""전체 앙상블 학습
|
||||
|
||||
Args:
|
||||
X_tabular: (N, 20) 탭뷸러 피처
|
||||
X_seq: (N, 60, 5) LSTM 시퀀스
|
||||
y: (N,) 레이블 (0=DOWN, 1=HOLD, 2=UP)
|
||||
|
||||
Returns:
|
||||
학습 메트릭
|
||||
"""
|
||||
self._ensure_dir()
|
||||
metrics = {}
|
||||
|
||||
if len(X_tabular) < 50:
|
||||
return {"error": f"학습 데이터 부족: {len(X_tabular)}개 (최소 50개 필요)"}
|
||||
|
||||
logger.info(f"앙상블 학습 시작: {len(X_tabular)}개 샘플, 클래스 분포: {np.bincount(y)}")
|
||||
|
||||
# 탭뷸러 피처 스케일링
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
self.scaler = StandardScaler()
|
||||
X_scaled = self.scaler.fit_transform(X_tabular)
|
||||
|
||||
# 학습/검증 분리 (시계열이므로 뒤쪽 20%를 검증으로)
|
||||
split_idx = int(len(X_scaled) * 0.8)
|
||||
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
|
||||
y_train, y_val = y[:split_idx], y[split_idx:]
|
||||
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
|
||||
|
||||
# 1. LightGBM
|
||||
logger.info(">>> LightGBM 학습 중...")
|
||||
try:
|
||||
import lightgbm as lgb
|
||||
self.lgbm_model = lgb.LGBMClassifier(
|
||||
n_estimators=200,
|
||||
max_depth=6,
|
||||
learning_rate=0.05,
|
||||
num_leaves=31,
|
||||
min_child_samples=10,
|
||||
subsample=0.8,
|
||||
colsample_bytree=0.8,
|
||||
reg_alpha=0.1,
|
||||
reg_lambda=0.1,
|
||||
random_state=42,
|
||||
verbose=-1,
|
||||
num_class=3,
|
||||
objective="multiclass",
|
||||
)
|
||||
self.lgbm_model.fit(
|
||||
X_train, y_train,
|
||||
eval_set=[(X_val, y_val)],
|
||||
)
|
||||
lgbm_val_pred = self.lgbm_model.predict(X_val)
|
||||
lgbm_acc = np.mean(lgbm_val_pred == y_val)
|
||||
metrics["lgbm_accuracy"] = float(lgbm_acc)
|
||||
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
|
||||
|
||||
# 피처 중요도
|
||||
importance = self.lgbm_model.feature_importances_
|
||||
top_features = sorted(
|
||||
zip(FEATURE_COLUMNS, importance),
|
||||
key=lambda x: x[1], reverse=True
|
||||
)[:5]
|
||||
metrics["lgbm_top_features"] = [
|
||||
{"name": name, "importance": int(imp)} for name, imp in top_features
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"LightGBM 학습 실패: {e}")
|
||||
metrics["lgbm_error"] = str(e)
|
||||
|
||||
# 2. PyTorch LSTM
|
||||
logger.info(">>> PyTorch LSTM 학습 중...")
|
||||
try:
|
||||
self.lstm_model = StockLSTM(
|
||||
input_size=len(LSTM_SEQUENCE_FEATURES),
|
||||
hidden_size=64,
|
||||
num_layers=2,
|
||||
num_classes=3,
|
||||
dropout=0.3,
|
||||
)
|
||||
lstm_metrics = self.lstm_model.train_model(
|
||||
X_seq_train, y_train,
|
||||
epochs=50, lr=0.001, batch_size=32,
|
||||
)
|
||||
# 검증
|
||||
lstm_val_probs = self.lstm_model.predict_proba(X_seq_val)
|
||||
lstm_val_pred = np.argmax(lstm_val_probs, axis=1)
|
||||
lstm_acc = np.mean(lstm_val_pred == y_val)
|
||||
metrics["lstm_accuracy"] = float(lstm_acc)
|
||||
metrics["lstm_training"] = lstm_metrics
|
||||
logger.info(f"LSTM 검증 정확도: {lstm_acc:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"LSTM 학습 실패: {e}")
|
||||
metrics["lstm_error"] = str(e)
|
||||
|
||||
# 3. Random Forest
|
||||
logger.info(">>> Random Forest 학습 중...")
|
||||
try:
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
self.rf_model = RandomForestClassifier(
|
||||
n_estimators=100,
|
||||
max_depth=8,
|
||||
min_samples_split=10,
|
||||
min_samples_leaf=5,
|
||||
random_state=42,
|
||||
n_jobs=-1,
|
||||
)
|
||||
self.rf_model.fit(X_train, y_train)
|
||||
rf_val_pred = self.rf_model.predict(X_val)
|
||||
rf_acc = np.mean(rf_val_pred == y_val)
|
||||
metrics["rf_accuracy"] = float(rf_acc)
|
||||
logger.info(f"Random Forest 검증 정확도: {rf_acc:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Random Forest 학습 실패: {e}")
|
||||
metrics["rf_error"] = str(e)
|
||||
|
||||
# 앙상블 검증
|
||||
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val)
|
||||
if ensemble_probs is not None:
|
||||
ensemble_pred = np.argmax(ensemble_probs, axis=1)
|
||||
ensemble_acc = np.mean(ensemble_pred == y_val)
|
||||
metrics["ensemble_accuracy"] = float(ensemble_acc)
|
||||
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
|
||||
|
||||
# 모델 저장
|
||||
self._save_models()
|
||||
self.is_trained = True
|
||||
self.training_metrics = metrics
|
||||
|
||||
logger.info(f"앙상블 학습 완료: {metrics}")
|
||||
return metrics
|
||||
|
||||
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""3개 모델의 가중 평균 확률"""
|
||||
probs_list = []
|
||||
weights_list = []
|
||||
|
||||
# LightGBM
|
||||
if self.lgbm_model is not None:
|
||||
try:
|
||||
lgbm_probs = self.lgbm_model.predict_proba(X_tabular)
|
||||
probs_list.append(lgbm_probs)
|
||||
weights_list.append(self.weights[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# LSTM
|
||||
if self.lstm_model is not None and self.lstm_model.model is not None:
|
||||
try:
|
||||
lstm_probs = self.lstm_model.predict_proba(X_seq)
|
||||
probs_list.append(lstm_probs)
|
||||
weights_list.append(self.weights[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# RF
|
||||
if self.rf_model is not None:
|
||||
try:
|
||||
rf_probs = self.rf_model.predict_proba(X_tabular)
|
||||
probs_list.append(rf_probs)
|
||||
weights_list.append(self.weights[2])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not probs_list:
|
||||
return None
|
||||
|
||||
# 가중 평균
|
||||
total_weight = sum(weights_list)
|
||||
weighted_probs = sum(
|
||||
p * (w / total_weight) for p, w in zip(probs_list, weights_list)
|
||||
)
|
||||
return weighted_probs
|
||||
|
||||
def predict(self, chart_data: list) -> Dict[str, Any]:
|
||||
"""단일 종목 예측.
|
||||
|
||||
Args:
|
||||
chart_data: KIS API 차트 데이터 (최신순)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ensemble_score": int (0~100),
|
||||
"confidence": float (0~1),
|
||||
"signal": str ("BUY"/"HOLD"/"SELL"),
|
||||
"model_scores": {"lgbm": ..., "lstm": ..., "rf": ...},
|
||||
"probabilities": {"up": ..., "hold": ..., "down": ...}
|
||||
}
|
||||
"""
|
||||
if not self.is_trained:
|
||||
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
tabular, sequence = extract_single_features(chart_data)
|
||||
if tabular is None or sequence is None:
|
||||
return {"error": "피처 추출 실패 (데이터 부족)", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
# 스케일링
|
||||
tabular_scaled = self.scaler.transform(tabular.reshape(1, -1))
|
||||
sequence_batch = sequence.reshape(1, 60, len(LSTM_SEQUENCE_FEATURES))
|
||||
|
||||
# 앙상블 예측
|
||||
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch)
|
||||
if probs is None:
|
||||
return {"error": "모든 모델 예측 실패", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
prob_down, prob_hold, prob_up = probs[0]
|
||||
|
||||
# 앙상블 스코어: UP 확률 기반 (0~100)
|
||||
ensemble_score = int(prob_up * 100)
|
||||
|
||||
# 신뢰도: 최고 확률과 나머지의 차이
|
||||
max_prob = max(prob_down, prob_hold, prob_up)
|
||||
confidence = float(max_prob)
|
||||
|
||||
# 신호 결정
|
||||
if prob_up > prob_down and prob_up > prob_hold and prob_up > 0.4:
|
||||
signal = "BUY"
|
||||
elif prob_down > prob_up and prob_down > prob_hold and prob_down > 0.4:
|
||||
signal = "SELL"
|
||||
else:
|
||||
signal = "HOLD"
|
||||
|
||||
# 개별 모델 스코어
|
||||
model_scores = {}
|
||||
if self.lgbm_model is not None:
|
||||
try:
|
||||
lp = self.lgbm_model.predict_proba(tabular_scaled)[0]
|
||||
model_scores["lgbm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
|
||||
except Exception:
|
||||
pass
|
||||
if self.lstm_model is not None and self.lstm_model.model is not None:
|
||||
try:
|
||||
lp = self.lstm_model.predict_proba(sequence_batch)[0]
|
||||
model_scores["lstm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
|
||||
except Exception:
|
||||
pass
|
||||
if self.rf_model is not None:
|
||||
try:
|
||||
lp = self.rf_model.predict_proba(tabular_scaled)[0]
|
||||
model_scores["rf"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ensemble_score": ensemble_score,
|
||||
"confidence": round(confidence, 4),
|
||||
"signal": signal,
|
||||
"model_scores": model_scores,
|
||||
"probabilities": {
|
||||
"up": round(float(prob_up), 4),
|
||||
"hold": round(float(prob_hold), 4),
|
||||
"down": round(float(prob_down), 4),
|
||||
},
|
||||
}
|
||||
|
||||
def _save_models(self):
|
||||
"""모델 파일 저장"""
|
||||
self._ensure_dir()
|
||||
|
||||
if self.lgbm_model is not None:
|
||||
joblib.dump(self.lgbm_model, self.model_dir / "lgbm_model.pkl")
|
||||
if self.rf_model is not None:
|
||||
joblib.dump(self.rf_model, self.model_dir / "rf_model.pkl")
|
||||
if self.lstm_model is not None:
|
||||
self.lstm_model.save(self.model_dir / "lstm_model.pt")
|
||||
if self.scaler is not None:
|
||||
joblib.dump(self.scaler, self.model_dir / "scaler.pkl")
|
||||
|
||||
# 메트릭 저장
|
||||
import json
|
||||
with open(self.model_dir / "metrics.json", "w") as f:
|
||||
json.dump(self.training_metrics, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
logger.info(f"모델 저장 완료: {self.model_dir}")
|
||||
|
||||
def load_models(self) -> bool:
|
||||
"""저장된 모델 로드"""
|
||||
try:
|
||||
scaler_path = self.model_dir / "scaler.pkl"
|
||||
if not scaler_path.exists():
|
||||
logger.warning("저장된 앙상블 모델이 없습니다")
|
||||
return False
|
||||
|
||||
self.scaler = joblib.load(scaler_path)
|
||||
|
||||
lgbm_path = self.model_dir / "lgbm_model.pkl"
|
||||
if lgbm_path.exists():
|
||||
self.lgbm_model = joblib.load(lgbm_path)
|
||||
|
||||
rf_path = self.model_dir / "rf_model.pkl"
|
||||
if rf_path.exists():
|
||||
self.rf_model = joblib.load(rf_path)
|
||||
|
||||
lstm_path = self.model_dir / "lstm_model.pt"
|
||||
if lstm_path.exists():
|
||||
self.lstm_model = StockLSTM(
|
||||
input_size=len(LSTM_SEQUENCE_FEATURES),
|
||||
hidden_size=64, num_layers=2, num_classes=3, dropout=0.3,
|
||||
)
|
||||
self.lstm_model.load(lstm_path)
|
||||
|
||||
# 메트릭 로드
|
||||
metrics_path = self.model_dir / "metrics.json"
|
||||
if metrics_path.exists():
|
||||
import json
|
||||
with open(metrics_path) as f:
|
||||
self.training_metrics = json.load(f)
|
||||
|
||||
self.is_trained = True
|
||||
logger.info("앙상블 모델 로드 완료")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"앙상블 모델 로드 실패: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""모델 상태 조회"""
|
||||
return {
|
||||
"is_trained": self.is_trained,
|
||||
"models": {
|
||||
"lgbm": self.lgbm_model is not None,
|
||||
"lstm": self.lstm_model is not None and self.lstm_model.model is not None,
|
||||
"rf": self.rf_model is not None,
|
||||
},
|
||||
"weights": {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]},
|
||||
"metrics": self.training_metrics,
|
||||
"model_dir": str(self.model_dir),
|
||||
}
|
||||
|
||||
|
||||
# ─── 싱글톤 인스턴스 ───
|
||||
|
||||
_ensemble_instance: Optional[MLEnsemble] = None
|
||||
|
||||
|
||||
def get_ensemble() -> MLEnsemble:
|
||||
"""앙상블 모델 싱글톤 인스턴스 반환"""
|
||||
global _ensemble_instance
|
||||
if _ensemble_instance is None:
|
||||
_ensemble_instance = MLEnsemble()
|
||||
_ensemble_instance.load_models()
|
||||
return _ensemble_instance
|
||||
108
backend/app/services/tensorflow_predictor.py
Normal file
108
backend/app/services/tensorflow_predictor.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TensorFlowNotAvailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TensorFlowPredictor:
|
||||
"""Lightweight wrapper around a Keras model + scaler.
|
||||
|
||||
- Lazily attempts to import TensorFlow/Keras and load model files.
|
||||
- If TensorFlow or model/scaler files are missing, raises TensorFlowNotAvailable
|
||||
so callers can handle gracefully (e.g., return 503 or a friendly message).
|
||||
- Fetches recent daily closes from KIS API.
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: str = "models/lstm_model.h5", scaler_path: str = "models/scaler.pkl", time_steps: int = 60):
|
||||
self.model_path = model_path
|
||||
self.scaler_path = scaler_path
|
||||
self.time_steps = time_steps
|
||||
|
||||
try:
|
||||
# Local import to avoid hard dependency when not installed
|
||||
import tensorflow as tf # noqa: F401
|
||||
from tensorflow import keras # noqa: F401
|
||||
import joblib # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"TensorFlow or joblib not available: {e}")
|
||||
raise TensorFlowNotAvailable("TensorFlow/joblib not installed in backend environment")
|
||||
|
||||
# Load resources
|
||||
try:
|
||||
from tensorflow import keras
|
||||
import joblib
|
||||
|
||||
self.model = keras.models.load_model(self.model_path)
|
||||
self.scaler = joblib.load(self.scaler_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load TF model/scaler: {e}")
|
||||
raise TensorFlowNotAvailable(f"Model or scaler not found: {e}")
|
||||
|
||||
async def fetch_kis_closes(self, stock_code: str, days: int = 60) -> np.ndarray:
|
||||
"""Fetch recent daily closes from KIS. Returns array oldest→latest.
|
||||
Uses korea_investment_api.get_daily_chart(stock_code, "D").
|
||||
"""
|
||||
try:
|
||||
resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
||||
output = resp.get("output") or []
|
||||
if not output:
|
||||
raise RuntimeError("empty_chart")
|
||||
# KIS usually returns newest first; reverse to chronological
|
||||
closes_desc = [float(item.get("stck_clpr", 0) or 0) for item in output]
|
||||
closes = list(reversed(closes_desc))
|
||||
arr = np.array(closes[-days:], dtype=float)
|
||||
if arr.size < days:
|
||||
# If fewer than requested, still return what's available
|
||||
logger.info(f"{stock_code}: only {arr.size} closes available (requested {days})")
|
||||
return arr
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch KIS closes for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def predict_stock(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""Return prediction dict with expected_return and simple signal."""
|
||||
try:
|
||||
prices = await self.fetch_kis_closes(stock_code, days=self.time_steps)
|
||||
if prices.size == 0:
|
||||
raise RuntimeError("no_price_data")
|
||||
|
||||
# Normalize and shape for LSTM (1, time_steps, 1)
|
||||
scaled = self.scaler.transform(prices.reshape(-1, 1))
|
||||
if scaled.shape[0] < self.time_steps:
|
||||
# Pad at the front if needed
|
||||
pad = np.repeat(scaled[0:1], self.time_steps - scaled.shape[0], axis=0)
|
||||
scaled = np.vstack([pad, scaled])
|
||||
X_input = scaled[-self.time_steps:].reshape(1, self.time_steps, 1)
|
||||
|
||||
# Predict
|
||||
predicted_scaled = self.model.predict(X_input, verbose=0)
|
||||
predicted_price = float(self.scaler.inverse_transform(predicted_scaled)[0][0])
|
||||
current_price = float(prices[-1])
|
||||
|
||||
expected_return = ((predicted_price - current_price) / current_price * 100.0) if current_price > 0 else 0.0
|
||||
signal = "BUY" if predicted_price > current_price * 1.02 else "HOLD"
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"current_price": current_price,
|
||||
"predicted_price": predicted_price,
|
||||
"expected_return": expected_return,
|
||||
"signal": signal,
|
||||
}
|
||||
except TensorFlowNotAvailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Prediction failed for {stock_code}: {e}")
|
||||
# Degrade gracefully
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"error": str(e),
|
||||
}
|
||||
63
backend/app/services/watchlist.py
Normal file
63
backend/app/services/watchlist.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
분석종목(Watchlist) 관리 공용 서비스
|
||||
|
||||
아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는
|
||||
초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다.
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.models import WatchlistStock, StockAnalysis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 분석종목 관련 상수
|
||||
WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수
|
||||
WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수
|
||||
WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수
|
||||
WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수
|
||||
|
||||
|
||||
async def initialize_watchlist(session: AsyncSession, analysis_date: date) -> int:
|
||||
"""아침 배치 완료 후 분석종목 초기화.
|
||||
|
||||
기존 활성 종목을 전부 비활성화하고,
|
||||
오늘 stock_analysis score 상위 WATCHLIST_INIT_SIZE 개로 새로 채운다.
|
||||
|
||||
Returns: 추가된 종목 수
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# 1. 기존 활성 종목 전부 비활성화
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(WatchlistStock.is_active == True)
|
||||
.values(is_active=False, removed_at=now, removed_reason="daily_reset")
|
||||
)
|
||||
|
||||
# 2. 오늘 score 상위 N개 조회
|
||||
result = await session.execute(
|
||||
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
|
||||
.where(StockAnalysis.analysis_date == analysis_date)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(WATCHLIST_INIT_SIZE)
|
||||
)
|
||||
top_stocks = result.all()
|
||||
|
||||
# 3. 신규 활성 종목 등록
|
||||
for row in top_stocks:
|
||||
session.add(WatchlistStock(
|
||||
stock_code=row.stock_code,
|
||||
stock_name=row.stock_name,
|
||||
added_at=now,
|
||||
added_reason="morning_batch",
|
||||
score_at_add=row.score,
|
||||
is_active=True,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"📋 분석종목 초기화 완료: {len(top_stocks)}개 (기준일: {analysis_date})")
|
||||
return len(top_stocks)
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Dict, List
|
||||
from fastapi import WebSocket
|
||||
import json
|
||||
from app.core.json_helpers import _dumps
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,7 +47,7 @@ class WebSocketManager:
|
||||
|
||||
async def broadcast_json(self, data: dict):
|
||||
"""JSON 데이터를 모든 클라이언트에게 브로드캐스트"""
|
||||
message = json.dumps(data)
|
||||
message = _dumps(data)
|
||||
await self.broadcast(message)
|
||||
|
||||
async def send_market_update(self, stock_code: str, data: dict):
|
||||
|
||||
Reference in New Issue
Block a user