feat: add PDF report generation for backtest executions
- 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.
This commit is contained in:
158
strategies/services/report.py
Normal file
158
strategies/services/report.py
Normal file
@@ -0,0 +1,158 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user