425 lines
17 KiB
Python
425 lines
17 KiB
Python
"""
|
|
报告生成器 - 生成综合分析报告
|
|
"""
|
|
import pandas as pd
|
|
import matplotlib.pyplot as plt
|
|
import seaborn as sns
|
|
import plotly.graph_objects as go
|
|
import plotly.express as px
|
|
from plotly.subplots import make_subplots
|
|
from reportlab.lib.pagesizes import letter, A4
|
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import inch
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
|
import os
|
|
from datetime import datetime
|
|
from typing import Dict, List
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class ReportGenerator:
|
|
def __init__(self, output_dir: str = "reports"):
|
|
self.output_dir = output_dir
|
|
self.chart_dir = os.path.join(output_dir, "charts")
|
|
|
|
# 创建输出目录
|
|
os.makedirs(self.output_dir, exist_ok=True)
|
|
os.makedirs(self.chart_dir, exist_ok=True)
|
|
|
|
# 设置中文字体
|
|
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
|
|
plt.rcParams['axes.unicode_minus'] = False
|
|
|
|
def generate_comprehensive_report(self, symbol: str, data: Dict, analysis_results: Dict) -> str:
|
|
"""生成综合分析报告"""
|
|
try:
|
|
# 生成图表
|
|
chart_files = self._generate_charts(symbol, data, analysis_results)
|
|
|
|
# 生成PDF报告
|
|
report_file = self._generate_pdf_report(symbol, data, analysis_results, chart_files)
|
|
|
|
logger.info(f"报告生成完成: {report_file}")
|
|
return report_file
|
|
except Exception as e:
|
|
logger.error(f"生成报告失败: {e}")
|
|
return ""
|
|
|
|
def _generate_charts(self, symbol: str, data: Dict, analysis_results: Dict) -> List[str]:
|
|
"""生成图表"""
|
|
chart_files = []
|
|
|
|
try:
|
|
# 股价走势图
|
|
price_chart = self._create_price_chart(symbol, data)
|
|
if price_chart:
|
|
chart_files.append(price_chart)
|
|
|
|
# 财务指标雷达图
|
|
radar_chart = self._create_radar_chart(symbol, analysis_results)
|
|
if radar_chart:
|
|
chart_files.append(radar_chart)
|
|
|
|
# 估值对比图
|
|
valuation_chart = self._create_valuation_chart(symbol, analysis_results)
|
|
if valuation_chart:
|
|
chart_files.append(valuation_chart)
|
|
|
|
# 财务健康度仪表盘
|
|
health_gauge = self._create_health_gauge(symbol, analysis_results)
|
|
if health_gauge:
|
|
chart_files.append(health_gauge)
|
|
|
|
except Exception as e:
|
|
logger.error(f"生成图表失败: {e}")
|
|
|
|
return chart_files
|
|
|
|
def _create_price_chart(self, symbol: str, data: Dict) -> str:
|
|
"""创建股价走势图"""
|
|
try:
|
|
stock_prices = data.get('stock_prices', pd.DataFrame())
|
|
if stock_prices.empty:
|
|
return ""
|
|
|
|
fig = go.Figure()
|
|
|
|
# 添加收盘价线
|
|
fig.add_trace(go.Scatter(
|
|
x=stock_prices['date'],
|
|
y=stock_prices['close'],
|
|
mode='lines',
|
|
name='收盘价',
|
|
line=dict(color='#1f77b4', width=2)
|
|
))
|
|
|
|
# 添加移动平均线
|
|
if len(stock_prices) >= 20:
|
|
stock_prices['MA20'] = stock_prices['close'].rolling(window=20).mean()
|
|
fig.add_trace(go.Scatter(
|
|
x=stock_prices['date'],
|
|
y=stock_prices['MA20'],
|
|
mode='lines',
|
|
name='20日均线',
|
|
line=dict(color='orange', width=1, dash='dash')
|
|
))
|
|
|
|
fig.update_layout(
|
|
title=f'{symbol} 股价走势图',
|
|
xaxis_title='日期',
|
|
yaxis_title='价格 (USD)',
|
|
template='plotly_white',
|
|
height=400
|
|
)
|
|
|
|
chart_file = os.path.join(self.chart_dir, f'{symbol}_price_chart.html')
|
|
fig.write_html(chart_file)
|
|
return chart_file
|
|
except Exception as e:
|
|
logger.error(f"创建股价图表失败: {e}")
|
|
return ""
|
|
|
|
def _create_radar_chart(self, symbol: str, analysis_results: Dict) -> str:
|
|
"""创建雷达图"""
|
|
try:
|
|
categories = ['估值吸引力', '财务健康度', '成长性', '风险控制']
|
|
values = [
|
|
analysis_results.get('valuation_score', 0),
|
|
analysis_results.get('financial_health_score', 0),
|
|
analysis_results.get('growth_score', 0),
|
|
analysis_results.get('risk_score', 0)
|
|
]
|
|
|
|
fig = go.Figure()
|
|
|
|
fig.add_trace(go.Scatterpolar(
|
|
r=values,
|
|
theta=categories,
|
|
fill='toself',
|
|
name=symbol,
|
|
line_color='blue'
|
|
))
|
|
|
|
fig.update_layout(
|
|
polar=dict(
|
|
radialaxis=dict(
|
|
visible=True,
|
|
range=[0, 100]
|
|
)),
|
|
showlegend=True,
|
|
title=f'{symbol} 综合评分雷达图',
|
|
height=400
|
|
)
|
|
|
|
chart_file = os.path.join(self.chart_dir, f'{symbol}_radar_chart.html')
|
|
fig.write_html(chart_file)
|
|
return chart_file
|
|
except Exception as e:
|
|
logger.error(f"创建雷达图失败: {e}")
|
|
return ""
|
|
|
|
def _create_valuation_chart(self, symbol: str, analysis_results: Dict) -> str:
|
|
"""创建估值对比图"""
|
|
try:
|
|
valuation_data = analysis_results.get('valuation_metrics', {})
|
|
|
|
metrics = ['PE比率', 'PB比率', 'PS比率', 'PEG比率']
|
|
values = [
|
|
valuation_data.get('pe_ratio', 0),
|
|
valuation_data.get('pb_ratio', 0),
|
|
valuation_data.get('ps_ratio', 0),
|
|
valuation_data.get('peg_ratio', 0)
|
|
]
|
|
|
|
# 行业平均值(示例数据)
|
|
industry_avg = [15, 2.5, 3.0, 1.2]
|
|
|
|
fig = go.Figure()
|
|
|
|
fig.add_trace(go.Bar(
|
|
name=symbol,
|
|
x=metrics,
|
|
y=values,
|
|
marker_color='lightblue'
|
|
))
|
|
|
|
fig.add_trace(go.Bar(
|
|
name='行业平均',
|
|
x=metrics,
|
|
y=industry_avg,
|
|
marker_color='lightcoral'
|
|
))
|
|
|
|
fig.update_layout(
|
|
title=f'{symbol} 估值指标对比',
|
|
xaxis_title='指标',
|
|
yaxis_title='数值',
|
|
barmode='group',
|
|
height=400
|
|
)
|
|
|
|
chart_file = os.path.join(self.chart_dir, f'{symbol}_valuation_chart.html')
|
|
fig.write_html(chart_file)
|
|
return chart_file
|
|
except Exception as e:
|
|
logger.error(f"创建估值图表失败: {e}")
|
|
return ""
|
|
|
|
def _create_health_gauge(self, symbol: str, analysis_results: Dict) -> str:
|
|
"""创建财务健康度仪表盘"""
|
|
try:
|
|
health_score = analysis_results.get('financial_health_score', 0)
|
|
|
|
fig = go.Figure(go.Indicator(
|
|
mode = "gauge+number+delta",
|
|
value = health_score,
|
|
domain = {'x': [0, 1], 'y': [0, 1]},
|
|
title = {'text': f"{symbol} 财务健康度"},
|
|
delta = {'reference': 50},
|
|
gauge = {
|
|
'axis': {'range': [None, 100]},
|
|
'bar': {'color': "darkblue"},
|
|
'steps': [
|
|
{'range': [0, 40], 'color': "lightgray"},
|
|
{'range': [40, 70], 'color': "yellow"},
|
|
{'range': [70, 100], 'color': "green"}
|
|
],
|
|
'threshold': {
|
|
'line': {'color': "red", 'width': 4},
|
|
'thickness': 0.75,
|
|
'value': 90
|
|
}
|
|
}
|
|
))
|
|
|
|
fig.update_layout(height=300)
|
|
|
|
chart_file = os.path.join(self.chart_dir, f'{symbol}_health_gauge.html')
|
|
fig.write_html(chart_file)
|
|
return chart_file
|
|
except Exception as e:
|
|
logger.error(f"创建健康度仪表盘失败: {e}")
|
|
return ""
|
|
|
|
def _generate_pdf_report(self, symbol: str, data: Dict, analysis_results: Dict, chart_files: List[str]) -> str:
|
|
"""生成PDF报告"""
|
|
try:
|
|
report_file = os.path.join(self.output_dir, f'{symbol}_analysis_report.pdf')
|
|
doc = SimpleDocTemplate(report_file, pagesize=A4)
|
|
styles = getSampleStyleSheet()
|
|
story = []
|
|
|
|
# 标题样式
|
|
title_style = ParagraphStyle(
|
|
'CustomTitle',
|
|
parent=styles['Heading1'],
|
|
fontSize=18,
|
|
spaceAfter=30,
|
|
alignment=TA_CENTER,
|
|
textColor=colors.darkblue
|
|
)
|
|
|
|
# 添加标题
|
|
story.append(Paragraph(f"{symbol} 股票分析报告", title_style))
|
|
story.append(Spacer(1, 20))
|
|
|
|
# 基本信息
|
|
company_info = data.get('company_info', {})
|
|
story.append(Paragraph("基本信息", styles['Heading2']))
|
|
|
|
basic_info_data = [
|
|
['公司名称', company_info.get('name', 'N/A')],
|
|
['行业', company_info.get('industry', 'N/A')],
|
|
['市值', f"${company_info.get('market_cap', 0):,.0f}"],
|
|
['员工数', f"{company_info.get('employees', 0):,}"],
|
|
['分析日期', datetime.now().strftime('%Y-%m-%d %H:%M:%S')]
|
|
]
|
|
|
|
basic_info_table = Table(basic_info_data, colWidths=[2*inch, 4*inch])
|
|
basic_info_table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
|
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, 0), 12),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
|
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
|
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
|
]))
|
|
|
|
story.append(basic_info_table)
|
|
story.append(Spacer(1, 20))
|
|
|
|
# 分析结果
|
|
story.append(Paragraph("分析结果", styles['Heading2']))
|
|
|
|
# 评分表格
|
|
scores_data = [
|
|
['分析维度', '评分', '评级'],
|
|
['估值吸引力', f"{analysis_results.get('valuation_score', 0):.1f}/100", self._get_rating(analysis_results.get('valuation_score', 0))],
|
|
['财务健康度', f"{analysis_results.get('financial_health_score', 0):.1f}/100", self._get_rating(analysis_results.get('financial_health_score', 0))],
|
|
['成长性', f"{analysis_results.get('growth_score', 0):.1f}/100", self._get_rating(analysis_results.get('growth_score', 0))],
|
|
['风险控制', f"{analysis_results.get('risk_score', 0):.1f}/100", self._get_rating(analysis_results.get('risk_score', 0))],
|
|
['综合评分', f"{analysis_results.get('overall_score', 0):.1f}/100", self._get_rating(analysis_results.get('overall_score', 0))]
|
|
]
|
|
|
|
scores_table = Table(scores_data, colWidths=[2*inch, 1.5*inch, 1.5*inch])
|
|
scores_table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, 0), 12),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
|
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
|
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
|
]))
|
|
|
|
story.append(scores_table)
|
|
story.append(Spacer(1, 20))
|
|
|
|
# 投资建议
|
|
recommendation = analysis_results.get('investment_recommendation', {})
|
|
story.append(Paragraph("投资建议", styles['Heading2']))
|
|
story.append(Paragraph(f"<b>建议:</b> {recommendation.get('recommendation', 'N/A')}", styles['Normal']))
|
|
story.append(Paragraph(f"<b>信心度:</b> {recommendation.get('confidence', 'N/A')}", styles['Normal']))
|
|
story.append(Paragraph(f"<b>综合评分:</b> {recommendation.get('overall_score', 0):.1f}/100", styles['Normal']))
|
|
|
|
# 关键优势
|
|
strengths = recommendation.get('key_strengths', [])
|
|
if strengths:
|
|
story.append(Spacer(1, 10))
|
|
story.append(Paragraph("关键优势:", styles['Heading3']))
|
|
for strength in strengths:
|
|
story.append(Paragraph(f"• {strength}", styles['Normal']))
|
|
|
|
# 关键担忧
|
|
concerns = recommendation.get('key_concerns', [])
|
|
if concerns:
|
|
story.append(Spacer(1, 10))
|
|
story.append(Paragraph("关键担忧:", styles['Heading3']))
|
|
for concern in concerns:
|
|
story.append(Paragraph(f"• {concern}", styles['Normal']))
|
|
|
|
# 风险提示
|
|
risk_warnings = recommendation.get('risk_warnings', [])
|
|
if risk_warnings:
|
|
story.append(Spacer(1, 10))
|
|
story.append(Paragraph("风险提示:", styles['Heading3']))
|
|
for warning in risk_warnings:
|
|
story.append(Paragraph(f"⚠️ {warning}", styles['Normal']))
|
|
|
|
story.append(Spacer(1, 20))
|
|
|
|
# 免责声明
|
|
story.append(Paragraph("免责声明", styles['Heading2']))
|
|
disclaimer = """
|
|
本报告仅供投资参考,不构成投资建议。投资有风险,入市需谨慎。
|
|
投资者应根据自身情况做出投资决策,并承担相应风险。
|
|
本报告基于公开信息分析,可能存在信息滞后或不准确的情况。
|
|
"""
|
|
story.append(Paragraph(disclaimer, styles['Normal']))
|
|
|
|
# 构建PDF
|
|
doc.build(story)
|
|
|
|
return report_file
|
|
except Exception as e:
|
|
logger.error(f"生成PDF报告失败: {e}")
|
|
return ""
|
|
|
|
def _get_rating(self, score: float) -> str:
|
|
"""根据分数获取评级"""
|
|
if score >= 90:
|
|
return "优秀"
|
|
elif score >= 80:
|
|
return "良好"
|
|
elif score >= 70:
|
|
return "中等"
|
|
elif score >= 60:
|
|
return "一般"
|
|
else:
|
|
return "较差"
|
|
|
|
def generate_summary_report(self, symbol: str, analysis_results: Dict) -> str:
|
|
"""生成简要报告(文本格式)"""
|
|
try:
|
|
recommendation = analysis_results.get('investment_recommendation', {})
|
|
|
|
summary = f"""
|
|
=== {symbol} 股票分析摘要 ===
|
|
|
|
📊 综合评分: {analysis_results.get('overall_score', 0):.1f}/100
|
|
💡 投资建议: {recommendation.get('recommendation', 'N/A')}
|
|
🎯 信心度: {recommendation.get('confidence', 'N/A')}
|
|
|
|
📈 各维度评分:
|
|
• 估值吸引力: {analysis_results.get('valuation_score', 0):.1f}/100
|
|
• 财务健康度: {analysis_results.get('financial_health_score', 0):.1f}/100
|
|
• 成长性: {analysis_results.get('growth_score', 0):.1f}/100
|
|
• 风险控制: {analysis_results.get('risk_score', 0):.1f}/100
|
|
|
|
✅ 关键优势:
|
|
{chr(10).join([f"• {s}" for s in recommendation.get('key_strengths', [])])}
|
|
|
|
⚠️ 关键担忧:
|
|
{chr(10).join([f"• {c}" for c in recommendation.get('key_concerns', [])])}
|
|
|
|
🚨 风险提示:
|
|
{chr(10).join([f"• {w}" for w in recommendation.get('risk_warnings', [])])}
|
|
|
|
分析时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
|
"""
|
|
|
|
# 保存简要报告
|
|
summary_file = os.path.join(self.output_dir, f'{symbol}_summary.txt')
|
|
with open(summary_file, 'w', encoding='utf-8') as f:
|
|
f.write(summary)
|
|
|
|
return summary_file
|
|
except Exception as e:
|
|
logger.error(f"生成简要报告失败: {e}")
|
|
return "" |