수정본

This commit is contained in:
2026-02-22 17:23:20 +09:00
parent b147498c31
commit 32e2263f0a
109 changed files with 13979 additions and 4580 deletions

View 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

View 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

View File

@@ -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()

View 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()

View 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()

View 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

View 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

View 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),
}

View 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)

View File

@@ -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):