"""미국 주식 백테스트 엔진 KIS API의 해외 주식 일봉 데이터를 활용한 백테스트. us_stock_strategy의 매수/매도 신호를 과거 데이터에 적용하여 성과 검증. """ import logging import math import random from datetime import date, datetime, timedelta from typing import Dict, List, Any, Optional from dataclasses import dataclass, asdict import numpy as np from app.services.indicators_core import bollinger_sma, rsi_wilder from app.services.korea_investment_api import korea_investment_api from app.services.us_stock_strategy import ( check_us_buy_signal, check_us_sell_signal, US_STOP_LOSS_PCT, US_TRAILING_STOP_PCT, ) logger = logging.getLogger(__name__) # ── 상수 ──────────────────────────────────────────────────────────────────────── US_COMMISSION_RATE = 0.001 # 해외 주식 수수료 약 0.1% (매수+매도 각각) RISK_FREE_RATE = 0.04 # 무위험 수익률 4% (연간, USD 기준) TRADING_DAYS_PER_YEAR = 252 US_SLIPPAGE_PCT = 0.0005 # +/-0.05% (미국 주식은 유동성이 높아 슬리피지가 낮음) def _apply_us_buy_slippage(price: float, seed: Optional[int] = None) -> float: """매수 시 슬리피지: 시장가 주문 시 약간 높은 가격에 체결 미국 주식은 유동성이 높아 국내(0.1%) 대비 낮은 0~0.05% 적용. """ rng = random.Random(seed) slip = rng.uniform(0, US_SLIPPAGE_PCT) return price * (1 + slip) def _apply_us_sell_slippage(price: float, seed: Optional[int] = None) -> float: """매도 시 슬리피지: 시장가 주문 시 약간 낮은 가격에 체결 미국 주식은 유동성이 높아 국내(0.1%) 대비 낮은 0~0.05% 적용. """ rng = random.Random(seed) slip = rng.uniform(0, US_SLIPPAGE_PCT) return price * (1 - slip) @dataclass class USBacktestTrade: """미국 주식 백테스트 거래 기록""" date: str action: str # "BUY" / "SELL" ticker: str price: float quantity: int amount: float commission: float reason: str pnl: float = 0.0 # 매도 시 실현 손익 pnl_pct: float = 0.0 # ── 백테스트 메트릭 계산 ───────────────────────────────────────────────────────── def calculate_backtest_metrics( trades: List[Dict[str, Any]], initial_capital: float, ) -> Dict[str, Any]: """순수 계산 함수 - 거래 리스트로부터 성과 지표 산출 Args: trades: 거래 기록 리스트 (각 항목에 action, amount, pnl, pnl_pct 포함) initial_capital: 초기 자본 (USD) Returns: {"total_return": float, "total_return_pct": float, "win_rate": float, "max_drawdown_pct": float, "sharpe_ratio": float, "trade_count": int, "win_count": int, "lose_count": int, "avg_win_pct": float, "avg_lose_pct": float, "profit_factor": float} """ if not trades: return { "total_return": 0.0, "total_return_pct": 0.0, "win_rate": 0.0, "max_drawdown_pct": 0.0, "sharpe_ratio": 0.0, "trade_count": 0, "win_count": 0, "lose_count": 0, "avg_win_pct": 0.0, "avg_lose_pct": 0.0, "profit_factor": 0.0, } # 매도 거래만 추출 (실현 손익 기준) sell_trades = [t for t in trades if t.get("action") == "SELL"] if not sell_trades: return { "total_return": 0.0, "total_return_pct": 0.0, "win_rate": 0.0, "max_drawdown_pct": 0.0, "sharpe_ratio": 0.0, "trade_count": len(trades), "win_count": 0, "lose_count": 0, "avg_win_pct": 0.0, "avg_lose_pct": 0.0, "profit_factor": 0.0, } # 승/패 분류 wins = [t for t in sell_trades if t.get("pnl", 0) > 0] loses = [t for t in sell_trades if t.get("pnl", 0) <= 0] total_sell = len(sell_trades) win_rate = (len(wins) / total_sell * 100) if total_sell > 0 else 0.0 # 총 수익 total_pnl = sum(t.get("pnl", 0) for t in sell_trades) total_return_pct = (total_pnl / initial_capital * 100) if initial_capital > 0 else 0.0 # 평균 수익/손실 avg_win_pct = ( sum(t.get("pnl_pct", 0) for t in wins) / len(wins) ) if wins else 0.0 avg_lose_pct = ( sum(t.get("pnl_pct", 0) for t in loses) / len(loses) ) if loses else 0.0 # Profit Factor gross_profit = sum(t.get("pnl", 0) for t in wins) gross_loss = abs(sum(t.get("pnl", 0) for t in loses)) profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf") if gross_profit > 0 else 0.0 # 최대 낙폭 (MDD) 계산 - 누적 손익 기준 equity = initial_capital peak = equity max_dd = 0.0 for t in sell_trades: equity += t.get("pnl", 0) if equity > peak: peak = equity dd = ((equity - peak) / peak) * 100 if peak > 0 else 0.0 if dd < max_dd: max_dd = dd # 샤프 비율 계산 pnl_returns = [t.get("pnl_pct", 0) / 100 for t in sell_trades] sharpe_ratio = _calculate_sharpe(pnl_returns) return { "total_return": round(total_pnl, 2), "total_return_pct": round(total_return_pct, 2), "win_rate": round(win_rate, 2), "max_drawdown_pct": round(max_dd, 2), "sharpe_ratio": round(sharpe_ratio, 2), "trade_count": len(trades), "win_count": len(wins), "lose_count": len(loses), "avg_win_pct": round(avg_win_pct, 2), "avg_lose_pct": round(avg_lose_pct, 2), "profit_factor": round(profit_factor, 2) if profit_factor != float("inf") else 999.99, } def _calculate_sharpe(returns: List[float]) -> float: """샤프 비율 계산 Args: returns: 거래별 수익률 리스트 (소수, 예: 0.03 = 3%) Returns: 연환산 샤프 비율 """ if not returns or len(returns) < 2: return 0.0 arr = np.array(returns, dtype=np.float64) mean_return = np.mean(arr) std_return = np.std(arr, ddof=1) if std_return == 0: return 0.0 # 거래당 초과 수익률 -> 연환산 # 대략적 연환산: 거래 횟수 기반 (정밀하지 않지만 실용적) trades_per_year = min(len(returns), TRADING_DAYS_PER_YEAR) daily_rf = RISK_FREE_RATE / TRADING_DAYS_PER_YEAR sharpe = (mean_return - daily_rf) / std_return * math.sqrt(trades_per_year) return float(sharpe) # ── 메인 백테스트 함수 ──────────────────────────────────────────────────────────── async def run_us_backtest( ticker: str, period_days: int = 180, initial_capital_usd: float = 10000.0, ) -> Dict[str, Any]: """미국 주식 백테스트 실행 Args: ticker: 미국 티커 (예: "AAPL", "NVDA") period_days: 백테스트 기간 (일) initial_capital_usd: 초기 자본 (USD) Returns: {"ticker": str, "period_days": int, "initial_capital": float, "metrics": dict, "trades": list, "equity_curve": list} """ logger.info(f"[US_BACKTEST] {ticker} 백테스트 시작 (기간: {period_days}일, 자본: ${initial_capital_usd:,.0f})") # 1) 과거 데이터 조회 (KIS 해외주식 일봉) try: daily_data = await _fetch_us_daily_data(ticker, period_days) except Exception as e: logger.error(f"[US_BACKTEST] {ticker} 데이터 조회 실패: {e}") return { "ticker": ticker, "period_days": period_days, "initial_capital": initial_capital_usd, "metrics": calculate_backtest_metrics([], initial_capital_usd), "trades": [], "equity_curve": [], "error": str(e), } if not daily_data or len(daily_data) < 30: msg = f"데이터 부족: {len(daily_data) if daily_data else 0}일 (최소 30일 필요)" logger.warning(f"[US_BACKTEST] {ticker} {msg}") return { "ticker": ticker, "period_days": period_days, "initial_capital": initial_capital_usd, "metrics": calculate_backtest_metrics([], initial_capital_usd), "trades": [], "equity_curve": [], "error": msg, } # 2) 기술적 지표 계산 indicators = _calculate_indicators(daily_data) # 3) 시뮬레이션 실행 # # [Look-ahead bias 수정] # T일 close로 신호 생성 -> T+1일 open + 슬리피지로 체결. # 마지막 봉 신호는 다음 봉이 없으므로 무시합니다. cash = initial_capital_usd position_qty = 0 position_avg_price = 0.0 position_high = 0.0 # 트레일링 스탑용 최고가 trades: List[Dict] = [] equity_curve: List[Dict] = [] # 전일 발생한 신호를 저장 (T일 close 신호 -> T+1일 open 체결) pending_buy_signal: Optional[Dict] = None pending_sell_signal: Optional[str] = None for i in range(20, len(daily_data)): # 지표 계산에 최소 20일 필요 bar = daily_data[i] ind = indicators[i] close = bar["close"] bar_date = bar["date"] bar_open = bar["open"] # -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 -- if pending_sell_signal is not None and position_qty > 0: exec_price = _apply_us_sell_slippage(bar_open) amount = position_qty * exec_price commission = amount * US_COMMISSION_RATE pnl = (exec_price - position_avg_price) * position_qty - commission pnl_pct = ((exec_price - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0 trade = { "date": bar_date, "action": "SELL", "ticker": ticker, "price": round(exec_price, 4), "quantity": position_qty, "amount": round(amount, 2), "commission": round(commission, 2), "reason": pending_sell_signal, "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2), } trades.append(trade) cash += amount - commission position_qty = 0 position_avg_price = 0.0 position_high = 0.0 pending_sell_signal = None if pending_buy_signal is not None and position_qty == 0: exec_price = _apply_us_buy_slippage(bar_open) buy_amount = min(cash * 0.95, cash) if buy_amount > 10: quantity = int(buy_amount / exec_price) if quantity > 0: cost = quantity * exec_price commission = cost * US_COMMISSION_RATE total_cost = cost + commission if total_cost <= cash: cash -= total_cost position_qty = quantity position_avg_price = exec_price position_high = exec_price trade = { "date": bar_date, "action": "BUY", "ticker": ticker, "price": round(exec_price, 4), "quantity": quantity, "amount": round(cost, 2), "commission": round(commission, 2), "reason": pending_buy_signal.get("reason", "buy_signal"), "pnl": 0.0, "pnl_pct": 0.0, } trades.append(trade) pending_buy_signal = None # -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) -- is_last_bar = (i == len(daily_data) - 1) # 트레일링 스탑용 최고가 갱신 (close 기준) if position_qty > 0: if close > position_high: position_high = close if not is_last_bar: # 매도 신호 체크 if position_qty > 0: sell_signal = _check_backtest_sell( close=close, avg_price=position_avg_price, high_since_entry=position_high, rsi=ind.get("rsi", 50), bb_middle=ind.get("bb_middle", close), bb_upper=ind.get("bb_upper", close), ) if sell_signal: pending_sell_signal = sell_signal # 매수 신호 체크 if position_qty == 0 and pending_sell_signal is None: prev_rsi = indicators[i - 3].get("rsi") if i >= 3 else None buy_signal = check_us_buy_signal( rsi=ind.get("rsi", 50), close=close, bb_upper=ind.get("bb_upper", close), bb_middle=ind.get("bb_middle", close), bb_lower=ind.get("bb_lower", close), avg_volume_20d=ind.get("avg_volume_20d", 0), current_volume=bar.get("volume", 0), current_price_usd=close, rsi_prev=prev_rsi, ) if buy_signal: pending_buy_signal = buy_signal # 일별 자산 기록 position_value = position_qty * close if position_qty > 0 else 0.0 total_equity = cash + position_value equity_curve.append({ "date": bar_date, "equity": round(total_equity, 2), "cash": round(cash, 2), "position_value": round(position_value, 2), }) # 4) 미청산 포지션 강제 청산 (백테스트 종료, 슬리피지 적용) if position_qty > 0 and daily_data: last_close = daily_data[-1]["close"] exec_price = _apply_us_sell_slippage(last_close) amount = position_qty * exec_price commission = amount * US_COMMISSION_RATE pnl = (exec_price - position_avg_price) * position_qty - commission pnl_pct = ((exec_price - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0 trades.append({ "date": daily_data[-1]["date"], "action": "SELL", "ticker": ticker, "price": round(exec_price, 4), "quantity": position_qty, "amount": round(amount, 2), "commission": round(commission, 2), "reason": "backtest_end_force_close", "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2), }) # 5) 메트릭 계산 metrics = calculate_backtest_metrics(trades, initial_capital_usd) logger.info( f"[US_BACKTEST] {ticker} 완료 - " f"수익률: {metrics['total_return_pct']:+.2f}%, " f"승률: {metrics['win_rate']:.1f}%, " f"MDD: {metrics['max_drawdown_pct']:.1f}%, " f"거래수: {metrics['trade_count']}" ) return { "ticker": ticker, "period_days": period_days, "initial_capital": initial_capital_usd, "metrics": metrics, "trades": trades, "equity_curve": equity_curve, } # ── 내부 유틸 함수 ──────────────────────────────────────────────────────────────── async def _fetch_us_daily_data(ticker: str, period_days: int) -> List[Dict]: """KIS API를 통해 미국 주식 일봉 데이터 조회 Returns: [{"date": str, "open": float, "high": float, "low": float, "close": float, "volume": float}, ...] (시간순 정렬) """ try: chart_data = await korea_investment_api.get_overseas_daily_chart( ticker=ticker, period="D", limit=period_days + 10, ) output = chart_data.get("output", chart_data.get("output2", [])) if not output: return [] result = [] for item in output: try: result.append({ "date": item.get("xymd", item.get("stck_bsop_date", "")), "open": float(item.get("open", item.get("ovrs_nmix_oprc", 0))), "high": float(item.get("high", item.get("ovrs_nmix_hgpr", 0))), "low": float(item.get("low", item.get("ovrs_nmix_lwpr", 0))), "close": float(item.get("clos", item.get("ovrs_nmix_prpr", 0))), "volume": float(item.get("tvol", item.get("acml_vol", 0))), }) except (ValueError, TypeError): continue # 시간순 정렬 (오래된 것부터) result.sort(key=lambda x: x["date"]) return result except Exception as e: logger.error(f"[US_BACKTEST_DATA] {ticker} 데이터 조회 실패: {e}") raise def _calculate_indicators(daily_data: List[Dict]) -> List[Dict]: """기술적 지표 계산 (RSI, 볼린저 밴드, 평균 거래량) Returns: daily_data와 동일 길이의 지표 리스트 """ closes = np.array([d["close"] for d in daily_data], dtype=np.float64) volumes = np.array([d["volume"] for d in daily_data], dtype=np.float64) n = len(closes) indicators = [{}] * n # RSI (14) rsi_values = _compute_rsi(closes, period=14) # 볼린저 밴드 (20, 2) bb_middle, bb_upper, bb_lower = _compute_bollinger(closes, period=20, std_mult=2.0) # 20일 평균 거래량 avg_vol_20d = _compute_sma(volumes, period=20) indicators = [] for i in range(n): indicators.append({ "rsi": rsi_values[i] if rsi_values[i] is not None else 50.0, "bb_middle": bb_middle[i] if bb_middle[i] is not None else closes[i], "bb_upper": bb_upper[i] if bb_upper[i] is not None else closes[i], "bb_lower": bb_lower[i] if bb_lower[i] is not None else closes[i], "avg_volume_20d": avg_vol_20d[i] if avg_vol_20d[i] is not None else 0.0, }) return indicators def _compute_rsi(closes: np.ndarray, period: int = 14) -> List[Optional[float]]: """RSI 계산 (Wilder 평활, **당일 종가 포함** — BB·실전과 동일 기준봉) [2026-09-27 §8.5-1 수정] 이전 구현은 인덱스 i 의 RSI 를 `closes[:i]` (= 직전 봉까지)로 계산해 정식 Wilder RSI 를 1봉 지연시켰다. 룩어헤드는 없었지만 - 같은 엔진의 볼린저 밴드(`_compute_bollinger`)는 `closes[:i + 1]`(당일 포함) - 실전 경로(us_stock_strategy.scan_us_stocks:336/344 — `calculate_rsi(closes)`, `calculate_bollinger(closes)`, `closes[-1]` = 당일 종가)도 당일 포함 이라 백테스트만 1봉 어긋난 신호로 검증되고 있었다. 이제 BB 와 동일하게 `closes[:i + 1]` 을 쓴다. ⚠️ 룩어헤드 없음: 인덱스 i 의 값은 `closes[:i + 1]`(= i 번째 봉의 종가까지)만 참조하며 i+1 이후 봉은 절대 참조하지 않는다. 이는 실전에서 당일 종가 확정 후 스캔이 도는 것과 동일한 정보 집합이다(체결은 다음 봉 시가 — 호출부 참조). rsi_prev(3봉 전 RSI) 정합성: 호출부는 `indicators[i - 3]["rsi"]` 를 쓰므로 이제 `closes[:i - 2]`(= i-3 번째 봉까지) 기준이 되어 실전 `calculate_rsi(closes[:-3])` 와 정확히 일치한다(수정 전에는 한 봉 더 과거였다). """ return [rsi_wilder(list(closes[:i + 1]), period) for i in range(len(closes))] def _compute_bollinger( closes: np.ndarray, period: int = 20, std_mult: float = 2.0, ) -> tuple: """볼린저 밴드 계산 -> (middle, upper, lower) [2026-09-27 §8.5-1 중복 통합] 계산 본체는 indicators_core.bollinger_sma() 로 이동. 원 구현이 `np.std(..., ddof=0)`(모표준편차)를 명시했고 indicators_core 도 ddof=0 이므로 수치가 동일하다(pandas `.std()` 기본 ddof=1 과 갈리는 함정은 해당 없음). 반환 순서는 기존 호출부 호환을 위해 (middle, upper, lower) 를 유지한다 — indicators_core 는 (upper, middle, lower) 순서임에 주의. """ n = len(closes) middle: List[Optional[float]] = [None] * n upper: List[Optional[float]] = [None] * n lower: List[Optional[float]] = [None] * n for i in range(period - 1, n): band = bollinger_sma([float(c) for c in closes[:i + 1]], period, std_mult) if band is None: continue upper[i], middle[i], lower[i] = band return middle, upper, lower def _compute_sma(data: np.ndarray, period: int = 20) -> List[Optional[float]]: """단순 이동평균""" n = len(data) sma = [None] * n for i in range(period - 1, n): sma[i] = float(np.mean(data[i - period + 1:i + 1])) return sma def _check_backtest_sell( close: float, avg_price: float, high_since_entry: float, rsi: float, bb_middle: float, bb_upper: float, ) -> Optional[str]: """백테스트용 매도 조건 판별 (간소화) Returns: 매도 이유 문자열 또는 None (매도 안 함) """ if avg_price <= 0: return None pnl_pct = ((close - avg_price) / avg_price) * 100 # 손절 if pnl_pct <= US_STOP_LOSS_PCT: return f"stop_loss ({pnl_pct:.1f}%)" # 트레일링 스탑 (고점 대비 -4%) if high_since_entry > 0: drawdown_from_high = ((close - high_since_entry) / high_since_entry) if drawdown_from_high <= -US_TRAILING_STOP_PCT and pnl_pct > 0: return f"trailing_stop (고점 대비 {drawdown_from_high * 100:.1f}%)" # 1차 익절: RSI >= 55 + BB 중앙선 상회 if rsi >= 55 and close > bb_middle: return f"take_profit_1 (RSI={rsi:.0f}, 중앙선 상회)" # 2차 익절: RSI >= 70 또는 BB 상단 근접 if rsi >= 70: return f"take_profit_2 (RSI={rsi:.0f})" if bb_upper > 0 and close >= bb_upper * 0.99: return f"take_profit_bb_upper (BB상단 도달)" return None