※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。
SBI証券で株式やETFを売買しており、取引履歴をデータとして振り返りたいと考えている方は多いはずです。
証券口座の管理画面では損益の合計は確認できますが、銘柄別の勝率や月次推移といった「自分だけの分析」はできません。約定履歴CSV(Comma Separated Values:カンマ区切りテキスト)をPythonで読み込めば、自由な切り口で投資成績を数値化できます。
📘 外部参考:Python 公式サイト(ダウンロード) / Python 公式ドキュメント(日本語)
しかし実際にCSVを開いてみると、日本語のヘッダーや全角数字、独特のカラム構成に戸惑い、手が止まる方が少なくありません。
原因は、SBI証券のCSVがExcelでの閲覧を前提とした形式になっている点にあります。エンコーディングの問題や、金額列のカンマ区切りといった「前処理の壁」が初中級者のつまずきポイントです。
本記事では、SBI証券からダウンロードした約定履歴CSVをpandasで正しく読み込み、銘柄別損益・勝率(Win Rate)・月次パフォーマンスを自動算出するPythonコードを提供します。
📘 外部参考:pandas User Guide(公式・英語)
コードはコピペで動く設計です。自分のCSVファイルパスを設定エリアで書き換えるだけで、すぐに分析を開始できます。
SBI証券の約定履歴CSVの構造
CSVのダウンロード手順
SBI証券の約定履歴CSVは、以下の手順で取得できます。
- SBI証券にログインし「口座管理」→「取引履歴」に進む
- 期間と商品種別(国内株式等)を指定して「照会」を押す
- 画面下部の「CSVダウンロード」ボタンをクリックする
ダウンロードされるファイルのエンコーディングはShift_JIS(cp932)です。UTF-8で開くと文字化けするため、読み込み時に明示的に指定する必要があります。
CSVの主要カラムとデータ型の注意点
SBI証券のCSVには、約定日・銘柄コード・銘柄名・売買区分・数量・単価・手数料・税額などのカラムが含まれます。分析上の注意点は以下のとおりです。
* 約定日:YYYY/MM/DD形式の文字列で格納されている。pd.to_datetimeで変換が必要
* 数量・単価・手数料:カンマ区切りの文字列(例:1,500)になっている場合がある。数値変換前にカンマ除去が必須
* 売買区分:「買」「売」などの日本語文字列。フィルタリングのキーとして使用する
CSVのカラム名やフォーマットはSBI証券の仕様変更により変わる可能性があります。実際のファイルをhead()で確認してからコードを実行してください。
【コピペOK】約定履歴CSV読み込みと損益分析コード
まず必要なライブラリをインストールしてください。
pip install pandas numpy matplotlib japanize-matplotlib
以下がメインの分析コードです。
import re
from pathlib import Path
from typing import List, Optional
import japanize_matplotlib # noqa: F401
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# ==============================
# 設定エリア
# ==============================
CSV_FILE_PATH: str = "trading_history.csv" # SBI証券からDLしたCSVのパス
ENCODING: str = "cp932" # SBI証券CSVのエンコーディング
# --- カラム名マッピング(実際のCSVヘッダーに合わせて変更) ---
COL_DATE: str = "約定日"
COL_TICKER: str = "銘柄コード"
COL_NAME: str = "銘柄名"
COL_SIDE: str = "売買区分"
COL_QTY: str = "約定数量"
COL_PRICE: str = "約定単価"
COL_COMMISSION: str = "手数料"
COL_TAX: str = "税額"
BUY_LABEL: str = "買"
SELL_LABEL: str = "売"
# ==============================
# CSV読み込み・前処理関数
# ==============================
def load_csv(file_path: str, encoding: str) -> pd.DataFrame:
'\"SBI証券CSVを読み込みDataFrameを返す'\"
path: Path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"CSVファイルが見つかりません: {file_path}")
df: pd.DataFrame = pd.read_csv(path, encoding=encoding)
return df
def clean_numeric_column(series: pd.Series) -> pd.Series:
'\"カンマ・全角数字を除去して数値型に変換する'\"
cleaned: pd.Series = series.astype(str).str.replace(",", ', regex=False)
cleaned = cleaned.str.replace(r"[0-9]", lambda m: chr(ord(m.group()) - 0xFEE0), regex=True)
cleaned = cleaned.str.replace(r"[^\d.\-]", ', regex=True)
return pd.to_numeric(cleaned, errors="coerce").fillna(0)
def preprocess(df: pd.DataFrame) -> pd.DataFrame:
'\"カラムの型変換と約定金額算出を行う'\"
df[COL_DATE] = pd.to_datetime(df[COL_DATE], errors="coerce")
for col in [COL_QTY, COL_PRICE, COL_COMMISSION, COL_TAX]:
if col in df.columns:
df[col] = clean_numeric_column(df[col])
df["約定金額"] = df[COL_QTY] * df[COL_PRICE]
df["コスト"] = df[COL_COMMISSION] + df[COL_TAX]
df = df.dropna(subset=[COL_DATE])
df = df.sort_values(COL_DATE).reset_index(drop=True)
return df
# ==============================
# 損益算出関数(銘柄別・先入先出法)
# ==============================
def calc_pnl_by_ticker(df: pd.DataFrame) -> pd.DataFrame:
'\"銘柄ごとにFIFO方式で損益を算出する'\"
results: List[dict] = []
tickers: List = df[COL_TICKER].unique().tolist()
for ticker in tickers:
ticker_df: pd.DataFrame = df[df[COL_TICKER] == ticker].copy()
name: str = ticker_df[COL_NAME].iloc[0] if COL_NAME in ticker_df.columns else str(ticker)
buy_queue: List[dict] = []
realized_pnl: float = 0.0
total_cost: float = 0.0
total_sell_amount: float = 0.0
total_buy_amount: float = 0.0
trade_count: int = 0
win_count: int = 0
for _, row in ticker_df.iterrows():
side: str = str(row[COL_SIDE]).strip()
qty: float = float(row[COL_QTY])
price: float = float(row[COL_PRICE])
cost: float = float(row["コスト"])
total_cost += cost
if side == BUY_LABEL:
buy_queue.append({"qty": qty, "price": price})
total_buy_amount += qty * price
elif side == SELL_LABEL:
sell_qty_remaining: float = qty
trade_pnl: float = 0.0
total_sell_amount += qty * price
while sell_qty_remaining > 0 and buy_queue:
oldest: dict = buy_queue[0]
matched_qty: float = min(sell_qty_remaining, oldest["qty"])
trade_pnl += matched_qty * (price - oldest["price"])
oldest["qty"] -= matched_qty
sell_qty_remaining -= matched_qty
if oldest["qty"] <= 0:
buy_queue.pop(0)
realized_pnl += trade_pnl
trade_count += 1
if trade_pnl > 0:
win_count += 1
remaining_qty: float = sum(b["qty"] for b in buy_queue)
win_rate: float = (win_count / trade_count * 100) if trade_count > 0 else 0.0
results.append({
"銘柄コード": ticker,
"銘柄名": name,
"売却回数": trade_count,
"勝ち回数": win_count,
"勝率(%)": round(win_rate, 1),
"実現損益": round(realized_pnl, 0),
"総コスト": round(total_cost, 0),
"純損益": round(realized_pnl - total_cost, 0),
"未決済数量": remaining_qty,
})
result_df: pd.DataFrame = pd.DataFrame(results)
result_df = result_df.sort_values("純損益", ascending=False).reset_index(drop=True)
return result_df
# ==============================
# 月次損益集計関数
# ==============================
def calc_monthly_pnl(df: pd.DataFrame) -> pd.DataFrame:
'\"月次の売却損益を簡易集計する'\"
sell_df: pd.DataFrame = df[df[COL_SIDE].str.strip() == SELL_LABEL].copy()
sell_df["年月"] = sell_df[COL_DATE].dt.to_period("M")
monthly: pd.DataFrame = sell_df.groupby("年月").agg(
売却金額=("約定金額", "sum"),
売却回数=("約定金額", "count"),
手数料合計=("コスト", "sum"),
).reset_index()
return monthly
# ==============================
# サマリー表示関数
# ==============================
def print_summary(pnl_df: pd.DataFrame) -> None:
'\"全体サマリーをコンソールに出力する'\"
total_pnl: float = pnl_df["純損益"].sum()
total_trades: int = int(pnl_df["売却回数"].sum())
total_wins: int = int(pnl_df["勝ち回数"].sum())
overall_wr: float = (total_wins / total_trades * 100) if total_trades > 0 else 0.0
print("=" * 50)
print("投資成績サマリー")
print("=" * 50)
print(f"総純損益 : ¥{total_pnl:,.0f}")
print(f"総売却回数 : {total_trades}回")
print(f"総勝率 : {overall_wr:.1f}%")
print(f"分析銘柄数 : {len(pnl_df)}銘柄")
print("=" * 50)
# ==============================
# 可視化関数
# ==============================
def plot_pnl_bar(pnl_df: pd.DataFrame) -> None:
'\"銘柄別純損益の棒グラフを描画する'\"
top: pd.DataFrame = pnl_df.head(20)
colors: list = ["green" if v >= 0 else "red" for v in top["純損益"]]
fig, ax = plt.subplots(figsize=(12, 6))
ax.barh(top["銘柄名"].astype(str), top["純損益"], color=colors)
ax.set_xlabel("純損益(円)")
ax.set_title("銘柄別 純損益ランキング(上位20件)")
ax.invert_yaxis()
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig("pnl_by_ticker.png", dpi=150)
plt.show()
def plot_monthly_trades(monthly_df: pd.DataFrame) -> None:
'\"月次売却回数の推移を描画する'\"
fig, ax = plt.subplots(figsize=(12, 4))
x_labels: List[str] = monthly_df["年月"].astype(str).tolist()
ax.bar(x_labels, monthly_df["売却回数"], color="steelblue")
ax.set_ylabel("売却回数")
ax.set_title("月次 売却回数推移")
plt.xticks(rotation=45, ha="right")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.savefig("monthly_trades.png", dpi=150)
plt.show()
# ==============================
# メイン処理
# ==============================
if __name__ == "__main__":
# データ読み込み・前処理
raw_df: pd.DataFrame = load_csv(CSV_FILE_PATH, ENCODING)
clean_df: pd.DataFrame = preprocess(raw_df)
print(f"読み込み件数: {len(clean_df)}行")
print(f"カラム一覧 : {clean_df.columns.tolist()}")
print()
# 銘柄別損益算出
pnl_df: pd.DataFrame = calc_pnl_by_ticker(clean_df)
print(pnl_df.to_string(index=False))
print()
# サマリー出力
print_summary(pnl_df)
# 月次集計
monthly_df: pd.DataFrame = calc_monthly_pnl(clean_df)
print("\n月次売却集計:")
print(monthly_df.to_string(index=False))
# 可視化
plot_pnl_bar(pnl_df)
plot_monthly_trades(monthly_df)
# CSV出力
pnl_df.to_csv("pnl_result.csv", index=False, encoding="utf-8-sig")
print("\n分析結果を pnl_result.csv に出力しました。")
コードの処理フロー解説
上記のコードは、以下の6ステップで構成されています。
* ステップ1(CSV読み込み):load_csvでShift_JIS(cp932)エンコーディングを指定してCSVを読み込む
* ステップ2(前処理):preprocessでカンマ除去・全角数字変換・日付パースを行い、約定金額とコスト列を追加する
* ステップ3(損益算出):calc_pnl_by_tickerで銘柄ごとにFIFO(First In First Out:先入先出法)方式の実現損益・勝率を算出する
* ステップ4(月次集計):calc_monthly_pnlで月単位の売却金額・回数・手数料を集計する
* ステップ5(可視化):銘柄別損益の棒グラフと月次売却回数の推移チャートを生成する
* ステップ6(CSV出力):分析結果をUTF-8(BOM付き)のCSVとして保存し、Excelでも文字化けなく開ける形にする
設定エリアのカラム名マッピングを変更すれば、楽天証券やマネックス証券のCSVにも対応できます。
分析結果の読み解き方
勝率と純損益のバランスを見る
勝率が高くても、1回の大きな負けで全体がマイナスになるケースがあります。pnl_result.csvで「勝率(%)」が60%以上なのに「純損益」がマイナスの銘柄がないか確認してください。
該当銘柄がある場合、損切りルールの甘さが原因です。損小利大のバランスを定量的に把握できることが、CSV分析の最大のメリットです。
FIFO方式の損益計算の限界
本コードのFIFO方式は、同一銘柄の買い注文を時系列順に売り注文と紐づけます。ナンピン買い(複数回に分けた買い増し)を正確に反映できる反面、特定の買いロットを任意に指定する「個別法」には対応していません。
SBI証券の「特定口座年間取引報告書」と照合し、大きなずれがないか必ずチェックしてください。税務上の損益とは計算方法が異なる場合があります。
【コピペOK】勝率・損益比率の詳細レポート追加
基本コードに加えて、プロフィットファクター(Profit Factor)やペイオフレシオ(Payoff Ratio)を算出する発展版です。メインコードのif __name__ == "__main__":ブロック末尾に追加してください。
# ==============================
# 詳細パフォーマンス指標(追加分析)
# ==============================
total_gross_win: float = float(pnl_df[pnl_df["実現損益"] > 0]["実現損益"].sum())
total_gross_loss: float = float(abs(pnl_df[pnl_df["実現損益"] < 0]["実現損益"].sum()))
win_trades: int = int(pnl_df[pnl_df["実現損益"] > 0]["売却回数"].sum())
lose_trades: int = int(pnl_df[pnl_df["実現損益"] < 0]["売却回数"].sum())
profit_factor: float = (
total_gross_win / total_gross_loss if total_gross_loss > 0 else float("inf")
)
avg_win: float = total_gross_win / win_trades if win_trades > 0 else 0.0
avg_loss: float = total_gross_loss / lose_trades if lose_trades > 0 else 0.0
payoff_ratio: float = avg_win / avg_loss if avg_loss > 0 else float("inf")
print("\n" + "=" * 50)
print("詳細パフォーマンス指標")
print("=" * 50)
print(f"プロフィットファクター : {profit_factor:.2f}")
print(f"ペイオフレシオ : {payoff_ratio:.2f}")
print(f"平均利益(勝ちトレード): ¥{avg_win:,.0f}")
print(f"平均損失(負けトレード): ¥{avg_loss:,.0f}")
print(f"総利益 / 総損失 : ¥{total_gross_win:,.0f} / ¥{total_gross_loss:,.0f}")
print("=" * 50)
プロフィットファクター(Profit Factor)は総利益÷総損失で算出されます。1.0を超えていれば利益が損失を上回っている状態です。目安として1.5以上を維持できていれば安定したトレード成績と判断できます。
ペイオフレシオ(Payoff Ratio)は平均利益÷平均損失です。勝率50%の場合、ペイオフレシオが1.0以上であれば収支はプラスになります。勝率とペイオフレシオのバランスを両方チェックすることが重要です。
よくあるエラーと対処法
UnicodeDecodeError: ‘utf-8’ codec can’t decode byte
SBI証券のCSVはShift_JIS(cp932)でエンコードされています。pandasのデフォルトはUTF-8のため、エンコーディング指定なしで読み込むとこのエラーが発生します。
以下を試してください。
* 設定エリアのENCODINGが"cp932"になっているか確認する
* CSVをテキストエディタで開き、「名前を付けて保存」でUTF-8に変換してから読み込む方法でも対処できる
* それでも解消しない場合はencoding="shift_jis"を試す
KeyError: ‘約定日’でカラムが見つからない
SBI証券のCSV仕様が変更された、またはダウンロードした商品種別によってカラム名が異なることが原因です。
以下を試してください。
* raw_df.columns.tolist()をprintで出力し、実際のカラム名を確認する
* 設定エリアのCOL_DATE等のカラム名マッピングを実際のCSVヘッダーに合わせて修正する
* CSVの先頭に不要なヘッダー行がある場合はpd.read_csvのskiprows引数で読み飛ばす
損益の金額がSBI証券の画面と一致しない
FIFO方式の計算結果と証券会社の表示が異なるのは、計算方法の違いが原因です。SBI証券は移動平均法を採用しているケースがあります。
完全一致を求める場合は、SBI証券の「特定口座損益」画面の数値を正とし、本コードの結果はトレードの傾向分析用と割り切って使用してください。数円〜数十円程度のずれであれば、端数処理やタイミングの違いによるものであり、分析上の問題はありません。
まとめ
この記事では、SBI証券からダウンロードした約定履歴CSVをPythonで読み込み、銘柄別損益・勝率・月次パフォーマンスを自動算出する方法を解説しました。
要点を整理します。
* SBI証券のCSVはShift_JIS(cp932)エンコーディングで、pd.read_csvにencoding="cp932"の指定が必須
* 金額列にカンマや全角数字が含まれるため、clean_numeric_column関数で前処理してから数値変換する
* FIFO方式で銘柄別の実現損益と勝率を算出し、pnl_result.csvとして出力する
* プロフィットファクター1.5以上・ペイオフレシオ1.0以上が安定した成績の目安となる
* 税務上の正確な損益は証券会社の「特定口座損益」画面と照合して確認する
次のステップとして、楽天証券やマネックス証券のCSVにも対応するよう、設定エリアのカラム名マッピングを切り替える仕組みを追加してみてください。
さらに、分析結果をStreamlitやDashで可視化するWebアプリに発展させれば、CSVをアップロードするだけでダッシュボードが表示される自分専用のトレード分析ツールが完成します。
無料データ、証券会社データ、リアルタイムデータの違いを比較した記事はこちら。
→ yfinance・楽天RSS・moomoo・SBI CSV データ取得方法比較

