주식 트레이드 강화 전략 추가
This commit is contained in:
549
backtester.py
549
backtester.py
@@ -1,241 +1,460 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import yfinance as yf
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # GUI 없는 환경 대응
|
||||
import matplotlib.pyplot as plt
|
||||
from modules.analysis.technical import TechnicalAnalyzer
|
||||
from modules.analysis.deep_learning import PricePredictor
|
||||
from modules.strategy.process import calculate_position_size
|
||||
|
||||
|
||||
class Backtester:
|
||||
"""
|
||||
[v2.0] 실전 백테스터
|
||||
|
||||
개선사항:
|
||||
1. ATR 기반 동적 손절/익절 + 트레일링 스탑
|
||||
2. 포지션 사이징 (변동성 기반)
|
||||
3. 정밀한 수수료/세금 계산
|
||||
4. 슬리피지(Slippage) 시뮬레이션
|
||||
5. 다중 성과 지표 (Sharpe, MDD, Win Rate, Avg P/L)
|
||||
6. 벤치마크 대비 알파 계산
|
||||
7. 실제 데이터 로드 (yfinance fallback to mock)
|
||||
"""
|
||||
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.holdings = 0
|
||||
self.avg_price = 0
|
||||
self.peak_price = 0 # [v2.0] 트레일링 스탑용
|
||||
|
||||
self.trade_log = []
|
||||
self.daily_values = []
|
||||
|
||||
# LSTM 모델 (재학습 시뮬레이션을 위해)
|
||||
self.daily_returns = []
|
||||
|
||||
# LSTM 모델
|
||||
self.predictor = PricePredictor()
|
||||
|
||||
|
||||
# [v2.0] 수수료/세금 설정 (한국 주식)
|
||||
self.buy_commission = 0.00015 # 매수 수수료 0.015%
|
||||
self.sell_commission = 0.00015 # 매도 수수료 0.015%
|
||||
self.sell_tax = 0.0018 # 증권거래세 0.18% (2024~)
|
||||
self.slippage = 0.001 # 슬리피지 0.1%
|
||||
|
||||
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) # 재현성을 위해 시드 고정
|
||||
|
||||
"""실제 시장과 유사한 Mock 데이터 (Random Walk + Mean Reversion)"""
|
||||
print(f"🎲 [Backtest] Generating realistic mock data for {days} days...")
|
||||
np.random.seed(42)
|
||||
|
||||
start_price = 70000
|
||||
returns = np.random.normal(0, 0.015, days) # 평균 0, 표준편차 1.5% 변동
|
||||
# Mean-reverting Random Walk (실제 주가에 더 가까움)
|
||||
mu = 0.0003 # 일평균 기대수익률 0.03%
|
||||
sigma = 0.018 # 일변동성 1.8%
|
||||
mean_reversion = 0.02 # 평균 회귀 속도
|
||||
|
||||
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)
|
||||
for i in range(days):
|
||||
# 변동성 클러스터링 (GARCH 효과 근사)
|
||||
if i > 0:
|
||||
prev_return = (price_series[-1] - price_series[-2]) / price_series[-2]
|
||||
dynamic_sigma = sigma * (1 + abs(prev_return) * 5) # 큰 변동 후 변동성 증가
|
||||
else:
|
||||
dynamic_sigma = sigma
|
||||
|
||||
# Mean reversion + trend
|
||||
if len(price_series) >= 20:
|
||||
ma20 = np.mean(price_series[-20:])
|
||||
reversion = -mean_reversion * (price_series[-1] - ma20) / ma20
|
||||
else:
|
||||
reversion = 0
|
||||
|
||||
shock = np.random.normal(mu + reversion, dynamic_sigma)
|
||||
new_price = price_series[-1] * (1 + shock)
|
||||
new_price = max(new_price, start_price * 0.5) # 최소 50% 하한
|
||||
price_series.append(new_price)
|
||||
|
||||
# 날짜 인덱스 생성
|
||||
date_range = pd.date_range(start="2023-01-01", periods=len(price_series))
|
||||
|
||||
date_range = pd.date_range(start=self.start_date, 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.")
|
||||
print(f"📈 [Mock Data] Start: {price_series[0]:.0f}, End: {price_series[-1]:.0f}, "
|
||||
f"Min: {min(price_series):.0f}, Max: {max(price_series):.0f}")
|
||||
return True
|
||||
|
||||
def fetch_data(self):
|
||||
"""(Legacy) yfinance를 이용해 과거 데이터 로드"""
|
||||
# 네트워크 이슈로 인해 Mock Data 우선 사용
|
||||
"""실제 데이터 로드 시도 → 실패 시 Mock"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
# 한국 주식은 .KS (KOSPI) 또는 .KQ (KOSDAQ) 접미사 필요
|
||||
yf_ticker = f"{self.ticker}.KS"
|
||||
df = yf.download(yf_ticker, start=self.start_date, end=self.end_date)
|
||||
if not df.empty and len(df) > 60:
|
||||
self.data = df['Close']
|
||||
print(f"✅ [Backtest] Loaded {len(self.data)} days from yfinance ({yf_ticker})")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Backtest] yfinance failed: {e}")
|
||||
|
||||
return self.generate_mock_data()
|
||||
|
||||
def _apply_slippage(self, price, is_buy):
|
||||
"""슬리피지 적용 (매수 시 높게, 매도 시 낮게)"""
|
||||
if is_buy:
|
||||
return price * (1 + self.slippage)
|
||||
else:
|
||||
return price * (1 - self.slippage)
|
||||
|
||||
def run(self):
|
||||
if not hasattr(self, 'data') or self.data.empty:
|
||||
if not self.fetch_data(): return
|
||||
if not self.fetch_data():
|
||||
return
|
||||
|
||||
prices = self.data.values
|
||||
dates = self.data.index
|
||||
|
||||
# 최소 30일 데이터 필요
|
||||
if len(prices) < 30:
|
||||
|
||||
# 최소 60일 데이터 필요 (LSTM seq_length)
|
||||
min_days = 60
|
||||
if len(prices) < min_days + 10:
|
||||
print("❌ Not enough data for backtest.")
|
||||
return
|
||||
|
||||
print("🚀 [Backtest] Simulation Started...")
|
||||
|
||||
# 30일차부터 하루씩 전진하며 시뮬레이션
|
||||
for i in range(30, len(prices)):
|
||||
print(f"🚀 [Backtest v2.0] Simulation Started ({len(prices)} days)...")
|
||||
print(f" Capital: {self.initial_capital:,.0f} KRW")
|
||||
print(f" Commission: Buy {self.buy_commission*100:.3f}% / Sell {self.sell_commission*100:.3f}%")
|
||||
print(f" Tax: {self.sell_tax*100:.2f}% / Slippage: {self.slippage*100:.1f}%")
|
||||
|
||||
for i in range(min_days, 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 모두 대응)
|
||||
|
||||
# 과거 데이터 윈도우
|
||||
history_window = prices[max(0, i-min_days):i+1]
|
||||
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, vol_ratio, ma_info = 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
|
||||
# 1. 기술적 분석
|
||||
tech_score, rsi, volatility, vol_ratio, ma_info = TechnicalAnalyzer.get_technical_score(
|
||||
today_price, current_window_list)
|
||||
|
||||
# 2. ATR 기반 동적 손절/익절
|
||||
sl_tp = TechnicalAnalyzer.calculate_dynamic_sl_tp(current_window_list)
|
||||
|
||||
# 3. LSTM 예측 (10일마다 재학습 → 속도 타협)
|
||||
lstm_score = 0.5
|
||||
ai_confidence = 0.5
|
||||
if i % 10 == 0 or i == min_days:
|
||||
pred_result = self.predictor.train_and_predict(current_window_list)
|
||||
else:
|
||||
# 재학습 없이 예측만
|
||||
pred_result = self.predictor.train_and_predict(current_window_list, ticker=f"bt_{self.ticker}")
|
||||
|
||||
if pred_result:
|
||||
ai_confidence = pred_result.get('confidence', 0.5)
|
||||
change_mag = min(abs(pred_result['change_rate']), 5.0) / 5.0
|
||||
if pred_result['trend'] == 'UP':
|
||||
lstm_score = 0.5 + (change_mag * ai_confidence * 0.4)
|
||||
else:
|
||||
lstm_score = 0.5 - (change_mag * ai_confidence * 0.4)
|
||||
lstm_score = max(0.0, min(1.0, lstm_score))
|
||||
|
||||
# 4. 뉴스 감정 (백테스트에서는 중립)
|
||||
sentiment_score = 0.5
|
||||
|
||||
# 5. 통합 점수 (ADX 기반 동적 가중치)
|
||||
adx_val = ma_info.get('adx', 20)
|
||||
if adx_val >= 30:
|
||||
w_tech, w_news, w_ai = 0.50, 0.15, 0.35
|
||||
elif adx_val < 20:
|
||||
w_tech, w_news, w_ai = 0.35, 0.30, 0.35
|
||||
else:
|
||||
w_tech, w_news, w_ai = 0.40, 0.25, 0.35
|
||||
|
||||
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
|
||||
|
||||
# 4. 리스크 관리 (손절/익절) 체크
|
||||
# 보유 중일 때만 체크
|
||||
|
||||
# 6. 리스크 관리 (보유 중일 때)
|
||||
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:
|
||||
|
||||
# A. 동적 손절 (ATR 기반)
|
||||
if profit_rate <= sl_tp['stop_loss_pct']:
|
||||
action = "SELL"
|
||||
action_reason = f"Stop Loss ({profit_rate:.2f}%)"
|
||||
elif profit_rate >= 8.0:
|
||||
action_reason = f"Dynamic SL ({profit_rate:.1f}% <= {sl_tp['stop_loss_pct']:.1f}%)"
|
||||
|
||||
# B. 동적 익절 (ATR 기반)
|
||||
elif profit_rate >= sl_tp['take_profit_pct']:
|
||||
action = "SELL"
|
||||
action_reason = f"Take Profit ({profit_rate:.2f}%)"
|
||||
else:
|
||||
# AI 매도 시그널
|
||||
if total_score <= 0.3:
|
||||
action_reason = f"Dynamic TP ({profit_rate:.1f}% >= {sl_tp['take_profit_pct']:.1f}%)"
|
||||
|
||||
# C. 트레일링 스탑
|
||||
elif self.peak_price > 0 and profit_rate > 2.0:
|
||||
drop_from_peak = ((today_price - self.peak_price) / self.peak_price) * 100
|
||||
if drop_from_peak <= -sl_tp['trailing_stop_pct']:
|
||||
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. 주문 실행
|
||||
action_reason = f"Trailing Stop ({drop_from_peak:.1f}% from peak)"
|
||||
|
||||
# D. AI 매도 시그널
|
||||
if action == "HOLD" and total_score <= 0.30:
|
||||
action = "SELL"
|
||||
action_reason = f"AI Signal (Score: {total_score:.2f})"
|
||||
|
||||
# 최고가 업데이트
|
||||
if today_price > self.peak_price:
|
||||
self.peak_price = today_price
|
||||
|
||||
# 7. 매수 로직 (v2.0 - 포지션 사이징)
|
||||
if action == "HOLD" and total_score >= 0.60:
|
||||
# 복합 조건 확인
|
||||
should_buy = False
|
||||
mtf_align = ma_info.get('mtf_alignment', 'NEUTRAL')
|
||||
|
||||
if tech_score >= 0.70 and lstm_score >= 0.55:
|
||||
should_buy = True
|
||||
elif total_score >= 0.65 and mtf_align in ['STRONG_BULL', 'BULL']:
|
||||
should_buy = True
|
||||
elif total_score >= 0.70:
|
||||
should_buy = True
|
||||
|
||||
if should_buy:
|
||||
# 포지션 사이징
|
||||
total_val = self.capital + (self.holdings * today_price)
|
||||
current_pos_val = self.holdings * today_price
|
||||
max_pos = total_val * 0.30 # 최대 30% 비중
|
||||
|
||||
if current_pos_val < max_pos:
|
||||
qty = calculate_position_size(
|
||||
total_capital=total_val,
|
||||
current_price=today_price,
|
||||
volatility=volatility,
|
||||
score=total_score,
|
||||
ai_confidence=ai_confidence
|
||||
)
|
||||
if qty > 0:
|
||||
action = "BUY"
|
||||
|
||||
# 8. 주문 실행
|
||||
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":
|
||||
# 슬리피지 적용
|
||||
exec_price = self._apply_slippage(today_price, is_buy=True)
|
||||
|
||||
total_val = self.capital + (self.holdings * today_price)
|
||||
qty = calculate_position_size(
|
||||
total_capital=total_val,
|
||||
current_price=exec_price,
|
||||
volatility=volatility,
|
||||
score=total_score,
|
||||
ai_confidence=ai_confidence
|
||||
)
|
||||
if qty <= 0:
|
||||
qty = 1
|
||||
|
||||
cost = qty * exec_price
|
||||
fee = cost * self.buy_commission
|
||||
|
||||
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.peak_price = max(self.peak_price, exec_price)
|
||||
|
||||
self.trade_log.append({
|
||||
"date": today_date.strftime("%Y-%m-%d"),
|
||||
"action": "BUY",
|
||||
"price": today_price,
|
||||
"exec_price": exec_price,
|
||||
"qty": qty,
|
||||
"score": round(total_score, 3),
|
||||
"volatility": round(volatility, 2),
|
||||
"fee": round(fee, 0),
|
||||
"balance": round(self.capital, 0)
|
||||
})
|
||||
|
||||
elif action == "SELL" and self.holdings > 0:
|
||||
exec_price = self._apply_slippage(today_price, is_buy=False)
|
||||
qty = self.holdings
|
||||
revenue = qty * today_price
|
||||
# 세금+수수료 약 0.23% 가정
|
||||
fee = revenue * 0.0023
|
||||
|
||||
profit = revenue - fee - (self.avg_price * qty)
|
||||
self.capital += (revenue - fee)
|
||||
|
||||
revenue = qty * exec_price
|
||||
fee = revenue * self.sell_commission
|
||||
tax = revenue * self.sell_tax
|
||||
net_revenue = revenue - fee - tax
|
||||
|
||||
profit = net_revenue - (self.avg_price * qty)
|
||||
self.capital += net_revenue
|
||||
|
||||
self.trade_log.append({
|
||||
"date": today_date.strftime("%Y-%m-%d"),
|
||||
"action": "SELL",
|
||||
"price": today_price,
|
||||
"exec_price": exec_price,
|
||||
"qty": qty,
|
||||
"reason": action_reason,
|
||||
"profit": profit,
|
||||
"balance": self.capital
|
||||
"profit": round(profit, 0),
|
||||
"fee": round(fee + tax, 0),
|
||||
"balance": round(self.capital, 0)
|
||||
})
|
||||
|
||||
|
||||
self.holdings = 0
|
||||
self.avg_price = 0
|
||||
self.peak_price = 0
|
||||
|
||||
# 일별 가치 기록
|
||||
total_val = self.capital + (self.holdings * today_price)
|
||||
self.daily_values.append(total_val)
|
||||
|
||||
|
||||
if len(self.daily_values) >= 2:
|
||||
daily_return = (self.daily_values[-1] - self.daily_values[-2]) / self.daily_values[-2]
|
||||
self.daily_returns.append(daily_return)
|
||||
|
||||
self.print_summary()
|
||||
|
||||
self.plot_results()
|
||||
|
||||
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', '')}")
|
||||
|
||||
# 성과 지표 계산
|
||||
returns = np.array(self.daily_returns) if self.daily_returns else np.array([0])
|
||||
|
||||
# Sharpe Ratio (연환산, 무위험수익률 3.5% 가정)
|
||||
rf_daily = 0.035 / 252
|
||||
if np.std(returns) > 0:
|
||||
sharpe = (np.mean(returns) - rf_daily) / np.std(returns) * np.sqrt(252)
|
||||
else:
|
||||
sharpe = 0
|
||||
|
||||
# Maximum Drawdown (MDD)
|
||||
peak = np.maximum.accumulate(self.daily_values)
|
||||
drawdown = (np.array(self.daily_values) - peak) / peak * 100
|
||||
mdd = drawdown.min()
|
||||
|
||||
# Win Rate
|
||||
sell_trades = [t for t in self.trade_log if t['action'] == 'SELL']
|
||||
wins = [t for t in sell_trades if t.get('profit', 0) > 0]
|
||||
losses = [t for t in sell_trades if t.get('profit', 0) <= 0]
|
||||
win_rate = len(wins) / max(1, len(sell_trades)) * 100
|
||||
|
||||
# 평균 손익
|
||||
avg_win = np.mean([t['profit'] for t in wins]) if wins else 0
|
||||
avg_loss = np.mean([t['profit'] for t in losses]) if losses else 0
|
||||
profit_factor = abs(avg_win / avg_loss) if avg_loss != 0 else float('inf')
|
||||
|
||||
# 총 수수료/세금
|
||||
total_fees = sum(t.get('fee', 0) for t in self.trade_log)
|
||||
|
||||
print("\n" + "=" * 55)
|
||||
print(f"📊 [Backtest v2.0 Result] {self.ticker}")
|
||||
print("=" * 55)
|
||||
print(f" 💰 Initial Capital : {self.initial_capital:>15,.0f} KRW")
|
||||
print(f" 💰 Final Value : {final_val:>15,.0f} KRW")
|
||||
print(f" 📈 Return (ROI) : {roi:>14.2f}%")
|
||||
print(f" 📉 Max Drawdown : {mdd:>14.2f}%")
|
||||
print(f" 📊 Sharpe Ratio : {sharpe:>14.2f}")
|
||||
print(f" 🎯 Win Rate : {win_rate:>14.1f}% ({len(wins)}/{len(sell_trades)})")
|
||||
print(f" 💵 Avg Win : {avg_win:>15,.0f} KRW")
|
||||
print(f" 💸 Avg Loss : {avg_loss:>15,.0f} KRW")
|
||||
print(f" ⚖️ Profit Factor : {profit_factor:>14.2f}")
|
||||
print(f" 💳 Total Fees/Tax : {total_fees:>15,.0f} KRW")
|
||||
print(f" 🔄 Total Trades : {len(self.trade_log):>14}")
|
||||
print("=" * 55)
|
||||
|
||||
# 최근 10개 거래 로그
|
||||
print("\n📝 Recent Trades:")
|
||||
for trade in self.trade_log[-10:]:
|
||||
emoji = "🔴" if trade['action'] == "BUY" else "🔵"
|
||||
line = (f" {trade['date']} {emoji} {trade['action']} "
|
||||
f"{trade['qty']}ea @ {trade['price']:,.0f}")
|
||||
if 'profit' in trade:
|
||||
p = trade['profit']
|
||||
line += f" | P&L: {p:+,.0f}"
|
||||
if 'reason' in trade:
|
||||
line += f" | {trade['reason']}"
|
||||
print(line)
|
||||
|
||||
def plot_results(self):
|
||||
"""결과 차트 생성"""
|
||||
if not self.daily_values:
|
||||
return
|
||||
|
||||
try:
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 10), gridspec_kw={'height_ratios': [3, 1, 1]})
|
||||
|
||||
# 1. 포트폴리오 가치 vs Buy & Hold
|
||||
ax1 = axes[0]
|
||||
ax1.plot(self.daily_values, label='Strategy', color='blue', linewidth=1.5)
|
||||
|
||||
# Buy & Hold 비교
|
||||
if hasattr(self, 'data'):
|
||||
prices = self.data.values[60:]
|
||||
if len(prices) == len(self.daily_values):
|
||||
bh_shares = self.initial_capital / prices[0]
|
||||
bh_values = bh_shares * prices
|
||||
ax1.plot(bh_values, label='Buy & Hold', color='gray', alpha=0.5, linestyle='--')
|
||||
|
||||
ax1.axhline(y=self.initial_capital, color='red', linestyle=':', alpha=0.3, label='Initial')
|
||||
ax1.set_title(f'Backtest Result: {self.ticker}', fontsize=14, fontweight='bold')
|
||||
ax1.set_ylabel('Portfolio Value (KRW)')
|
||||
ax1.legend(loc='upper left')
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# 매매 포인트 표시
|
||||
for trade in self.trade_log:
|
||||
day_idx = None
|
||||
for j, d in enumerate(self.data.index[60:]):
|
||||
if d.strftime("%Y-%m-%d") == trade['date']:
|
||||
day_idx = j
|
||||
break
|
||||
if day_idx is not None and day_idx < len(self.daily_values):
|
||||
if trade['action'] == 'BUY':
|
||||
ax1.scatter(day_idx, self.daily_values[day_idx], marker='^',
|
||||
color='red', s=40, zorder=5)
|
||||
else:
|
||||
ax1.scatter(day_idx, self.daily_values[day_idx], marker='v',
|
||||
color='blue', s=40, zorder=5)
|
||||
|
||||
# 2. Drawdown
|
||||
ax2 = axes[1]
|
||||
peak = np.maximum.accumulate(self.daily_values)
|
||||
drawdown = (np.array(self.daily_values) - peak) / peak * 100
|
||||
ax2.fill_between(range(len(drawdown)), drawdown, 0, color='red', alpha=0.3)
|
||||
ax2.set_ylabel('Drawdown (%)')
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
# 3. Daily Returns
|
||||
ax3 = axes[2]
|
||||
if self.daily_returns:
|
||||
colors = ['green' if r >= 0 else 'red' for r in self.daily_returns]
|
||||
ax3.bar(range(len(self.daily_returns)), [r * 100 for r in self.daily_returns],
|
||||
color=colors, alpha=0.5, width=1)
|
||||
ax3.set_ylabel('Daily Return (%)')
|
||||
ax3.set_xlabel('Trading Days')
|
||||
ax3.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
chart_path = f"data/backtest_{self.ticker}.png"
|
||||
plt.savefig(chart_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"\n📊 Chart saved: {chart_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Chart generation failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 삼성전자(005930), 6개월 백테스팅
|
||||
# 최근 6개월간 로직이 통했는지 검증
|
||||
# (종목 코드는 KOSPI: 코드, KOSDAQ: 코드)
|
||||
backtester = Backtester("005930", start_date="2023-06-01", end_date="2024-01-01")
|
||||
print("=" * 55)
|
||||
print("🚀 AI Trading Backtester v2.0")
|
||||
print("=" * 55)
|
||||
|
||||
# 삼성전자 6개월 백테스팅
|
||||
backtester = Backtester("005930", start_date="2025-06-01", end_date="2026-02-01")
|
||||
backtester.run()
|
||||
|
||||
Reference in New Issue
Block a user