CH.05 · 財金程式設計互動教材

金融資料視覺化與互動圖表

數字不如圖表直覺——用 Chart.js 做出即時互動的複利成長曲線與債券價格-殖利率曲線,並學會辨識誤導性圖表的六種手法。

data → chart → insight

本章學習目標

  • 分辨折線圖、長條圖、散布圖等圖表的適用情境
  • 用 Chart.js 建立即時互動的複利與債券圖表
  • 理解債券價格與殖利率的反向關係與凸性
  • 辨識截短座標軸等六種常見圖表誤導手法

01金融資料為何需要視覺化

前四章已完成複利試算、貨幣時間價值、JSON 債券資料庫與 Python 股票報酬分析。這些工具能產生終值、現值、報酬率與波動度等數字,但單看數字,未必能快速理解金融變化——複利資產成長 4.32 倍不如折線圖直覺;債券價格與殖利率的反向關係,不如價格-殖利率曲線清楚。

假設本金 100,000 元、年報酬率 5%、投資 30 年,部分年度終值如下表。表格能呈現精確數字,但不容易感受到成長速度的加速:

年數資產金額(複利)單利金額複利超出單利
0 年100,000 元100,000 元0 元
5 年127,628 元125,000 元2,628 元
10 年162,889 元150,000 元12,889 元
20 年265,330 元200,000 元65,330 元
30 年432,194 元250,000 元182,194 元
i

若將年數放在橫軸、資產金額放在縱軸繪製折線圖,複利曲線在後期逐漸變陡的特徵便會非常清楚——這就是「複利的威力後期才顯現」的視覺化。

金融圖表常回答的問題

金融圖表的三個層次

層次說明常見缺失
資料層決定圖表使用哪些資料,如何清理與計算使用未清理資料,缺漏或重複
視覺層決定圖形類型、座標、標題、圖例與標示無座標單位、無標題、誤導比例
解釋層說明圖表代表什麼、有哪些限制及如何判讀只有圖,沒有文字說明假設與限制
!

很多金融圖表只完成前兩層,卻沒有解釋層。金融教育網站應在圖表下方加入文字,幫助使用者正確理解。

02常見金融圖表類型與選擇指南

圖表類型適合用途避免情境對應工具
折線圖股票價格、累積報酬、複利成長、利率變化大量類別比較Chart.js/Plotly px.line()
長條圖多檔股票報酬率比較、各產業配置比例連續時間序列Chart.js/Plotly px.bar()
散布圖風險-報酬比較、Beta 與報酬關係單一變數分布Plotly px.scatter()
圓餅圖投資組合配置(類別 ≤5 個)類別超過 6 個Chart.js type:'pie'
直方圖每日報酬分布、蒙地卡羅模擬結果比較不同類別Plotly px.histogram()
i

直方圖與長條圖外觀相似但用途不同:長條圖比較不同類別(如各股票報酬率),直方圖顯示連續數值分組後的頻率分布。圓餅圖類別超過 5~6 項時,通常建議改用水平長條圖。

03Chart.js 與 Plotly 的分工

使用情境建議工具本書對應範例
前台即時計算後更新圖表Chart.js ✓複利計算器圖表
純 HTML 靜態網站部署Chart.js ✓Cloudflare Pages 直接部署
Python + Pandas 分析結果視覺化Plotly ✓股票報酬分析程式
需要縮放、拖曳、hover 提示Plotly ✓互動式風險報酬圖
Streamlit 管理後台Plotly ✓第八章後台儀表板

Chart.js 核心優點——容易嵌入 HTML,不需 Python 環境;可與前端表單整合,使用者輸入後即時更新圖表;適合靜態網站部署。

<!-- 固定主要版本(正式專案建議)-->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>

<!-- ⚠️ Chart.js 必須在 visualization.js 之前載入 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="js/visualization.js"></script>

Plotly 核心優點——可直接使用 Pandas DataFrame,不需手動轉換格式;hover 提示可顯示詳細資訊,支援縮放與拖曳;write_html() 輸出獨立 HTML,瀏覽器直接開啟。

# 安裝 Plotly(在虛擬環境中執行)
python -m pip install plotly
python -m pip install pandas plotly   # 若尚未安裝 Pandas,一次安裝兩個
python -c "import plotly; print(plotly.__version__)"

04建立金融視覺化頁面結構

financial-platform/
├─ visualization.html          ← 前台金融圖表頁面(新增)
├─ css/
│  └─ visualization.css        ← 圖表頁面樣式(新增)
├─ js/
│  └─ visualization.js         ← Chart.js 程式(新增)
├─ python/
│  └─ stock-charts.py          ← Plotly 分析程式(新增)
├─ data/
│  └─ multi-stock-prices.csv   ← 三檔股票資料(新增)
└─ output/
   ├─ stock-price-chart.html
   ├─ cumulative-return-chart.html
   └─ risk-return-chart.html

05Chart.js 基本結構與關鍵設定

建立圖表的四個步驟:① 取得 canvas 元素 → ② 準備資料(labels 橫軸標籤、datasets 數值陣列)→ ③ 設定圖表類型與選項 → ④ 呼叫 new Chart(canvas, config) 建立圖表物件。

// 取得 canvas 元素
const canvas = document.getElementById("compoundChart");

// 建立圖表
const chart = new Chart(canvas, {
  type: "line", // 圖表類型:line / bar / scatter / pie
  data: {
    labels: ["0年","1年","2年","3年"],       // 橫軸標籤
    datasets: [{
      label: "複利成長",                       // 圖例名稱
      data: [100000, 105000, 110250, 115763], // 數值
      borderWidth: 3,   // 線條粗細
      tension: 0.25,    // 曲線平滑度(0=折線)
      pointRadius: 2,   // 資料點大小
    }]
  },
  options: {
    responsive: true,               // 自動調整寬度
    maintainAspectRatio: false,     // 允許 CSS 控制高度
    plugins: {
      title: { display: true, text: '複利資產成長' },
    },
    scales: {
      x: { title: { display: true, text: '投資期間' } },
      y: { beginAtZero: true, title: { display: true, text: '資產金額(元)' } }
    }
  }
});

Chart.js 關鍵選項說明

選項說明金融應用場景
type: 'line'折線圖(也有 'bar'/'scatter'/'pie')複利成長、累積報酬、股價走勢
responsive: true圖表隨容器寬度自動縮放手機版自動縮小
maintainAspectRatio: false允許 CSS 設定的容器高度生效搭配 .chart-container { min-height: 360px }
beginAtZero: true縱軸從零開始複利圖表避免差距視覺放大
interaction: mode:'index'同時顯示同一 X 軸上所有資料集的提示同時看單利與複利數值
chart.destroy()銷毀舊圖表更新前必須先刪除,避免 canvas 重複使用
!

每次重新建立圖表前,必須先呼叫 chart.destroy()。若直接建立新圖表,會出現 Canvas is already in use 錯誤,導致新舊圖表重疊。

06金融視覺化 HTML 頁面

visualization.html 包含四個區域:頁首導覽列、複利成長比較卡、債券價格-殖利率曲線卡、圖表判讀提醒。

程式區塊visualization.html(節錄核心結構)
<!-- ③ 儀表板:2 欄 Grid -->
<div class="dashboard-grid">

  <!-- 圖表卡片 A:複利成長比較 -->
  <article class="chart-card">
    <div class="card-header">
      <p class="chart-category">複利理財</p>
      <h2>單利與複利成長比較</h2>
    </div>

    <!-- 三欄輸入 Grid -->
    <div class="input-grid">
      <div class="form-group">
        <label for="principal">本金(元)</label>
        <input type="number" id="principal" min="1" step="1000" value="100000">
      </div>
      <div class="form-group">
        <label for="annualRate">年報酬率(%)</label>
        <input type="number" id="annualRate" step="0.1" value="5">
      </div>
      <div class="form-group">
        <label for="investmentYears">投資年數</label>
        <input type="number" id="investmentYears" min="1" max="100" step="1" value="30">
      </div>
    </div>

    <button type="button" id="updateCompoundChart" class="primary-button">更新複利圖表</button>
    <div id="compoundMessage" class="message" aria-live="polite"></div>

    <!-- canvas:Chart.js 繪圖區域 -->
    <div class="chart-container">
      <canvas id="compoundChart" aria-label="單利與複利成長比較圖"></canvas>
    </div>
    <div id="compoundSummary" class="chart-summary"></div>
  </article>

  <!-- 圖表卡片 B:債券價格殖利率曲線(結構相同,欄位改為面額/票面利率/到期年數)-->
  <article class="chart-card">...</article>

</div>

<!-- ⑤ Chart.js 必須在 visualization.js 之前載入 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="js/visualization.js"></script>

07圖表頁面 CSS 重點

/* ── 儀表板:2欄 Grid ── */
.dashboard-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr)); /* 桌面版 2 欄 */
  gap: 24px;
}

/* ── 圖表容器:設定高度讓 Chart.js 正確渲染 ── */
.chart-container { position: relative; min-height: 360px; margin-top: 12px; }

/* ── RWD 斷點 ── */
@media (max-width: 960px) {
  .dashboard-grid { grid-template-columns: 1fr; } /* 960px 以下:單欄 */
}
@media (max-width: 700px) {
  .input-grid { grid-template-columns: 1fr; }     /* 手機版:單欄輸入 */
  .chart-container { min-height: 300px; }          /* 手機版降低最小高度 */
}

08複利成長資料設計:單利 vs 複利公式

FV_simple = PV × (1 + r × n)

單利每年只以原始本金計息,成長為一條直線。

FV_compound = PV × (1 + r)ⁿ

複利每年將利息加入本金,下一年再對更大的本金計息,成長為向上彎曲的曲線。

i

為何圖表包含第 0 年——第 0 年代表剛投入本金的時間點,此時兩條線皆等於本金,讓使用者清楚看到所有方案都從相同起點出發。若省略第 0 年,曲線的起始點差異會讓比較失真。

09債券價格-殖利率曲線的金融原理

P = Σ [C / (1+y)ᵗ] + F / (1+y)ⁿ

P 為債券價格、C 為每年票息(= 面額 × 票面利率)、F 為面額、y 為市場殖利率、n 為到期年數。票息的現值加上面額的現值,就是債券的合理價格。

市場殖利率 vs 票面利率債券價格稱為
市場殖利率 = 票面利率= 面額平價債券(par)
市場殖利率 < 票面利率> 面額溢價債券(premium)
市場殖利率 > 票面利率< 面額折價債券(discount)
i

直覺解釋:市場殖利率上升,代表新債券可以提供更高收益。舊債券的固定票息顯得吸引力不足,因此市場價格必須下降,使其實際殖利率提高,才能與新債券競爭。

凸性(Convexity)的直覺——債券價格-殖利率曲線不是直線,而是向上凸起的曲線。這意味著:殖利率下降 1% 帶來的價格上漲,通常大於殖利率上升 1% 帶來的價格下跌。這就是凸性對投資人有利的直覺解釋。

10Chart.js 完整互動程式

程式分為兩個圖表模組:複利成長比較圖與債券價格-殖利率曲線。每個模組包含:資料產生函數、圖表建立/更新函數、輸入驗證函數與事件監聽。

程式區塊js/visualization.js(完整版含注解)
"use strict";

// ── ① 取得所有 HTML 元素 ────────────────────────
const principalInput = document.getElementById("principal");
const annualRateInput = document.getElementById("annualRate");
const investmentYearsInput = document.getElementById("investmentYears");
const updateCompoundButton = document.getElementById("updateCompoundChart");
const compoundMessage = document.getElementById("compoundMessage");
const compoundSummary = document.getElementById("compoundSummary");
const faceValueInput = document.getElementById("faceValue");
const couponRateInput = document.getElementById("couponRate");
const maturityYearsInput = document.getElementById("maturityYears");
const updateBondButton = document.getElementById("updateBondChart");
const bondMessage = document.getElementById("bondMessage");
const bondSummary = document.getElementById("bondSummary");

// ── ② 圖表物件(初始為 null,更新前先 destroy) ──
let compoundChart = null;
let bondChart = null;

// ── ③ 工具函數 ──────────────────────────────────
function getNumber(inputEl) {
  const v = inputEl.value.trim();
  return v === "" ? Number.NaN : Number(v);
}
function formatCurrency(amount) {
  return new Intl.NumberFormat("zh-TW", {
    style: "currency", currency: "TWD", maximumFractionDigits: 0
  }).format(amount);
}

// ── ④ 複利輸入驗證 ──────────────────────────────
function validateCompoundInputs(principal, ratePercent, years) {
  if (Number.isNaN(principal) || Number.isNaN(ratePercent) || Number.isNaN(years))
    return "請完整輸入本金、報酬率與年數。";
  if (principal <= 0) return "本金必須大於 0。";
  if (ratePercent <= -100) return "報酬率不得小於或等於 -100%。";
  if (years <= 0) return "投資年數必須大於 0。";
  if (!Number.isInteger(years)) return "投資年數請輸入整數。";
  if (years > 100) return "投資年數請勿超過 100 年。";
  return "";
}

// ── ⑤ 產生複利資料(逐年計算單利與複利) ──────
function generateCompoundData(principal, annualRate, years) {
  const labels = [], simpleValues = [], compoundValues = [];
  for (let year = 0; year <= years; year++) {
    const simple = principal * (1 + annualRate * year);          // 單利
    const compound = principal * Math.pow(1 + annualRate, year); // 複利
    labels.push(`${year}年`);
    simpleValues.push(Math.round(simple));
    compoundValues.push(Math.round(compound));
  }
  return { labels, simpleValues, compoundValues };
}

// ── ⑥ 建立/更新複利圖表 ─────────────────────────
function createOrUpdateCompoundChart(chartData) {
  if (compoundChart !== null) compoundChart.destroy();   // 必須先銷毀舊圖
  const canvas = document.getElementById("compoundChart");
  compoundChart = new Chart(canvas, {
    type: "line",
    data: {
      labels: chartData.labels,
      datasets: [
        { label: "單利成長", data: chartData.simpleValues, borderWidth: 2, pointRadius: 1, tension: 0.2 },
        { label: "複利成長", data: chartData.compoundValues, borderWidth: 3, pointRadius: 1, tension: 0.25 }
      ]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: "index", intersect: false },
      plugins: {
        title: { display: true, text: "單利與複利資產成長" },
        tooltip: { callbacks: {
          label: ctx => `${ctx.dataset.label}:${formatCurrency(ctx.parsed.y)}`
        }}
      },
      scales: {
        x: { title: { display: true, text: "投資期間" } },
        y: { beginAtZero: true,  // ← 縱軸從零開始,避免視覺放大
             title: { display: true, text: "資產金額" },
             ticks: { callback: v => Number(v).toLocaleString('zh-TW') } }
      }
    }
  });
}

// ── ⑦ 主更新函數(複利)─────────────────────────
function updateCompoundChart() {
  compoundMessage.textContent = "";
  const principal = getNumber(principalInput);
  const ratePercent = getNumber(annualRateInput);
  const years = getNumber(investmentYearsInput);
  const err = validateCompoundInputs(principal, ratePercent, years);
  if (err) { compoundMessage.textContent = err; return; }
  const annualRate = ratePercent / 100;
  const chartData = generateCompoundData(principal, annualRate, years);
  createOrUpdateCompoundChart(chartData);
  const finalSimple = chartData.simpleValues[chartData.simpleValues.length - 1];
  const finalCompound = chartData.compoundValues[chartData.compoundValues.length - 1];
  compoundSummary.innerHTML = `
    

期末單利金額:${formatCurrency(finalSimple)}

期末複利金額:${formatCurrency(finalCompound)}

複利高於單利:${formatCurrency(finalCompound - finalSimple)}

`; } // ── ⑧ 債券定價函數 ────────────────────────────── function calculateBondPrice(faceValue, couponRate, marketYield, maturityYears) { const annualCoupon = faceValue * couponRate; let price = 0; for (let t = 1; t <= maturityYears; t++) { price += annualCoupon / Math.pow(1 + marketYield, t); // 各期票息現值 } price += faceValue / Math.pow(1 + marketYield, maturityYears); // 面額現值 return price; } // ── ⑨ 產生債券曲線資料(殖利率 0.5%~10%,間距 0.25%) function generateBondCurveData(faceValue, couponRate, maturityYears) { const labels = [], prices = []; for (let yld = 0.5; yld <= 10.0001; yld += 0.25) { const marketYield = yld / 100; const price = calculateBondPrice(faceValue, couponRate, marketYield, maturityYears); labels.push(`${yld.toFixed(2)}%`); prices.push(Math.round(price)); } return { labels, prices }; } // ── ⑩~⑫ 建立/更新債券圖表、輸入驗證、主更新函數(結構與複利圖表相同) // ── ⑬ 事件監聽與初始化 ────────────────────────── updateCompoundButton.addEventListener("click", updateCompoundChart); updateBondButton.addEventListener("click", updateBondChart); // 頁面載入時自動顯示預設值圖表 updateCompoundChart(); updateBondChart();

11Chart.js 重要選項詳解

選項設定方式說明
beginAtZeroscales.y.beginAtZero: true複利圖設定,從 0 開始避免視覺誤導
interaction mode{ mode:'index', intersect:false }滑鼠在同一 X 位置顯示所有資料集
tooltip callbacktooltip.callbacks.label自訂 hover 格式,顯示新臺幣千分位
tensiontension: 0.25曲線平滑度,0=折線,1=極度彎曲
destroy()chart.destroy()防止 canvas 重複使用錯誤,建立前呼叫
aria-labelcanvas aria-label='...'無障礙標記,讓螢幕閱讀器識別圖表

12建立多檔股票示範資料

為製作風險-報酬散布圖,建立三檔股票的 CSV 資料,各有不同的報酬與波動特徵:

股票代號特徵期間報酬(參考)年化波動度(參考)
STOCK_A中度報酬、中度波動約 12%
STOCK_B較高報酬、較高波動約 18%
STOCK_C較低報酬、較低波動約 4.5%
程式區塊data/multi-stock-prices.csv(節錄)
date,symbol,close
2026-01-02,STOCK_A,100
2026-01-05,STOCK_A,102
...
2026-01-15,STOCK_A,112
2026-01-02,STOCK_B,100
2026-01-05,STOCK_B,105
...
2026-01-15,STOCK_B,118
2026-01-02,STOCK_C,100
2026-01-05,STOCK_C,100.5
...
2026-01-15,STOCK_C,104.5

13Plotly 完整股票圖表程式

本程式讀取三檔股票 CSV,產生三種圖表:股票價格走勢、累積報酬率比較、風險-報酬散布圖。跟第四章一樣,這是 Python 程式,無法在瀏覽器中直接執行。

程式區塊python/stock-charts.py(節錄核心邏輯)
from pathlib import Path
import math, sys
import pandas as pd
import plotly.express as px

TRADING_DAYS = 252

# ── 計算報酬率與統計指標 ──────────────────────
def calculate_metrics(data: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
    result = data.copy()
    result["daily_return"] = (
        result.groupby("symbol")["close"].pct_change(fill_method=None)
    )
    result["cumulative_return"] = (
        result.groupby("symbol")["daily_return"]
        .transform(lambda r: (1 + r).cumprod() - 1)
    )
    summary_rows = []
    for symbol, group in result.groupby("symbol"):
        valid_returns = group["daily_return"].dropna()
        summary_rows.append({
            "symbol": symbol,
            "total_return": group["close"].iloc[-1] / group["close"].iloc[0] - 1,
            "annualized_return": valid_returns.mean() * TRADING_DAYS,
            "annualized_volatility": valid_returns.std(ddof=1) * math.sqrt(TRADING_DAYS),
        })
    return result, pd.DataFrame(summary_rows)

# ── 圖表 A:多檔股票價格走勢 ──────────────────
def create_price_chart(data: pd.DataFrame) -> None:
    fig = px.line(
        data, x='date', y='close', color='symbol', markers=True,
        title="股票價格走勢比較",
        labels={"date": "日期", "close": "收盤價", "symbol": "股票代號"}
    )
    fig.update_layout(hovermode="x unified")
    fig.write_html(PRICE_FILE, include_plotlyjs="cdn")

# ── 圖表 C:風險-報酬散布圖 ──────────────────
def create_risk_return_chart(summary: pd.DataFrame) -> None:
    d = summary.copy()
    d["annualized_return_pct"] = d["annualized_return"] * 100
    d["annualized_volatility_pct"] = d["annualized_volatility"] * 100
    d["total_return_pct"] = d["total_return"] * 100
    fig = px.scatter(
        d,
        x="annualized_volatility_pct",  # 橫軸:波動度(風險)
        y="annualized_return_pct",      # 縱軸:報酬率
        text="symbol",                   # 顯示股票代號
        size=d["total_return_pct"].abs() + 1,  # 點大小(防止負值)
        hover_name="symbol",
        title="資產風險-報酬比較",
        labels={
            "annualized_volatility_pct": "年化波動度(%)",
            "annualized_return_pct": "年化平均報酬率(%)",
        }
    )
    fig.update_traces(textposition="top center")
    fig.write_html(RISK_RETURN_FILE, include_plotlyjs="cdn")
!

px.scatter 的 size 參數不接受負數。若期間總報酬為負,需先用 .abs() + 1 轉換,或改用固定點大小 size_max=20。

14執行 Plotly 圖表程式

# 在專案資料夾執行(需先啟動虛擬環境)
python python\stock-charts.py

# 成功後 output/ 資料夾出現三份 HTML:
# output/
# ├─ stock-price-chart.html          ← 股票價格走勢
# ├─ cumulative-return-chart.html    ← 累積報酬率比較
# └─ risk-return-chart.html          ← 風險-報酬散布圖

# 直接以瀏覽器開啟,即可使用 hover / 縮放 / 拖曳功能
函數圖表類型本章用途
px.line(x,y,color,markers)折線圖股票價格走勢、累積報酬率比較
px.scatter(x,y,text,size)散布圖風險-報酬比較圖(每點代表一檔股票)
px.bar(x,y,color)長條圖進階:各月報酬率長條圖
fig.write_html(path,...)輸出 HTML產生可獨立開啟的互動圖表 HTML

15將 Plotly 圖表整合至網站

<!-- 方式一:超連結開啟(最簡單)-->
<a href="output/risk-return-chart.html" target="_blank" rel="noopener">
  開啟風險報酬互動圖
</a>

<!-- 方式二:iframe 嵌入頁面(可直接在頁面看到互動圖)-->
<iframe
  src="output/risk-return-chart.html"
  title="股票風險報酬互動圖"   ← 無障礙標記必填
  width="100%"
  height="600"
  loading="lazy"              ← 延遲載入,提升頁面速度
></iframe>
整合方式優點注意事項
超連結簡單,不影響頁面效能使用者需點擊後開啟新頁
iframe圖表直接顯示在頁面中需設定足夠高度;部署時記得上傳 output/ 資料夾

16金融圖表的正確判讀

複利曲線:後期加速的真正原因——複利曲線後期逐漸變陡,不是因為報酬率提高,而是每年利息加入本金,使下一年的計息基礎增加。10 萬元以 5% 複利,第 1 年增加 5,000 元,第 20 年增加 13,266 元——利率沒變,基數變大了。

風險-報酬散布圖:四個象限解讀

散布圖區域特徵初學者常見誤解
左上(低波動、高報酬)理想位置,但短期樣本可能不穩定看起來最好,但需確認資料期間與樣本量
右上(高波動、高報酬)高風險高報酬,波動大期間短則年化報酬可能被放大
左下(低波動、低報酬)保守型,如定存不是一定不好,視投資目標而定
右下(高波動、低報酬)最不理想,高風險低報酬可能是短期觀察,或資料有問題
i

散布圖位置只反映樣本期間的統計特徵,不能直接推論未來表現。左上方(高報酬低波動)看起來最理想,但需確認資料是否完整、是否選擇了有利期間。

股價圖 vs 累積報酬圖的差別——甲股票 1,000 元漲到 1,100 元(+10%);乙股票 50 元漲到 60 元(+20%)。若只畫股價走勢,甲股票上漲幅度(100 元)看起來比乙股票(10 元)大得多,但報酬率其實乙股票更高。比較不同股票的投資績效,應使用累積報酬率圖,而非股價水準圖。

17避免誤導性金融圖表

誤導手法說明防範方式
截短縱軸縱軸從接近最小值開始,視覺放大差距查看縱軸起點,確認 beginAtZero 設定
不一致時間區間甲資產用多頭期間,乙資產含金融危機確認兩個比較期間完全相同
缺漏大跌資料缺少不利期間,累積報酬被高估完成資料清理後再繪圖,確認日期連續
過多線條十幾條線無法辨識提供篩選功能,預設顯示少數重要資料
選擇性展示期間只選上漲的期間呈現明確標示資料起訖日期
雙縱軸比例誤導刻意調整比例讓兩線看起來高度相關清楚標示兩軸單位,說明關係
!

負報酬率測試:單利公式的邊界——當年報酬率為負數時(如 −5%),複利終值仍有意義(資產逐漸縮小);但單利公式在長期負報酬下,可能讓資產變成負值(例如第 21 年 = 100,000 × (1 − 0.05 × 21) = −5,000),這在現實中不可能發生。測試技巧:輸入年報酬率 −5%、投資 25 年,觀察單利曲線是否穿越 0 以下。

18金融圖表的 RWD 與行動裝置設計

設計要點CSS/程式設定說明
圖表寬度自動調整.chart-container { width:100% }Chart.js responsive: true 搭配容器寬度
設定最小高度min-height: 360px手機版降至 300px,桌面版 360px 以上
輸入欄改為單欄@media 700px三欄輸入在手機太窄,改為單欄
儀表板改為單欄@media 960px兩張圖在手機上下排列

19金融圖表設計原則

原則不好的做法建議做法
一圖回答一個問題在一張圖塞入 15 條線每張圖只呈現一個核心比較
標題具有資訊「圖表一」「不同報酬率下的 30 年複利資產成長」
座標軸標示單位縱軸無說明「資產金額(元)」、「報酬率(%)」
圖例名稱清楚「資料 1」、「資料 2」「單利成長」、「複利成長」
圖表下方有結論只有圖,無文字「30 年後複利約 43.2 萬,高於單利 18.2 萬」
揭露資料假設不說明假設標示資料來源、期間、是否含股利、是否扣費用

20操作實務與測試

複利圖表七項測試

測試輸入預期結果
標準測試本金 10 萬、5%、30 年複利 432,194 元;單利 250,000 元;差距 182,194 元
零報酬率本金 10 萬、0%、20 年單利與複利皆維持 100,000 元,兩線重疊
負報酬率本金 10 萬、-5%、10 年複利約 59,874 元(資產逐年縮小)
空白欄位清空本金顯示錯誤:請完整輸入
極大年數年數 101顯示錯誤:年數不超過 100 年

債券圖表五項關鍵測試

測試輸入預期結果
平價面額 10 萬、票面 3%、10 年、殖利率 3%價格約等於 100,000 元
溢價上述條件,殖利率改 2%價格 > 100,000 元
折價上述條件,殖利率改 5%價格 < 100,000 元
零票息債券票面利率 0%價格=面額折現值,無票息,曲線更陡

21常見錯誤與除錯

錯誤訊息 / 症狀最可能原因修正方式
Chart is not definedChart.js 未載入或載入順序錯誤確認 Chart.js <script> 在 visualization.js 之前
Canvas is already in use更新圖表時未先呼叫 chart.destroy()建立前加 if (chart !== null) chart.destroy()
圖表高度為零chart-container 未設定 height.chart-container { min-height: 360px }
圖表資料顯示 NaN輸入空白或百分比未除以 100加入 validateInputs 並確認 ratePercent / 100
債券價格不等於面額(殖利率=票面利率)票面或殖利率忘記除以 100確認 couponRate = couponRatePercent / 100
ModuleNotFoundError: plotlyPlotly 未安裝或使用了不同環境在虛擬環境中 python -m pip install plotly
散布圖點大小出現錯誤total_return_percent 為負值直接當 size改用 .abs() + 1 或固定大小

22關鍵字

金融資料視覺化Chart.jsPlotly Plotly Express(px)折線圖長條圖 散布圖圓餅圖直方圖 canvasdatasetlabels tooltip callbackslegendresponsive maintainAspectRatiobeginAtZerodestroy() CDNpx.line()px.scatter() write_html()include_plotlyjsiframe RWD累積報酬率價格-殖利率曲線 凸性風險-報酬散布圖截短座標軸 選擇性呈現無障礙圖表

23實作練習

(一)基礎操作
  • 將複利圖本金改為 300,000 元、報酬率 4%、投資 20 年,記錄單利與複利差距
  • 測試 0% 報酬率,確認單利與複利兩線重疊
  • 測試 −5% 報酬率、10 年,觀察複利資產如何逐年縮小
  • 將債券面額改為 500,000 元、票面 4%、到期 5 年,確認殖利率 4% 時價格接近面額
  • 執行 Plotly 程式,開啟三份互動 HTML 圖表,測試 hover 功能
(二)功能修改
  • 增加第三條「10%」報酬率線到複利圖,比較三種情境
  • 在複利圖縱軸加入通貨膨脹調整(實質報酬率 = 名目報酬率 − 通膨率)
  • 在債券圖上加入一條水平線標示面額位置(平價基準線)
  • 增加殖利率輸入範圍,讓使用者自訂最大殖利率(預設 10%)
  • 在 Plotly 散布圖加入移動平均線(rolling(5).mean())
  • 增加下載圖表按鈕(Chart.js 可用 canvas.toDataURL() 輸出 PNG)
(三)進階挑戰
  • 建立每日報酬率直方圖(Plotly px.histogram),觀察報酬分布形狀
  • 建立相關係數熱度圖(三檔股票兩兩相關)
  • 建立最大回撤圖(rolling_max − current_value)
  • 將 Python 分析結果輸出為 JSON,改由 Chart.js 前端讀取並顯示
  • 在 Streamlit 中整合所有 Plotly 圖表,建立金融分析儀表板

Chart.js 是純前端技術,可以在瀏覽器中真的執行,所以本章的挑戰任務(04 分頁)延續第一~三章的模式:修改真正會執行的程式碼,系統會即時告訴你哪裡出錯。Plotly/Python 的部分則跟第四章一樣,請在自己電腦安裝好 Python + Pandas + Plotly 之後練習。

本章結語——本章將前四章產生的金融數字轉化為可理解、可比較及可互動的圖表。Chart.js 適合放在金融網站前台,讓使用者輸入本金、報酬率、債券面額與到期年數後即時更新圖表;Plotly 則適合搭配 Python + Pandas,將股票資料轉化為可縮放的互動 HTML 圖表。

讀者完成了:單利與複利成長比較圖、債券價格-殖利率曲線(並理解凸性直覺)、多檔股票價格走勢、累積報酬率比較、風險-報酬散布圖,以及圖表誤導手法的識別與防範。

下一章進入 GitHub 版本控制與 Cloudflare Pages 部署。讀者將學習建立 Repository、上傳網站檔案、保存修改紀錄,並將目前完成的金融工具正式發布到網路上,讓任何人都可以存取。

互動圖表分析工具(教材原始範例)

這是真正用 Chart.js 繪製、可以互動的圖表——調整本金/報酬率/年數,或面額/票面利率/到期年數,點擊按鈕即時更新。這就是課本第六~十節的 visualization.html/visualization.css/visualization.js 完全對應的成品,等一下的挑戰任務就是在這份程式碼上動手修改。

  visualization.html — 在瀏覽器中執行

觀念測驗

共 15 題選擇題,涵蓋本章所有核心觀念。每題作答後會立即顯示解析。

0 / 15 已作答

挑戰任務:修改程式碼,讓工具自己告訴你哪裡錯

Chart.js 是純前端技術,可以在瀏覽器中真的執行。下面三個練習都是「真的會執行」的程式碼,修改完成後按下「執行測試」,系統會把你的程式碼放進真實瀏覽器環境中運行,並用白話文告訴你哪裡不對。

任務一:功能修改

對應教材「二十三、實作練習(二)功能修改」

難度:入門
  • 在複利圖摘要中,新增顯示「複利領先百分比」
  • HTML 已經留好空欄位,只需要在 JavaScript 中賦值
js/visualization.js — 可直接編輯
提示(卡住了再打開)
在對應的 update 函式算出結果之後,加入一行把計算結果寫進指定的空欄位。
💡 查看範例解答(建議自己先試過再打開)


            

看完解答後,可以按「還原初始程式碼」重新自己動手做一次,或按「換一題再練習」挑戰另一個類似的任務。

任務二:抓出隱藏的 id 錯誤

對應教材「二十一、常見錯誤與除錯」— Cannot read properties of null

難度:除錯
  • 這份程式碼交接自另一位同學,執行後完全沒有反應
  • 找出問題並修正,讓圖表恢復正常運作
js/visualization.js — 藏有 1 個 bug
提示(卡住了再打開)
瀏覽器主控台(F12 → Console)如果出現「Cannot read properties of null」,代表 document.getElementById("...") 抓到的是 null。逐一比對 HTML 裡每個 id="..." 跟 JS 裡 getElementById("...") 的拼字是否一模一樣。
💡 查看範例解答(建議自己先試過再打開)


            

看完解答後,可以按「還原初始程式碼」重新自己抓一次,或按「換一題再練習」練習抓另一個 id 錯誤。

任務三:抓出百分比換算錯誤

對應教材「二十一、常見錯誤與除錯」— 圖表資料顯示 NaN/數字異常

難度:除錯
  • 這份程式碼可以正常執行、不會報錯,但算出來的數字不合理
  • 本金 100,000、報酬率 5%、投資 30 年,正確的「複利高於單利」差距應約為 182,194 元
  • 找出問題所在並修正
js/visualization.js — 數字不合理,但沒有紅字錯誤
提示(卡住了再打開)
這是「跑得動、但答案錯」的類型,找不到紅字錯誤時,回頭檢查每一個數學運算式,尤其是使用者輸入的報酬率百分比到底有沒有被正確換算成小數。
💡 查看範例解答(建議自己先試過再打開)


            

看完解答後,可以按「還原初始程式碼」重新自己抓一次,或按「換一題再練習」練習抓另一種百分比換算錯誤。