Bybitの取引履歴をAPIで取得して年間損益を集計するPython
確定申告用データの作り方

Bybitには日本の「年間取引報告書」に相当するものがなく、損益は自分で集計する必要があります。しかもAPIは時間範囲が最大7日で、過去2年分しか保持しません。この記事では全件取得の実装から、取得漏れが起きやすい項目の洗い出し、円換算、総平均法による取得原価計算、Cryptact/Gtax向けCSV変換までをサンプルコード8本でまとめます。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
内容
データ集計の技術解説

※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。

本記事は税務アドバイスではありません

この記事は「取引データを漏れなく取得し、計算可能な形に整える」技術的な手順を解説するものです。税制の解釈、所得区分の判断、必要経費の範囲、申告方法については一切の助言を行いません。

最終的な申告内容は必ず国税庁の公表情報を確認し、税理士に相談してください。特に海外取引所の暗号資産取引は論点が多く、個別事情によって扱いが変わります。本記事のコードが出力する数値はあくまで集計の材料であり、申告額を保証するものではありません。

目次

  1. なぜ自分で集計する必要があるのか
  2. 集計すべきデータの全体像
  3. 取得漏れが起きやすい項目
  4. 全件取得|7日窓と2年の壁
  5. 円換算|レートの選択と根拠の保存
  6. 取得原価の計算|総平均法と移動平均法
  7. CryptactとGtax向けCSVの生成
  8. 突合チェック|漏れを検出する
  9. 監査可能性の確保
  10. よくある疑問Q&A

なぜ自分で集計する必要があるのか

制約内容実装上の意味
7日窓 get_executions / get_closed_pnl / get_transaction_log はいずれも endTime − startTime ≤ 7日 1年分で約53回のループが必要
2年で消える APIが保持するのは過去2年分 定期的に取得して蓄積するしかない
現物の損益がない closed_pnl は現物に非対応 取得原価を自分で計算する
通貨が混在 USDT・USDC・BTC建ての損益が並ぶ 円換算のレートと時点を決める
「startTimeに1月1日を指定」では7日分しか取れない

最も危険な失敗はこれです。startTime に年初を指定してもエラーにならず、7日分だけが返ります。「取得できた」と思って集計すると、実際の数十分の1の金額になります。

金額が小さく出るため「あまり取引していなかった」と誤解しやすく、気づきにくい。記事18で実装した窓ループを必ず使ってください。

集計すべきデータの全体像

データAPI含まれるもの
約定明細 get_executions 売買・手数料・Maker/Taker(現物とデリバティブ両方)
決済損益 get_closed_pnl デリバティブのトレード単位の実現損益
取引ログ get_transaction_log 最も網羅的。資金調達・決済・清算・振替・手数料など全キャッシュフロー
入金 get_deposit_records 外部からの入金(取得価額の起点)
出金 get_withdrawal_records 外部への出金
内部振替 get_internal_transfer_records 口座間の移動(損益ではないが突合に必要)
取引ログ(transaction_log)が集計の軸になる

get_transaction_log は口座のキャッシュフローをすべて記録しており、cashFlow / change / cashBalance フィールドから残高の変動を追えます。資金調達・清算・手数料も含まれます。

集計の基本方針は「取引ログを軸に、約定明細で内訳を補う」です。約定明細だけを見ると資金調達や清算が抜け、取引ログだけでは1約定ごとの詳細が分かりません。

取得漏れが起きやすい項目

この節がこの記事の実質的な価値です。約定履歴だけを見て集計すると、以下が抜けます。

項目どこに現れるか抜けたときの影響
資金調達 transaction_log の SETTLEMENT、execution の execType=Funding 長期保有の戦略では年率数%〜十数%の差(記事10)
清算 execType が BustTrade / transaction_log の該当種別 大きな損失が計上されない
ADL(自動デレバレッジ) execType が AdlTrade 意図しない決済分が抜ける
先物の受渡・決済 execType が Delivery / Settle 満期のある商品で発生
手数料リベート execFee がマイナスの約定 収入側の計上漏れ
キャンペーン・ボーナス transaction_log の BONUS 系 / bonusChange 受領時の扱いが論点になる
Earn・ステーキング報酬 Earn系のAPI(本記事の範囲外) 受領した報酬が抜ける
借入金利 INTEREST 系 証拠金取引を使った場合
通貨交換 CURRENCY_BUY / CURRENCY_SELL USDT↔USDCなどの交換も課税イベントになりうる
coverage_audit.pyサンプル1
"""coverage_audit.py — どの種別のレコードが存在するかを洗い出す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

集計を始める前にこれを走らせ、自分の口座に
どんな種類のキャッシュフローが存在するかを把握する。
「知らなかった項目」を見つけるのが目的。
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pandas as pd

# transaction_log の type(公式のEnumを参照して随時追加すること)
KNOWN_TYPES = {
    "TRADE": "売買",
    "SETTLEMENT": "資金調達の受払い",
    "DELIVERY": "先物の受渡",
    "LIQUIDATION": "清算",
    "ADL": "自動デレバレッジ",
    "BONUS": "ボーナス・キャンペーン",
    "BONUS_RECOLLECT": "ボーナスの回収",
    "FEE_REFUND": "手数料の返還",
    "INTEREST": "借入金利",
    "CURRENCY_BUY": "通貨の購入(交換)",
    "CURRENCY_SELL": "通貨の売却(交換)",
    "TRANSFER_IN": "内部振替(入)",
    "TRANSFER_OUT": "内部振替(出)",
    "AUTO_DEDUCTION": "自動控除",
}

# execution の execType
KNOWN_EXEC_TYPES = {
    "Trade": "通常の約定",
    "Funding": "資金調達",
    "AdlTrade": "自動デレバレッジ",
    "BustTrade": "清算",
    "Delivery": "受渡",
    "Settle": "決済",
    "BlockTrade": "ブロック取引",
}


def audit_transaction_types(http, year: int,
                            account_type: str = "UNIFIED") -> pd.DataFrame:
    """1年分の取引ログを種別ごとに集計する(7日窓ループ)。"""
    start = datetime(year, 1, 1, tzinfo=timezone.utc)
    end = min(datetime(year + 1, 1, 1, tzinfo=timezone.utc),
              datetime.now(timezone.utc))

    rows: list[dict] = []
    cursor_start = start
    windows = 0

    while cursor_start < end:
        cursor_end = min(cursor_start + timedelta(days=7), end)
        cursor = None
        for _ in range(500):
            kw: dict = {
                "accountType": account_type,
                "startTime": int(cursor_start.timestamp() * 1000),
                "endTime": int(cursor_end.timestamp() * 1000),
                "limit": 50,
            }
            if cursor:
                kw["cursor"] = cursor
            res = http.get_transaction_log(**kw)
            if res.get("retCode") != 0:
                print(f"  [warn] {cursor_start:%Y-%m-%d}: {res.get('retMsg')}")
                break
            page = res["result"]["list"]
            rows.extend(page)
            cursor = res["result"].get("nextPageCursor")
            if not cursor or not page:
                break
        windows += 1
        cursor_start = cursor_end

    print(f"  {windows}窓を走査 / {len(rows)}件を取得")

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    for col in ("cashFlow", "change", "funding", "fee", "bonusChange"):
        if col in df.columns:
            df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0)

    agg = df.groupby(["type", "currency"], dropna=False).agg(
        件数=("type", "count"),
        cashFlow合計=("cashFlow", "sum"),
        change合計=("change", "sum"),
        手数料=("fee", "sum") if "fee" in df.columns else ("type", "count"),
    ).reset_index()
    agg["説明"] = agg["type"].map(KNOWN_TYPES).fillna("**未知の種別**")
    return agg


def audit_exec_types(http, year: int,
                     categories=("linear", "spot", "inverse")) -> pd.DataFrame:
    """約定明細のexecTypeを集計する。"""
    from exec_fetch import fetch_executions

    start = datetime(year, 1, 1, tzinfo=timezone.utc)
    end = min(datetime(year + 1, 1, 1, tzinfo=timezone.utc),
              datetime.now(timezone.utc))

    frames = []
    for category in categories:
        df = fetch_executions(http, start, end, category=category,
                              verbose=False)
        if not df.empty:
            df["category"] = category
            frames.append(df)

    if not frames:
        return pd.DataFrame()

    all_df = pd.concat(frames)
    agg = all_df.groupby(["category", "execType"]).agg(
        件数=("execFee", "count"),
        手数料=("execFee", "sum"),
        約定金額=("execValue", "sum"),
    ).reset_index()
    agg["説明"] = agg["execType"].map(KNOWN_EXEC_TYPES).fillna("**未知の種別**")
    return agg


if __name__ == "__main__":
    from client import make_client

    YEAR = 2025
    c = make_client()

    print(f"=== {YEAR}年の取引ログ種別 ===")
    tx = audit_transaction_types(c.http, YEAR)
    if tx.empty:
        print("  該当なし")
    else:
        print(tx.to_string(index=False,
                           float_format=lambda v: f"{v:,.4f}"))
        unknown = tx[tx["説明"].str.startswith("**")]
        if not unknown.empty:
            print("\n[!] 未知の種別があります。公式のEnumを確認してください:")
            print(unknown[["type", "件数"]].to_string(index=False))

    print(f"\n=== {YEAR}年の約定種別 ===")
    ex = audit_exec_types(c.http, YEAR)
    if ex.empty:
        print("  該当なし")
    else:
        print(ex.to_string(index=False,
                           float_format=lambda v: f"{v:,.4f}"))

        # 清算・ADLがあれば警告する
        risky = ex[ex["execType"].isin(["BustTrade", "AdlTrade"])]
        if not risky.empty:
            print("\n[!] 清算またはADLが発生しています。")
            print("    損益に大きく影響するため、必ず集計に含めてください。")

        rebate = ex[ex["手数料"] < 0]
        if not rebate.empty:
            print("\n[i] 手数料がマイナス(リベート)の区分があります。")
            print("    収入として計上が必要か確認してください。")

    print("\n※ ここで見つかった全種別を、集計対象に含める必要があります。")
    print("  Earn・ステーキング報酬は別APIなので、利用している場合は")
    print("  別途取得してください(本記事の範囲外)。")

全件取得|7日窓と2年の壁

tax_fetch.pyサンプル2
"""tax_fetch.py — 年間の全データを取得してSQLiteに蓄積する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

重要: APIは過去2年分しか保持しない。
      毎年(できれば毎月)実行して蓄積しておくこと。
"""
from __future__ import annotations

import sqlite3
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pandas as pd

DB_PATH = Path("data/tax.sqlite")
WINDOW = timedelta(days=7)

SCHEMA = """
CREATE TABLE IF NOT EXISTS tx_log (
    id            TEXT PRIMARY KEY,
    ts_ms         INTEGER NOT NULL,
    type          TEXT,
    category      TEXT,
    symbol        TEXT,
    side          TEXT,
    currency      TEXT,
    qty           REAL,
    trade_price   REAL,
    funding       REAL,
    fee           REAL,
    cash_flow     REAL,
    change_amt    REAL,
    cash_balance  REAL,
    bonus_change  REAL,
    order_id      TEXT,
    order_link_id TEXT,
    trade_id      TEXT
);
CREATE INDEX IF NOT EXISTS idx_tx_ts ON tx_log(ts_ms);
CREATE INDEX IF NOT EXISTS idx_tx_type ON tx_log(type);
CREATE INDEX IF NOT EXISTS idx_tx_currency ON tx_log(currency);

CREATE TABLE IF NOT EXISTS deposits (
    id       TEXT PRIMARY KEY,
    ts_ms    INTEGER NOT NULL,
    coin     TEXT,
    amount   REAL,
    chain    TEXT,
    status   TEXT,
    tx_hash  TEXT
);

CREATE TABLE IF NOT EXISTS withdrawals (
    id       TEXT PRIMARY KEY,
    ts_ms    INTEGER NOT NULL,
    coin     TEXT,
    amount   REAL,
    fee      REAL,
    chain    TEXT,
    status   TEXT,
    tx_hash  TEXT
);
"""


def _f(v, default: float = 0.0) -> float:
    if v in (None, "", "null"):
        return default
    try:
        return float(v)
    except (TypeError, ValueError):
        return default


class TaxStore:
    def __init__(self, path: Path = DB_PATH):
        path.parent.mkdir(parents=True, exist_ok=True)
        self.conn = sqlite3.connect(str(path), isolation_level=None)
        self.conn.row_factory = sqlite3.Row
        self.conn.execute("PRAGMA journal_mode=WAL")
        self.conn.executescript(SCHEMA)

    # ---- 取引ログ --------------------------------------------------------
    def sync_tx_log(self, http, start: datetime, end: datetime,
                    account_type: str = "UNIFIED",
                    sleep: float = 0.08, verbose: bool = True) -> int:
        """7日窓ループで取引ログを全件取得して保存する。"""
        total = 0
        cursor_start = start
        window_no = 0
        n_windows = int((end - start) / WINDOW) + 1

        while cursor_start < end:
            cursor_end = min(cursor_start + WINDOW, end)
            window_no += 1
            cursor = None
            win_count = 0

            for _ in range(1000):
                kw: dict = {
                    "accountType": account_type,
                    "startTime": int(cursor_start.timestamp() * 1000),
                    "endTime": int(cursor_end.timestamp() * 1000),
                    "limit": 50,
                }
                if cursor:
                    kw["cursor"] = cursor

                res = http.get_transaction_log(**kw)
                if res.get("retCode") != 0:
                    print(f"  [warn] {cursor_start:%Y-%m-%d}: "
                          f"{res.get('retCode')} {res.get('retMsg')}")
                    break

                page = res["result"]["list"]
                if page:
                    self._insert_tx(page)
                    win_count += len(page)
                cursor = res["result"].get("nextPageCursor")
                if not cursor or not page:
                    break
                time.sleep(sleep)

            total += win_count
            if verbose and (win_count or window_no % 10 == 0):
                print(f"  [{window_no}/{n_windows}] "
                      f"{cursor_start:%Y-%m-%d}: {win_count}件 "
                      f"(累計 {total})")
            cursor_start = cursor_end
            time.sleep(sleep)

        return total

    def _insert_tx(self, rows: list[dict]) -> None:
        payload = []
        for r in rows:
            payload.append((
                str(r.get("id")), int(r.get("transactionTime") or 0),
                r.get("type"), r.get("category"), r.get("symbol"),
                r.get("side"), r.get("currency"),
                _f(r.get("qty")), _f(r.get("tradePrice")),
                _f(r.get("funding")), _f(r.get("fee")),
                _f(r.get("cashFlow")), _f(r.get("change")),
                _f(r.get("cashBalance")), _f(r.get("bonusChange")),
                r.get("orderId"), r.get("orderLinkId"), r.get("tradeId"),
            ))
        self.conn.executemany(
            "INSERT OR REPLACE INTO tx_log VALUES "
            "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", payload)

    # ---- 入出金 ----------------------------------------------------------
    def sync_deposits(self, http, start: datetime, end: datetime,
                      verbose: bool = True) -> int:
        """入金記録を取得する(30日窓が上限のことが多い)。"""
        total = 0
        cursor_start = start
        window = timedelta(days=30)

        while cursor_start < end:
            cursor_end = min(cursor_start + window, end)
            cursor = None
            for _ in range(200):
                kw: dict = {
                    "startTime": int(cursor_start.timestamp() * 1000),
                    "endTime": int(cursor_end.timestamp() * 1000),
                    "limit": 50,
                }
                if cursor:
                    kw["cursor"] = cursor
                res = http.get_deposit_records(**kw)
                if res.get("retCode") != 0:
                    break
                rows = res["result"].get("rows") or []
                for r in rows:
                    self.conn.execute(
                        "INSERT OR REPLACE INTO deposits VALUES (?,?,?,?,?,?,?)",
                        (str(r.get("txID") or r.get("id")),
                         int(r.get("successAt") or r.get("createdTime") or 0),
                         r.get("coin"), _f(r.get("amount")),
                         r.get("chain"), str(r.get("status")),
                         r.get("txID")))
                total += len(rows)
                cursor = res["result"].get("nextPageCursor")
                if not cursor or not rows:
                    break
            cursor_start = cursor_end

        if verbose:
            print(f"  入金: {total}件")
        return total

    def sync_withdrawals(self, http, start: datetime, end: datetime,
                         verbose: bool = True) -> int:
        total = 0
        cursor_start = start
        window = timedelta(days=30)

        while cursor_start < end:
            cursor_end = min(cursor_start + window, end)
            cursor = None
            for _ in range(200):
                kw: dict = {
                    "startTime": int(cursor_start.timestamp() * 1000),
                    "endTime": int(cursor_end.timestamp() * 1000),
                    "limit": 50,
                }
                if cursor:
                    kw["cursor"] = cursor
                res = http.get_withdrawal_records(**kw)
                if res.get("retCode") != 0:
                    break
                rows = res["result"].get("rows") or []
                for r in rows:
                    self.conn.execute(
                        "INSERT OR REPLACE INTO withdrawals "
                        "VALUES (?,?,?,?,?,?,?)",
                        (str(r.get("withdrawId") or r.get("txID")),
                         int(r.get("updateTime") or r.get("createTime") or 0),
                         r.get("coin"), _f(r.get("amount")),
                         _f(r.get("withdrawFee")), r.get("chain"),
                         str(r.get("status")), ))
                total += len(rows)
                cursor = res["result"].get("nextPageCursor")
                if not cursor or not rows:
                    break
            cursor_start = cursor_end

        if verbose:
            print(f"  出金: {total}件")
        return total

    # ---- 参照 ------------------------------------------------------------
    def tx_frame(self, year: int | None = None) -> pd.DataFrame:
        q = "SELECT * FROM tx_log"
        params: tuple = ()
        if year:
            s = int(datetime(year, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)
            e = int(datetime(year + 1, 1, 1,
                             tzinfo=timezone.utc).timestamp() * 1000)
            q += " WHERE ts_ms >= ? AND ts_ms < ?"
            params = (s, e)
        q += " ORDER BY ts_ms"
        df = pd.read_sql_query(q, self.conn, params=params)
        if df.empty:
            return df
        df["ts"] = pd.to_datetime(df["ts_ms"], unit="ms", utc=True)
        return df.set_index("ts")

    def summary(self) -> str:
        r = self.conn.execute(
            "SELECT COUNT(*) n, MIN(ts_ms) a, MAX(ts_ms) b FROM tx_log"
        ).fetchone()

        def fmt(ms) -> str:
            if not ms:
                return "-"
            return f"{datetime.fromtimestamp(ms / 1000, timezone.utc):%Y-%m-%d}"

        d = self.conn.execute("SELECT COUNT(*) FROM deposits").fetchone()[0]
        w = self.conn.execute("SELECT COUNT(*) FROM withdrawals").fetchone()[0]
        return (f"取引ログ {r['n']}件  {fmt(r['a'])} 〜 {fmt(r['b'])}\n"
                f"入金 {d}件 / 出金 {w}件")


def sync_year(http, year: int, store: TaxStore | None = None) -> TaxStore:
    """1年分を全部取得する。"""
    store = store or TaxStore()
    start = datetime(year, 1, 1, tzinfo=timezone.utc)
    end = min(datetime(year + 1, 1, 1, tzinfo=timezone.utc),
              datetime.now(timezone.utc))

    # 2年より前は取得できないので警告する
    limit = datetime.now(timezone.utc) - timedelta(days=730)
    if start < limit:
        print(f"[警告] {year}年の一部はAPIの保持期間(2年)を超えています。")
        print(f"       {limit:%Y-%m-%d} より前のデータは取得できません。")
        print("       蓄積済みのデータがあればそれを使ってください。")

    print(f"=== {year}年の取引ログを取得 ===")
    store.sync_tx_log(http, start, end)
    print(f"=== {year}年の入出金を取得 ===")
    store.sync_deposits(http, start, end)
    store.sync_withdrawals(http, start, end)

    print()
    print(store.summary())
    return store


if __name__ == "__main__":
    from client import make_client

    c = make_client()
    sync_year(c.http, 2025)
    print("\n※ 毎月このスクリプトをcronで実行しておけば、")
    print("  2年の保持期限を過ぎてもデータが手元に残る(記事38)。")

円換算|レートの選択と根拠の保存

レートの選択には根拠が必要

USDT建ての損益を円換算する際、どの時点・どのソースのレートを使うかで金額が変わります。日次終値・取引時点のレート・月中平均など複数の考え方があり、どれが適切かは税務の判断です。

本記事のコードは「選んだレートと、その根拠を記録に残す」ことを重視した実装にしています。どのレートを採用すべきかは税理士に確認してください。重要なのは一貫した方法を使い、根拠を保存することです。

fx_rates.pyサンプル3
"""fx_rates.py — 円換算レートの管理(取得・キャッシュ・根拠の記録)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

方針:
  - レートのソースと取得日時を必ず記録する(監査可能性)
  - 手動でCSVを与えることもできる(税理士の指示に従う場合)
  - どのレートを使うべきかは判断しない(技術的な提供のみ)
"""
from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path

import pandas as pd

RATE_DIR = Path("data/fx")


@dataclass(frozen=True)
class RateSource:
    name: str
    description: str
    url: str = ""

    def to_dict(self) -> dict:
        return {"name": self.name, "description": self.description,
                "url": self.url}


# レートのソース候補。どれを使うかは利用者の判断
SOURCES = {
    "bybit_usdtjpy": RateSource(
        "bybit_usdtjpy", "BybitのUSDT/JPY相当の日次終値",
        "https://bybit-exchange.github.io/docs/v5/market/kline"),
    "manual_csv": RateSource(
        "manual_csv", "手動で用意したCSV(税理士の指示に従う場合)"),
}


class FxRates:
    """日次の対円レートを保持する。"""

    def __init__(self, source: str = "manual_csv",
                 rate_dir: Path = RATE_DIR):
        if source not in SOURCES:
            raise ValueError(f"未知のソース: {source}")
        self.source = SOURCES[source]
        self.rate_dir = rate_dir
        self.rate_dir.mkdir(parents=True, exist_ok=True)
        self._rates: dict[str, dict[date, Decimal]] = {}
        self._meta: dict = {}

    # ---- 読み込み --------------------------------------------------------
    def load_csv(self, coin: str, path: str | Path,
                 date_col: str = "date", rate_col: str = "jpy") -> int:
        """CSVからレートを読み込む。

        CSVの形式: date,jpy
                   2025-01-01,157.23
        """
        df = pd.read_csv(path)
        rates: dict[date, Decimal] = {}
        for _, row in df.iterrows():
            d = pd.Timestamp(row[date_col]).date()
            rates[d] = Decimal(str(row[rate_col]))
        self._rates[coin.upper()] = rates
        self._meta[coin.upper()] = {
            "source": self.source.to_dict(),
            "file": str(path),
            "loaded_at": datetime.now(timezone.utc).isoformat(),
            "rows": len(rates),
            "range": [str(min(rates)), str(max(rates))] if rates else [],
        }
        return len(rates)

    def fetch_from_bybit(self, http, coin: str = "USDT",
                         year: int = 2025) -> int:
        """BybitのJPYペアから日次終値を取得する(存在する場合)。

        注: JPY建てのペアが存在しない場合は使えない。
            その場合は外部のレートをCSVで用意すること。
        """
        symbol = f"{coin.upper()}JPY"
        res = http.get_kline(category="spot", symbol=symbol,
                             interval="D", limit=1000)
        if res.get("retCode") != 0:
            raise RuntimeError(
                f"{symbol} のレートを取得できません: {res.get('retMsg')}\n"
                f"  外部ソースのCSVを load_csv() で読み込んでください")

        rates: dict[date, Decimal] = {}
        for row in res["result"]["list"]:
            d = datetime.fromtimestamp(int(row[0]) / 1000,
                                       timezone.utc).date()
            rates[d] = Decimal(str(row[4]))       # 終値

        self._rates[coin.upper()] = rates
        self._meta[coin.upper()] = {
            "source": SOURCES["bybit_usdtjpy"].to_dict(),
            "symbol": symbol,
            "fetched_at": datetime.now(timezone.utc).isoformat(),
            "rows": len(rates),
        }
        return len(rates)

    # ---- 参照 ------------------------------------------------------------
    def rate(self, coin: str, when: datetime | date,
             method: str = "same_day") -> Decimal:
        """指定日のレートを返す。

        method="same_day"  : その日のレート(無ければ直近の過去)
        method="prev_day"  : 前日のレート
        """
        coin = coin.upper()
        if coin == "JPY":
            return Decimal(1)
        rates = self._rates.get(coin)
        if not rates:
            raise LookupError(
                f"{coin} のレートが読み込まれていません。"
                f"load_csv() か fetch_from_bybit() を先に実行してください")

        d = when.date() if isinstance(when, datetime) else when
        if method == "prev_day":
            d = d - pd.Timedelta(days=1).to_pytimedelta()

        if d in rates:
            return rates[d]

        # 直近の過去のレートで代替する(休日など)
        past = [k for k in rates if k <= d]
        if not past:
            raise LookupError(f"{coin} の {d} 以前のレートがありません")
        return rates[max(past)]

    def convert(self, amount: Decimal | float, coin: str,
                when: datetime | date, method: str = "same_day") -> Decimal:
        return Decimal(str(amount)) * self.rate(coin, when, method)

    def save_meta(self, path: Path | None = None) -> Path:
        """使用したレートのソースと範囲を記録に残す(監査可能性)。"""
        path = path or (self.rate_dir / "rate_meta.json")
        path.write_text(json.dumps(self._meta, ensure_ascii=False, indent=2),
                        encoding="utf-8")
        return path

    def coverage(self, coin: str, start: date, end: date) -> dict:
        """指定期間のレートが揃っているかを確認する。"""
        rates = self._rates.get(coin.upper(), {})
        needed = pd.date_range(start, end, freq="D").date
        missing = [d for d in needed if d not in rates]
        return {"coin": coin, "必要日数": len(needed),
                "揃っている": len(needed) - len(missing),
                "欠損": len(missing),
                "欠損日の例": [str(d) for d in missing[:5]]}


SAMPLE_CSV = """date,jpy
2025-01-01,157.20
2025-01-02,157.45
2025-01-03,157.10
"""

if __name__ == "__main__":
    sample = RATE_DIR / "usdt_jpy_sample.csv"
    RATE_DIR.mkdir(parents=True, exist_ok=True)
    if not sample.exists():
        sample.write_text(SAMPLE_CSV, encoding="utf-8")
        print(f"サンプルCSVを生成: {sample}")

    fx = FxRates(source="manual_csv")
    n = fx.load_csv("USDT", sample)
    print(f"{n}日分のレートを読み込みました")

    print(f"\n2025-01-02のレート: {fx.rate('USDT', date(2025, 1, 2))}")
    print(f"1000 USDT = "
          f"{fx.convert(1000, 'USDT', date(2025, 1, 2)):,.2f} 円")
    # 存在しない日は直近の過去で代替される
    print(f"2025-01-05(データなし): "
          f"{fx.rate('USDT', date(2025, 1, 5))} <- 直近の過去を使用")

    print(f"\nカバレッジ: "
          f"{fx.coverage('USDT', date(2025, 1, 1), date(2025, 1, 5))}")
    print(f"\nメタ情報を保存: {fx.save_meta()}")

    print("\n※ どのレートソースを使うべきかは税務の判断です。")
    print("  国税庁の情報を確認し、税理士に相談してください。")
    print("  重要なのは、一貫した方法を使い根拠を残すことです。")

取得原価の計算|総平均法と移動平均法

現物の売買では取得原価が必要です。日本では総平均法と移動平均法の2つの考え方があり、どちらを採用するかは税務の判断です(一度選んだ方法は継続する必要があるとされています)。両方を計算して比較できるようにします。

cost_basis.pyサンプル4
"""cost_basis.py — 取得原価の計算(総平均法・移動平均法)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注: どちらの方法を採用すべきかは税務の判断です。
    本コードは両方を計算して比較できるようにするだけです。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal

import pandas as pd


@dataclass
class AcquisitionEvent:
    """取得(買い・受領)のイベント。"""

    ts: datetime
    coin: str
    qty: Decimal
    jpy_cost: Decimal          # 円換算の取得価額(手数料込み)
    note: str = ""


@dataclass
class DisposalEvent:
    """譲渡(売り・交換・支払い)のイベント。"""

    ts: datetime
    coin: str
    qty: Decimal
    jpy_proceeds: Decimal      # 円換算の譲渡価額(手数料差引後)
    note: str = ""


@dataclass
class DisposalResult:
    event: DisposalEvent
    unit_cost: Decimal         # 適用した1単位あたり取得原価
    cost: Decimal              # 取得原価の合計
    gain: Decimal              # 損益

    def line(self) -> str:
        return (f"{self.event.ts:%Y-%m-%d}  {self.event.coin:<6}"
                f"{self.event.qty:>14}{self.event.jpy_proceeds:>14,.0f}"
                f"{self.cost:>14,.0f}{self.gain:>+14,.0f}")


# ---- 総平均法 -----------------------------------------------------------
def total_average_method(acquisitions: list[AcquisitionEvent],
                         disposals: list[DisposalEvent],
                         coin: str) -> tuple[Decimal, list[DisposalResult]]:
    """総平均法。期間中の全取得の平均単価を使う。

    単価 = 期間中の取得価額の合計 / 取得数量の合計
    (期首残高がある場合はそれも含める必要がある)
    """
    acq = [a for a in acquisitions if a.coin == coin]
    dis = [d for d in disposals if d.coin == coin]

    total_qty = sum((a.qty for a in acq), Decimal(0))
    total_cost = sum((a.jpy_cost for a in acq), Decimal(0))
    unit = (total_cost / total_qty) if total_qty > 0 else Decimal(0)

    results = []
    for d in sorted(dis, key=lambda x: x.ts):
        cost = unit * d.qty
        results.append(DisposalResult(event=d, unit_cost=unit, cost=cost,
                                      gain=d.jpy_proceeds - cost))
    return unit, results


# ---- 移動平均法 ---------------------------------------------------------
@dataclass
class MovingAverageState:
    qty: Decimal = Decimal(0)
    cost: Decimal = Decimal(0)

    @property
    def unit_cost(self) -> Decimal:
        return (self.cost / self.qty) if self.qty > 0 else Decimal(0)


def moving_average_method(acquisitions: list[AcquisitionEvent],
                          disposals: list[DisposalEvent],
                          coin: str) -> tuple[list[DisposalResult], list[str]]:
    """移動平均法。取得のたびに平均単価を更新する。

    時系列順に処理するため、取得と譲渡を混ぜて並べる。
    """
    events: list[tuple[datetime, str, object]] = []
    for a in acquisitions:
        if a.coin == coin:
            events.append((a.ts, "acq", a))
    for d in disposals:
        if d.coin == coin:
            events.append((d.ts, "dis", d))
    events.sort(key=lambda x: (x[0], 0 if x[1] == "acq" else 1))

    state = MovingAverageState()
    results: list[DisposalResult] = []
    warnings: list[str] = []

    for ts, kind, ev in events:
        if kind == "acq":
            a: AcquisitionEvent = ev            # type: ignore[assignment]
            state.qty += a.qty
            state.cost += a.jpy_cost
        else:
            d: DisposalEvent = ev               # type: ignore[assignment]
            if state.qty <= 0:
                warnings.append(
                    f"{d.ts:%Y-%m-%d} {coin}: 在庫がないのに譲渡があります"
                    f"(数量 {d.qty})。取得の記録が漏れている可能性")
                unit = Decimal(0)
            else:
                unit = state.unit_cost
                if d.qty > state.qty:
                    warnings.append(
                        f"{d.ts:%Y-%m-%d} {coin}: 譲渡数量 {d.qty} が"
                        f"在庫 {state.qty} を超えています")

            sold = min(d.qty, state.qty) if state.qty > 0 else Decimal(0)
            cost = unit * d.qty
            state.qty -= sold
            state.cost -= unit * sold
            if state.qty <= 0:
                state.qty = Decimal(0)
                state.cost = Decimal(0)

            results.append(DisposalResult(event=d, unit_cost=unit,
                                          cost=cost,
                                          gain=d.jpy_proceeds - cost))

    return results, warnings


# ---- 取引ログからイベントを組み立てる -----------------------------------
def build_events(tx: pd.DataFrame, fx,
                 fx_method: str = "same_day"
                 ) -> tuple[list[AcquisitionEvent], list[DisposalEvent],
                            list[str]]:
    """取引ログから取得・譲渡イベントを組み立てる。

    注意: どの取引を「取得」「譲渡」とみなすかは税務の判断が絡む。
          ここでは現物の売買を機械的に分類しているだけ。
    """
    acquisitions: list[AcquisitionEvent] = []
    disposals: list[DisposalEvent] = []
    notes: list[str] = []

    spot = tx[(tx["category"] == "spot") & (tx["type"] == "TRADE")]
    if spot.empty:
        notes.append("現物の売買記録が見つかりません")

    for ts, r in spot.iterrows():
        symbol = str(r.get("symbol") or "")
        base = symbol.replace("USDT", "").replace("USDC", "")
        quote = "USDT" if symbol.endswith("USDT") else "USDC"
        qty = Decimal(str(abs(r.get("qty") or 0)))
        price = Decimal(str(r.get("trade_price") or 0))
        fee = Decimal(str(abs(r.get("fee") or 0)))
        if qty <= 0 or price <= 0:
            continue

        gross_quote = qty * price
        try:
            rate = fx.rate(quote, ts.to_pydatetime(), fx_method)
        except LookupError as e:
            notes.append(f"{ts:%Y-%m-%d}: レート取得失敗 ({e})")
            continue

        if str(r.get("side")) == "Buy":
            # 買い: base を取得(手数料は取得価額に含める考え方)
            jpy = (gross_quote + fee) * rate
            acquisitions.append(AcquisitionEvent(
                ts=ts.to_pydatetime(), coin=base, qty=qty, jpy_cost=jpy,
                note=f"spot buy {symbol}"))
        else:
            # 売り: base を譲渡
            jpy = (gross_quote - fee) * rate
            disposals.append(DisposalEvent(
                ts=ts.to_pydatetime(), coin=base, qty=qty, jpy_proceeds=jpy,
                note=f"spot sell {symbol}"))

    return acquisitions, disposals, notes


if __name__ == "__main__":
    from datetime import datetime as dt

    # 手計算で検証できる小さな例
    acq = [
        AcquisitionEvent(dt(2025, 1, 10), "BTC", Decimal("0.1"),
                         Decimal("1500000")),      # 1BTC=1500万円
        AcquisitionEvent(dt(2025, 3, 20), "BTC", Decimal("0.1"),
                         Decimal("1800000")),      # 1BTC=1800万円
    ]
    dis = [
        DisposalEvent(dt(2025, 6, 15), "BTC", Decimal("0.05"),
                      Decimal("950000")),          # 1BTC=1900万円で売却
        DisposalEvent(dt(2025, 9, 1), "BTC", Decimal("0.05"),
                      Decimal("800000")),          # 1BTC=1600万円で売却
    ]

    print("=== 総平均法 ===")
    unit, results = total_average_method(acq, dis, "BTC")
    print(f"平均単価: {unit:,.0f} 円/BTC")
    print(f"{'日付':<12}{'coin':<6}{'数量':>14}{'譲渡価額':>14}"
          f"{'取得原価':>14}{'損益':>14}")
    print("-" * 76)
    for r in results:
        print(r.line())
    print(f"{'合計':<12}{'':<6}{'':>14}{'':>14}{'':>14}"
          f"{sum(r.gain for r in results):>+14,.0f}")

    print("\n=== 移動平均法 ===")
    results2, warns = moving_average_method(acq, dis, "BTC")
    print(f"{'日付':<12}{'coin':<6}{'数量':>14}{'譲渡価額':>14}"
          f"{'取得原価':>14}{'損益':>14}")
    print("-" * 76)
    for r in results2:
        print(r.line())
    print(f"{'合計':<12}{'':<6}{'':>14}{'':>14}{'':>14}"
          f"{sum(r.gain for r in results2):>+14,.0f}")
    for w in warns:
        print(f"  [!] {w}")

    diff = sum(r.gain for r in results) - sum(r.gain for r in results2)
    print(f"\n2つの方法の差: {diff:+,.0f} 円")
    print("\n※ どちらを採用するかは税務の判断です。")
    print("  一度選んだ方法は継続する必要があるとされています。")
    print("  税理士に確認してください。")

CryptactとGtax向けCSVの生成

専門の損益計算サービスを使う場合、カスタムCSV形式で取り込むのが実務的です。Bybitの生データをそのまま渡せない場合に変換します。

export_tax_csv.pyサンプル5
"""export_tax_csv.py — 損益計算サービス向けのCSVを生成する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注意: 各サービスのフォーマット仕様は変更されます。
      必ず公式の最新仕様を確認し、取り込み後に件数と金額を照合してください。
      本コードは「変換の考え方」を示すものです。
"""
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd

OUT_DIR = Path("reports/tax")


@dataclass
class ExportStats:
    rows: int
    skipped: int
    path: Path
    notes: list[str]

    def report(self) -> str:
        lines = [f"出力: {self.path}",
                 f"  行数 {self.rows} / スキップ {self.skipped}"]
        for n in self.notes:
            lines.append(f"  ! {n}")
        return "\n".join(lines)


def to_cryptact_custom(tx: pd.DataFrame,
                       out_dir: Path = OUT_DIR) -> ExportStats:
    """Cryptactのカスタムファイル形式に変換する。

    列: Timestamp, Action, Source, Base, Volume, Price, Counter, Fee, FeeCcy
    ※ 最新の仕様は公式ドキュメントで確認してください。
    """
    out_dir.mkdir(parents=True, exist_ok=True)
    rows: list[dict] = []
    skipped = 0
    notes: list[str] = []

    for ts, r in tx.iterrows():
        tx_type = str(r.get("type") or "")
        symbol = str(r.get("symbol") or "")
        currency = str(r.get("currency") or "")
        qty = abs(float(r.get("qty") or 0))
        price = float(r.get("trade_price") or 0)
        fee = abs(float(r.get("fee") or 0))
        cash_flow = float(r.get("cash_flow") or 0)
        funding = float(r.get("funding") or 0)

        base = symbol.replace("USDT", "").replace("USDC", "") or currency
        counter = ("USDT" if symbol.endswith("USDT")
                   else "USDC" if symbol.endswith("USDC") else currency)
        stamp = ts.strftime("%Y/%m/%d %H:%M:%S")

        if tx_type == "TRADE" and qty > 0 and price > 0:
            action = "BUY" if str(r.get("side")) == "Buy" else "SELL"
            rows.append({
                "Timestamp": stamp, "Action": action, "Source": "Bybit",
                "Base": base, "Volume": qty, "Price": price,
                "Counter": counter, "Fee": fee, "FeeCcy": currency,
            })

        elif tx_type == "SETTLEMENT" and funding != 0:
            # 資金調達の受払い。受取りはBONUS、支払いはFEEとして扱う例
            rows.append({
                "Timestamp": stamp,
                "Action": "BONUS" if funding > 0 else "FEE",
                "Source": "Bybit", "Base": currency,
                "Volume": abs(funding), "Price": "", "Counter": "",
                "Fee": 0, "FeeCcy": currency,
            })

        elif tx_type in ("BONUS", "FEE_REFUND") and cash_flow != 0:
            rows.append({
                "Timestamp": stamp, "Action": "BONUS", "Source": "Bybit",
                "Base": currency, "Volume": abs(cash_flow), "Price": "",
                "Counter": "", "Fee": 0, "FeeCcy": currency,
            })

        elif tx_type in ("TRANSFER_IN", "TRANSFER_OUT"):
            # 内部振替は損益ではないので出力しない(突合では使う)
            skipped += 1

        else:
            skipped += 1

    if not rows:
        notes.append("出力対象のレコードがありません")

    df = pd.DataFrame(rows)
    path = out_dir / "cryptact_custom.csv"
    df.to_csv(path, index=False, encoding="utf-8-sig")

    notes.append("仕様は変更されます。取り込み後に件数と金額を照合してください")
    return ExportStats(rows=len(df), skipped=skipped, path=path, notes=notes)


def to_generic_csv(tx: pd.DataFrame, out_dir: Path = OUT_DIR) -> ExportStats:
    """汎用形式(列名を日本語にした読みやすい形式)。

    自分で確認する用、または税理士に渡す用。
    """
    out_dir.mkdir(parents=True, exist_ok=True)
    df = tx.copy()
    df = df.reset_index()
    df["日時(UTC)"] = df["ts"].dt.strftime("%Y-%m-%d %H:%M:%S")
    df["日時(JST)"] = (df["ts"].dt.tz_convert("Asia/Tokyo")
                       .dt.strftime("%Y-%m-%d %H:%M:%S"))

    out = df[[
        "日時(UTC)", "日時(JST)", "type", "category", "symbol", "side",
        "currency", "qty", "trade_price", "funding", "fee",
        "cash_flow", "change_amt", "cash_balance", "order_link_id",
    ]].rename(columns={
        "type": "種別", "category": "区分", "symbol": "銘柄",
        "side": "売買", "currency": "通貨", "qty": "数量",
        "trade_price": "価格", "funding": "資金調達", "fee": "手数料",
        "cash_flow": "キャッシュフロー", "change_amt": "増減",
        "cash_balance": "残高", "order_link_id": "注文ID",
    })

    path = out_dir / "bybit_all_transactions.csv"
    out.to_csv(path, index=False, encoding="utf-8-sig")
    return ExportStats(rows=len(out), skipped=0, path=path,
                       notes=["Excelで開ける形式(BOM付きUTF-8)"])


def to_summary_csv(tx: pd.DataFrame, fx=None,
                   out_dir: Path = OUT_DIR) -> ExportStats:
    """種別×通貨×月の集計。全体像の把握と突合に使う。"""
    out_dir.mkdir(parents=True, exist_ok=True)
    df = tx.copy()
    df["月"] = df.index.tz_convert("Asia/Tokyo").strftime("%Y-%m")

    agg = df.groupby(["月", "type", "currency"], dropna=False).agg(
        件数=("type", "count"),
        キャッシュフロー=("cash_flow", "sum"),
        資金調達=("funding", "sum"),
        手数料=("fee", "sum"),
    ).reset_index()

    path = out_dir / "bybit_summary_by_month.csv"
    agg.to_csv(path, index=False, encoding="utf-8-sig")
    return ExportStats(rows=len(agg), skipped=0, path=path,
                       notes=["月次の集計。突合チェックに使う"])


if __name__ == "__main__":
    from tax_fetch import TaxStore

    store = TaxStore()
    tx = store.tx_frame(year=2025)

    if tx.empty:
        raise SystemExit("データがありません。先に tax_fetch.py を実行してください")

    print(f"{len(tx)}件のレコードを変換します\n")
    for fn in (to_generic_csv, to_summary_csv, to_cryptact_custom):
        print(fn(tx).report())
        print()

    print("※ 各サービスのフォーマット仕様は変更されます。")
    print("  公式の最新仕様を確認し、取り込み後に必ず照合してください。")
    print("  最終的な申告内容は税理士に確認してください。")

突合チェック|漏れを検出する

集計が正しいかを機械的に確認します。「残高の理論値」と「実際の残高」が一致すれば、取りこぼしがない証拠になります。

reconcile_tax.pyサンプル6
"""reconcile_tax.py — 集計の網羅性を突合で検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

原理:
  取引ログには cashBalance(各時点の残高)が記録されている。
  「前のレコードのcashBalance + change = 今のcashBalance」が
  成り立たない箇所があれば、その間にレコードの欠損がある。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal

import pandas as pd

TOLERANCE = Decimal("0.00000001")


@dataclass
class GapFinding:
    currency: str
    ts_before: pd.Timestamp
    ts_after: pd.Timestamp
    expected: Decimal
    actual: Decimal

    @property
    def diff(self) -> Decimal:
        return self.actual - self.expected

    def line(self) -> str:
        return (f"  {self.currency:<6} {self.ts_before:%Y-%m-%d %H:%M} -> "
                f"{self.ts_after:%Y-%m-%d %H:%M}  "
                f"期待 {self.expected:>16,.8f}  実際 {self.actual:>16,.8f}  "
                f"差 {self.diff:>+14,.8f}")


@dataclass
class ReconcileReport:
    gaps: list[GapFinding] = field(default_factory=list)
    balance_checks: list[dict] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)

    @property
    def is_complete(self) -> bool:
        return not self.gaps

    def report(self) -> str:
        lines = ["=== 突合チェック ==="]

        if self.is_complete:
            lines.append("  残高の連続性: OK(レコードの欠損は検出されず)")
        else:
            lines.append(f"  残高の連続性: {len(self.gaps)}箇所で不整合")
            lines.append("  (この期間にレコードの欠損がある可能性)")
            for g in self.gaps[:10]:
                lines.append(g.line())
            if len(self.gaps) > 10:
                lines.append(f"  ... 他 {len(self.gaps) - 10}箇所")

        if self.balance_checks:
            lines.append("\n  最終残高の照合:")
            for b in self.balance_checks:
                mark = "OK" if b["一致"] else "NG"
                lines.append(
                    f"    [{mark}] {b['通貨']:<6} "
                    f"ログの最終残高 {b['ログ']:>16,.8f}  "
                    f"実際 {b['実際']:>16,.8f}  "
                    f"差 {b['差']:>+14,.8f}")

        for n in self.notes:
            lines.append(f"  ! {n}")
        return "\n".join(lines)


def check_balance_continuity(tx: pd.DataFrame) -> list[GapFinding]:
    """cashBalanceの連続性を通貨ごとに検証する。"""
    gaps: list[GapFinding] = []

    for currency, sub in tx.groupby("currency", dropna=False):
        sub = sub.sort_index()
        if len(sub) < 2:
            continue

        prev_balance = None
        prev_ts = None
        for ts, r in sub.iterrows():
            balance = Decimal(str(r.get("cash_balance") or 0))
            change = Decimal(str(r.get("change_amt") or 0))

            if prev_balance is not None:
                expected = prev_balance + change
                if abs(expected - balance) > TOLERANCE:
                    gaps.append(GapFinding(
                        currency=str(currency), ts_before=prev_ts,
                        ts_after=ts, expected=expected, actual=balance))
            prev_balance = balance
            prev_ts = ts

    return gaps


def check_final_balance(tx: pd.DataFrame, http) -> list[dict]:
    """ログの最終残高と、実際のウォレット残高を照合する。"""
    checks: list[dict] = []

    res = http.get_wallet_balance(accountType="UNIFIED")
    if res.get("retCode") != 0:
        return checks

    actual: dict[str, Decimal] = {}
    for coin in res["result"]["list"][0].get("coin", []):
        actual[coin["coin"]] = Decimal(str(coin.get("walletBalance") or 0))

    for currency, sub in tx.groupby("currency", dropna=False):
        if pd.isna(currency):
            continue
        sub = sub.sort_index()
        log_balance = Decimal(str(sub["cash_balance"].iloc[-1]))
        real = actual.get(str(currency), Decimal(0))
        diff = real - log_balance
        checks.append({
            "通貨": str(currency), "ログ": log_balance, "実際": real,
            "差": diff, "一致": abs(diff) <= Decimal("0.01"),
        })

    return checks


def reconcile(tx: pd.DataFrame, http=None) -> ReconcileReport:
    rep = ReconcileReport()

    if tx.empty:
        rep.notes.append("データがありません")
        return rep

    rep.gaps = check_balance_continuity(tx)

    if http is not None:
        rep.balance_checks = check_final_balance(tx, http)

    # 期間の境界を確認する
    span_days = (tx.index[-1] - tx.index[0]).days
    rep.notes.append(f"対象期間: {tx.index[0]:%Y-%m-%d} 〜 "
                     f"{tx.index[-1]:%Y-%m-%d}({span_days}日)")

    # 種別の網羅性
    types = sorted(tx["type"].dropna().unique())
    rep.notes.append(f"含まれる種別: {', '.join(types)}")

    if "SETTLEMENT" not in types:
        rep.notes.append(
            "SETTLEMENT(資金調達)がありません。"
            "無期限先物を保有していたなら取得漏れの可能性")

    return rep


def totals_by_type(tx: pd.DataFrame) -> pd.DataFrame:
    """種別ごとのキャッシュフロー合計。全体像の確認に使う。"""
    return tx.groupby(["type", "currency"], dropna=False).agg(
        件数=("type", "count"),
        キャッシュフロー=("cash_flow", "sum"),
        資金調達=("funding", "sum"),
        手数料=("fee", "sum"),
        ボーナス=("bonus_change", "sum"),
    ).sort_values("キャッシュフロー")


if __name__ == "__main__":
    from client import make_client
    from tax_fetch import TaxStore

    store = TaxStore()
    tx = store.tx_frame(year=2025)

    if tx.empty:
        raise SystemExit("データがありません。先に tax_fetch.py を実行してください")

    c = make_client()
    rep = reconcile(tx, c.http)
    print(rep.report())

    print("\n=== 種別ごとの合計 ===")
    print(totals_by_type(tx).to_string(
        float_format=lambda v: f"{v:,.6f}"))

    if not rep.is_complete:
        print("\n[!] 残高の連続性に不整合があります。")
        print("    該当期間を再取得してください:")
        print("      python tax_fetch.py  (該当年を指定)")
        print("    それでも解消しない場合、APIの保持期間(2年)を")
        print("    超えている可能性があります。")
残高の連続性が最強の検証

「前の残高 + 増減 = 今の残高」が全レコードで成り立てば、その期間にレコードの欠損はありません。これは個別の種別を数え上げるより確実な検証です。

逆に不整合が見つかった箇所は、その間に取得できていないレコードがあることを意味します。7日窓のループに漏れがあるか、APIの保持期間を超えているかのどちらかです。

監査可能性の確保

audit_trail.pyサンプル7
"""audit_trail.py — 集計の再現性と監査可能性を確保する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

目的:
  後から「この数字はどう計算したのか」を説明できる状態にする。
  税務調査や、翌年の申告で前年と整合を取るために必要。
"""
from __future__ import annotations

import hashlib
import json
import platform
import subprocess
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path

import pandas as pd

AUDIT_DIR = Path("reports/audit")


@dataclass
class AuditRecord:
    """1回の集計処理の記録。"""

    created_at: str
    year: int
    method_cost_basis: str          # total_average / moving_average
    method_fx: str                  # レートのソースと方法
    fx_meta: dict = field(default_factory=dict)
    record_counts: dict = field(default_factory=dict)
    totals: dict = field(default_factory=dict)
    file_hashes: dict = field(default_factory=dict)
    environment: dict = field(default_factory=dict)
    reconcile_ok: bool = False
    notes: list[str] = field(default_factory=list)


def file_sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def environment_info() -> dict:
    info = {
        "python": sys.version.split()[0],
        "platform": platform.platform(),
        "pandas": pd.__version__,
    }
    try:
        import pybit

        info["pybit"] = getattr(pybit, "__version__", "unknown")
    except Exception:
        pass
    try:
        sha = subprocess.run(["git", "rev-parse", "HEAD"],
                             capture_output=True, text=True, timeout=5)
        if sha.returncode == 0:
            info["git_commit"] = sha.stdout.strip()
    except Exception:
        pass
    return info


def build_audit_record(year: int, tx: pd.DataFrame,
                       cost_method: str, fx, reconcile_ok: bool,
                       output_files: list[Path],
                       notes: list[str] | None = None) -> AuditRecord:
    counts = {
        "総レコード数": len(tx),
        "種別数": int(tx["type"].nunique()),
    }
    for t, n in tx["type"].value_counts().items():
        counts[f"type:{t}"] = int(n)

    totals = {}
    for currency, sub in tx.groupby("currency", dropna=False):
        if pd.isna(currency):
            continue
        totals[str(currency)] = {
            "キャッシュフロー合計": float(sub["cash_flow"].sum()),
            "資金調達合計": float(sub["funding"].sum()),
            "手数料合計": float(sub["fee"].sum()),
            "最終残高": float(sub.sort_index()["cash_balance"].iloc[-1]),
        }

    hashes = {}
    for p in output_files:
        if p.exists():
            hashes[p.name] = file_sha256(p)

    return AuditRecord(
        created_at=datetime.now(timezone.utc).isoformat(),
        year=year,
        method_cost_basis=cost_method,
        method_fx=fx.source.name if hasattr(fx, "source") else "unknown",
        fx_meta=getattr(fx, "_meta", {}),
        record_counts=counts,
        totals=totals,
        file_hashes=hashes,
        environment=environment_info(),
        reconcile_ok=reconcile_ok,
        notes=notes or [],
    )


def save_audit(record: AuditRecord,
               out_dir: Path = AUDIT_DIR) -> Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    path = out_dir / f"audit_{record.year}_{stamp}.json"
    path.write_text(json.dumps(asdict(record), ensure_ascii=False, indent=2),
                    encoding="utf-8")
    return path


def print_audit(record: AuditRecord) -> None:
    print("=== 集計の記録 ===")
    print(f"  作成日時       : {record.created_at}")
    print(f"  対象年         : {record.year}")
    print(f"  取得原価の方法  : {record.method_cost_basis}")
    print(f"  レートのソース  : {record.method_fx}")
    print(f"  突合チェック    : {'OK' if record.reconcile_ok else 'NG'}")
    print(f"  総レコード数    : {record.record_counts.get('総レコード数')}")
    print("\n  通貨別の合計:")
    for currency, t in record.totals.items():
        print(f"    {currency:<6} CF {t['キャッシュフロー合計']:>16,.6f}  "
              f"資金調達 {t['資金調達合計']:>14,.6f}  "
              f"手数料 {t['手数料合計']:>12,.6f}")
    print("\n  出力ファイルのハッシュ:")
    for name, h in record.file_hashes.items():
        print(f"    {name:<34} {h[:16]}...")
    print("\n  実行環境:")
    for k, v in record.environment.items():
        print(f"    {k:<12} {v}")


if __name__ == "__main__":
    from client import make_client
    from export_tax_csv import (to_cryptact_custom, to_generic_csv,
                                to_summary_csv)
    from fx_rates import FxRates
    from reconcile_tax import reconcile
    from tax_fetch import TaxStore

    YEAR = 2025

    store = TaxStore()
    tx = store.tx_frame(year=YEAR)
    if tx.empty:
        raise SystemExit("データがありません。先に tax_fetch.py を実行してください")

    c = make_client()

    # 1) 突合
    rep = reconcile(tx, c.http)
    print(rep.report())
    print()

    # 2) 出力
    outputs = []
    for fn in (to_generic_csv, to_summary_csv, to_cryptact_custom):
        stats = fn(tx)
        outputs.append(stats.path)
        print(stats.report())
    print()

    # 3) 記録を残す
    fx = FxRates(source="manual_csv")
    record = build_audit_record(
        YEAR, tx, cost_method="total_average", fx=fx,
        reconcile_ok=rep.is_complete, output_files=outputs,
        notes=["本記録は集計の再現性を確保するためのもの",
               "最終的な申告内容は税理士に確認すること"],
    )
    print_audit(record)
    path = save_audit(record)
    print(f"\n記録を保存: {path}")
    print("\n※ この記録とローデータ(data/tax.sqlite)を保管しておけば、")
    print("  後から数字の根拠を説明できます。")
run_tax_report.pyサンプル8
"""run_tax_report.py — 年間損益データの作成を一括実行する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

これ1つで、取得から出力・記録までを通す。
毎月cronで実行しておけば、年末に慌てずに済む(記事38)。
"""
from __future__ import annotations

import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Bybitの年間損益データを作成する(税務アドバイスではありません)")
    parser.add_argument("--year", type=int,
                        default=datetime.now(timezone.utc).year - 1)
    parser.add_argument("--fx-csv", type=str, default="",
                        help="円換算レートのCSV(date,jpy)")
    parser.add_argument("--cost-method", choices=("total_average",
                                                  "moving_average"),
                        default="total_average")
    parser.add_argument("--skip-fetch", action="store_true",
                        help="取得を省略して蓄積済みデータのみ使う")
    args = parser.parse_args()

    from client import make_client
    from export_tax_csv import (to_cryptact_custom, to_generic_csv,
                                to_summary_csv)
    from reconcile_tax import reconcile, totals_by_type
    from tax_fetch import TaxStore, sync_year

    c = make_client()
    store = TaxStore()

    # ---- 1) 取得 --------------------------------------------------------
    if not args.skip_fetch:
        print("=" * 62)
        print(f"[1/5] {args.year}年のデータを取得します")
        print("=" * 62)
        sync_year(c.http, args.year, store)
    else:
        print("[1/5] 取得をスキップします")

    tx = store.tx_frame(year=args.year)
    if tx.empty:
        print(f"\n{args.year}年のデータがありません")
        print("  APIの保持期間(過去2年)を超えている可能性があります")
        return 1

    # ---- 2) 網羅性の確認 -------------------------------------------------
    print("\n" + "=" * 62)
    print("[2/5] 種別の網羅性を確認")
    print("=" * 62)
    print(totals_by_type(tx).to_string(float_format=lambda v: f"{v:,.6f}"))

    # ---- 3) 突合 ---------------------------------------------------------
    print("\n" + "=" * 62)
    print("[3/5] 突合チェック")
    print("=" * 62)
    rep = reconcile(tx, c.http)
    print(rep.report())

    # ---- 4) 円換算の準備 -------------------------------------------------
    print("\n" + "=" * 62)
    print("[4/5] 円換算レート")
    print("=" * 62)
    from fx_rates import FxRates

    fx = FxRates(source="manual_csv")
    if args.fx_csv and Path(args.fx_csv).exists():
        n = fx.load_csv("USDT", args.fx_csv)
        print(f"  {n}日分のレートを読み込みました({args.fx_csv})")
        print(f"  {fx.coverage('USDT', tx.index[0].date(), tx.index[-1].date())}")
    else:
        print("  レートCSVが指定されていません(--fx-csv)")
        print("  円換算なしで出力します。円換算が必要なら")
        print("  国税庁の情報を確認し、適切なレートをCSVで用意してください")

    # ---- 5) 出力 ---------------------------------------------------------
    print("\n" + "=" * 62)
    print("[5/5] CSV出力と記録")
    print("=" * 62)
    outputs = []
    for fn in (to_generic_csv, to_summary_csv, to_cryptact_custom):
        stats = fn(tx)
        outputs.append(stats.path)
        print(stats.report())

    from audit_trail import build_audit_record, print_audit, save_audit

    record = build_audit_record(
        args.year, tx, cost_method=args.cost_method, fx=fx,
        reconcile_ok=rep.is_complete, output_files=outputs)
    audit_path = save_audit(record)

    print("\n" + "=" * 62)
    print("完了")
    print("=" * 62)
    print(f"  記録: {audit_path}")
    print()
    print("  【重要】")
    print("  出力された数値は集計の材料です。")
    print("  所得区分・必要経費・申告方法の判断は行っていません。")
    print("  必ず国税庁の情報を確認し、税理士に相談してください。")

    return 0 if rep.is_complete else 2


if __name__ == "__main__":
    sys.exit(main())
参考リンク(一次情報)

よくある疑問Q&A

Bybitに年間取引報告書はありますか?
日本の国内業者が発行するような「年間取引報告書」はありません。取引履歴のエクスポート機能はありますが、日本の税制に合わせた損益計算書ではありません。
そのため自分で集計するか、損益計算サービスを使うことになります。本記事のコードは、そのための元データを漏れなく揃えることを目的としています。なお公式のTax API(V3)も存在するため、そちらが要件を満たすなら利用を検討してください。
2年より前のデータが取得できません
APIの仕様です(保持期間は過去2年)。それより古いデータは取得できません。
対策は「今から蓄積を始める」しかありません。サンプル2の TaxStore をcronで毎月実行すれば(記事38)、以後は保持期間を超えても手元に残ります。
既に失われた期間については、取引所の画面からエクスポートできる範囲を確認するか、税理士に状況を相談してください。
どのレートで円換算すべきですか?
これは税務の判断であり、本記事では回答できません。取引時点のレート、日次終値、月中平均など複数の考え方があり、国税庁の情報と税理士の判断に従う必要があります。
技術的に重要なのは、(1) 一貫した方法を使う、(2) レートのソースと取得日時を記録に残す(サンプル3の save_meta())、(3) 後から再計算できるようローデータを保管する——この3点です。
資金調達の受払いは損益に含めるのですか?
扱いは税務の判断ですが、技術的には必ず集計対象に含めてください。無期限先物を長期保有していると、資金調達だけで年率数%〜十数%の金額になります(記事10)。
これを集計から落とすと、実際のキャッシュフローと帳簿が合わなくなります。サンプル6の突合チェックで残高の連続性を確認すれば、資金調達を落としているかどうかが検出できます。所得区分や計上時期の判断は税理士に確認してください。
現物と無期限先物で扱いは違いますか?
技術的な集計方法は明確に違います。無期限先物は closed_pnl で実現損益が直接取れますが、現物は取得原価を自分で計算する必要があります(サンプル4)。closed_pnl は現物に対応していません。
税務上の扱い(所得区分、損益通算の可否など)については判断できません。特に海外取引所のデリバティブ取引は論点が多いため、必ず税理士に確認してください。
キャンペーンで受け取ったボーナスも集計対象ですか?
技術的には get_transaction_log の BONUS 系や bonusChange フィールドに現れるため、サンプル1の洗い出しで確認できます。
ただし受領時点で課税されるのか、使用時なのか、そもそも所得になるのかは税務の判断です。回答できません。集計対象として金額を把握しておき、扱いは税理士に確認してください。「気づいていなかった」が最も避けたい状態なので、まず洗い出すことが重要です。
突合チェックで不整合が出ました
その期間のレコードが取得できていません。原因は3つ考えられます。(1) 7日窓ループに漏れがある(該当期間を再取得してください)、(2) APIの保持期間を超えている(2年より前は取得不可)、(3) 別のaccountTypeにレコードがある(UNIFIED 以外に CONTRACT や FUND の口座を使っていた場合)。
まず(1)を試し、それでも解消しないなら(3)を確認してください。不整合を残したまま申告用の数字を作るのは危険です。
このコードの数字をそのまま申告に使えますか?
使えません。本記事のコードが出力するのは「集計の材料」であり、申告書に記載する金額ではありません。所得区分の判定、必要経費の範囲、損益通算の可否、円換算方法の妥当性——これらはすべて税務の判断です。
正しい進め方は、(1) 本記事のコードでローデータと集計表を作る、(2) 突合チェックで網羅性を確認する、(3) それを税理士に渡して判断を仰ぐ——という順序です。データの網羅性と再現性を担保することが、この記事の役割です。

注意事項・免責事項

投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。

当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。

当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。

当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。

当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。

当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。

当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。