INVESTMENT #06

PythonとPlotlyでポートフォリオを可視化する(ホバー・ズーム・期間選択)

Python経験者向けの投資分析シリーズ第6回。plotlyマウスを乗せると内訳が出て、ドラッグで拡大でき、ボタンで期間を切り替えられるポートフォリオのグラフを組み立てます。最後は1枚のHTMLファイルにして、毎日ブラウザで開ける道具にします。

🎯 対象: Python経験者・投資分析 📦 plotly 6.8.0 / pandas 3.0.3 🧪 2026-08-28 に実行して確認 ⏱️ 読了: 約28分

1. この記事のゴールと連載の位置づけ

本記事は「Python経験者が、自分の投資ポートフォリオを管理・可視化するアプリを作る」連載の第6回です。前回のMatplotlibでローソク足チャートを描くで、ローソク足・移動平均線・出来高を1枚にまとめたチャートを作りました。あの回の最後に、こう書いて終わっています——「期間を切り替えたい・特定の足の値を確認したいとなると、画像では手が届きません」と。

その続きが今回です。PNGは「見る」ためのもの、Plotlyで作るHTMLは「触る」ためのものだと考えてください。マウスを乗せればその日の内訳が数字で出て、気になる時期はドラッグで拡大でき、「直近3か月だけ」はボタン1つで切り替わります。資料に貼るなら画像、自分で毎日眺めて確かめるなら操作できるHTML——この使い分けができるようになると、手元のデータの見え方が変わります。

題材は第4回のPythonで保有銘柄の損益を計算する(pandas)で作った損益一覧です。あのとき出したのは「今日いくらか」という1枚のスナップショットでした。今回はそこに時間の軸を足して、「そこに至るまでにどう増えて、どこで減ったのか」を追えるようにします。

🎯
この回のゴール

取引履歴と日次の株価を渡すと、評価額の推移・構成比・銘柄別の損益が1画面に並んだHTMLファイルを書き出す関数を書けるようになること。ブラウザで開けば、ホバー・ズーム・期間切り替えがそのまま使えます。

完成コードだけ先に見たい方は 10章「完成コード」へ。そのままファイルに保存して動く形で載せています(そこまでの本文は、その1行1行が何をしているのかを確かめる道のりです)。

「動かせるグラフ」がどんなものかを先に見たい方は、6章に実際に触れるデモを埋め込んであります6章のコードが書き出したHTMLを埋め込んだもので、マウスを乗せる・ドラッグで拡大する・ボタンで期間を切り替える、をその場で試せます。

🧪
この記事の検証環境

本文のコード・出力・図は、Windows 11 / Python 3.12.10 / plotly 6.8.0 / kaleido 1.3.0 / pandas 3.0.3 / NumPy 2.4.6 の環境で 2026年8月28日に実際に実行して確認したものです。掲載している出力と画像は、上から順にコードを実行したときのそのままの結果です。登場する銘柄・価格・数量はすべて架空で、実在の銘柄や相場を示すものではありません。

2. PlotlyとMatplotlibの違い、そしてインストール

前回のMatplotlibと今回のPlotlyは、どちらが優れているかではなく、出力先が違うライブラリです。Matplotlibは画像(PNG・PDF)を作ります。PlotlyはHTMLとJavaScriptを作ります。この一点から、性格の違いが全部出てきます。

Matplotlib(第5回)Plotly(この回)
出力PNG・PDF などの画像HTML+JavaScript(画像も書き出せる)
操作できない(静止画)ホバー・ズーム・パン・凡例クリックが標準で付く
向く用途記事・レポート・印刷・大量バッチ生成自分用ダッシュボード・値を確認しながら眺める
閲覧に要るもの画像ビューアブラウザ(描画にJavaScriptを使う)
ファイルの重さ数十KB〜数百KBデータ量しだい。9章で実測(58KB/4.9MB)

つまり「印刷して配る」「記事に貼る」ならMatplotlib、「自分で触って確かめる」ならPlotlyです。第5回で作ったローソク足の関数は今も現役で、今回のダッシュボードと役割がぶつかることはありません。

インストールはpipだけで済みます。PNGの書き出しも試すなら kaleido も一緒に入れてください(後述しますが、Plotly単体ではPNGを作れません)。

pip install plotly kaleido pandas

入ったバージョンを確認しておきます。Plotlyは版によって書き方が変わることがあるので、記事のコードが動かないときは最初にここを見ます。

import plotly

print("plotly:", plotly.__version__)
plotly: 6.8.0
📦
Plotlyのライセンスと提供元(一次情報で確認)

Plotly(Python版のパッケージ名 plotly)は MITライセンスで公開されています。GitHubリポジトリの LICENSE.txt には MIT License / Copyright (c) 2016-2024 Plotly Technologies Inc. と記載されており、PyPIのページでもライセンス欄は MIT、分類(classifier)にも License :: OSI Approved :: MIT License が付いています(いずれも2026年8月28日に確認)。自分の用途で使えるかどうかは、条文の原文をご確認ください。

同じ日にPyPIで確認した最新版は 7.0.0(2026年8月25日公開)で、本記事が使っている 6.8.0 は2026年6月3日公開のものです。7系での動作は当サイトでは検証していませんので、本記事のコードは 6.8.0 で確認した内容としてお読みください。

3. 描く材料をそろえる(評価額の推移を作る)

グラフを描く前に、描く材料が要ります。今回ほしいのは「日付 × 銘柄 × その日の評価額」の表です。第4回で作ったのは今日1日ぶんの損益一覧だったので、過去にさかのぼった日次の株価と、その日に何株持っていたかの2つを用意すれば、掛け算で評価額の推移になります。

日次の株価を架空データで作る

まず株価です。本記事では固定シードの乱数から生成した架空データを使います。実在の株価を使わない理由は第5回と同じで、①実在銘柄の値動きを載せると意図せず特定銘柄への言及になる ②ネットワークがなくても手元で試せる ③同じコードを実行すれば誰でも同じ図が出るの3点です。

ただし今回はもう一工夫しています。第4回の取引履歴には「2025年4月10日に2,500円で買った」「2026年2月20日に3,400円で売った」といった価格が出てきます。生成した株価がその日にまるで違う値だと、話がつながりません。そこで節目の価格をちょうど通る乱数にしました。節目と節目のあいだは、ランダムウォークから直線ぶんの傾きを引き算して両端が節目にぴったり戻るようにしています(ブラウン橋と呼ばれる作り方です)。

import numpy as np
import pandas as pd

# 第4回の売買価格・現在値(すべて架空の銘柄・価格)
ANCHORS = {
    "ALPHA": {"2025-04-10": 2500, "2025-09-12": 3000, "2026-02-20": 3400, "2026-08-28": 3250},
    "BETA":  {"2025-05-15": 1200, "2026-01-30": 1050, "2026-08-28": 1010},
    "GAMMA": {"2025-06-01":  800, "2025-11-11":  950, "2026-08-28":  780},
}


def make_price_history(seed: int = 42) -> pd.DataFrame:
    """架空3銘柄の日次終値を作る。上の節目の価格をちょうど通る(実在の銘柄・相場とは無関係)。"""
    dates = pd.bdate_range("2025-04-01", "2026-08-28")
    t = np.arange(len(dates))
    rng = np.random.default_rng(seed)                            # 固定シード=毎回同じ値

    out = {}
    for ticker, points in ANCHORS.items():
        pos = dates.get_indexer(pd.to_datetime(list(points)), method="nearest")
        base = np.interp(t, pos, np.log(list(points.values())))  # 節目を結んだ対数価格
        walk = np.cumsum(rng.normal(0, 0.011, len(dates)))       # ランダムウォーク
        walk -= np.interp(t, pos, walk[pos])                     # 節目でぴったり0に戻す
        out[ticker] = np.round(np.exp(base + walk), 0)
    return pd.DataFrame(out, index=pd.DatetimeIndex(dates, name="date"))


prices = make_price_history()
print(prices.head(3))
print()
print(prices.tail(3))
print()
print(f"行数: {len(prices)}  期間: {prices.index[0]:%Y-%m-%d} 〜 {prices.index[-1]:%Y-%m-%d}")
             ALPHA    BETA  GAMMA
date                             
2025-04-01  2578.0  1197.0  715.0
2025-04-02  2549.0  1197.0  698.0
2025-04-03  2570.0  1214.0  705.0

             ALPHA    BETA  GAMMA
date                             
2026-08-26  3276.0   990.0  788.0
2026-08-27  3245.0  1000.0  793.0
2026-08-28  3250.0  1010.0  780.0

行数: 369  期間: 2025-04-01 〜 2026-08-28

2025年4月1日から2026年8月28日までの369日ぶんができました。pd.bdate_range() が返すのは土日を除いた平日で、祝日は除いていません(第5回では祝日を抜いた61営業日を扱ったので、そこは方針が違います。今回は描画が主題なので、休場日を1日単位で厳密に合わせることはしていません)。最終日を見ると ALPHA 3,250円・BETA 1,010円・GAMMA 780円——第4回で「現在値」として使った3つの数字と同じです。節目を通す作り方にしたので、ここは狙って一致させています。

🎲
実データに差し替えるときは、この関数だけを置き換える

make_price_history() が返しているのは「日付が索引、銘柄が列」の DataFrameです。第1回のyfinanceで株価データを取得するで複数銘柄の終値を取れば同じ形になるので、差し替えるのはこの関数だけで、以降の描画コードはそのまま動きます。なお np.random.default_rng(42) はシードを固定しているため同じ環境なら何度でも同じ値になりますが、NumPyのバージョンが変わると乱数の中身が変わる可能性があります(本記事は NumPy 2.4.6 で実行)。銘柄名は第4回・第5回と共通ですが、株価そのものは記事ごとに別のシードで生成した架空の系列です——本記事は第4回の売買価格・現在値に合わせてあり、第5回のローソク足の日足とは一致しません。

取引履歴から「その日の保有数量」を作る

次に数量です。取引履歴にあるのは売買のあった日だけなので、買いを+・売りを−にして累積すれば、その日の保有数量になります。ポイントは約定日が株価データの日付に無い場合への備えです。ここでは reindex両方の日付を合わせた索引を渡し、累積してから株価側の日付にそろえています。こうしておくと、休場日に付いた記録や、カレンダーのずれで取引が丸ごと消える事故を防げます。サンプルの取引履歴にも2025年6月1日(日曜)の約定が混じっていますが、この作りなら取りこぼさず、次の営業日である6月2日から評価額に反映されます

# 第4回と同じ架空の取引履歴
transactions = pd.DataFrame(
    [
        ("2025-04-10", "ALPHA", "buy",  100, 2500.0, 250.0),
        ("2025-05-15", "BETA",  "buy",  200, 1200.0, 220.0),
        ("2025-06-01", "GAMMA", "buy",  300,  800.0, 330.0),
        ("2025-09-12", "ALPHA", "buy",  100, 3000.0, 250.0),
        ("2025-11-11", "GAMMA", "buy",  100,  950.0, 275.0),
        ("2026-01-30", "BETA",  "sell", 200, 1050.0, 200.0),
        ("2026-02-20", "ALPHA", "sell",  50, 3400.0, 275.0),
    ],
    columns=["date", "ticker", "type", "qty", "price", "fee"],
)
transactions["date"] = pd.to_datetime(transactions["date"])


def build_value_history(transactions: pd.DataFrame, prices: pd.DataFrame):
    """取引履歴と日次終値から、銘柄ごとの保有数量と評価額の推移を作る。"""
    signed = transactions["qty"] * np.where(transactions["type"] == "buy", 1, -1)
    moves = (transactions.assign(signed=signed)
             .pivot_table(index="date", columns="ticker", values="signed", aggfunc="sum"))

    qty = (moves.reindex(moves.index.union(prices.index))   # 約定日が価格データに無くても取りこぼさない
                .fillna(0).cumsum()                         # 累積=その日の保有数量
                .reindex(prices.index)                      # 価格データの日付にそろえる
                .reindex(columns=prices.columns, fill_value=0)
                .astype("int64"))
    return qty, qty * prices


qty, value = build_value_history(transactions, prices)
print(value.loc["2026-01-28":"2026-02-03"])
print()
print(value.tail(1))
print(f"最終日の評価額合計: {value.iloc[-1].sum():,.0f} 円")
               ALPHA      BETA     GAMMA
date                                    
2026-01-28  649600.0  214800.0  350400.0
2026-01-29  659800.0  212000.0  355200.0
2026-01-30  659400.0       0.0  352400.0
2026-02-02  655000.0       0.0  360800.0
2026-02-03  654000.0       0.0  360000.0

               ALPHA  BETA     GAMMA
date                                
2026-08-28  487500.0   0.0  312000.0
最終日の評価額合計: 799,500 円

2026年1月30日を境にBETAの評価額が0になっているのが見えます。これは値下がりではなくその日に200株すべてを売ったからです。この「売却による段差」を、あとでホバーとグラフの形からきちんと読み取れるようにするのが、この記事の裏テーマでもあります。

第4回の集計結果を突き合わせる

最後に、第4回が出した損益一覧を持ってきます。今回の推移データの最終日と、第4回の評価額が一致するかをここで確かめておくと、後の章で数字が食い違ったときに原因を探さずに済みます。

# 第4回の build_portfolio() が返した損益一覧(そのまま持ってきた架空の集計結果)
summary = pd.DataFrame({
    "ticker":       ["ALPHA",  "BETA",    "GAMMA"],
    "qty":          [150,      0,         400],
    "market_value": [487500.0, 0.0,       312000.0],
    "unrealized":   [74625.0,  0.0,       -23605.0],
    "realized":     [32100.0,  -30420.0,  0.0],
    "dividend":     [5000.0,   4800.0,    6000.0],
    "total_pl":     [111725.0, -25620.0,  -17605.0],
})

# 第4回の評価額と、今回作った推移データの最終日が一致するかを確認する
check = summary.set_index("ticker")["market_value"] - value.iloc[-1]
print(check)
print(f"差の絶対値の最大: {check.abs().max():,.0f} 円")
ticker
ALPHA    0.0
BETA     0.0
GAMMA    0.0
dtype: float64
差の絶対値の最大: 0 円

3銘柄とも差は0円でした。別々の道筋で計算した数字が一致するので、以降のグラフは第4回の結果と同じものを別の角度から見ている、と言い切れます。自分のデータでやるときも、この突き合わせは1行書いておく価値があります。可視化は間違った数字でも同じようにきれいに描けてしまうからです。

4. まずは1枚描いて、ブラウザで開いてみる

材料がそろったので、いきなり凝ったものを作らず評価額の合計を1本の折れ線で描きます。Plotlyの最小構成は go.Figure(go.Scatter(...)) ——「図」の中に「trace(描く線やバーの単位)」を入れるという形です。Matplotlibの fig, ax と役割が似ていますが、Plotlyではtraceを足していくとそのまま重なるので、Axesを意識する場面はほとんどありません。

import plotly.graph_objects as go

total = value.sum(axis=1)

fig = go.Figure(go.Scatter(x=total.index, y=total, mode="lines", name="評価額合計",
                           line=dict(color="#2563eb", width=2)))
fig.update_layout(title="保有銘柄の評価額の推移(架空データ)",
                  yaxis_title="評価額(円)", template="plotly_white",
                  width=1000, height=460, margin=dict(l=90, r=30, t=60, b=50))

fig.write_html("portfolio.html", include_plotlyjs="cdn")   # ブラウザで開くと動かせる
fig.write_image("portfolio_line.png", width=1000, height=460, scale=2)   # 静止画で保存
print(f"最終日の評価額合計: {total.iloc[-1]:,.0f} 円")
最終日の評価額合計: 799,500 円
架空の3銘柄で構成したポートフォリオの評価額合計を描いた折れ線グラフ。2025年4月から2026年8月まで、買い付けのたびに5回階段状に増え、2026年1月末と2月下旬の売却で2回下がり、最終日は約80万円になっている
▲ 4章のコードが書き出したPNG。ブラウザで portfolio.html を開くと、これが動くグラフになる

出てくるファイルは2つです。portfolio.html がこの記事の主役で、ダブルクリックしてブラウザで開けば、そのまま拡大も移動もできます。portfolio_line.png のほうは記事に貼るための静止画で、PNGの書き出しには kaleido が必要です(入っていないと write_image() でエラーになります。詳しくは12章のFAQへ)。画面にすぐ出したいだけなら fig.show() でも構いません。

グラフの形を読むと、階段状に立ち上がっている場所が5か所あります。ここは値上がりではなく買い付けた日で、3章の取引履歴にある買い5件と対応しています。逆に段差で落ちているのが2か所——2026年1月30日(BETAを200株すべて売却)と2月20日(ALPHAを50株だけ売却)です。合計の線を眺めているだけで、売買した日がそのまま形に出ます。

⚖️
評価額の推移は「成績のグラフ」ではない

このグラフが上がっているのは、値上がりしたからとは限りません。買い増せば増えますし、売れば減ります。入出金と値動きが混ざった線だからです。運用成績を測りたいなら、投下元本を分けて考えるか、金額ではなく比率で見る必要があります(本連載では第15回のリスク分析で扱う予定です)。グラフの形から「儲かっている/損している」を読み取らないでください——ここは自分でも間違えやすいところなので、タイトルに「評価額」と明記して自分に釘を刺しています。

5. つまずき実演:ホバーの数字が「1.0118M」になる

さて、4章で書き出した portfolio.html をブラウザで開いて、段差のところにマウスを乗せてみます。Plotlyは何も設定しなくてもホバー表示(ツールチップ)が出ます——出るのですが、こうなります。

Plotlyの既定のホバー表示。折れ線の2026年1月30日の点にマウスを乗せると「(Jan 30, 2026, 1.0118M)」という英語表記かつ丸められた数値のツールチップが出ている
▲ 既定のホバー。日付は英語、金額は「1.0118M」——円単位でいくらなのか読めない

(Jan 30, 2026, 1.0118M)これでは「いくらか」が分かりません。1.0118M は101万1,800円のことですが、頭の中で桁を戻す作業が毎回発生します。しかも知りたいのは合計だけではありません。「なぜここで下がったのか」を知るには銘柄ごとの内訳が要ります。

📷
この2枚だけは、撮り方が違います(正直な注記)

ホバーのツールチップはマウスを乗せている間だけブラウザが描くものなので、fig.write_image() で書き出したPNGには写りません。そこで本章の2枚に限り、4章と5章のコードが出力したHTMLをヘッドレスChromeで開き、該当の点にマウスを移動させた状態でスクリーンショットを撮っています(撮影は2026年8月28日、記事のコードとは別の検証用スクリプト)。ツールチップの中身は加工していません。とはいえ静止画では「動かせること」自体は伝わらないので、6章に実際に触れるデモを置きました。手元で確かめるなら portfolio.html を開くのが一番早いです。

hovertemplate で、出したいものを自分で決める

ホバーの中身は hovertemplate で自由に組み立てられます。書き方は%{変数} を埋め込んだ文字列で、HTMLのタグ(<b><br>)もそのまま使えます。よく使う変数はこの4つです。

書き方意味
%{x} / %{y}その点のx値・y値
%{y:,.0f}数値の書式(d3-formatの記法)10118001,011,800
%{x|%Y年%m月%d日}日付の書式(| の後ろに指定)2026年01月30日
%{customdata[0]}traceに持たせた追加データ銘柄別の評価額など

肝は customdata です。グラフに描いていない値でも、traceに持たせておけばホバーに出せます。ここでは合計の線1本しか描いていないのに、ツールチップには3銘柄の内訳を出すという使い方をします。

fig = go.Figure(go.Scatter(
    x=total.index, y=total, mode="lines", name="評価額合計",
    line=dict(color="#2563eb", width=2),
    customdata=np.stack([value["ALPHA"], value["BETA"], value["GAMMA"]], axis=-1),
    hovertemplate=("<b>%{x|%Y年%m月%d日}</b><br>"
                   "評価額合計 <b>%{y:,.0f} 円</b><br>"
                   " ALPHA %{customdata[0]:,.0f} 円<br>"
                   " BETA  %{customdata[1]:,.0f} 円<br>"
                   " GAMMA %{customdata[2]:,.0f} 円"
                   "<extra></extra>"),
))
fig.update_layout(title="保有銘柄の評価額の推移(架空データ)",
                  yaxis_title="評価額(円)", template="plotly_white",
                  width=1000, height=460, margin=dict(l=90, r=30, t=60, b=50),
                  hoverlabel=dict(align="left", font_size=13))
fig.write_html("portfolio_hover.html", include_plotlyjs="cdn")
print(value.loc["2026-01-30"])
ALPHA    659400.0
BETA          0.0
GAMMA    352400.0
Name: 2026-01-30 00:00:00, dtype: float64
hovertemplateを指定したあとのホバー表示。同じ2026年1月30日の点に、2026年01月30日・評価額合計1,011,800円・ALPHA 659,400円・BETA 0円・GAMMA 352,400円と日本語で表示されている
▲ 同じ点・同じデータでも、書式を決めるだけで読めるようになる。BETAが0円=売却済みだと分かる

「1.0118M」が「1,011,800 円」になり、内訳まで出るようになりました。そしてBETAが0円と表示されたことで、1月末の段差が値下がりではなく売却だったと、グラフを離れずに確認できます。これが操作できるグラフの実用的な価値です——表に戻って行を探す手間が消えます。

コードで押さえておく点は3つあります。

  • customdata には np.stack([...], axis=-1) で列を束ねて渡す。点の数ぶんの行がある2次元配列にすると、%{customdata[0]}[1][2] で取り出せます
  • <extra></extra> は「右側に出る余分な箱」を消す指定。空にしておかないと、trace名が入った吹き出しが別に付いてきます
  • 桁区切りは :,、小数を出さないのは .0f。この書式はPython側ではなくブラウザ側(d3-format)が解釈します。Pythonのf文字列とは別物ですが、よく使う指定はほぼ同じ書き方で通ります

6. ズーム・パンと期間選択ボタン(rangeselector)

ホバーの次は期間です。Plotlyのグラフには、何も書かなくても最初から付いてくる操作があります。

操作やり方用途
ズームグラフ内をドラッグして範囲を囲む気になる時期だけを拡大する
パン右上のツールバーで十字アイコンを選んでドラッグ拡大したまま横に動かす
元に戻すダブルクリック(または家アイコン)全期間に戻す
系列の表示切替凡例をクリック1銘柄だけ消して見る
PNG保存ツールバーのカメラアイコン見えている状態を画像で残す

ただ、ドラッグで「直近3か月」をぴったり選ぶのは面倒です。毎日見る道具なら、ボタンで一発で切り替わってほしい。それが rangeselectorrangeslider です。

  • rangeselector: 「3か月」「年初来」などの期間ボタンをグラフの上に並べる
  • rangeslider: グラフの下に全期間のミニチャートを置き、両端をつまんで範囲を決められるようにする
RANGE_BUTTONS = [
    dict(count=3, step="month", stepmode="backward", label="3か月"),
    dict(count=6, step="month", stepmode="backward", label="6か月"),
    dict(step="year", stepmode="todate", label="年初来"),
    dict(count=1, step="year", stepmode="backward", label="1年"),
    dict(step="all", label="全期間"),
]

fig.update_xaxes(
    rangeselector=dict(buttons=RANGE_BUTTONS, x=0, y=1.16,
                       bgcolor="#eef2ff", activecolor="#c7d2fe", font=dict(size=12)),
    rangeslider=dict(visible=True, thickness=0.09),
    tickformat="%Y/%m",
)
fig.update_yaxes(tickformat=",")
fig.update_layout(title=dict(x=0.5, xanchor="center"),
                  height=520, margin=dict(l=100, r=30, t=100, b=40))

fig.write_html("portfolio_range.html", include_plotlyjs="cdn")
fig.write_image("portfolio_range.png", width=1000, height=520, scale=2)
print("x軸の型:", fig.full_figure_for_development(warn=False).layout.xaxis.type)
x軸の型: date
期間選択ボタンとレンジスライダーを付けた評価額推移のグラフ。左上に3か月・6か月・年初来・1年・全期間の5つのボタンが並び、グラフ下部に全期間のミニチャートが表示されている。縦軸は1,200,000のように桁区切り、横軸は2025/05のような数字表記
▲ 期間ボタン(左上)とレンジスライダー(下)。軸の書式も読みやすく直した

ボタンの中身は stepstepmode の組み合わせで決まります。count=3, step="month", stepmode="backward" は「基準日から3か月ぶん遡る」step="year", stepmode="todate" は「その年の初めから基準日まで(年初来)」です。当サイトの検証環境で実際にボタンを押してx軸の範囲を調べたところ、「3か月」で ["2026-05-28", "2026-08-28"]、「年初来」で ["2026-01-01", "2026-08-28"] になりました。基準は「今日」ではなくデータの右端(最終日)です——過去のデータを眺めるときに戸惑わないよう、ここは覚えておいてください。

ついでに軸の書式も直しました。update_yaxes(tickformat=",") で縦軸が「1.2M」から「1,200,000」にtickformat="%Y/%m" で横軸が「May 2025」から「2025/05」に変わります。Plotlyの日付ラベルは既定では英語表記なので、言語に依存しない数字の書式にしてしまうのが手軽です。ここでもホバーと同じ tickformat の記法が使えます。

実物を触ってみる(架空データのデモ)

ここまでのグラフを、このページに埋め込みました。マウスを乗せる・ドラッグで拡大する・ボタンで期間を切り替えるを、そのまま試せます(ダブルクリックで元に戻ります)。

▲ 上のコードが書き出したHTMLを、このページ用に4点だけ調整して埋め込んだものです(データはすべて架空)。グラフのデータ・ホバーの中身・期間ボタンは本文のコードのままです。

  • iframeの幅に追従させるため width の固定を外し、高さを470pxにした
  • タイトルの末尾に「・デモ」と付けた
  • 9章で説明する config(ロゴ非表示・responsive・使わないボタンの削除・カメラアイコンで保存するときのファイル名を portfolio-demo に)を先に適用した
  • ページ側に架空データの注記と noindex を足した

読み込みは表示位置に近づいてから始まります。

🛠️
これを自分のデータで作るには

いま触った操作(ホバーで内訳を見る・ドラッグで拡大する・ボタンで期間を切り替える)は、10章の完成コード(dashboard.pyが書き出すHTMLにもそのまま入っています。保存して python dashboard.py と実行すれば、推移・構成比・損益を1画面にまとめた版が手元にできます。実データに差し替えるときに触るのは株価・取引履歴・損益一覧の3か所だけで、描画側のコードはそのままです。

つまずき実演:期間選択ボタンが表示されない

この rangeselector書いたのに何も出てこないことがあります。原因はほぼ1つで、x軸が日付として認識されていないためです。実際にやってみます。

# ⚠️ 失敗例:x に「2025/04/01」形式の文字列を渡すと、日付ではなくカテゴリとして扱われる
bad = go.Figure(go.Scatter(x=total.index.strftime("%Y/%m/%d"), y=total, mode="lines"))
bad.update_xaxes(rangeselector=dict(buttons=RANGE_BUTTONS), rangeslider=dict(visible=True))
bad.update_layout(title="x軸が文字列だと期間選択ボタンが消える(失敗例)",
                  template="plotly_white", width=1000, height=520,
                  margin=dict(l=90, r=30, t=100, b=40))
bad.write_image("range_category.png", width=1000, height=520, scale=2)

for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y年%m月%d日"]:
    f = go.Figure(go.Scatter(x=total.index.strftime(fmt), y=total, mode="lines"))
    print(fmt, "->", f.full_figure_for_development(warn=False).layout.xaxis.type)
%Y-%m-%d -> date
%Y/%m/%d -> category
%Y年%m月%d日 -> category
x軸に文字列を渡した失敗例のグラフ。期間選択ボタンが表示されず、横軸には2025/04/01のような日付の文字列が月ごとにまとまらないまま縦書きでびっしり並び、下のスライダーも日付の目盛りを持たないカテゴリ表示になっている
▲ 失敗例。ボタンが消え、x軸は日付として扱われず、日付の文字列が等間隔の目盛りとしてびっしり並ぶ(同じデータ・同じ指定なのに)

ボタンは「効かない」のではなく、そもそも描かれません。当サイトの環境で生成後のHTMLを調べたところ、ボタンの要素自体が0個でした。カテゴリ軸には「1か月前」という概念がないので、Plotlyが描画をあきらめている格好です。x軸のラベルが「2025/05」のような月単位にまとまらず、日付の文字列がそのまま等間隔の目盛りとして並んでしまうのも同じ理由です。カテゴリ=「順番に並んだラベルの列」として扱われているので、Plotlyから見ればこれは日付ではなく、ただの369個のラベルなのです。

おもしろいのは文字列の書き方によって結果が変わるところです。上の出力のとおり、2025-04-01(ISO形式)は日付と解釈されるのに、2025/04/012025年04月01日 はカテゴリになりました証券会社のCSVは YYYY/MM/DD 表記のものが多いので、これは他人事ではありません。

🔧
直し方:描く前に pd.to_datetime() を通す

対処は「日付らしい文字列」に頼らず、渡す前に日付型へ変換しておくことです。CSVから読み込んだ列なら df["date"] = pd.to_datetime(df["date"])、索引にするなら df.index = pd.to_datetime(df.index)。うまくいっているかは fig.full_figure_for_development(warn=False).layout.xaxis.type で確かめられます(date なら成功、category なら失敗)。証券会社のCSVを材料にするなら、文字コードと日付の扱いを第3回の証券会社のCSVをpandasで読み込むにまとめてあります。

7. 内訳と構成比を見る(積み上げエリアとドーナツ)

合計の推移が読めるようになったので、次は「その合計を、どの銘柄がどれだけ支えているか」です。合計の線を銘柄ごとに積み上げたエリアに変えると、面積の厚みがそのまま構成比になります。Plotlyでは go.Scatterstackgroup を付けるだけで積み上がります。

COLORS = {"ALPHA": "#2563eb", "BETA": "#f59e0b", "GAMMA": "#10b981"}

area = go.Figure()
for ticker in value.columns:
    area.add_trace(go.Scatter(
        x=value.index, y=value[ticker], name=ticker, mode="lines",
        stackgroup="one",                       # 同じ stackgroup 名のtraceが積み上がる
        line=dict(width=0.8, color=COLORS[ticker]),
        customdata=qty[ticker],
        hovertemplate="%{y:,.0f} 円(%{customdata:,} 株)<extra>" + ticker + "</extra>",
    ))
area.update_layout(title="評価額の内訳(架空データ)", template="plotly_white",
                   hovermode="x unified", width=1000, height=460)
print(value.loc["2025-12-30"])
ALPHA    653600.0
BETA     220200.0
GAMMA    381200.0
Name: 2025-12-30 00:00:00, dtype: float64

3本のtraceに同じ stackgroup="one" を付けたので、上下に積み重なります。ここで hovermode="x unified" にしているのがポイントで、同じ日付の3銘柄がまとめて1つの吹き出しに並ぶ形です。積み上げグラフは「上の面の値」を読み間違えやすい(見えている厚みが値で、上端は合計)ので、数字で確認できる状態にしておくと誤読を防げます

短く書きたいなら Plotly Express という別解もある

ここまで plotly.graph_objectsgo)で1本ずつtraceを作ってきましたが、plotly.expresspx)を使えば同じ積み上げエリアが1行で済むのです。実際に実行するとtraceが3本、型は Scatter ——go で書いたものと同じ中身が自動で組み立てられている、というわけです。

import plotly.express as px

area_px = px.area(value, labels={"value": "評価額(円)", "date": "", "variable": "銘柄"},
                  color_discrete_map=COLORS, title="評価額の内訳(Plotly Express版)")
print(f"trace数: {len(area_px.data)}  1本目の型: {type(area_px.data[0]).__name__}")
trace数: 3  1本目の型: Scatter

1行で済むなら px でいいのでは、と思うところですが、本記事は go を主軸にしています。理由は連載の目的にあります——px は「DataFrameの形に合わせて自動で組み立てる」ぶん、列の持ち方が変わると出力も変わってしまうのです。一方 go1本ずつ明示的に足すので、銘柄ごとに色を変える・特定の銘柄だけホバーの中身を変える・8章のようにサブプロットへ移植する、といった作り替えが素直にできます。まず px で当たりを付けて、作り込む段になったら go に降りる——という進め方が現実的です(px が返すのも同じ Figure なので、update_layout() 等はどちらでも同じように効きます)。

今日の構成比はドーナツで

推移とは別に、「今この瞬間の配分」も見たいところです。円グラフ(go.Pie)に hole を指定するとドーナツになり、中央の空きに合計金額を置けます。

held = summary[summary["qty"] > 0]        # 保有0株のBETAは円グラフから外す

donut = go.Figure(go.Pie(
    labels=held["ticker"], values=held["market_value"], hole=0.55, sort=False,
    marker=dict(colors=[COLORS[t] for t in held["ticker"]]),
    texttemplate="%{label}<br>%{percent}", insidetextorientation="horizontal",
    hovertemplate="%{label} %{value:,.0f} 円<extra></extra>",
    showlegend=False,                      # trace側で切る=8章で移植しても凡例に出ない
))
donut.update_layout(title="構成比(最終日・架空データ)", template="plotly_white",
                    width=520, height=460)
print(held[["ticker", "market_value"]].to_string(index=False))
print(f"合計: {held['market_value'].sum():,.0f} 円")
ticker  market_value
 ALPHA      487500.0
 GAMMA      312000.0
合計: 799,500 円

保有0株のBETAは、あらかじめ除いています。値が0のスライスは描かれないので入れても見た目は変わりませんが、凡例やホバーに「0円の銘柄」が残ると邪魔になるためです。sort=False を付けているのは、Plotlyが既定で値の大きい順に並べ替えてしまうのを止めて、渡した順(=銘柄コード順)を保つためです。毎日見るグラフで並び順が日によって変わると、見る側の目が迷います。

なお、セクター(業種)ごとの分散を見る話は第7回で扱う予定です。ここでの円グラフは「銘柄ごとの評価額の割合」だけを見ています。

8. 3つのグラフを1枚のダッシュボードにまとめる

部品がそろいました。推移・構成比・銘柄別の損益を1画面に並べます。使うのは plotly.subplotsmake_subplots です。Matplotlibの plt.subplots(2, 1) に当たるものですが、Plotlyでは枠ごとに「型」を宣言するのが特徴です。

指定意味
specs=[[{"type": "xy", "colspan": 2}, None], ...]上段は2列ぶんを1つの枠として使う(None は「ここは使わない」の意味)
{"type": "domain"}円グラフ・ツリーマップなど、x/y軸を持たないグラフ用の枠
row_heights=[0.60, 0.40]上下の高さの比
subplot_titles=(...)枠ごとの小見出し
from plotly.subplots import make_subplots

UP, DOWN = "#e11d48", "#2563eb"           # プラス=赤・マイナス=青(第5回と同じ配色)

fig = make_subplots(
    rows=2, cols=2,
    specs=[[{"type": "xy", "colspan": 2}, None],      # 上段は2列ぶん使う
           [{"type": "domain"}, {"type": "xy"}]],     # 円グラフの枠は "domain"
    row_heights=[0.60, 0.40], vertical_spacing=0.14, horizontal_spacing=0.12,
    subplot_titles=("評価額の推移(銘柄別・積み上げ)", "構成比(最終日)", "トータル損益(第4回の集計)"),
)

for trace in area.data:                   # 7章の積み上げエリアをそのまま移植
    fig.add_trace(trace, row=1, col=1)
fig.add_trace(donut.data[0], row=2, col=1)
fig.add_trace(go.Bar(
    x=summary["total_pl"], y=summary["ticker"], orientation="h",
    marker_color=np.where(summary["total_pl"] >= 0, UP, DOWN),
    texttemplate="%{x:,.0f} 円", textposition="outside", showlegend=False,
    hovertemplate="%{y} %{x:,.0f} 円<extra></extra>",
), row=2, col=2)

fig.update_xaxes(rangeselector=dict(buttons=RANGE_BUTTONS, x=0, y=1.10,
                                    bgcolor="#eef2ff", activecolor="#c7d2fe"),
                 tickformat="%Y/%m", row=1, col=1)
fig.update_yaxes(tickformat=",", title_text="評価額(円)", row=1, col=1)
fig.update_xaxes(tickformat=",", range=[-90000, 175000], row=2, col=2)

center = fig.data[3].domain                # 円グラフの中心にテキストを置く
fig.add_annotation(x=sum(center.x) / 2, y=sum(center.y) / 2, xref="paper", yref="paper",
                   text=f"合計<br><b>{held['market_value'].sum():,.0f} 円</b>",
                   showarrow=False, font=dict(size=14))

fig.update_layout(
    title=dict(text="ポートフォリオ・ダッシュボード(架空データ)", x=0.5, xanchor="center", y=0.98),
    template="plotly_white", width=1100, height=760, hovermode="x unified",
    legend=dict(orientation="h", y=1.10, x=1, xanchor="right", yanchor="bottom"),
    margin=dict(l=90, r=40, t=120, b=40),
)
fig.write_image("dashboard.png", width=1100, height=760, scale=2)
print("traceの数:", len(fig.data))
traceの数: 5
完成したポートフォリオ・ダッシュボード。上段は3銘柄を積み上げた評価額推移のエリアチャートと期間選択ボタン、左下は保有2銘柄の構成比を示すドーナツグラフで中央に合計799,500円、右下は銘柄別トータル損益の横棒グラフでALPHAが111,725円のプラス、BETAが25,620円、GAMMAが17,605円のマイナス。すべて架空データ
▲ 完成形。推移・構成比・損益が1画面に収まった(数値はすべて架空)

7章で作ったtraceを、そのまま add_trace(trace, row=..., col=...) に渡して移植しているところに注目してください。Plotlyのtraceは「どのグラフに属するか」を持たないデータの塊なので、単体の図で作り込んでからダッシュボードに引っ越す、という順番で作れます。いきなり4枚組を組み立てて崩れると原因が分かりにくいので、1枚ずつ完成させてから寄せるほうが結局は速いです。

横棒グラフ(go.Barorientation="h")の色は、プラスを赤・マイナスを青にしました。第5回のローソク足と同じ配色です(日本の証券会社でよく見る配色に合わせています。欧米では逆になることが多い点も第5回のとおりです)。

つまずき実演:make_subplots に円グラフを足すと ValueError で止まる

🕳️
直し方:その枠を {"type": "domain"} にする(凡例は trace 側で切る)

make_subplotsspecs{"type": "domain"} を指定し忘れて go.Pie を足すと、その場で ValueError になって図が作れません。当サイトで再現したときのメッセージは Trace type 'pie' is not compatible with subplot type 'xy' at grid position (1, 2) でした(末尾の位置は図の構成で変わります)。円グラフはx軸・y軸ではなく「描画領域の何割を使うか(domain)」で位置が決まるためです。凡例の扱いも同じところでつまずきます——円グラフのラベルは既定で凡例に出るので、移植すると折れ線の凡例と混ざります。trace側に showlegend=False を書いておくと、単体の図でもダッシュボードでも同じ見た目を保てます(layout側の showlegend は図ごとの設定なので、移植すると効かなくなります)。

中央の合計金額は add_annotation() で置いています。座標に使っている fig.data[3].domainmake_subplots が円グラフに割り当てた領域で、その中心を計算すればレイアウトを変えてもドーナツの穴に追従します。数字を直接書かないこの手のひと工夫が、あとで行数や比率を変えたときに効いてきます。

9. write_html でHTMLとして保存して、毎日使う道具にする

最後の仕上げです。fig.write_html() で保存すると1つのHTMLファイルになり、Pythonを起動しなくてもダブルクリックで開いて操作できます。ここで効いてくるのが include_plotlyjsconfig の2つの引数です。

import os

CONFIG = {
    "displaylogo": False,                                   # Plotlyのロゴを消す
    "responsive": True,                                     # 画面幅に追従させる
    "modeBarButtonsToRemove": ["lasso2d", "select2d"],
    "toImageButtonOptions": {"format": "png", "filename": "portfolio", "scale": 2},
}

fig.write_html("dashboard.html", include_plotlyjs="cdn", config=CONFIG)
fig.write_html("dashboard_offline.html", include_plotlyjs=True, config=CONFIG)

for name in ["dashboard.html", "dashboard_offline.html"]:
    print(f"{name}: {os.path.getsize(name):,} bytes")
dashboard.html: 58,127 bytes
dashboard_offline.html: 4,906,049 bytes

同じグラフなのに58KBと4.9MB——およそ84倍の差が出ました。違いは include_plotlyjs だけです。

include_plotlyjs中身実測サイズ向く場面
"cdn"plotly.jsはCDNから読み込む(plotly-3.6.0.min.js をバージョン指定で参照)58,127 bytesネットにつながる環境で日常的に開く
Trueplotly.js本体をHTMLに埋め込む4,906,049 bytesオフラインで開く・ファイル1つで完結させたい

どちらを選ぶかは「オフラインで開くか」で決まります。当サイトの検証環境でネットワークを遮断して開いたところ、"cdn" 版は真っ白のまま何も描画されず、埋め込み版は問題なく表示されました。ノートPCで移動中にも見たいなら埋め込み版、社内やクラウド上に置いて毎日開くなら軽い "cdn" 版、という選び分けになります。

config のほうはブラウザ側の挙動です。displaylogo でツールバーのロゴを消し、responsive で画面幅に追従させ、modeBarButtonsToRemove で使わないボタンを外しました。毎日使うものほど、余計なボタンは減らしたほうが迷いません。

🔒
書き出したHTMLには、データが丸ごと入っています

write_html() が作るファイルには、グラフに渡した数値がそのまま埋め込まれます。つまり自分の保有銘柄・数量・評価額が読み取れる状態のファイルです(テキストエディタで開けば見えます)。共有ストレージ・公開サーバー・チャットへの貼り付けは避けてください。本連載が「資産データはローカル完結」を方針にしているのは、この手の取り扱いミスを構造的に避けるためです。人に見せる用途なら、この記事のように架空データで作り直すのが安全です。

10. 完成コード(そのまま保存して動く形)

ここまでのコードを、そのままファイルに保存して使える形にまとめます。下のコードをまるごと dashboard.py という名前で保存し、python dashboard.py を実行すると、同じフォルダに portfolio_dashboard.html ができます。必要なライブラリは pip install plotly pandas だけです(PNGを書き出さないので kaleido は不要。NumPyはpandasと一緒に入ります)。

"""架空の取引履歴から、ブラウザで動かせるポートフォリオ・ダッシュボードを作る。"""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

COLORS = {"ALPHA": "#2563eb", "BETA": "#f59e0b", "GAMMA": "#10b981"}
UP, DOWN = "#e11d48", "#2563eb"
RANGE_BUTTONS = [
    dict(count=3, step="month", stepmode="backward", label="3か月"),
    dict(count=6, step="month", stepmode="backward", label="6か月"),
    dict(step="year", stepmode="todate", label="年初来"),
    dict(count=1, step="year", stepmode="backward", label="1年"),
    dict(step="all", label="全期間"),
]
CONFIG = {"displaylogo": False, "responsive": True,
          "modeBarButtonsToRemove": ["lasso2d", "select2d"]}
ANCHORS = {          # 架空3銘柄の節目の価格(第4回の売買価格・現在値)
    "ALPHA": {"2025-04-10": 2500, "2025-09-12": 3000, "2026-02-20": 3400, "2026-08-28": 3250},
    "BETA":  {"2025-05-15": 1200, "2026-01-30": 1050, "2026-08-28": 1010},
    "GAMMA": {"2025-06-01":  800, "2025-11-11":  950, "2026-08-28":  780},
}


def make_price_history(seed: int = 42) -> pd.DataFrame:
    """架空3銘柄の日次終値を作る(実在の銘柄・相場とは無関係)。実データでは第1回のyfinanceに差し替える。"""
    dates = pd.bdate_range("2025-04-01", "2026-08-28")
    t = np.arange(len(dates))
    rng = np.random.default_rng(seed)
    out = {}
    for ticker, points in ANCHORS.items():
        pos = dates.get_indexer(pd.to_datetime(list(points)), method="nearest")
        base = np.interp(t, pos, np.log(list(points.values())))
        walk = np.cumsum(rng.normal(0, 0.011, len(dates)))
        walk -= np.interp(t, pos, walk[pos])
        out[ticker] = np.round(np.exp(base + walk), 0)
    return pd.DataFrame(out, index=pd.DatetimeIndex(dates, name="date"))


def build_value_history(transactions: pd.DataFrame, prices: pd.DataFrame):
    """取引履歴と日次終値から、銘柄ごとの保有数量と評価額の推移を作る。"""
    signed = transactions["qty"] * np.where(transactions["type"] == "buy", 1, -1)
    moves = (transactions.assign(signed=signed)
             .pivot_table(index="date", columns="ticker", values="signed", aggfunc="sum"))
    qty = (moves.reindex(moves.index.union(prices.index))
                .fillna(0).cumsum()
                .reindex(prices.index)
                .reindex(columns=prices.columns, fill_value=0)
                .astype("int64"))
    return qty, qty * prices


def build_dashboard(qty: pd.DataFrame, value: pd.DataFrame, summary: pd.DataFrame):
    """評価額の推移・構成比・銘柄別損益を1枚にまとめた Figure を返す。"""
    held = summary[summary["qty"] > 0]
    fig = make_subplots(
        rows=2, cols=2,
        specs=[[{"type": "xy", "colspan": 2}, None],
               [{"type": "domain"}, {"type": "xy"}]],
        row_heights=[0.60, 0.40], vertical_spacing=0.14, horizontal_spacing=0.12,
        subplot_titles=("評価額の推移(銘柄別・積み上げ)", "構成比(最終日)", "トータル損益"),
    )

    for ticker in value.columns:
        fig.add_trace(go.Scatter(
            x=value.index, y=value[ticker], name=ticker, mode="lines", stackgroup="one",
            line=dict(width=0.8, color=COLORS.get(ticker)), customdata=qty[ticker],
            hovertemplate="%{y:,.0f} 円(%{customdata:,} 株)<extra>" + ticker + "</extra>",
        ), row=1, col=1)

    fig.add_trace(go.Pie(
        labels=held["ticker"], values=held["market_value"], hole=0.55, sort=False,
        marker=dict(colors=[COLORS.get(t) for t in held["ticker"]]),
        texttemplate="%{label}<br>%{percent}", insidetextorientation="horizontal",
        hovertemplate="%{label} %{value:,.0f} 円<extra></extra>", showlegend=False,
    ), row=2, col=1)

    fig.add_trace(go.Bar(
        x=summary["total_pl"], y=summary["ticker"], orientation="h",
        marker_color=np.where(summary["total_pl"] >= 0, UP, DOWN),
        texttemplate="%{x:,.0f} 円", textposition="outside", showlegend=False,
        hovertemplate="%{y} %{x:,.0f} 円<extra></extra>",
    ), row=2, col=2)

    fig.update_xaxes(rangeselector=dict(buttons=RANGE_BUTTONS, x=0, y=1.10,
                                        bgcolor="#eef2ff", activecolor="#c7d2fe"),
                     tickformat="%Y/%m", row=1, col=1)
    fig.update_yaxes(tickformat=",", title_text="評価額(円)", row=1, col=1)
    fig.update_xaxes(tickformat=",", range=[-90000, 175000], row=2, col=2)

    center = fig.data[3].domain
    fig.add_annotation(x=sum(center.x) / 2, y=sum(center.y) / 2, xref="paper", yref="paper",
                       text=f"合計<br><b>{held['market_value'].sum():,.0f} 円</b>",
                       showarrow=False, font=dict(size=14))
    fig.update_layout(
        title=dict(text="ポートフォリオ・ダッシュボード(架空データ)", x=0.5, xanchor="center", y=0.98),
        template="plotly_white", width=1100, height=760, hovermode="x unified",
        legend=dict(orientation="h", y=1.10, x=1, xanchor="right", yanchor="bottom"),
        margin=dict(l=90, r=40, t=120, b=40),
    )
    return fig


if __name__ == "__main__":
    # 実運用では、第3回のCSV読み込み・第4回の集計結果に差し替える(以下は架空のサンプル)
    transactions = pd.DataFrame(
        [
            ("2025-04-10", "ALPHA", "buy",  100, 2500.0, 250.0),
            ("2025-05-15", "BETA",  "buy",  200, 1200.0, 220.0),
            ("2025-06-01", "GAMMA", "buy",  300,  800.0, 330.0),
            ("2025-09-12", "ALPHA", "buy",  100, 3000.0, 250.0),
            ("2025-11-11", "GAMMA", "buy",  100,  950.0, 275.0),
            ("2026-01-30", "BETA",  "sell", 200, 1050.0, 200.0),
            ("2026-02-20", "ALPHA", "sell",  50, 3400.0, 275.0),
        ],
        columns=["date", "ticker", "type", "qty", "price", "fee"],
    )
    transactions["date"] = pd.to_datetime(transactions["date"])

    summary = pd.DataFrame({          # 第4回の build_portfolio() の戻り値のうち、使う列だけ
        "ticker":       ["ALPHA",  "BETA",   "GAMMA"],
        "qty":          [150,      0,        400],
        "market_value": [487500.0, 0.0,      312000.0],
        "total_pl":     [111725.0, -25620.0, -17605.0],
    })

    prices = make_price_history()                     # 架空の日次終値
    qty, value = build_value_history(transactions, prices)
    fig = build_dashboard(qty, value, summary)

    fig.write_html("portfolio_dashboard.html", include_plotlyjs="cdn", config=CONFIG)
    print(f"{len(value)}日ぶんのダッシュボードを書き出しました: portfolio_dashboard.html")
    print(f"最終日の評価額合計: {value.iloc[-1].sum():,.0f} 円")
369日ぶんのダッシュボードを書き出しました: portfolio_dashboard.html
最終日の評価額合計: 799,500 円

関数に切り出すときに考えた設計が3つあります。

設計そうした理由
build_value_history() を分けた「取引履歴+株価 → 評価額の推移」は可視化以外でも使う。第15回のリスク分析でもこの表が入口になる
build_dashboard()Figure を返すHTMLに書くか・PNGにするか・画面に出すかを呼び出し側が決められる。第13回のStreamlitでは st.plotly_chart(fig) にそのまま渡せる
COLORS.get(ticker) で色を引く辞書に無い銘柄が来ても None(=Plotlyの自動配色)になるだけで止まらない。銘柄が増えても壊れない

実データで使うときに差し替えるのは3か所だけです。make_price_history() を第1回のyfinanceの取得処理に、transactions を第3回で読み込んだ証券会社のCSVに、summary を第4回の build_portfolio() の戻り値に。この3つを渡す形に統一してあるので、描画側のコードは触らずに済みます。

11. つまずきやすいポイントと次につなげる

架空データで動いても、自分のデータを流し込むと別の問題が出てきます。よく踏む落とし穴を先に共有しておきます。

つまずき何が起きるか対処
x軸が文字列のまま期間ボタンが出ない・日付ラベルがびっしり並ぶpd.to_datetime() を通す。6章のとおり xaxis.type で確認できる
write_image() でエラーPNGが書き出せないpip install kaleido。kaleido v1 はChromeを使って描画するため、無い環境では plotly_get_chrome(plotly 付属のコマンド)で取得する
ホバーが「1.0118M」表記金額が読めないhovertemplate%{y:,.0f} を指定する(5章)
円グラフを make_subplots に足すと落ちるValueError で止まるspecs のその枠を {"type": "domain"} にする(8章)
点が多すぎて重いブラウザの操作がもたつく日足に丸める・表示期間を絞る・traceを減らす(本記事の規模=369日×3銘柄・trace 5本では、当サイトの環境で問題は起きませんでした)
HTMLが数MBになる開くのが遅い・共有しづらいinclude_plotlyjs="cdn" にする(9章の実測で58KB)

この表の「点が多すぎて重い」は、手元のパソコンの余裕にも関わる話です。Plotlyの描画はPythonではなくブラウザ側の仕事なので、銘柄数や期間を増やすほどメモリとCPUを使います。当サイトのPython学習におすすめのパソコン6選には用途別の推奨スペック早見表があり、「データ分析・pandas(CSV集計・可視化)」の目安をメモリ16GB以上・GPUは内蔵で足りるとしています。どのくらいの機械なら余裕があるかを決める材料になります(リンク先はアフィリエイト広告を含むページです)。

エラーメッセージの読み解き方に迷ったときはPythonのよくあるエラーと対処法を、他のライブラリとの組み合わせを考えるときはPythonライブラリ一覧もどうぞ。Plotlyの引数を網羅的に調べたいときは、公式リファレンスの scatter のページが確実です(hovertemplate に書ける変数も一覧になっています)。

📖
次に読むなら:pandasの土台をまとめて

今回いちばん引っかかりやすいのは、描画の指定そのものより「渡す前のデータづくり」——3章の pivot_tablereindex、そして日付型の扱いです。ここは pandas のデータモデルを一度通しで押さえておくと、グラフを変えるたびに調べ直さずに済みます。Pythonによるデータ分析入門 第3版は pandas の開発者本人が書いた定番書で、読み込み・整形・時系列の扱いまで手を動かしながら学べます。当サイトのおすすめ本ランキングでは総合5位として紹介しており、リンク先では対象レベル・全612ページというボリューム・出版社の公式ページを確認でき、そこから楽天の価格・在庫ページへ進めます。なお、リンク先は当サイトの書籍紹介ページで、アフィリエイト広告を含みますのでご了承ください。

次につなげる:割合で見る、そしてアプリへ

今回は金額で見てきました。次の第7回ではセクター(業種)ごとの分散を扱う予定です。同じ円グラフでも、銘柄ではなく業種でまとめると「気づかないうちに1業種に寄っていないか」という別の問いに答えられます。そして第13回では、今回のダッシュボードをStreamlitのアプリに載せ替えます。build_dashboard()Figure を返す形にしてあるので、そのまま渡すだけで済むはずです。連載の全体像と公開済みの記事は投資×Python シリーズ一覧から辿れます。

📌
この連載のスタンス

本連載は「自分の資産を自分で管理・可視化するツールを、Pythonの学習題材として自作する」ことが目的です。特定銘柄の売買や投資手法を勧めるものではなく、記事中のデータもすべて描画方法を示すための架空例です。当サイトは投資助言業(金融商品取引業)の登録を行っておりません。グラフの読み方や個別の投資判断についてのご質問にはお答えできません。投資判断はご自身の責任で行ってください(詳細は免責事項をご覧ください)。

12. よくある質問(FAQ)

Plotlyでポートフォリオを可視化するときにつまずきやすい点をまとめました。

Q. PlotlyとMatplotlibは、どちらを使えばいいですか?

出力先が違うので、用途で選びます。記事・レポート・印刷など「画像が欲しい」ならMatplotlib手元で値を確認しながら眺めたい・期間を切り替えたいならPlotlyです。Plotlyのグラフはブラウザで動くHTMLになり、ホバー表示・ズーム・パン・凡例クリックが標準で付いてきます。逆にPlotlyでPNGを書き出すには kaleido が別途必要です。両方を併用しても構いません(本連載は第5回がMatplotlib、第6回がPlotlyです)。

Q. Plotlyのライセンスは? 無料で使えますか?

Python版のPlotly(pip install plotly)はMITライセンスで公開されています。GitHubリポジトリの LICENSE.txt には MIT License / Copyright (c) 2016-2024 Plotly Technologies Inc. と記載があり、PyPIのライセンス欄も MIT です(2026年8月28日に確認)。実際に自分の用途で使えるかどうかは、ライセンス条文の原文をご確認ください。なお同社は Dash などの別製品も提供しており、それらの条件はパッケージごとに異なります。

Q. グラフにマウスを乗せたときの数字が「1.0118M」になります。円単位で出せますか?

できます。traceに hovertemplate を指定してください。たとえば hovertemplate="評価額 %{y:,.0f} 円<extra></extra>" と書くと 1,011,800 円 と表示されます。%{y:,.0f} の書式はブラウザ側のd3-formatが解釈するもので、, が桁区切り、.0f が小数点以下なしです。日付は %{x|%Y年%m月%d日} のように | の後ろで書式を指定します。<extra></extra> は、右側に出るtrace名の箱を消すための指定です。

Q. 期間選択ボタン(rangeselector)を書いたのに表示されません。

x軸が日付として認識されていない可能性が高いです。fig.full_figure_for_development(warn=False).layout.xaxis.type を表示して、date ではなく category になっていれば原因はこれです。当サイトの検証では、同じ日付でも 2025-04-01(ISO形式)の文字列は日付軸になりましたが、2025/04/012025年04月01日 はカテゴリ軸になりました。pd.to_datetime() で日付型に変換してから渡してください。

Q. fig.write_image() でPNGが保存できません。

PNG・JPEG・SVGなどの静止画書き出しには kaleido パッケージが必要です(pip install kaleido)。本記事の検証環境では kaleido 1.3.0 を使用しました。kaleido のバージョン1系はChrome/Chromiumを呼び出して描画する仕組みです。ブラウザを取ってくるコマンドは plotly_get_chrome(plotly が用意)と kaleido_get_chrome(kaleido が用意)の2つで、2章のとおり両方のパッケージを入れていればどちらも使えます(当サイトの環境で、それぞれのパッケージが登録するコマンドとして確認しました)。ブラウザが見つからない環境では、これでダウンロードしてから再実行してください。なお write_html() だけを使うのであれば kaleido は不要です。

Q. write_html() で保存したHTMLを、そのまま人に渡してもいいですか?

おすすめしません。書き出したHTMLには、グラフに渡した数値がそのまま埋め込まれています。保有銘柄・数量・評価額がテキストとして読み取れる状態なので、共有ストレージや公開サーバー、チャットへの貼り付けは避けてください。人に見せたい場合は、値を架空のものに置き換えて作り直すか、割合だけのグラフにするのが安全です。

Q. include_plotlyjs="cdn" で保存したHTMLが真っ白になります。

"cdn" はグラフ描画に使うplotly.js本体をCDNから読み込む設定なので、ネットワークにつながっていないと何も表示されません(当サイトの検証環境でも、ネットワークを遮断して開くと真っ白のままでした)。オフラインで開きたいときは include_plotlyjs=True にしてください。plotly.js本体がHTMLに埋め込まれ、ファイル1つで完結します。ただしサイズは大きくなり、本記事のダッシュボードでは 58,127 bytes から 4,906,049 bytes になりました。

Q. 評価額の推移が右肩上がりなら、うまく運用できているということですか?

いいえ、そうとは限りません。評価額の推移には値動きだけでなく、買い増しや売却も混ざります。買えば増え、売れば減るため、線が上がっていること自体は運用成績を意味しません。本記事のグラフでも、階段状に増えている箇所は買い付け、段差で下がっている箇所は売却です。成績を見たい場合は、投下元本と損益を分けて集計する必要があります(第4回で扱っています)。

🧰
読み終えたら:次に手を動かす2本

10章の dashboard.py を保存しておけば、連載の後半でそのまま再利用できます。第7回以降は順次公開ですが、公開までの間に地続きで手を動かせるのは次の2本です。

連載の全体像と公開済みの記事は投資×Python シリーズ一覧にまとめてあります。続きが出たときに気づけるよう、この一覧ページをブックマークしておくのが確実です。RSSリーダーを使っているなら、更新は新着記事のRSSでも配信しているので、登録しておけば第7回の公開に自動で気づけます。

本記事は、生成AIを活用して下書きし、運営者が内容を確認・編集したうえで公開しています。掲載しているコード・出力・図は Python 3.12.10 / plotly 6.8.0 / kaleido 1.3.0 / pandas 3.0.3 / NumPy 2.4.6 で実際に実行して確認したものですが、登場する銘柄名・価格・数量はすべて架空であり、実在の銘柄や相場を示すものではありません。本記事は可視化技術の情報提供を目的としたもので、グラフの解釈・特定の銘柄の売買・投資手法を推奨するものではありません。投資判断はご自身の責任で行ってください。AIの利用方針は免責事項をご覧ください。