243 lines
10 KiB
Python
243 lines
10 KiB
Python
import pandas as pd
|
|
import numpy as np
|
|
import yfinance as yf
|
|
import matplotlib.pyplot as plt
|
|
from analysis_module import TechnicalAnalyzer
|
|
from ai_predictor import PricePredictor
|
|
import torch
|
|
|
|
class Backtester:
|
|
def __init__(self, ticker, start_date, end_date, initial_capital=10000000):
|
|
self.ticker = ticker
|
|
self.start_date = start_date
|
|
self.end_date = end_date
|
|
self.initial_capital = initial_capital
|
|
self.capital = initial_capital
|
|
self.holdings = 0 # 보유 주식 수
|
|
self.avg_price = 0 # 평단가
|
|
|
|
self.trade_log = []
|
|
self.daily_values = []
|
|
|
|
# LSTM 모델 (재학습 시뮬레이션을 위해)
|
|
self.predictor = PricePredictor()
|
|
|
|
def generate_mock_data(self, days=200):
|
|
"""
|
|
yfinance 연결 실패 시 사용할 가상 주가 데이터 생성 (Random Walk)
|
|
삼성전자와 유사한 6~7만원대 가격 흐름 생성
|
|
"""
|
|
print(f"🎲 [Backtest] Generating mock data for {days} days...")
|
|
np.random.seed(42) # 재현성을 위해 시드 고정
|
|
|
|
start_price = 70000
|
|
returns = np.random.normal(0, 0.015, days) # 평균 0, 표준편차 1.5% 변동
|
|
price_series = [start_price]
|
|
|
|
# 인위적인 강력한 상승 추세 추가 (우상향)
|
|
for i, r in enumerate(returns):
|
|
trend = 0.003 # 매일 0.3%씩 강제 상승 (복리 효과로 엄청난 급등)
|
|
# 중간에 잠깐 조정장
|
|
if 80 < i < 100: trend = -0.01
|
|
|
|
new_price = price_series[-1] * (1 + r + trend)
|
|
price_series.append(new_price)
|
|
|
|
# 날짜 인덱스 생성
|
|
date_range = pd.date_range(start="2023-01-01", periods=len(price_series))
|
|
self.data = pd.Series(price_series, index=date_range)
|
|
|
|
# [Debugging] 차트가 너무 밋밋하지 않게 변동성 추가 확인
|
|
print(f"📈 [Mock Data] Start: {price_series[0]:.0f}, End: {price_series[-1]:.0f}")
|
|
|
|
print(f"✅ Generated {len(self.data)} days of mock data.")
|
|
return True
|
|
|
|
def fetch_data(self):
|
|
"""(Legacy) yfinance를 이용해 과거 데이터 로드"""
|
|
# 네트워크 이슈로 인해 Mock Data 우선 사용
|
|
return self.generate_mock_data()
|
|
|
|
def run(self):
|
|
if not hasattr(self, 'data') or self.data.empty:
|
|
if not self.fetch_data(): return
|
|
|
|
prices = self.data.values
|
|
dates = self.data.index
|
|
|
|
# 최소 30일 데이터 필요
|
|
if len(prices) < 30:
|
|
print("❌ Not enough data for backtest.")
|
|
return
|
|
|
|
print("🚀 [Backtest] Simulation Started...")
|
|
|
|
# 30일차부터 하루씩 전진하며 시뮬레이션
|
|
for i in range(30, len(prices)):
|
|
today_date = dates[i]
|
|
today_price = float(prices[i])
|
|
|
|
# 과거 30일 데이터 (오늘 포함 시점의 과거 데이터)
|
|
# 주의: 실제 매매 결정을 내리는 시점(장중/장마감)에 따라 index 처리 중요.
|
|
# 여기서는 '장 마감 후 분석 -> 다음날 시가 매매' 또는 '당일 종가 매매' 가정.
|
|
# 보수적으로 '당일 종가 매매' 가정 (분석 후 즉시 실행)
|
|
|
|
history_window = prices[i-30:i+1] # 31개 (어제까지 30개 + 오늘)
|
|
# [수정] 타입 체크 및 변환 (Numpy Array, Series, List 모두 대응)
|
|
if hasattr(history_window, 'values'):
|
|
current_window_list = history_window.values.tolist()
|
|
elif isinstance(history_window, np.ndarray):
|
|
current_window_list = history_window.tolist()
|
|
else:
|
|
current_window_list = list(history_window)
|
|
|
|
# 1. 기술적 분석
|
|
tech_score, rsi, volatility = TechnicalAnalyzer.get_technical_score(today_price, current_window_list)
|
|
|
|
# 2. AI 예측 (Online Learning Simulation)
|
|
# 매일 재학습하면 너무 느리므로, 5일에 한번씩만 학습한다고 가정 (타협)
|
|
# 또는 실제 Bot처럼 매번 학습하되, Backtest 속도 고려
|
|
# 여기서는 정확성을 위해 매번 학습 시도 (데이터셋이 작으므로)
|
|
|
|
# Mocking News Sentiment (Historical news unavailable -> Neutral)
|
|
sentiment_score = 0.5
|
|
|
|
# LSTM Predict
|
|
# (속도를 위해 간략화된 학습 사용)
|
|
pred_result = self.predictor.train_and_predict(current_window_list)
|
|
if not pred_result: continue
|
|
|
|
lstm_score = 0.5
|
|
if pred_result['trend'] == 'UP':
|
|
idx = min(pred_result['change_rate'], 3.0)
|
|
lstm_score = 0.5 + (idx * 0.1)
|
|
else:
|
|
idx = max(pred_result['change_rate'], -3.0)
|
|
lstm_score = 0.5 + (idx * 0.1)
|
|
lstm_score = max(0.0, min(1.0, lstm_score))
|
|
|
|
# 3. 통합 점수
|
|
w_tech, w_news, w_ai = 0.4, 0.3, 0.3
|
|
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
|
|
|
|
# 4. 리스크 관리 (손절/익절) 체크
|
|
# 보유 중일 때만 체크
|
|
action = "HOLD"
|
|
action_reason = ""
|
|
|
|
if self.holdings > 0:
|
|
# 수익률 계산
|
|
profit_rate = ((today_price - self.avg_price) / self.avg_price) * 100
|
|
|
|
# 손절 (-5%) / 익절 (+8%)
|
|
if profit_rate <= -5.0:
|
|
action = "SELL"
|
|
action_reason = f"Stop Loss ({profit_rate:.2f}%)"
|
|
elif profit_rate >= 8.0:
|
|
action = "SELL"
|
|
action_reason = f"Take Profit ({profit_rate:.2f}%)"
|
|
else:
|
|
# AI 매도 시그널
|
|
if total_score <= 0.3:
|
|
action = "SELL"
|
|
action_reason = f"AI Signal (Score: {total_score:.2f})"
|
|
|
|
# 매수 로직
|
|
if action == "HOLD" and total_score >= 0.7:
|
|
# 중복 매수 필터 (간단화를 위해 최대 1회 진입 가정 or Pyramiding)
|
|
# 여기선 불타기 허용 (최대 30% 비중까지만)
|
|
max_pos = self.initial_capital * 0.3
|
|
current_val = self.holdings * today_price
|
|
|
|
if current_val < max_pos:
|
|
action = "BUY"
|
|
|
|
# 5. 주문 실행
|
|
if action == "BUY":
|
|
# 포지션 사이징
|
|
invest_amt = 1000000 # 기본
|
|
if volatility >= 3.0: invest_amt = 500000
|
|
elif volatility <= 1.5: invest_amt = 1500000
|
|
|
|
# 잔고 확인
|
|
invest_amt = min(invest_amt, self.capital)
|
|
qty = int(invest_amt / today_price)
|
|
|
|
if qty > 0:
|
|
cost = qty * today_price
|
|
# 수수료 0.015% 가정
|
|
fee = cost * 0.00015
|
|
if self.capital >= cost + fee:
|
|
# 평단가 갱신
|
|
total_cost = (self.avg_price * self.holdings) + cost
|
|
self.holdings += qty
|
|
self.avg_price = total_cost / self.holdings
|
|
self.capital -= (cost + fee)
|
|
|
|
self.trade_log.append({
|
|
"date": today_date.strftime("%Y-%m-%d"),
|
|
"action": "BUY",
|
|
"price": today_price,
|
|
"qty": qty,
|
|
"score": total_score,
|
|
"volatility": volatility,
|
|
"balance": self.capital
|
|
})
|
|
|
|
elif action == "SELL":
|
|
qty = self.holdings
|
|
revenue = qty * today_price
|
|
# 세금+수수료 약 0.23% 가정
|
|
fee = revenue * 0.0023
|
|
|
|
profit = revenue - fee - (self.avg_price * qty)
|
|
self.capital += (revenue - fee)
|
|
|
|
self.trade_log.append({
|
|
"date": today_date.strftime("%Y-%m-%d"),
|
|
"action": "SELL",
|
|
"price": today_price,
|
|
"qty": qty,
|
|
"reason": action_reason,
|
|
"profit": profit,
|
|
"balance": self.capital
|
|
})
|
|
|
|
self.holdings = 0
|
|
self.avg_price = 0
|
|
|
|
# 일별 가치 기록
|
|
total_val = self.capital + (self.holdings * today_price)
|
|
self.daily_values.append(total_val)
|
|
|
|
self.print_summary()
|
|
|
|
def print_summary(self):
|
|
if not self.daily_values:
|
|
print("❌ No simulation data.")
|
|
return
|
|
|
|
final_val = self.daily_values[-1]
|
|
roi = ((final_val - self.initial_capital) / self.initial_capital) * 100
|
|
|
|
print("\n" + "="*40)
|
|
print(f"📊 [Backtest Result] {self.ticker}")
|
|
print(f"• Initial Capital: {self.initial_capital:,.0f} KRW")
|
|
print(f"• Final Capital : {final_val:,.0f} KRW")
|
|
print(f"• Return (ROI) : {roi:.2f}%")
|
|
print(f"• Total Trades : {len(self.trade_log)}")
|
|
print("="*40)
|
|
|
|
# 최근 5개 거래 로그
|
|
print("📝 Recent Trades:")
|
|
for trade in self.trade_log[-5:]:
|
|
action_emoji = "🔴" if trade['action'] == "BUY" else "🔵"
|
|
print(f"{trade['date']} {action_emoji} {trade['action']} {trade['qty']}ea @ {trade['price']:,.0f} | {trade.get('reason', '')}")
|
|
|
|
if __name__ == "__main__":
|
|
# 삼성전자(005930), 6개월 백테스팅
|
|
# 최근 6개월간 로직이 통했는지 검증
|
|
# (종목 코드는 KOSPI: 코드, KOSDAQ: 코드)
|
|
backtester = Backtester("005930", start_date="2023-06-01", end_date="2024-01-01")
|
|
backtester.run()
|