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 元 |
若將年數放在橫軸、資產金額放在縱軸繪製折線圖,複利曲線在後期逐漸變陡的特徵便會非常清楚——這就是「複利的威力後期才顯現」的視覺化。
金融圖表常回答的問題
- 資產如何隨時間變化?(折線圖)
- 哪一項資產報酬較高、波動較大?(長條圖/散布圖)
- 兩個變數是否具有關係?(散布圖)
- 某項金融指標是否出現異常?(折線圖+閾值線)
- 資金在不同類別的分配比例為何?(圓餅圖,類別≤5)
金融圖表的三個層次
| 層次 | 說明 | 常見缺失 |
|---|---|---|
| 資料層 | 決定圖表使用哪些資料,如何清理與計算 | 使用未清理資料,缺漏或重複 |
| 視覺層 | 決定圖形類型、座標、標題、圖例與標示 | 無座標單位、無標題、誤導比例 |
| 解釋層 | 說明圖表代表什麼、有哪些限制及如何判讀 | 只有圖,沒有文字說明假設與限制 |
很多金融圖表只完成前兩層,卻沒有解釋層。金融教育網站應在圖表下方加入文字,幫助使用者正確理解。
02常見金融圖表類型與選擇指南
| 圖表類型 | 適合用途 | 避免情境 | 對應工具 |
|---|---|---|---|
| 折線圖 | 股票價格、累積報酬、複利成長、利率變化 | 大量類別比較 | Chart.js/Plotly px.line() |
| 長條圖 | 多檔股票報酬率比較、各產業配置比例 | 連續時間序列 | Chart.js/Plotly px.bar() |
| 散布圖 | 風險-報酬比較、Beta 與報酬關係 | 單一變數分布 | Plotly px.scatter() |
| 圓餅圖 | 投資組合配置(類別 ≤5 個) | 類別超過 6 個 | Chart.js type:'pie' |
| 直方圖 | 每日報酬分布、蒙地卡羅模擬結果 | 比較不同類別 | Plotly px.histogram() |
直方圖與長條圖外觀相似但用途不同:長條圖比較不同類別(如各股票報酬率),直方圖顯示連續數值分組後的頻率分布。圓餅圖類別超過 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 包含四個區域:頁首導覽列、複利成長比較卡、債券價格-殖利率曲線卡、圖表判讀提醒。
<!-- ③ 儀表板: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 複利公式
單利每年只以原始本金計息,成長為一條直線。
複利每年將利息加入本金,下一年再對更大的本金計息,成長為向上彎曲的曲線。
為何圖表包含第 0 年——第 0 年代表剛投入本金的時間點,此時兩條線皆等於本金,讓使用者清楚看到所有方案都從相同起點出發。若省略第 0 年,曲線的起始點差異會讓比較失真。
09債券價格-殖利率曲線的金融原理
P 為債券價格、C 為每年票息(= 面額 × 票面利率)、F 為面額、y 為市場殖利率、n 為到期年數。票息的現值加上面額的現值,就是債券的合理價格。
| 市場殖利率 vs 票面利率 | 債券價格 | 稱為 |
|---|---|---|
| 市場殖利率 = 票面利率 | = 面額 | 平價債券(par) |
| 市場殖利率 < 票面利率 | > 面額 | 溢價債券(premium) |
| 市場殖利率 > 票面利率 | < 面額 | 折價債券(discount) |
直覺解釋:市場殖利率上升,代表新債券可以提供更高收益。舊債券的固定票息顯得吸引力不足,因此市場價格必須下降,使其實際殖利率提高,才能與新債券競爭。
凸性(Convexity)的直覺——債券價格-殖利率曲線不是直線,而是向上凸起的曲線。這意味著:殖利率下降 1% 帶來的價格上漲,通常大於殖利率上升 1% 帶來的價格下跌。這就是凸性對投資人有利的直覺解釋。
10Chart.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 重要選項詳解
| 選項 | 設定方式 | 說明 |
|---|---|---|
| beginAtZero | scales.y.beginAtZero: true | 複利圖設定,從 0 開始避免視覺誤導 |
| interaction mode | { mode:'index', intersect:false } | 滑鼠在同一 X 位置顯示所有資料集 |
| tooltip callback | tooltip.callbacks.label | 自訂 hover 格式,顯示新臺幣千分位 |
| tension | tension: 0.25 | 曲線平滑度,0=折線,1=極度彎曲 |
| destroy() | chart.destroy() | 防止 canvas 重複使用錯誤,建立前呼叫 |
| aria-label | canvas aria-label='...' | 無障礙標記,讓螢幕閱讀器識別圖表 |
12建立多檔股票示範資料
為製作風險-報酬散布圖,建立三檔股票的 CSV 資料,各有不同的報酬與波動特徵:
| 股票代號 | 特徵 | 期間報酬(參考) | 年化波動度(參考) |
|---|---|---|---|
| STOCK_A | 中度報酬、中度波動 | 約 12% | 中 |
| STOCK_B | 較高報酬、較高波動 | 約 18% | 高 |
| STOCK_C | 較低報酬、較低波動 | 約 4.5% | 低 |
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 程式,無法在瀏覽器中直接執行。
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 元——利率沒變,基數變大了。
風險-報酬散布圖:四個象限解讀
| 散布圖區域 | 特徵 | 初學者常見誤解 |
|---|---|---|
| 左上(低波動、高報酬) | 理想位置,但短期樣本可能不穩定 | 看起來最好,但需確認資料期間與樣本量 |
| 右上(高波動、高報酬) | 高風險高報酬,波動大 | 期間短則年化報酬可能被放大 |
| 左下(低波動、低報酬) | 保守型,如定存 | 不是一定不好,視投資目標而定 |
| 右下(高波動、低報酬) | 最不理想,高風險低報酬 | 可能是短期觀察,或資料有問題 |
散布圖位置只反映樣本期間的統計特徵,不能直接推論未來表現。左上方(高報酬低波動)看起來最理想,但需確認資料是否完整、是否選擇了有利期間。
股價圖 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 defined | Chart.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: plotly | Plotly 未安裝或使用了不同環境 | 在虛擬環境中 python -m pip install plotly |
| 散布圖點大小出現錯誤 | total_return_percent 為負值直接當 size | 改用 .abs() + 1 或固定大小 |
22關鍵字
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 完全對應的成品,等一下的挑戰任務就是在這份程式碼上動手修改。
—觀念測驗
共 15 題選擇題,涵蓋本章所有核心觀念。每題作答後會立即顯示解析。
挑戰任務:修改程式碼,讓工具自己告訴你哪裡錯
Chart.js 是純前端技術,可以在瀏覽器中真的執行。下面三個練習都是「真的會執行」的程式碼,修改完成後按下「執行測試」,系統會把你的程式碼放進真實瀏覽器環境中運行,並用白話文告訴你哪裡不對。
任務一:功能修改
對應教材「二十三、實作練習(二)功能修改」
- 在複利圖摘要中,新增顯示「複利領先百分比」
- HTML 已經留好空欄位,只需要在 JavaScript 中賦值
提示(卡住了再打開)
💡 查看範例解答(建議自己先試過再打開)
看完解答後,可以按「還原初始程式碼」重新自己動手做一次,或按「換一題再練習」挑戰另一個類似的任務。
任務二:抓出隱藏的 id 錯誤
對應教材「二十一、常見錯誤與除錯」— Cannot read properties of null
- 這份程式碼交接自另一位同學,執行後完全沒有反應
- 找出問題並修正,讓圖表恢復正常運作
提示(卡住了再打開)
document.getElementById("...") 抓到的是 null。逐一比對 HTML 裡每個 id="..." 跟 JS 裡 getElementById("...") 的拼字是否一模一樣。
💡 查看範例解答(建議自己先試過再打開)
看完解答後,可以按「還原初始程式碼」重新自己抓一次,或按「換一題再練習」練習抓另一個 id 錯誤。
任務三:抓出百分比換算錯誤
對應教材「二十一、常見錯誤與除錯」— 圖表資料顯示 NaN/數字異常
- 這份程式碼可以正常執行、不會報錯,但算出來的數字不合理
- 本金 100,000、報酬率 5%、投資 30 年,正確的「複利高於單利」差距應約為 182,194 元
- 找出問題所在並修正
提示(卡住了再打開)
💡 查看範例解答(建議自己先試過再打開)
看完解答後,可以按「還原初始程式碼」重新自己抓一次,或按「換一題再練習」練習抓另一種百分比換算錯誤。