- Implemented `generate_backtest_report` to create PDF reports for backtest results. - Added `report_file` field to `StrategyExecution` for storing report paths. - Introduced `/executions/<execution_id>/report/` endpoint for downloading reports. - Enhanced backtesting flow to generate and save reports upon completion. - Updated dependencies to include `matplotlib` for report generation.
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
import logging
|
|
import os
|
|
|
|
import matplotlib
|
|
matplotlib.use('Agg')
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.backends.backend_pdf import PdfPages
|
|
|
|
from django.conf import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def generate_backtest_report(result, execution_id):
|
|
"""백테스트 결과를 PDF 리포트로 생성
|
|
|
|
Args:
|
|
result: run_backtest()가 반환한 결과 딕셔너리
|
|
execution_id: StrategyExecution ID
|
|
|
|
Returns:
|
|
MEDIA_ROOT 기준 상대 경로 (예: 'reports/backtest_1.pdf')
|
|
"""
|
|
reports_dir = os.path.join(settings.MEDIA_ROOT, 'reports')
|
|
os.makedirs(reports_dir, exist_ok=True)
|
|
|
|
relative_path = f'reports/backtest_{execution_id}.pdf'
|
|
file_path = os.path.join(settings.MEDIA_ROOT, relative_path)
|
|
|
|
with PdfPages(file_path) as pdf:
|
|
_draw_summary_page(pdf, result)
|
|
_draw_equity_curve_page(pdf, result)
|
|
|
|
logger.info(f'PDF report generated: {file_path}')
|
|
return relative_path
|
|
|
|
|
|
def _draw_summary_page(pdf, result):
|
|
"""페이지 1: 입력 파라미터 + 성과 지표"""
|
|
fig, (ax_params, ax_metrics) = plt.subplots(2, 1, figsize=(8.27, 11.69))
|
|
fig.suptitle(f"Backtest Report: {result.get('strategy', 'N/A')}", fontsize=16, fontweight='bold', y=0.97)
|
|
|
|
# 입력 파라미터 테이블
|
|
ax_params.set_title('Input Parameters', fontsize=13, fontweight='bold', loc='left', pad=12)
|
|
ax_params.axis('off')
|
|
|
|
variant = result.get('variant', '')
|
|
param_data = [
|
|
['Strategy', result.get('strategy', 'N/A')],
|
|
['Variant', variant if variant else 'N/A'],
|
|
['Period', f"{result.get('backtest_start_date', '')} ~ {result.get('backtest_end_date', '')}"],
|
|
['Initial Capital', f"${result.get('initial_capital', 0):,.0f}"],
|
|
['Final Capital', f"${result.get('final_capital', 0):,.0f}"],
|
|
]
|
|
|
|
param_table = ax_params.table(
|
|
cellText=param_data,
|
|
colLabels=['Parameter', 'Value'],
|
|
colWidths=[0.35, 0.55],
|
|
loc='center',
|
|
cellLoc='left',
|
|
)
|
|
param_table.auto_set_font_size(False)
|
|
param_table.set_fontsize(10)
|
|
param_table.scale(1, 1.6)
|
|
for (row, col), cell in param_table.get_celld().items():
|
|
if row == 0:
|
|
cell.set_facecolor('#4472C4')
|
|
cell.set_text_props(color='white', fontweight='bold')
|
|
else:
|
|
cell.set_facecolor('#F2F2F2' if row % 2 == 0 else 'white')
|
|
|
|
# 성과 지표 테이블
|
|
ax_metrics.set_title('Performance Metrics', fontsize=13, fontweight='bold', loc='left', pad=12)
|
|
ax_metrics.axis('off')
|
|
|
|
metrics = result.get('metrics', {})
|
|
metrics_data = [
|
|
['CAGR', f"{metrics.get('cagr', 0):.2f}%"],
|
|
['Total Return', f"{metrics.get('total_return', 0):.2f}%"],
|
|
['Annual Volatility', f"{metrics.get('annual_volatility', 0):.2f}%"],
|
|
['Max Drawdown', f"{metrics.get('max_drawdown', 0):.2f}%"],
|
|
['Sharpe Ratio', f"{metrics.get('sharpe_ratio', 0):.2f}"],
|
|
['Sortino Ratio', f"{metrics.get('sortino_ratio', 0):.2f}"],
|
|
['Calmar Ratio', f"{metrics.get('calmar_ratio', 0):.2f}"],
|
|
['Win Rate (Monthly)', f"{metrics.get('win_rate', 0):.2f}%"],
|
|
['Best Month', f"{metrics.get('best_month', 0):.2f}%"],
|
|
['Worst Month', f"{metrics.get('worst_month', 0):.2f}%"],
|
|
]
|
|
|
|
metrics_table = ax_metrics.table(
|
|
cellText=metrics_data,
|
|
colLabels=['Metric', 'Value'],
|
|
colWidths=[0.35, 0.55],
|
|
loc='center',
|
|
cellLoc='left',
|
|
)
|
|
metrics_table.auto_set_font_size(False)
|
|
metrics_table.set_fontsize(10)
|
|
metrics_table.scale(1, 1.6)
|
|
for (row, col), cell in metrics_table.get_celld().items():
|
|
if row == 0:
|
|
cell.set_facecolor('#4472C4')
|
|
cell.set_text_props(color='white', fontweight='bold')
|
|
else:
|
|
cell.set_facecolor('#F2F2F2' if row % 2 == 0 else 'white')
|
|
|
|
fig.tight_layout(rect=[0, 0, 1, 0.95])
|
|
pdf.savefig(fig)
|
|
plt.close(fig)
|
|
|
|
|
|
def _draw_equity_curve_page(pdf, result):
|
|
"""페이지 2: 에쿼티 커브 차트"""
|
|
equity_curve = result.get('daily_equity_curve', [])
|
|
if not equity_curve:
|
|
return
|
|
|
|
dates = [entry['date'] for entry in equity_curve]
|
|
values = [entry['value'] for entry in equity_curve]
|
|
|
|
fig, ax = plt.subplots(figsize=(8.27, 11.69 / 2))
|
|
fig.suptitle('Equity Curve', fontsize=14, fontweight='bold')
|
|
|
|
ax.plot(range(len(values)), values, color='#4472C4', linewidth=1.2)
|
|
ax.fill_between(range(len(values)), values, alpha=0.1, color='#4472C4')
|
|
ax.set_ylabel('Portfolio Value ($)')
|
|
ax.set_xlabel('Date')
|
|
ax.grid(True, alpha=0.3)
|
|
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))
|
|
|
|
# x축 레이블: 적당한 간격으로 날짜 표시
|
|
num_labels = min(8, len(dates))
|
|
if num_labels > 0:
|
|
step = max(1, len(dates) // num_labels)
|
|
tick_positions = list(range(0, len(dates), step))
|
|
tick_labels = [dates[i] for i in tick_positions]
|
|
ax.set_xticks(tick_positions)
|
|
ax.set_xticklabels(tick_labels, rotation=45, ha='right', fontsize=8)
|
|
|
|
# Drawdown subplot
|
|
drawdown_series = result.get('drawdown_series', [])
|
|
if drawdown_series:
|
|
ax2 = fig.add_axes([0.125, 0.08, 0.775, 0.2])
|
|
dd_values = [d['drawdown'] for d in drawdown_series]
|
|
ax2.fill_between(range(len(dd_values)), dd_values, color='#C0504D', alpha=0.4)
|
|
ax2.plot(range(len(dd_values)), dd_values, color='#C0504D', linewidth=0.8)
|
|
ax2.set_ylabel('Drawdown (%)')
|
|
ax2.grid(True, alpha=0.3)
|
|
ax2.set_xlim(ax.get_xlim())
|
|
ax2.set_xticklabels([])
|
|
|
|
# 메인 차트 여백 조정
|
|
ax.set_position([0.125, 0.35, 0.775, 0.55])
|
|
|
|
fig.tight_layout(rect=[0, 0.05, 1, 0.95])
|
|
pdf.savefig(fig)
|
|
plt.close(fig)
|