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 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 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.contrib import admin
from django.urls import path, include from django.urls import path, include
@@ -21,3 +23,6 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/', include('strategies.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", "yfinance>=0.2.66",
"gunicorn>=21.2.0", "gunicorn>=21.2.0",
"requests>=2.31.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_sent_at = models.DateTimeField(null=True, blank=True, help_text="콜백 전송 일시")
callback_response = models.JSONField(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): def __str__(self):
return f"Execution of {self.strategy_version} - {self.status}" 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/backtest/', views.backtest_strategy, name='backtest_strategy'),
path('strategies/implementations/', views.list_available_implementations, name='list_available_implementations'), 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>/', 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.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.utils import timezone from django.utils import timezone
import json
import threading
import time
import requests
import logging
from .models import QuantStrategy, StrategyVersion, StrategyExecution from .models import QuantStrategy, StrategyVersion, StrategyExecution
from .base import StrategyRegistry from .base import StrategyRegistry
from .services.backtest import run_backtest from .services.backtest import run_backtest
from .services.report import generate_backtest_report
from . import implementations # 구현체들을 로드하여 레지스트리에 등록 from . import implementations # 구현체들을 로드하여 레지스트리에 등록
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -218,6 +222,9 @@ def execution_status(request, execution_id):
if execution.status == 'completed' and execution.result: if execution.status == 'completed' and execution.result:
response_data['result'] = 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: if execution.status == 'failed' and execution.error_message:
response_data['error_message'] = execution.error_message response_data['error_message'] = execution.error_message
@@ -312,6 +319,13 @@ def backtest_strategy(request):
execution.status = 'completed' execution.status = 'completed'
execution.result = result execution.result = result
execution.completed_at = timezone.now() 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() execution.save()
except Exception as e: except Exception as e:
@@ -343,3 +357,20 @@ def backtest_strategy(request):
return JsonResponse({ return JsonResponse({
'error': str(e) 'error': str(e)
}, status=500) }, 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