From 940d9bfe6e8f3f3b63beb05ac00609c7af844283 Mon Sep 17 00:00:00 2001 From: Jongheon Kim Date: Sun, 8 Feb 2026 14:39:23 +0900 Subject: [PATCH] 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//report/` endpoint for downloading reports. - Enhanced backtesting flow to generate and save reports upon completion. - Updated dependencies to include `matplotlib` for report generation. --- executor/urls.py | 5 + pyproject.toml | 1 + .../0004_strategyexecution_report_file.py | 18 ++ strategies/models.py | 2 + strategies/services/report.py | 158 ++++++++++++++++++ strategies/urls.py | 1 + strategies/views.py | 43 ++++- 7 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 strategies/migrations/0004_strategyexecution_report_file.py create mode 100644 strategies/services/report.py diff --git a/executor/urls.py b/executor/urls.py index f8448d7..fa80867 100644 --- a/executor/urls.py +++ b/executor/urls.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 41759ea..f5dc9c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,5 @@ dependencies = [ "yfinance>=0.2.66", "gunicorn>=21.2.0", "requests>=2.31.0", + "matplotlib>=3.9.0", ] diff --git a/strategies/migrations/0004_strategyexecution_report_file.py b/strategies/migrations/0004_strategyexecution_report_file.py new file mode 100644 index 0000000..17b1946 --- /dev/null +++ b/strategies/migrations/0004_strategyexecution_report_file.py @@ -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), + ), + ] diff --git a/strategies/models.py b/strategies/models.py index d8ba772..1895f3f 100644 --- a/strategies/models.py +++ b/strategies/models.py @@ -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}" diff --git a/strategies/services/report.py b/strategies/services/report.py new file mode 100644 index 0000000..ab16678 --- /dev/null +++ b/strategies/services/report.py @@ -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) diff --git a/strategies/urls.py b/strategies/urls.py index ddfde78..4d41d53 100644 --- a/strategies/urls.py +++ b/strategies/urls.py @@ -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//', views.execution_status, name='execution_status'), + path('executions//report/', views.download_report, name='download_report'), ] \ No newline at end of file diff --git a/strategies/views.py b/strategies/views.py index fba872c..5fe0b31 100644 --- a/strategies/views.py +++ b/strategies/views.py @@ -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