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:
2026-02-08 14:39:23 +09:00
parent 0d7574e3c9
commit 940d9bfe6e
7 changed files with 222 additions and 6 deletions

View File

@@ -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)

View File

@@ -8,4 +8,5 @@ dependencies = [
"yfinance>=0.2.66",
"gunicorn>=21.2.0",
"requests>=2.31.0",
"matplotlib>=3.9.0",
]

View 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),
),
]

View File

@@ -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}"

View 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)

View File

@@ -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'),
]

View File

@@ -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