※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
この記事は「取引データを漏れなく取得し、計算可能な形に整える」技術的な手順を解説するものです。税制の解釈、所得区分の判断、必要経費の範囲、申告方法については一切の助言を行いません。
最終的な申告内容は必ず国税庁の公表情報を確認し、税理士に相談してください。特に海外取引所の暗号資産取引は論点が多く、個別事情によって扱いが変わります。本記事のコードが出力する数値はあくまで集計の材料であり、申告額を保証するものではありません。
| 制約 | 内容 | 実装上の意味 |
|---|---|---|
| 7日窓 | get_executions / get_closed_pnl / get_transaction_log はいずれも endTime − startTime ≤ 7日 |
1年分で約53回のループが必要 |
| 2年で消える | APIが保持するのは過去2年分 | 定期的に取得して蓄積するしかない |
| 現物の損益がない | closed_pnl は現物に非対応 |
取得原価を自分で計算する |
| 通貨が混在 | USDT・USDC・BTC建ての損益が並ぶ | 円換算のレートと時点を決める |
最も危険な失敗はこれです。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 | 口座間の移動(損益ではないが突合に必要) |
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 — どの種別のレコードが存在するかを洗い出す
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
集計を始める前にこれを走らせ、自分の口座に
どんな種類のキャッシュフローが存在するかを把握する。
「知らなかった項目」を見つけるのが目的。
"""
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(" 別途取得してください(本記事の範囲外)。")
"""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 — 円換算レートの管理(取得・キャッシュ・根拠の記録)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
方針:
- レートのソースと取得日時を必ず記録する(監査可能性)
- 手動で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 — 取得原価の計算(総平均法・移動平均法)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
注: どちらの方法を採用すべきかは税務の判断です。
本コードは両方を計算して比較できるようにするだけです。
"""
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(" 税理士に確認してください。")
専門の損益計算サービスを使う場合、カスタムCSV形式で取り込むのが実務的です。Bybitの生データをそのまま渡せない場合に変換します。
"""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 — 集計の網羅性を突合で検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
原理:
取引ログには 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 — 集計の再現性と監査可能性を確保する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
目的:
後から「この数字はどう計算したのか」を説明できる状態にする。
税務調査や、翌年の申告で前年と整合を取るために必要。
"""
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 — 年間損益データの作成を一括実行する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
これ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())
TaxStore をcronで毎月実行すれば(記事38)、以後は保持期間を超えても手元に残ります。save_meta())、(3) 後から再計算できるようローデータを保管する——この3点です。closed_pnl で実現損益が直接取れますが、現物は取得原価を自分で計算する必要があります(サンプル4)。closed_pnl は現物に対応していません。get_transaction_log の BONUS 系や bonusChange フィールドに現れるため、サンプル1の洗い出しで確認できます。UNIFIED 以外に CONTRACT や FUND の口座を使っていた場合)。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。