INVESTMENT #04

Pythonで保有銘柄の損益を計算する(pandas)

Python経験者向けの投資分析シリーズ第4回。自分の取引履歴を入力に、平均取得単価・含み損益・実現損益pandas で算出します。さらに配当を含めたトータルリターンまで求め、最後はポートフォリオ全銘柄の損益一覧を返す関数にまとめます。

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

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

本記事は「Python経験者が、自分の投資ポートフォリオを管理・可視化するアプリを作る」連載の第4回です。第1回のyfinanceで株価データを取得する現在値を取る部品を、第3回の証券会社のCSVをpandasで読み込む証券会社が出すCSVを文字化けさせずに DataFrame に載せる部品を作りました。

ただし、第3回で読み込んだのは保有銘柄の一覧CSV——「いま何株持っているか」のスナップショットです。そこには約定日・売買区分・手数料といった取引の履歴が入っていません。損益を計算するのに必要なのは、スナップショットではなく、この履歴のほうです。そこで今回は取引履歴を入力データとして新しく定義し、第1回の現在値と掛け合わせて「で、自分はいくら儲かっているのか」を数字にします。

ここが連載の中でも山場です。株価の取得やCSVの読み込みは「データを持ってくる」だけでしたが、損益計算は持っているデータをどう解釈するかを自分で決める工程だからです。同じ取引履歴でも、平均取得単価の求め方や手数料の扱い方を変えれば、出てくる金額は変わります。だからこそ、計算のルールをコードとして明示的に書き切ることに価値があります。証券会社のアプリが表示する数字を眺めるのと、自分の手で同じ数字を組み立てられるのとでは、ポートフォリオの理解度がまるで違ってきます。

🎯
この回のゴール

取引履歴の DataFrame を渡すと、銘柄ごとの「保有数量・平均取得単価・含み損益・実現損益・配当・トータルリターン」が並んだ損益一覧 DataFrame が返ってくる関数を書けるようになること。この関数は、連載後半のグラフ描画(第5〜7回)と Streamlit ダッシュボード(第13回)がそのまま食べられる形をしています。

完成コードだけ先に見たい方は 8章「全銘柄の損益一覧を返す関数にまとめる」へ。そのままファイルに保存して動く形で載せています(ここから8章までの本文は、その1行1行が「なぜその式になるのか」を確かめる道のりです)。

🧪
この記事の検証環境

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

2. 損益計算の全体像 — 何を入力に、何が出るのか

コードを書く前に、損益という言葉が指すものを3つに分解しておきます。ここが曖昧なままだと、実装の途中で「この金額はどっちに足すんだっけ」と必ず迷います。

種類意味計算のもと
含み損益(未実現)まだ売っていない保有分の、いまの評価額と取得額の差現在値 × 保有数量 − 取得総額
実現損益売却によって確定した損益(売却単価 − 平均取得単価)× 売却数量 − 手数料
配当保有期間中に受け取った現金収入受取配当の合計

この3つを足したものがトータルリターン(合計損益)です。含み損益だけを見ていると、「利益確定した分」と「配当でもらった分」が視界から消えてしまいます。逆に実現損益だけを見ると、いま抱えている評価損が見えません。3つを同時に並べて初めて、そのポートフォリオが自分にとってプラスなのかマイナスなのかが分かります

そして、この3つすべての土台になるのが平均取得単価です。含み損益にも実現損益にも「取得単価」が登場するからで、ここを間違えると全部の数字がずれます。本記事の実装も、平均取得単価 → 含み損益 → 実現損益 → 配当 の順に積み上げます。

⚠️
この記事の計算は「資産管理のための概算」です

本記事のコードは、自分のポートフォリオを把握するための計算です。税務上の取得費や譲渡損益の計算とは目的が違います。国税庁のタックスアンサーでは、同一銘柄の株式等を2回以上にわたって購入して一部を譲渡した場合の取得費は「総平均法に準ずる方法」で計算すると示されています(国税庁 No.1466 同一銘柄の株式等を2回以上にわたって購入している場合の取得費)。本記事の移動平均方式は考え方が近いものの、株式分割・NISA口座・複数の証券会社にまたがる保有・諸費用の扱いなど、実際の税額計算には多くの条件が絡みます。確定申告や納税額の判断には、証券会社が発行する年間取引報告書(特定口座)などの公式な書類を使い、判断に迷う場合は税務署・税理士にご確認ください。

3. サンプルデータを定義する(取引履歴と配当)

まずは入力データの形を決めます。本連載では取引履歴を transactions、配当を dividends という DataFrame で扱います。列の構成は次のとおりです。

DataFrame意味
transactionsdate約定日(datetime)
ticker銘柄の識別子(文字列)
typebuy または sell
qty数量(株数)
price約定単価
fee売買手数料
dividendsdate入金日(datetime)
ticker銘柄の識別子
amount受取額

このデータは自分で用意するものです。前述のとおり、第3回で扱った保有銘柄CSVには売買の履歴が入っていません。そのため取引履歴は約定のたびに自分で記録しておくのが基本の運用になります(表計算ソフトで1行ずつ足して、CSVとして書き出す程度で十分です)。取引履歴(約定履歴)をCSVでダウンロードできる証券会社なら、それを上の列構成に整えて渡してもかまいません。その場合につまずきやすいのは、文字コード(cp932)・ヘッダーより上の説明行(skiprows)・桁区切りカンマ(thousands)の3点でしょう。いずれも越え方は、第3回のテクニックがそのまま使えます。

この記事のサンプルはすべて架空の銘柄(ALPHA・BETA・GAMMA)と架空の価格です。実在の銘柄とは関係ありません。3銘柄それぞれに違う役割を持たせてあり、買い増しあり・一部売却あり/全数売却して手じまい/買いのみで保有継続という、実際のポートフォリオで起きる3パターンを1つのデータでまとめて検証できるようにしています。

import pandas as pd

transactions = pd.DataFrame(
    [
        # date,        ticker,  type,   qty,  price,  fee
        ("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"])

dividends = pd.DataFrame(
    [
        ("2025-06-25", "ALPHA", 2000.0),
        ("2025-09-30", "BETA",  4800.0),
        ("2025-12-25", "ALPHA", 3000.0),
        ("2026-03-30", "GAMMA", 6000.0),
    ],
    columns=["date", "ticker", "amount"],
)
dividends["date"] = pd.to_datetime(dividends["date"])

print(transactions)
print()
print(transactions.dtypes)
        date ticker  type  qty   price    fee
0 2025-04-10  ALPHA   buy  100  2500.0  250.0
1 2025-05-15   BETA   buy  200  1200.0  220.0
2 2025-06-01  GAMMA   buy  300   800.0  330.0
3 2025-09-12  ALPHA   buy  100  3000.0  250.0
4 2025-11-11  GAMMA   buy  100   950.0  275.0
5 2026-01-30   BETA  sell  200  1050.0  200.0
6 2026-02-20  ALPHA  sell   50  3400.0  275.0

date      datetime64[us]
ticker               str
type                 str
qty                int64
price            float64
fee              float64
dtype: object

dtypes を必ず確認してください。datedatetime64 になっていること、qtypricefee が数値型になっていることが、この先の計算の前提です。第3回で触れたとおり、証券会社CSVをそのまま読むと金額が文字列のままということがよくあります。文字列のまま掛け算すると、エラーにならずに文字列が繰り返されるだけ、という気づきにくい事故が起きます。

💡
pandas 3.0 では文字列列の dtype が str と表示される

上の出力で tickertype の dtype が object ではなく str になっている点に気づいたでしょうか。pandas 3.0 から文字列列に専用の dtype が既定で使われるようになったためです。本記事のコードは pandas 2 系でも動き、計算結果も同じになりますが、dtypes の表示は変わります。文字列列は object と表示されます。date 列も datetime64[us] ではなく datetime64[ns] です(pandas 2 系では日時が既定でナノ秒精度のため)。出力を見比べるときに戸惑わないよう、先に触れておきます。

4. 平均取得単価を移動平均方式で求める

損益計算の心臓部です。同じ銘柄を複数回に分けて買った場合、取得単価をいくらとみなすかを決めなければ、損益は出せません。本記事では移動平均方式を採用します。ルールはたった2つです。

  • 買い増したとき: 取得総額に「数量 × 単価 + 手数料」を足し、保有数量で割り直して単価を更新する(加重平均)
  • 売却したとき: 単価は変えず、売った数量ぶんの取得額だけを取得総額から引く

1銘柄の取引を1行ずつ処理して、単価の動きを見る

「売っても単価が変わらない」のがポイントです。売却は手持ちの一部を同じ単価のまま外に出す操作であって、残った株の取得原価に影響しない、と考えます。まずは1銘柄(ALPHA)だけを取り出して、取引を1行ずつ処理しながら単価がどう動くか見てみましょう。

tx = transactions[transactions["ticker"] == "ALPHA"].sort_values("date")

qty = 0        # 保有数量
cost = 0.0     # 保有分の取得総額(買付手数料込み)

for row in tx.itertuples():
    if row.type == "buy":
        qty += row.qty
        cost += row.qty * row.price + row.fee     # 買い増し:取得総額を増やす
    elif row.type == "sell":
        avg = cost / qty
        cost -= avg * row.qty                     # 売却:単価は据え置き、取得総額だけ減らす
        qty -= row.qty
    print(f"{row.date:%Y-%m-%d} {row.type:5s} 数量={qty:>4} 取得総額={cost:>9,.0f} 平均取得単価={cost / qty:>8,.2f}")
2025-04-10 buy   数量= 100 取得総額=  250,250 平均取得単価=2,502.50
2025-09-12 buy   数量= 200 取得総額=  550,500 平均取得単価=2,752.50
2026-02-20 sell  数量= 150 取得総額=  412,875 平均取得単価=2,752.50

2,502.50 → 2,752.50 と単価が上がり、売却後も 2,752.50 のまま据え置かれているのが確認できます。1回目は 2,500円で100株+手数料250円なので 250,250 ÷ 100 = 2,502.50円。2回目に 3,000円で100株+手数料250円を足すと 550,500 ÷ 200 = 2,752.50円。手数料を取得原価に含めているぶん、約定単価そのものより少しだけ高いのが分かります。

groupby 一発では解けない理由 — 売却は経路依存だから

ここで「なぜ groupby 一発で書かないのか」という疑問が出るはずです。実は、買いしかない銘柄なら groupby で一気に計算できます。

# ⚠️ 失敗例:売却を無視して集計するため、一部売却した銘柄の保有数量が過大になる
# ⚠️ 正しい実装は、この節の後半の calc_position() を参照
buys = transactions[transactions["type"] == "buy"].copy()
buys["amount"] = buys["qty"] * buys["price"] + buys["fee"]

g = buys.groupby("ticker").agg(qty=("qty", "sum"), cost=("amount", "sum"))
g["avg_cost"] = g["cost"] / g["qty"]
print(g)
        qty      cost   avg_cost
ticker                          
ALPHA   200  550500.0  2752.5000
BETA    200  240220.0  1201.1000
GAMMA   400  335605.0   839.0125

単価そのものは正しく出ています。ところがALPHAの数量が200株になっているのが問題です。実際には50株を売却済みで、保有は150株。売却を無視して集計しているので、保有数量が過大に出ているわけです。これが損益計算の本質的な難しさで、売却の結果は「それまでの取引をどの順番で処理したか」に依存する(経路依存)ため、行の順序を無視する集計では表現できません。だから売買が混ざる場合はループで1行ずつ処理するのが素直な解になります。

1銘柄ぶんの計算を calc_position() にまとめる

この考え方を関数にまとめます。売却時に実現損益も同時に計算してしまうのがコツです。

def calc_position(tx: pd.DataFrame) -> dict:
    """1銘柄の取引履歴から、保有数量・平均取得単価・実現損益を求める。"""
    qty = 0
    cost = 0.0        # 保有分の取得総額(買付手数料込み)
    realized = 0.0    # 実現損益の累計

    for row in tx.sort_values("date").itertuples():
        if row.type == "buy":
            qty += row.qty
            cost += row.qty * row.price + row.fee
        elif row.type == "sell":
            if row.qty > qty:
                raise ValueError(f"保有数量({qty})を超える売却です: {row.ticker} {row.date:%Y-%m-%d}")
            avg = cost / qty
            realized += (row.price - avg) * row.qty - row.fee
            cost -= avg * row.qty
            qty -= row.qty
        else:
            raise ValueError(f"未知の取引種別です: {row.type}")

    return {
        "qty": qty,
        "avg_cost": cost / qty if qty else 0.0,
        "cost": cost,
        "realized": realized,
    }


positions = pd.DataFrame(
    [{"ticker": t, **calc_position(tx)} for t, tx in transactions.groupby("ticker")]
)
print(positions)
  ticker  qty   avg_cost          cost  realized
0  ALPHA  150  2752.5000  4.128750e+05   32100.0
1   BETA    0     0.0000  2.910383e-11  -30420.0
2  GAMMA  400   839.0125  3.356050e+05       0.0

保有0株なのに取得総額が 2.910383e-11 になる(浮動小数点の残差)

数量と単価は正しく出ましたが、BETAの cost2.910383e-11 になっています。BETAは200株すべてを売却してゼロになったので、取得総額も0円のはずです。これは浮動小数点の丸め誤差で、cost -= avg * row.qty の引き算がぴったり0にならず、ごく小さな端数が残ったものです。1銘柄でもこうした値が混ざると、列全体が指数表記になって読みづらくなるという副作用もあります。

実害としては「保有0株なのに取得額が残っている」ことで、この後の含み損益がわずかにマイナスになります。金額としては1円未満でも、ゼロであるべき数字がゼロでないのは気持ち悪いので、数量が0になったら取得総額も明示的に0へ戻す1行を入れて閉じます。

    # ...ループの直後に追記する
    if qty == 0:
        cost = 0.0        # 全数売却したら取得総額もゼロに戻す(浮動小数点の残差対策)

    return {
        "qty": qty,
        "avg_cost": cost / qty if qty else 0.0,
        "cost": cost,
        "realized": realized,
    }
  ticker  qty   avg_cost      cost  realized
0  ALPHA  150  2752.5000  412875.0   32100.0
1   BETA    0     0.0000       0.0  -30420.0
2  GAMMA  400   839.0125  335605.0       0.0

きれいになりました。お金の計算で「ほぼ0」を放置しないのは、この手のツールを長く使ううえで地味に効いてきます。より厳密にやるなら float ではなく decimal.Decimal を使う手もありますが、pandas との相性を考えると扱いが重くなるため、個人のポートフォリオ管理なら「表示前に丸める+ゼロを明示的に戻す」で十分実用的です。

🛡️
保有数量を超える売却は例外で止める

コード内の if row.qty > qty: raise ValueError(...) は、データの取り違えを早期に発見するためのガードです。取引履歴の一部が欠けていたり、複数口座のデータを混ぜてしまったりすると起こります。試しに100株しか買っていない銘柄を200株売る履歴を渡すと、ValueError: 保有数量(100)を超える売却です: DELTA 2026-05-01 のように、どの銘柄のいつの取引が原因かまで表示されて止まります。黙って負の数量で計算を続けるより、ここで気づけるほうがずっと安全です。

5. 含み損益を計算する

取得総額が分かったので、あとは現在値 × 保有数量 = 評価額を求めて差を取るだけです。現在値は本来、第1回で作った取得関数で取ってきます。ライブラリで取れない銘柄に出会って「株価サイトから直接取ってこよう」と考えたときは、その前に第2回のPythonで株価をスクレイピングする前に(規約とrobots.txtの確認方法)を読んでください。主要な金融サイトの多くは自動取得を禁止しており、取得元を選ぶ判断はコードを書く前の工程です。

# 現在値は本来、第1回で作った fetch_latest_price() で取得する(ネットワークが必要)
# prices = {t: fetch_latest_price(t) for t in positions["ticker"]}

# 本記事では結果を固定して進める(すべて架空の値)
prices = {"ALPHA": 3250.0, "BETA": 1010.0, "GAMMA": 780.0}

positions["price"] = positions["ticker"].map(prices)
positions["market_value"] = positions["qty"] * positions["price"]
positions["unrealized"] = positions["market_value"] - positions["cost"]

print(positions[["ticker", "qty", "avg_cost", "cost", "price", "market_value", "unrealized"]])
  ticker  qty   avg_cost      cost   price  market_value  unrealized
0  ALPHA  150  2752.5000  412875.0  3250.0      487500.0     74625.0
1   BETA    0     0.0000       0.0  1010.0           0.0         0.0
2  GAMMA  400   839.0125  335605.0   780.0      312000.0    -23605.0

ALPHAは 150株 × 3,250円 = 487,500円の評価額に対し取得総額が 412,875円なので、含み益は 74,625円。GAMMAは取得単価 839.01円に対して現在値が 780円まで下がっているため、含み損 23,605円です。BETAは保有0株なので評価額も含み損益も0——前章で誤差を潰しておいたおかげで、きっちり 0.0 と表示されています。

Series.map() に辞書を渡すと、キーが見つからない銘柄は NaN になります。取得に失敗した銘柄があっても処理は止まらず、その行だけ NaN になるのは、実運用では都合のいい挙動です。ただし合計を出すときに黙って除外されるので、positions["price"].isna().any() で確認して警告を出す、といった一手間を入れておくと安心でしょう。

🔗
CSVの銘柄コードと、価格取得用のティッカーは別物

第3回でも触れたとおり、証券会社CSVの銘柄コード(例: 4桁の数字)と、yfinanceで使うティッカー(例: 末尾に .T が付く形式)は表記が違います。実運用では、transactionsticker をどちらの表記で持つかを最初に決め、変換表(辞書やCSV)を1か所にまとめて持つのが安全です。本記事のサンプルは架空の識別子なので、そのまま prices のキーと一致させています。

6. 実現損益を計算する(売却1件ごとの明細)

実現損益はすでに calc_position() が銘柄ごとの合計として返しています。ただ、実際に使い始めると「どの売却でいくら儲かった(損した)のか」を1件ずつ見たくなります。年間の損益を振り返るときも、合計値だけでは判断材料になりません。

ここで注意したいのが、実現損益に使う平均取得単価は「売却した時点」のものだという点です。売却後にさらに買い増していれば、最終的な平均取得単価は売却時点とは違う値になっています。あとから最終単価を掛けて計算し直すと、答えがずれるわけです。だから明細は、ループの中で売却を処理したその場で記録しておく必要があります。

関数に省略可能な記録用リストを渡せるようにして対応します。既存の呼び出し方を変えずに機能を足せる書き方です。

def calc_position(tx: pd.DataFrame, trade_log: list | None = None) -> dict:
    """1銘柄の取引履歴から保有数量・平均取得単価・実現損益を求める。
    trade_log にリストを渡すと、売却1件ごとの明細を追記する。"""
    qty = 0
    cost = 0.0
    realized = 0.0

    for row in tx.sort_values("date").itertuples():
        if row.type == "buy":
            qty += row.qty
            cost += row.qty * row.price + row.fee
        elif row.type == "sell":
            if row.qty > qty:
                raise ValueError(f"保有数量({qty})を超える売却です: {row.ticker} {row.date:%Y-%m-%d}")
            avg = cost / qty                                  # 売却時点の平均取得単価
            pl = (row.price - avg) * row.qty - row.fee        # 実現損益
            realized += pl
            if trade_log is not None:
                trade_log.append({
                    "date": row.date, "ticker": row.ticker, "qty": row.qty,
                    "sell_price": row.price, "avg_cost": avg, "fee": row.fee, "realized": pl,
                })
            cost -= avg * row.qty
            qty -= row.qty
        else:
            raise ValueError(f"未知の取引種別です: {row.type}")

    if qty == 0:
        cost = 0.0

    return {"qty": qty, "avg_cost": cost / qty if qty else 0.0,
            "cost": cost, "realized": realized}


trade_log = []
for ticker, tx in transactions.groupby("ticker"):
    calc_position(tx, trade_log)

trades = pd.DataFrame(trade_log).sort_values("date").reset_index(drop=True)
print(trades)
print()
print(f"実現損益の合計: {trades['realized'].sum():,.0f} 円")
        date ticker  qty  sell_price  avg_cost    fee  realized
0 2026-01-30   BETA  200      1050.0    1201.1  200.0  -30420.0
1 2026-02-20  ALPHA   50      3400.0    2752.5  275.0   32100.0

実現損益の合計: 1,680 円

売却1件ごとに、売却単価・そのときの平均取得単価・手数料・実現損益が並びました。BETAは 1,201.1円で取得したものを 1,050円で売ったので 30,420円の損失、ALPHAは 2,752.5円のものを 3,400円で売って 32,100円の利益です。合計するとプラス1,680円——利益と損失が近い金額で打ち消し合い、合計だけ見ればほぼ横ばい、という状況が明細から一目で分かります。

手数料の扱いも確認しておきましょう。本記事では買付手数料は取得原価に含め、売却手数料は実現損益から差し引くという方針で統一しています。ALPHAの計算で言えば、(3,400 − 2,752.5) × 50 = 32,375円から手数料275円を引いて32,100円。どちらの手数料も、最終的には利益を減らす方向に一度だけ効くので、二重に引いたり抜けたりしていないかは必ず検算してください。

7. 配当を含めたトータルリターン

含み損益と実現損益が揃いました。最後のピースが配当です。ここを足さないとリターンを過小評価することになり、とくに保有期間が長い銘柄ほど差が大きくなります。

配当は groupby で銘柄ごとに合計し、merge で損益一覧にくっつけます。同時にリターン率の分母も決めます。本記事では投下元本=期間中の買付総額(手数料込み)としました。「自分がこの銘柄にいくら突っ込んで、いくら返ってきたか」という素直な見方です。

div_sum = dividends.groupby("ticker")["amount"].sum().rename("dividend")
positions = positions.merge(div_sum, on="ticker", how="left")
positions["dividend"] = positions["dividend"].fillna(0.0)   # 配当なしの銘柄は0

# 投下元本=買付総額(手数料込み)
buys = transactions[transactions["type"] == "buy"].copy()
buys["invested"] = buys["qty"] * buys["price"] + buys["fee"]
invested = buys.groupby("ticker")["invested"].sum()
positions = positions.merge(invested, on="ticker", how="left")

positions["total_pl"] = positions["unrealized"] + positions["realized"] + positions["dividend"]
positions["return_pct"] = positions["total_pl"] / positions["invested"] * 100

print(positions[["ticker", "unrealized", "realized", "dividend", "total_pl", "invested", "return_pct"]].round(2))
print()
print(f"合計損益: {positions['total_pl'].sum():,.0f} 円")
print(f"投下元本: {positions['invested'].sum():,.0f} 円")
print(f"リターン: {positions['total_pl'].sum() / positions['invested'].sum() * 100:.2f} %")
  ticker  unrealized  realized  dividend  total_pl  invested  return_pct
0  ALPHA     74625.0   32100.0    5000.0  111725.0  550500.0       20.30
1   BETA         0.0  -30420.0    4800.0  -25620.0  240220.0      -10.67
2  GAMMA    -23605.0       0.0    6000.0  -17605.0  335605.0       -5.25

合計損益: 68,500 円
投下元本: 1,126,325 円
リターン: 6.08 %

3銘柄の性格の違いがはっきり出ました。BETAは売却で30,420円の損失を出しましたが、保有中に受け取った配当4,800円がその一部を埋めて、合計では −25,620円。配当を無視していたら、この銘柄の損失を実際より大きく捉えることになります。GAMMAは含み損23,605円に対して配当6,000円で、合計 −17,605円です。

そしてポートフォリオ全体では合計損益 68,500円・投下元本 1,126,325円・リターン 6.08%。ALPHAの含み益が全体を押し上げている構図ですが、この68,500円のうち51,020円は「まだ売っていない含み益」である点は意識しておくべきでしょう。相場が動けば消える数字と、すでに確定した数字を、同じ列に並べつつ別の列として持っておく——それが、この損益一覧の設計意図です。

📐
リターン率の分母は「自分で決める」もの

本記事は投下元本(買付総額)を分母にしましたが、ほかにも取り方はあります。現在の取得総額(保有分のみ)を分母にすれば「いま抱えているポジションの騰落率」になりますし、入金額ベースで測ればキャッシュフロー全体の効率が見えます。保有期間を考慮した年率換算(IRR・時間加重収益率)まで踏み込むという手もあるでしょう。どれが正解ということはなく、何を知りたいかで選ぶものです。大事なのは、自分の計算式を1か所に書いておいて、前月と同じ物差しで比べられるようにすることです。

8. 全銘柄の損益一覧を返す関数にまとめる

ここまでのコードを、そのままファイルに保存して使える完成形にまとめます。取引履歴・配当・現在値の3つを渡すと、銘柄別の損益一覧と売却明細の2つの DataFrame が返ってくる設計です。

"""保有銘柄の損益一覧を作る(移動平均方式・配当込み)。"""
import pandas as pd


def calc_position(tx: pd.DataFrame, trade_log: list | None = None) -> dict:
    """1銘柄の取引履歴から保有数量・平均取得単価・実現損益を求める。"""
    qty, cost, realized = 0, 0.0, 0.0

    for row in tx.sort_values("date").itertuples():
        if row.type == "buy":
            qty += row.qty
            cost += row.qty * row.price + row.fee
        elif row.type == "sell":
            if row.qty > qty:
                raise ValueError(f"保有数量({qty})を超える売却です: {row.ticker} {row.date:%Y-%m-%d}")
            avg = cost / qty
            pl = (row.price - avg) * row.qty - row.fee
            realized += pl
            if trade_log is not None:
                trade_log.append({"date": row.date, "ticker": row.ticker, "qty": row.qty,
                                  "sell_price": row.price, "avg_cost": avg,
                                  "fee": row.fee, "realized": pl})
            cost -= avg * row.qty
            qty -= row.qty
        else:
            raise ValueError(f"未知の取引種別です: {row.type}")

    if qty == 0:
        cost = 0.0
    return {"qty": qty, "avg_cost": cost / qty if qty else 0.0,
            "cost": cost, "realized": realized}


def build_portfolio(transactions: pd.DataFrame, dividends: pd.DataFrame,
                    prices: dict) -> tuple[pd.DataFrame, pd.DataFrame]:
    """取引履歴・配当・現在値から、銘柄別の損益一覧と売却明細を作る。"""
    trade_log: list = []
    rows = []
    for ticker, tx in transactions.groupby("ticker"):
        pos = calc_position(tx, trade_log)
        price = prices.get(ticker)
        rows.append({"ticker": ticker, **pos, "price": price,
                     "market_value": pos["qty"] * price if price is not None else float("nan")})

    summary = pd.DataFrame(rows)
    summary["unrealized"] = summary["market_value"] - summary["cost"]

    # 配当合計(受取なしの銘柄は0)
    div = dividends.groupby("ticker")["amount"].sum().rename("dividend")
    summary = summary.merge(div, on="ticker", how="left")
    summary["dividend"] = summary["dividend"].fillna(0.0)

    # 投下元本=買付総額(手数料込み)
    buys = transactions[transactions["type"] == "buy"]
    invested = (buys["qty"] * buys["price"] + buys["fee"]).groupby(buys["ticker"]).sum()
    summary = summary.merge(invested.rename("invested"), on="ticker", how="left")

    summary["total_pl"] = summary["unrealized"] + summary["realized"] + summary["dividend"]
    summary["return_pct"] = summary["total_pl"] / summary["invested"] * 100

    trades = pd.DataFrame(trade_log)
    if not trades.empty:
        trades = trades.sort_values("date").reset_index(drop=True)
    return summary, trades


if __name__ == "__main__":
    # 実運用では自分の取引履歴から作った DataFrame を渡す(以下は架空のサンプル)
    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"])

    dividends = pd.DataFrame(
        [
            ("2025-06-25", "ALPHA", 2000.0),
            ("2025-09-30", "BETA",  4800.0),
            ("2025-12-25", "ALPHA", 3000.0),
            ("2026-03-30", "GAMMA", 6000.0),
        ],
        columns=["date", "ticker", "amount"],
    )
    dividends["date"] = pd.to_datetime(dividends["date"])

    prices = {"ALPHA": 3250.0, "BETA": 1010.0, "GAMMA": 780.0}   # 第1回のyfinanceで置き換える

    summary, trades = build_portfolio(transactions, dividends, prices)

    cols = ["ticker", "qty", "avg_cost", "cost", "market_value",
            "unrealized", "realized", "dividend", "total_pl", "return_pct"]
    print(summary[cols].round(2).to_string(index=False))
    print()
    print(f"含み損益 合計 : {summary['unrealized'].sum():>12,.0f} 円")
    print(f"実現損益 合計 : {summary['realized'].sum():>12,.0f} 円")
    print(f"配当     合計 : {summary['dividend'].sum():>12,.0f} 円")
    print(f"トータル損益  : {summary['total_pl'].sum():>12,.0f} 円")
    print(f"投下元本      : {summary['invested'].sum():>12,.0f} 円")
    print(f"リターン      : {summary['total_pl'].sum() / summary['invested'].sum() * 100:>11.2f} %")

実行結果はこうなります。

ticker  qty  avg_cost     cost  market_value  unrealized  realized  dividend  total_pl  return_pct
 ALPHA  150   2752.50 412875.0      487500.0     74625.0   32100.0    5000.0  111725.0       20.30
  BETA    0      0.00      0.0           0.0         0.0  -30420.0    4800.0  -25620.0      -10.67
 GAMMA  400    839.01 335605.0      312000.0    -23605.0       0.0    6000.0  -17605.0       -5.25

含み損益 合計 :       51,020 円
実現損益 合計 :        1,680 円
配当     合計 :       15,800 円
トータル損益  :       68,500 円
投下元本      :    1,126,325 円
リターン      :        6.08 %

これで「取引履歴を渡せば損益一覧が返る」という部品が1つ完成しました。summary はそのまま表として表示できますし、unrealized 列を棒グラフに、market_value 列を円グラフに渡せば、そのまま可視化に進めます。戻り値を DataFrame に揃えておくと、後続の工程で選択肢が広がる——これが、連載を通じて部品を作り足していくうえでの設計方針です。

to_string(index=False) を使っているのは、行番号を消して表を見やすくするためです。ターミナルに出す前提の簡易表示なので、アプリに組み込むときはここを Streamlit の st.dataframe() などに差し替えることになります。

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

実データを流し込むと、サンプルでは起きなかった問題が出てきます。よく踏む落とし穴を、先に共有しておきます。

実データで踏みやすい5つの落とし穴

つまずき何が起きるか対処
日付順に並んでいない売却が買付より前に処理され、数量エラーや誤った単価になるループ前に sort_values("date")(本記事の関数は内部で実施済み)
同じ日に複数の約定がある日付だけでは順序が決まらず、結果が実行ごとに揺れる可能性がある約定時刻や連番の列を持たせ、その列も含めて並べ替える
株式分割・併合がある数量と単価が実態とずれる(分割後に単価が高いまま残る)分割比率を取引履歴に反映する調整行を入れる
外貨建ての銘柄が混ざる円と外貨を合算してしまい、合計が意味をなさない通貨列を持ち、通貨ごとに集計してから換算する
金額が文字列のまま掛け算がエラーにならず、文字列の繰り返しになる第3回の thousands=","pd.to_numeric() で数値化

とくに株式分割外貨建ては、実データを扱い始めるとかなりの確率で遭遇します。どちらも「取引履歴のデータモデルをどう設計するか」の問題なので、あとから直すと計算式のあちこちに手が入ります。最初から通貨列を持たせておく程度の備えはしておくと後が楽です。エラーが出たときの読み解き方はPythonのよくあるエラーと対処法にまとめてあります。

また、今回の実装は groupbymergefillnaitertuples といった pandas の基本操作の組み合わせでできています。ここを体系的に押さえておくと、この先のグラフ描画や集計がぐっと速く書けるようになります。各メソッドの詳しい引数はpandas公式のDataFrameリファレンスが確実です。

📖
次に読むなら:pandasの集計を体系立てて

本記事で使った groupbymerge を軸に、実データの集計・整形をひととおり学べる1冊としてPythonによるデータ分析入門 第3版(当サイトのおすすめ本ランキング総合5位)を紹介しています。pandas の開発者本人が書いた本なので、「なぜこの設計なのか」まで踏み込んで理解できます。なお、リンク先は当サイトの書籍紹介ページで、アフィリエイト広告を含みますのでご了承ください。

次につなげる:損益一覧を可視化する

次回以降は、ここで作った損益一覧を目で見える形にしていきます。株価チャートの描画、ポートフォリオのインタラクティブな可視化、セクター分散の円グラフ——いずれも入力は今回の summary と、第1回で取得した価格データです。連載の全体像と公開済みの記事は投資×Python シリーズ一覧から辿れます。

📌
この連載のスタンス

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

10. よくある質問(FAQ)

pandasでの損益計算でつまずきやすい点をまとめました。

Q. 同じ銘柄を複数回買った場合、取得単価はどう計算しますか?

買い増しのたびに加重平均で計算し直します。具体的には「それまでの取得総額 + 今回の数量 × 単価 + 手数料」を新しい保有数量で割ります。本記事のサンプルでは、2,500円で100株(手数料250円)買ったあとに3,000円で100株(手数料250円)買い増したので、550,500円 ÷ 200株 = 2,752.50円が平均取得単価になります。

Q. 売却すると平均取得単価は変わりますか?

移動平均方式では変わりません。売却は「同じ単価の株を一部外に出す」操作として扱い、取得総額から売却分(平均取得単価 × 売却数量)を引くだけにします。残った株の単価は売却前と同じままです。本記事のサンプルでも、50株を売却したあとの平均取得単価は 2,752.50円のまま据え置かれています。

Q. なぜ総平均法ではなく移動平均方式で計算するのですか?

取引のたびに単価が更新されるため、「いま何円で持っているか」を常に追える方式だからです。総平均法は期間全体の買付をまとめて1つの単価にする考え方で、期末にまとめて計算する用途に向きます。ここで大事なのは、本記事の移動平均方式は資産管理のための独自ロジックであり、税法上の計算方法そのものではないという点です。国税庁は同一銘柄の株式等を2回以上購入した場合の取得費について「総平均法に準ずる方法」を示しています(国税庁 No.1466)。日々のポートフォリオ把握は本記事の計算で、確定申告は証券会社が発行する年間取引報告書などの公式な書類で——と用途で使い分けるのが安全です。

Q. 含み損益と実現損益は何が違いますか?

含み損益はまだ売っていない保有分の評価上の損益で、株価が動けば変わります。実現損益は売却によって確定した損益で、あとから変わりません。両方を別の列として持ち、合計するときだけ足すのがおすすめです。「合計はプラスだが、その大半はまだ売っていない含み益」といった状況が見えるようになります。

Q. 売買手数料はどこに含めればいいですか?

本記事では、買付手数料は取得原価に含め(取得総額に加算)、売却手数料は実現損益から差し引く方針で統一しています。どちらも最終的に利益を減らす方向に一度だけ効きます。大事なのは方針を統一することで、二重に引いたり片方だけ抜けたりすると損益がずれるため、少額のサンプルで手計算と突き合わせて検算してください。

Q. 全株を売却した銘柄で、取得総額が 2.910383e-11 のような値になります。

浮動小数点の丸め誤差です。売却のたびに取得総額から引き算していくと、ぴったり0にならず極小の端数が残ることがあります。保有数量が0になったタイミングで取得総額も明示的に 0.0 に戻すのが簡単な対処です。表示上も、1銘柄でも極小値が混ざると列全体が指数表記になって読みづらくなるため、早めに潰しておくのがおすすめです。

Q. この計算結果を確定申告に使えますか?

本記事の計算はポートフォリオを把握するための概算であり、税務上の損益計算とは目的が異なります。株式分割・NISA口座・複数の証券会社にまたがる保有など、実際の税額計算には多くの条件が絡みます。確定申告や納税額の判断には、証券会社が発行する年間取引報告書(特定口座)などの公式な書類を使い、判断に迷う場合は税務署や税理士にご確認ください。

🧰
読み終えたら:続きの第5回・第6回と、手を動かせる2本

8章の build_portfolio()portfolio.py として保存しておけば、連載の後半でそのまま再利用できます。続きの第5回 Matplotlibでローソク足チャートを描くを公開しました。ここで作った損益一覧の数字を、そのままチャートにしていく回です。第6回 PythonとPlotlyでポートフォリオを可視化するでは、同じ損益一覧をブラウザで触れるグラフにしています(第7回以降は順次公開予定)。あわせて、地続きで手を動かせるのが次の2本です。

連載の全体像と公開済みの記事は投資×Python シリーズ一覧にまとめてあります。続きが出たときに気づけるよう、この一覧ページをブックマークしておくのが確実です。

本記事は、生成AIを活用して下書きし、運営者が内容を確認・編集したうえで公開しています。掲載しているコードと出力は Python 3.12.10 / pandas 3.0.3 で実行して確認したものですが、登場する銘柄名・価格・取引・配当はすべて架空であり、実在の銘柄や相場を示すものではありません。本記事は情報提供を目的としたもので、特定の銘柄の売買や投資手法を推奨するものではありません。税務上の取り扱いについては税務署・税理士等の専門家にご確認ください。投資判断はご自身の責任で行ってください。AIの利用方針は免責事項をご覧ください。