# Python으로 Money Forward 스타일 재무 PDF 리포트 만들기 - Part 9 ## 복합 차트 및 스타일링 ### 이번 파트에서 다룰 차트 1. **레이더 차트** - 財務ハイライト의 6축 분석 2. **워터폴 차트** - 변동손익 분석 3. **도넛 차트** - 비용 구성 4. **손익분기점 차트** - BEP 분석 ### 레이더 차트 (Spider Chart) `src/charts/radar_chart.py`: ```python """레이더 차트 - 재무 지표 분석""" import matplotlib.pyplot as plt import numpy as np from io import BytesIO from typing import Optional def create_radar_chart( categories: list[str], actual_values: list[float], benchmark_values: Optional[list[float]] = None, max_value: float = 5, title: str = "財務分析結果", width: float = 5, height: float = 5, ) -> BytesIO: """6축 레이더 차트 Args: categories: 축 레이블 ['売上持続性', '収益性', '生産性', '健全性', '効率性', '安全性'] actual_values: 실제 점수 (0~5) benchmark_values: 업종 기준값 (점선) max_value: 최대값 title: 차트 제목 """ # 각도 계산 n = len(categories) angles = [i / float(n) * 2 * np.pi for i in range(n)] angles += angles[:1] # 닫힌 도형 # 데이터 닫기 actual_values = list(actual_values) + [actual_values[0]] if benchmark_values: benchmark_values = list(benchmark_values) + [benchmark_values[0]] fig, ax = plt.subplots(figsize=(width, height), subplot_kw=dict(polar=True)) # 실적 영역 ax.fill(angles, actual_values, color='#4472C4', alpha=0.25) ax.plot(angles, actual_values, 'o-', color='#4472C4', linewidth=2, label='2026年3月期') # 기준값 (점선) if benchmark_values: ax.plot(angles, benchmark_values, '--', color='#888888', linewidth=1.5, label='業種基準値') # 축 설정 ax.set_xticks(angles[:-1]) ax.set_xticklabels(categories, fontsize=9) ax.set_ylim(0, max_value) ax.set_yticks([1, 2, 3, 4, 5]) ax.set_yticklabels(['1', '2', '3', '4', '5'], fontsize=8) # 그리드 스타일 ax.grid(True, linestyle='-', alpha=0.3) # 제목 ax.set_title(title, fontsize=11, pad=20) # 범례 ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.0), fontsize=9) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer def create_financial_highlight_radar( scores: dict, # {'売上持続性': 0, '収益性': 5, ...} benchmarks: Optional[dict] = None, ) -> BytesIO: """財務ハイライト용 레이더 차트""" categories = ['売上持続性\n(売上高増加率)', '収益性\n(営業利益率)', '生産性\n(労働生産性)', '健全性\n(EBITDA有利子負債倍率)', '効率性\n(営業運転資本回転期間)', '安全性\n(自己資本比率)'] actual_values = [ scores.get('売上持続性', 0), scores.get('収益性', 0), scores.get('生産性', 0), scores.get('健全性', 0), scores.get('効率性', 0), scores.get('安全性', 0), ] benchmark_values = None if benchmarks: benchmark_values = [ benchmarks.get('売上持続性', 3), benchmarks.get('収益性', 3), benchmarks.get('生産性', 3), benchmarks.get('健全性', 3), benchmarks.get('効率性', 3), benchmarks.get('安全性', 3), ] return create_radar_chart( categories=categories, actual_values=actual_values, benchmark_values=benchmark_values, title="財務分析結果", ) ``` ### 워터폴 차트 (폭포 차트) `src/charts/waterfall_chart.py`: ```python """워터폴 차트 - 변동손익 분석""" import matplotlib.pyplot as plt import numpy as np from io import BytesIO def create_waterfall_chart( labels: list[str], values: list[float], colors: Optional[list[str]] = None, title: str = "", width: float = 8, height: float = 4, ) -> BytesIO: """워터폴 차트 Args: labels: ['売上高', '変動費', '限界利益', '固定費', '営業利益'] values: [29101002, 0, 29101002, -9977984, 19123018] colors: 막대 색상 """ fig, ax = plt.subplots(figsize=(width, height)) n = len(labels) x = np.arange(n) # 기본 색상 if colors is None: colors = [] for i, val in enumerate(values): if i == 0: # 시작 colors.append('#4472C4') elif i == n - 1: # 끝 colors.append('#70AD47') elif val >= 0: colors.append('#9DC3E6') # 양수 else: colors.append('#F4B183') # 음수 # 누적 계산 cumulative = [0] for i, val in enumerate(values[:-1]): cumulative.append(cumulative[-1] + val) # 막대 그리기 for i in range(n): if i == 0: # 시작 막대 ax.bar(i, values[i], color=colors[i], edgecolor='black', linewidth=0.5) elif i == n - 1: # 최종 막대 ax.bar(i, values[i], color=colors[i], edgecolor='black', linewidth=0.5) else: # 중간 막대 bottom = cumulative[i] height = values[i] ax.bar(i, height, bottom=bottom, color=colors[i], edgecolor='black', linewidth=0.5) # 연결선 for i in range(n - 1): if i == 0: y_start = values[0] else: y_start = cumulative[i] + values[i] ax.plot([i + 0.4, i + 0.6], [y_start, y_start], color='gray', linestyle='-', linewidth=1) # 값 레이블 for i, val in enumerate(values): if i == 0 or i == n - 1: y_pos = val / 2 else: y_pos = cumulative[i] + val / 2 label = f'{val/1e6:.1f}M' if abs(val) >= 1e6 else f'{val/1e3:.0f}K' ax.text(i, y_pos, label, ha='center', va='center', fontsize=8) ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=9) ax.set_title(title, fontsize=11) ax.axhline(y=0, color='black', linewidth=0.5) ax.yaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'{x/1e6:.0f}M') ) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer def create_variable_cost_waterfall() -> BytesIO: """変動損益計算シミュレーション 차트""" labels = ['売上高', '変動費', '限界利益', '固定費', '営業利益'] values = [29101002, 0, 29101002, -9977984, 19123018] return create_waterfall_chart( labels=labels, values=values, title="2025年度(4月1日〜3月31日)の変動損益計算図" ) ``` ### 도넛 차트 `src/charts/donut_chart.py`: ```python """도넛 차트 - 비용 구성""" import matplotlib.pyplot as plt from io import BytesIO def create_donut_chart( labels: list[str], values: list[float], colors: Optional[list[str]] = None, title: str = "", center_text: str = "", width: float = 5, height: float = 5, ) -> BytesIO: """도넛 차트 Args: labels: ['売上高', '限界利益', '固定費', '営業利益'] values: 각 항목 값 colors: 색상 center_text: 중앙 텍스트 """ fig, ax = plt.subplots(figsize=(width, height)) if colors is None: colors = ['#4472C4', '#70AD47', '#FFC000', '#C00000'] # 파이 차트 (가운데 빈 도넛) wedges, texts, autotexts = ax.pie( values, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90, pctdistance=0.75, wedgeprops=dict(width=0.5, edgecolor='white'), ) # 중앙 원 (도넛 효과) centre_circle = plt.Circle((0, 0), 0.35, fc='white') ax.add_patch(centre_circle) # 중앙 텍스트 if center_text: ax.text(0, 0, center_text, ha='center', va='center', fontsize=10, fontweight='bold') ax.set_title(title, fontsize=11, pad=20) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer ``` ### 손익분기점 차트 `src/charts/breakeven_chart.py`: ```python """손익분기점 분석 차트""" import matplotlib.pyplot as plt import numpy as np from io import BytesIO def create_breakeven_chart( max_revenue: float, fixed_cost: float, variable_cost_ratio: float, actual_revenue: float, width: float = 6, height: float = 4, ) -> BytesIO: """손익분기점 분석 차트 Args: max_revenue: 최대 매출 (X축 범위) fixed_cost: 고정비 variable_cost_ratio: 변동비율 (0~1) actual_revenue: 실제 매출 """ fig, ax = plt.subplots(figsize=(width, height)) # X축: 매출 x = np.linspace(0, max_revenue, 100) # 총비용선 = 고정비 + 변동비 total_cost = fixed_cost + x * variable_cost_ratio # 매출선 revenue_line = x # 손익분기점 if variable_cost_ratio < 1: bep = fixed_cost / (1 - variable_cost_ratio) else: bep = float('inf') # 그래프 그리기 ax.plot(x, revenue_line, label='売上高', color='#4472C4', linewidth=2) ax.plot(x, total_cost, label='総費用', color='#C00000', linewidth=2) ax.axhline(y=fixed_cost, color='#FFC000', linestyle='--', label='固定費', linewidth=1.5) # 손익분기점 표시 if bep <= max_revenue: ax.plot(bep, bep, 'ko', markersize=10) ax.annotate(f'損益分岐点\n{bep/1e6:.1f}M円', xy=(bep, bep), xytext=(bep * 1.1, bep * 0.8), fontsize=9, arrowprops=dict(arrowstyle='->', color='black')) # 실제 매출 표시 ax.axvline(x=actual_revenue, color='#70AD47', linestyle=':', linewidth=2, label=f'実績売上高 ({actual_revenue/1e6:.1f}M)') # 이익 영역 색칠 ax.fill_between(x, revenue_line, total_cost, where=(revenue_line > total_cost), color='#70AD47', alpha=0.2, label='利益領域') ax.fill_between(x, revenue_line, total_cost, where=(revenue_line <= total_cost), color='#C00000', alpha=0.2, label='損失領域') ax.set_xlabel('売上高', fontsize=10) ax.set_ylabel('金額', fontsize=10) ax.set_title('損益分岐点分析', fontsize=11) ax.xaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'{x/1e6:.0f}M') ) ax.yaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'{x/1e6:.0f}M') ) ax.legend(loc='upper left', fontsize=8) ax.set_xlim(0, max_revenue) ax.set_ylim(0, max_revenue) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer ``` ### 財務ハイライト 페이지 통합 `src/pdf/pages/highlight.py`: ```python """財務ハイライト 페이지""" from reportlab.pdfgen.canvas import Canvas from ..styles import COLORS, FONT_GOTHIC, PAGE_STYLE from ..components.chart_renderer import ChartRenderer from ..components.financial_table import FinancialTableRenderer from charts.radar_chart import create_financial_highlight_radar class HighlightPage: """財務ハイライト 페이지""" def __init__(self, canvas: Canvas, report): self.canvas = canvas self.report = report self.chart_renderer = ChartRenderer(canvas) self.table_renderer = FinancialTableRenderer(canvas) def render(self, page_width: float, page_height: float): """페이지 렌더링""" c = self.canvas margin = PAGE_STYLE['margin_left'] top = page_height - PAGE_STYLE['margin_top'] # 헤더 c.setFont(FONT_GOTHIC, 18) c.setFillColor(COLORS['text']) c.drawCentredString(page_width / 2, top, "財務ハイライト") # 회사 정보 c.setFont(FONT_GOTHIC, 10) c.drawString(margin, top - 30, f"組織名:{self.report.company_name}") c.drawString(margin, top - 44, f"会計期間:{self.report.fiscal_year}") c.drawString(margin, top - 58, f"対象:{self.report.target_month}") # 좌측: 총합평가 + 지표 카드 self._draw_evaluation_section(margin, top - 100, 350) # 우측: 레이더 차트 self._draw_radar_section(margin + 400, top - 100, page_width) def _draw_evaluation_section(self, x: float, y: float, width: float): """총합평가 섹션""" c = self.canvas # 총합평가 박스 c.setStrokeColor(COLORS['border']) c.setFillColor(COLORS['white']) c.roundRect(x, y - 80, width, 80, 5, stroke=1, fill=1) c.setFont(FONT_GOTHIC, 10) c.setFillColor(COLORS['text']) c.drawString(x + 10, y - 20, "総合評価") # 평가 등급 c.setFont(FONT_GOTHIC, 36) c.drawString(x + 30, y - 60, "B") # 점수 c.setFont(FONT_GOTHIC, 24) c.drawString(x + width/2, y - 60, "21/30") # 지표 카드들 (6개) indicators = [ ('売上持続性', '売上高増加率', 0, 'NaN%'), ('収益性', '営業利益率', 5, '68.9%'), ('生産性', '労働生産性', 5, '10,570,000'), ('健全性', 'EBITDA有利子負債倍率', 5, '-0.3倍'), ('効率性', '営業運転資本回転期間', 1, '4.2か月'), ('安全性', '自己資本比率', 5, '81.2%'), ] card_y = y - 110 for i, (title, subtitle, score, value) in enumerate(indicators): card_x = x + (i % 2) * (width / 2) if i > 0 and i % 2 == 0: card_y -= 70 self._draw_indicator_card( card_x, card_y, width/2 - 10, title, subtitle, score, value ) def _draw_indicator_card( self, x: float, y: float, width: float, title: str, subtitle: str, score: int, value: str ): """지표 카드""" c = self.canvas c.setStrokeColor(COLORS['border']) c.roundRect(x, y - 60, width, 60, 3, stroke=1, fill=0) c.setFont(FONT_GOTHIC, 9) c.setFillColor(COLORS['text']) c.drawString(x + 5, y - 15, title) c.setFont(FONT_GOTHIC, 7) c.setFillColor(COLORS['text_light']) c.drawString(x + 5, y - 27, f"({subtitle})") # 점수 c.setFont(FONT_GOTHIC, 10) c.setFillColor(COLORS['text']) c.drawString(x + 5, y - 45, f"点数: {score}/5") # 값 c.drawRightString(x + width - 5, y - 45, value) def _draw_radar_section(self, x: float, y: float, page_width: float): """레이더 차트 섹션""" # 레이더 차트 생성 scores = { '売上持続性': 0, '収益性': 5, '生産性': 5, '健全性': 5, '効率性': 1, '安全性': 5, } benchmarks = { '売上持続性': 3, '収益性': 3, '生産性': 3, '健全性': 3, '効率性': 3, '安全性': 3, } chart_buffer = create_financial_highlight_radar(scores, benchmarks) chart_width = page_width - x - PAGE_STYLE['margin_right'] chart_height = 300 self.chart_renderer.draw_chart( chart_buffer, x, y - chart_height, chart_width, chart_height ) ``` ### 다음 단계 Part 10에서는 전체 코드를 통합하고, CLI 인터페이스와 자동화 기능을 추가합니다. --- **시리즈 네비게이션** - [x] Part 1: 프로젝트 소개 - [x] Part 2: 환경 설정 - [x] Part 3: Sheets API 연동 - [x] Part 4: 데이터 모델 - [x] Part 5: PDF 기본 레이아웃 - [x] Part 6: KPI 대시보드 - [x] Part 7: 테이블 구현 - [x] Part 8: 차트 생성 - [x] Part 9: 복합 차트 (현재) - [ ] Part 10: 완성