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
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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_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}"
|
||||||
|
|
||||||
|
|||||||
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/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'),
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user