feat: add Heikin Ashi candlestick and volume charts to PDF reports

- Add matplotlib for chart generation
- Implement _calculate_heikin_ashi() for HA OHLC calculation
- Implement _generate_price_chart() to create candlestick + volume charts
- Charts are generated as PNG images and embedded in PDF
- Fallback to text summary if chart generation fails
- First page now shows visual charts like the web interface
This commit is contained in:
MarkLo 2025-12-14 03:11:47 +08:00
parent 9ceff4cf9b
commit 3f13475485
2 changed files with 227 additions and 26 deletions

View File

@ -2,21 +2,30 @@
"""
PDF Generation Service for Analyst Reports
Converts markdown reports to PDF format with Chinese character support
Includes Heikin Ashi candlestick charts and volume bar charts
"""
import io
import re
from typing import Optional
from typing import Optional, List, Dict
from datetime import datetime
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib.enums import TA_LEFT, TA_CENTER
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Image
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.colors import HexColor
import markdown
# Matplotlib for chart generation
import matplotlib
matplotlib.use('Agg') # Use non-interactive backend for server
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.patches import Rectangle
import numpy as np
class PDFGenerator:
"""Generate PDF reports from markdown content"""
@ -162,6 +171,178 @@ class PDFGenerator:
# Set primary font
self.primary_font = self.custom_font if self.custom_font else self.chinese_font
def _calculate_heikin_ashi(self, price_data: List[Dict]) -> List[Dict]:
"""
Calculate Heikin Ashi values from regular OHLC data
Args:
price_data: List of dicts with Open, High, Low, Close
Returns:
List of dicts with HA_Open, HA_High, HA_Low, HA_Close
"""
if not price_data:
return []
ha_data = []
for i, candle in enumerate(price_data):
open_price = candle.get('Open', 0)
high_price = candle.get('High', 0)
low_price = candle.get('Low', 0)
close_price = candle.get('Adj Close', candle.get('Close', 0))
# Current HA Close = (Open + High + Low + Close) / 4
ha_close = (open_price + high_price + low_price + close_price) / 4
if i == 0:
# First candle: HA Open = (Open + Close) / 2
ha_open = (open_price + close_price) / 2
else:
# HA Open = (Previous HA Open + Previous HA Close) / 2
prev_ha = ha_data[i - 1]
ha_open = (prev_ha['HA_Open'] + prev_ha['HA_Close']) / 2
# HA High = Max(High, HA Open, HA Close)
ha_high = max(high_price, ha_open, ha_close)
# HA Low = Min(Low, HA Open, HA Close)
ha_low = min(low_price, ha_open, ha_close)
ha_data.append({
'Date': candle.get('Date', ''),
'HA_Open': ha_open,
'HA_High': ha_high,
'HA_Low': ha_low,
'HA_Close': ha_close,
'Volume': candle.get('Volume', 0),
})
return ha_data
def _generate_price_chart(self, price_data: List[Dict], ticker: str) -> bytes:
"""
Generate Heikin Ashi candlestick chart and volume bar chart as PNG image
Args:
price_data: List of price data dicts
ticker: Stock ticker symbol
Returns:
PNG image as bytes
"""
if not price_data or len(price_data) < 2:
return None
# Calculate Heikin Ashi data
ha_data = self._calculate_heikin_ashi(price_data)
# Prepare data for plotting
dates = []
ha_opens = []
ha_highs = []
ha_lows = []
ha_closes = []
volumes = []
for i, d in enumerate(ha_data):
dates.append(i) # Use index for x-axis
ha_opens.append(d['HA_Open'])
ha_highs.append(d['HA_High'])
ha_lows.append(d['HA_Low'])
ha_closes.append(d['HA_Close'])
volumes.append(d['Volume'])
# Create figure with two subplots
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6),
gridspec_kw={'height_ratios': [3, 1]},
sharex=True)
fig.patch.set_facecolor('white')
# Set Chinese font for matplotlib
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'STSong', 'sans-serif']
plt.rcParams['axes.unicode_minus'] = False
# Plot Heikin Ashi candlesticks
width = 0.8
for i in range(len(dates)):
# Determine color: green if close > open (bullish), red otherwise
if ha_closes[i] >= ha_opens[i]:
color = '#22c55e' # Green for bullish
body_color = '#22c55e'
else:
color = '#ef4444' # Red for bearish
body_color = '#ef4444'
# Draw the wick (high-low line)
ax1.plot([dates[i], dates[i]], [ha_lows[i], ha_highs[i]],
color=color, linewidth=1)
# Draw the body (open-close rectangle)
body_bottom = min(ha_opens[i], ha_closes[i])
body_height = abs(ha_closes[i] - ha_opens[i])
rect = Rectangle((dates[i] - width/2, body_bottom), width, body_height,
facecolor=body_color, edgecolor=color, linewidth=0.5)
ax1.add_patch(rect)
# Style price chart
ax1.set_ylabel('Price ($)', fontsize=10)
ax1.set_title(f'{ticker} Heikin Ashi Chart', fontsize=12, fontweight='bold')
ax1.grid(True, alpha=0.3)
ax1.set_facecolor('#fafafa')
# Plot volume bars
volume_colors = ['#22c55e' if ha_closes[i] >= ha_opens[i] else '#ef4444'
for i in range(len(dates))]
ax2.bar(dates, volumes, width=width, color=volume_colors, alpha=0.7)
# Style volume chart
ax2.set_ylabel('Volume', fontsize=10)
ax2.set_xlabel('Trading Days', fontsize=10)
ax2.grid(True, alpha=0.3)
ax2.set_facecolor('#fafafa')
# Format volume y-axis
ax2.yaxis.set_major_formatter(plt.FuncFormatter(
lambda x, p: f'{x/1e6:.1f}M' if x >= 1e6 else f'{x/1e3:.0f}K' if x >= 1e3 else f'{x:.0f}'
))
# Add date labels at intervals
if len(ha_data) > 0:
# Show first, middle, and last date labels
label_indices = [0, len(ha_data)//2, len(ha_data)-1]
labels = []
positions = []
for idx in label_indices:
if idx < len(ha_data):
date_str = ha_data[idx].get('Date', '')
if date_str:
# Format date to show only month/day
try:
if len(date_str) >= 10:
labels.append(date_str[5:10]) # MM-DD
else:
labels.append(date_str)
except:
labels.append(date_str)
positions.append(idx)
if positions and labels:
ax2.set_xticks(positions)
ax2.set_xticklabels(labels)
# Tight layout
plt.tight_layout()
# Save to bytes buffer
buf = io.BytesIO()
plt.savefig(buf, format='png', dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def generate_analyst_report_pdf(
self,
analyst_name: str,
@ -329,32 +510,51 @@ class PDFGenerator:
elements.append(Paragraph("結束價格", stat_label_style))
elements.append(Paragraph(f"${end_price:.2f}", stat_value_style))
# Add latest price data summary if available
if price_data and len(price_data) > 0:
elements.append(Spacer(1, 0.5*cm))
elements.append(Paragraph("最近交易數據", heading_style))
elements.append(Spacer(1, 0.2*cm))
# Show last 5 trading days
recent_data = price_data[-5:] if len(price_data) >= 5 else price_data
for day in reversed(recent_data):
date = day.get('Date', 'N/A')
close = day.get('Close', 0)
adj_close = day.get('Adj Close', close)
volume = day.get('Volume', 0)
# Add Heikin Ashi Chart and Volume Chart
if price_data and len(price_data) >= 5:
try:
# Generate chart image
chart_bytes = self._generate_price_chart(price_data, ticker)
# Format volume
if volume >= 1000000000:
vol_str = f"{volume/1000000000:.2f}B"
elif volume >= 1000000:
vol_str = f"{volume/1000000:.2f}M"
elif volume >= 1000:
vol_str = f"{volume/1000:.2f}K"
else:
vol_str = str(volume)
if chart_bytes:
elements.append(Spacer(1, 0.5*cm))
elements.append(Paragraph("價格走勢與交易量", heading_style))
elements.append(Spacer(1, 0.3*cm))
# Create image from bytes
chart_buffer = io.BytesIO(chart_bytes)
# Add chart image to PDF (width fits A4 page with margins)
chart_img = Image(chart_buffer, width=17*cm, height=10.2*cm)
elements.append(chart_img)
except Exception as e:
# If chart generation fails, fall back to text summary
print(f"Chart generation failed: {e}")
elements.append(Spacer(1, 0.5*cm))
elements.append(Paragraph("最近交易數據", heading_style))
elements.append(Spacer(1, 0.2*cm))
day_text = f"{date}:收盤 ${adj_close:.2f},成交量 {vol_str}"
elements.append(Paragraph(day_text, stat_style))
# Show last 5 trading days as text fallback
recent_data = price_data[-5:] if len(price_data) >= 5 else price_data
for day in reversed(recent_data):
date = day.get('Date', 'N/A')
close = day.get('Close', 0)
adj_close = day.get('Adj Close', close)
volume = day.get('Volume', 0)
# Format volume
if volume >= 1000000000:
vol_str = f"{volume/1000000000:.2f}B"
elif volume >= 1000000:
vol_str = f"{volume/1000000:.2f}M"
elif volume >= 1000:
vol_str = f"{volume/1000:.2f}K"
else:
vol_str = str(volume)
day_text = f"{date}:收盤 ${adj_close:.2f},成交量 {vol_str}"
elements.append(Paragraph(day_text, stat_style))
# Page break before analyst content
elements.append(PageBreak())

View File

@ -52,6 +52,7 @@ tenacity>=8.2.0
# PDF and document generation
reportlab>=4.0.0
markdown>=3.5.0
matplotlib>=3.8.0
# Toon format for token optimization
git+https://github.com/toon-format/toon-python.git