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:
@@ -14,6 +14,8 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
@@ -21,3 +23,6 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('strategies.urls')),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -8,4 +8,5 @@ dependencies = [
|
||||
"yfinance>=0.2.66",
|
||||
"gunicorn>=21.2.0",
|
||||
"requests>=2.31.0",
|
||||
"matplotlib>=3.9.0",
|
||||
]
|
||||
|
||||
18
strategies/migrations/0004_strategyexecution_report_file.py
Normal file
18
strategies/migrations/0004_strategyexecution_report_file.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-08 05:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('strategies', '0003_strategyexecution_callback_response_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='strategyexecution',
|
||||
name='report_file',
|
||||
field=models.CharField(blank=True, help_text='생성된 PDF 리포트 파일 경로', max_length=500),
|
||||
),
|
||||
]
|
||||
@@ -78,6 +78,8 @@ class StrategyExecution(models.Model):
|
||||
callback_sent_at = models.DateTimeField(null=True, blank=True, help_text="콜백 전송 일시")
|
||||
callback_response = models.JSONField(null=True, blank=True, help_text="콜백 응답 데이터")
|
||||
|
||||
report_file = models.CharField(max_length=500, blank=True, help_text="생성된 PDF 리포트 파일 경로")
|
||||
|
||||
def __str__(self):
|
||||
return f"Execution of {self.strategy_version} - {self.status}"
|
||||
|
||||
|
||||
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)
|
||||
@@ -7,4 +7,5 @@ urlpatterns = [
|
||||
path('strategies/backtest/', views.backtest_strategy, name='backtest_strategy'),
|
||||
path('strategies/implementations/', views.list_available_implementations, name='list_available_implementations'),
|
||||
path('executions/<int:execution_id>/', views.execution_status, name='execution_status'),
|
||||
path('executions/<int:execution_id>/report/', views.download_report, name='download_report'),
|
||||
]
|
||||
@@ -1,17 +1,21 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.utils import timezone
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from .models import QuantStrategy, StrategyVersion, StrategyExecution
|
||||
from .base import StrategyRegistry
|
||||
from .services.backtest import run_backtest
|
||||
from .services.report import generate_backtest_report
|
||||
from . import implementations # 구현체들을 로드하여 레지스트리에 등록
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -218,6 +222,9 @@ def execution_status(request, execution_id):
|
||||
if execution.status == 'completed' and execution.result:
|
||||
response_data['result'] = execution.result
|
||||
|
||||
if execution.report_file:
|
||||
response_data['report_url'] = f'/api/executions/{execution.id}/report/'
|
||||
|
||||
if execution.status == 'failed' and execution.error_message:
|
||||
response_data['error_message'] = execution.error_message
|
||||
|
||||
@@ -312,6 +319,13 @@ def backtest_strategy(request):
|
||||
execution.status = 'completed'
|
||||
execution.result = result
|
||||
execution.completed_at = timezone.now()
|
||||
|
||||
try:
|
||||
report_path = generate_backtest_report(result, execution.id)
|
||||
execution.report_file = report_path
|
||||
except Exception as report_err:
|
||||
logger.exception(f'PDF report generation failed for execution {execution.id}: {report_err}')
|
||||
|
||||
execution.save()
|
||||
|
||||
except Exception as e:
|
||||
@@ -343,3 +357,20 @@ def backtest_strategy(request):
|
||||
return JsonResponse({
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def download_report(request, execution_id):
|
||||
"""백테스트 PDF 리포트 다운로드"""
|
||||
execution = get_object_or_404(StrategyExecution, id=execution_id)
|
||||
|
||||
if not execution.report_file:
|
||||
return JsonResponse({'error': 'Report not available'}, status=404)
|
||||
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, execution.report_file)
|
||||
if not os.path.exists(file_path):
|
||||
return JsonResponse({'error': 'Report file not found'}, status=404)
|
||||
|
||||
response = FileResponse(open(file_path, 'rb'), content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="backtest_{execution_id}.pdf"'
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user