Bybitの公開ティックデータで長期の約定履歴を取得する
情報駆動バーの生成とPython実装

BybitはAPIとは別に、public.bybit.com から日次CSVで全約定履歴を無料公開しています。BTCUSDTなら2020年3月から現在まで、1ティックも欠けずに取得できます。日本語ではほとんど知られていませんが、APIの get_public_trade_history は直近しか返さないため、長期の高頻度検証にはこちらが必須です。取得から情報駆動バーの生成までをサンプルコード8本で実装します。

確認日
2026-07-31
データ源
public.bybit.com
Python
3.10 以上
依存
pandas / duckdb / polars

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

目次

  1. なぜAPIではなく公開データなのか
  2. アーカイブの構造とファイル形式
  3. 日次CSVの一括ダウンロード
  4. パースとParquet変換
  5. 直近のティックはAPIで補う
  6. 情報駆動バー|tick / volume / dollar
  7. 時間足との統計的性質の比較
  8. 売買方向の推定|Lee-Readyルール
  9. 大容量データの扱い|DuckDBとPolars
  10. よくある疑問Q&A

なぜAPIではなく公開データなのか

API(get_public_trade_history)public.bybit.com
取得範囲直近のみ(1回1000件程度)2020年から全期間
コストレート制限を消費HTTPダウンロードのみ(制限外)
粒度1約定ごと1約定ごと
用途リアルタイムの補完長期のバックテスト・統計分析
容量小大(BTCUSDT 1日で数十〜数百MB)
ローソク足では見えないものが見える

OHLCVは「その足の中で何が起きたか」を捨てています。ティックデータがあれば、高値と安値のどちらが先に付いたか、大口が一度に入ったのか小口が積み重なったのか、売り手と買い手のどちらが攻めていたか——これらが分かります。

特に同じ足でTPとSLの両方に触れた場合の判定(記事31の課題)は、ティックデータでしか正確に解けません。

アーカイブの構造とファイル形式

URLの構造仕様
ベース: https://public.bybit.com/trading/

  https://public.bybit.com/trading/{SYMBOL}/
    -> その銘柄の日次ファイル一覧

  https://public.bybit.com/trading/{SYMBOL}/{SYMBOL}{YYYY-MM-DD}.csv.gz
    -> 1日分の全約定(gzip圧縮CSV)

例:
  https://public.bybit.com/trading/BTCUSDT/BTCUSDT2025-01-09.csv.gz

利用できる銘柄:
  - USDT無期限(BTCUSDT, ETHUSDT, ...)
  - USDC無期限(BTCPERP, ...)
  - コイン建て(BTCUSD, ...)
  - 日付付き先物(BTC-27JUN25 など)
  - 現物ペア

BTCUSDTは2020年3月25日から提供されています。
銘柄によって開始日は異なります(上場日以降)。
列名はファイルのヘッダから読む

CSVには一般に timestamp / symbol / side / size / price / tickDirection / trdMatchID / grossValue / homeNotional / foreignNotional といった列が含まれますが、銘柄や時期によって列構成が変わることがあります。

本記事のパーサはヘッダ行を読んで実際の列名に合わせる実装にしてあります(サンプル3)。列名を決め打ちすると、古いファイルや別カテゴリで壊れます。

日次CSVの一括ダウンロード

public_data.pyサンプル1
"""public_data.py — public.bybit.com から日次ティックCSVを取得する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

特徴:
  - 並列ダウンロード(ただし相手はCDNなので節度を持つ)
  - レジューム(既にあるファイルはスキップ)
  - 存在しない日付(休止期間など)を記録して再試行しない
"""
from __future__ import annotations

import json
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path

import requests

BASE = "https://public.bybit.com/trading"
RAW_DIR = Path("data/public/raw")
STATE_PATH = Path("data/public/download_state.json")

# 相手はCDNだが、節度を持った並列数にする
DEFAULT_WORKERS = 4
TIMEOUT = 120


@dataclass
class DownloadStats:
    symbol: str
    downloaded: int = 0
    skipped: int = 0
    missing: int = 0
    failed: list[str] = field(default_factory=list)
    bytes_total: int = 0

    def report(self) -> str:
        mb = self.bytes_total / 1024 / 1024
        return (f"{self.symbol}: 新規 {self.downloaded} / "
                f"既存 {self.skipped} / 欠番 {self.missing} / "
                f"失敗 {len(self.failed)}  ({mb:,.1f} MB)")


def list_available_dates(symbol: str, timeout: int = 30) -> list[date]:
    """銘柄ディレクトリのHTMLから、利用できる日付を抽出する。"""
    url = f"{BASE}/{symbol}/"
    r = requests.get(url, timeout=timeout)
    r.raise_for_status()

    # ファイル名: {SYMBOL}{YYYY-MM-DD}.csv.gz
    pattern = re.compile(rf"{re.escape(symbol)}(\d{{4}}-\d{{2}}-\d{{2}})\.csv\.gz")
    dates = sorted({
        datetime.strptime(m, "%Y-%m-%d").date()
        for m in pattern.findall(r.text)
    })
    return dates


def daterange(start: date, end: date):
    cur = start
    while cur <= end:
        yield cur
        cur += timedelta(days=1)


class PublicDataDownloader:
    def __init__(self, raw_dir: Path = RAW_DIR,
                 state_path: Path = STATE_PATH,
                 workers: int = DEFAULT_WORKERS):
        self.raw_dir = raw_dir
        self.state_path = state_path
        self.workers = workers
        self.raw_dir.mkdir(parents=True, exist_ok=True)
        self._state = self._load_state()

    # ---- 欠番の記録(再試行を避ける) -----------------------------------
    def _load_state(self) -> dict:
        if self.state_path.exists():
            try:
                return json.loads(self.state_path.read_text(encoding="utf-8"))
            except Exception:
                pass
        return {"missing": {}}

    def _save_state(self) -> None:
        self.state_path.parent.mkdir(parents=True, exist_ok=True)
        tmp = self.state_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(self._state), encoding="utf-8")
        tmp.replace(self.state_path)

    def _is_known_missing(self, symbol: str, d: date) -> bool:
        return str(d) in self._state["missing"].get(symbol, [])

    def _mark_missing(self, symbol: str, d: date) -> None:
        self._state["missing"].setdefault(symbol, [])
        if str(d) not in self._state["missing"][symbol]:
            self._state["missing"][symbol].append(str(d))

    # ---- ダウンロード ----------------------------------------------------
    def path_for(self, symbol: str, d: date) -> Path:
        return self.raw_dir / symbol / f"{symbol}{d:%Y-%m-%d}.csv.gz"

    def _download_one(self, symbol: str, d: date,
                      session: requests.Session) -> tuple[str, int]:
        """戻り値: (状態, バイト数)。状態は ok / skip / missing / fail。"""
        path = self.path_for(symbol, d)
        if path.exists() and path.stat().st_size > 0:
            return "skip", path.stat().st_size
        if self._is_known_missing(symbol, d):
            return "missing", 0

        url = f"{BASE}/{symbol}/{symbol}{d:%Y-%m-%d}.csv.gz"
        try:
            with session.get(url, stream=True, timeout=TIMEOUT) as r:
                if r.status_code == 404:
                    self._mark_missing(symbol, d)
                    return "missing", 0
                r.raise_for_status()

                path.parent.mkdir(parents=True, exist_ok=True)
                tmp = path.with_suffix(".part")
                size = 0
                with tmp.open("wb") as f:
                    for chunk in r.iter_content(chunk_size=1 << 20):
                        f.write(chunk)
                        size += len(chunk)
                tmp.replace(path)          # 部分ファイルを残さない
                return "ok", size
        except Exception:
            return "fail", 0

    def download(self, symbol: str, start: date, end: date,
                 verbose: bool = True) -> DownloadStats:
        stats = DownloadStats(symbol=symbol)
        days = list(daterange(start, end))

        session = requests.Session()
        with ThreadPoolExecutor(max_workers=self.workers) as pool:
            futures = {pool.submit(self._download_one, symbol, d, session): d
                       for d in days}
            done = 0
            for fut in as_completed(futures):
                d = futures[fut]
                status, size = fut.result()
                done += 1

                if status == "ok":
                    stats.downloaded += 1
                    stats.bytes_total += size
                elif status == "skip":
                    stats.skipped += 1
                    stats.bytes_total += size
                elif status == "missing":
                    stats.missing += 1
                else:
                    stats.failed.append(str(d))

                if verbose and done % 50 == 0:
                    print(f"  {done}/{len(days)} … "
                          f"{stats.bytes_total / 1024 / 1024:,.0f} MB")

        self._save_state()
        if verbose:
            print(stats.report())
        return stats

    def estimate_size(self, symbol: str, sample_days: int = 3) -> float:
        """1日あたりの平均サイズ(MB)を推定する。取得前の見積り用。"""
        files = sorted((self.raw_dir / symbol).glob("*.csv.gz"))[-sample_days:]
        if not files:
            return 0.0
        return sum(f.stat().st_size for f in files) / len(files) / 1024 / 1024


if __name__ == "__main__":
    SYMBOL = "BTCUSDT"

    print(f"=== {SYMBOL} の利用可能な期間を確認 ===")
    dates = list_available_dates(SYMBOL)
    print(f"  {len(dates)}日分  {dates[0]} 〜 {dates[-1]}")

    dl = PublicDataDownloader()

    # まず1週間だけ取ってサイズを見る
    print(f"\n=== 直近7日を取得してサイズを確認 ===")
    end = dates[-1]
    dl.download(SYMBOL, end - timedelta(days=6), end)

    per_day = dl.estimate_size(SYMBOL)
    print(f"\n1日あたり約 {per_day:.1f} MB")
    print(f"  1年分なら約 {per_day * 365 / 1024:.1f} GB")
    print(f"  全期間({len(dates)}日)なら約 "
          f"{per_day * len(dates) / 1024:.1f} GB")
    print("\n※ ディスク容量を確認してから全期間の取得を始めてください。")
容量を見積もってから取得する

BTCUSDTのティックは1日あたり数十〜数百MBあります。5年分を無計画にダウンロードすると数十GBになり、VPSのディスクを埋めます。

サンプル1はまず数日取ってサイズを推定する流れにしてあります。実際には必要な期間だけ取り、Parquetに変換したら生CSVは削除するのが実務的です(Parquetは1/5〜1/10になります)。

パースとParquet変換

tick_parser.pyサンプル2
"""tick_parser.py — 日次CSVをパースしてParquetに変換する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

要点:
  - 列名を決め打ちせず、ヘッダから実際の列を読む
  - dtypeを明示してメモリを節約する
  - 変換後は生CSVを削除できる(Parquetは1/5〜1/10)
"""
from __future__ import annotations

import gzip
from dataclasses import dataclass
from datetime import date
from pathlib import Path

import pandas as pd

PARQUET_DIR = Path("data/public/parquet")

# 想定される列名の候補(時期・銘柄で揺れるため複数を許容する)
COLUMN_ALIASES = {
    "ts": ["timestamp", "trdTime", "time"],
    "price": ["price"],
    "size": ["size", "qty", "volume"],
    "side": ["side"],
    "tick_direction": ["tickDirection", "tick_direction"],
    "trade_id": ["trdMatchID", "tradeId", "id"],
    "gross_value": ["grossValue"],
    "home_notional": ["homeNotional"],
    "foreign_notional": ["foreignNotional"],
}


@dataclass
class ParseResult:
    path: Path
    rows: int
    columns: list[str]
    unmapped: list[str]

    def report(self) -> str:
        base = f"{self.path.name}: {self.rows:,}行 / 列 {len(self.columns)}"
        if self.unmapped:
            base += f"  未マップ列: {self.unmapped}"
        return base


def peek_header(csv_gz: Path) -> list[str]:
    """ファイルのヘッダ行だけを読む(列構成の確認用)。"""
    with gzip.open(csv_gz, "rt", encoding="utf-8") as f:
        header = f.readline().strip()
    return [c.strip() for c in header.split(",")]


def build_rename_map(columns: list[str]) -> tuple[dict[str, str], list[str]]:
    """実際の列名から、標準名へのマッピングを作る。"""
    lower = {c.lower(): c for c in columns}
    rename: dict[str, str] = {}
    for std, aliases in COLUMN_ALIASES.items():
        for a in aliases:
            if a.lower() in lower:
                rename[lower[a.lower()]] = std
                break
    unmapped = [c for c in columns if c not in rename]
    return rename, unmapped


def parse_csv(csv_gz: Path, chunksize: int | None = None) -> pd.DataFrame:
    """日次CSVをDataFrameに読み込む。"""
    columns = peek_header(csv_gz)
    rename, _ = build_rename_map(columns)

    # メモリを節約するためdtypeを指定する
    dtypes: dict[str, str] = {}
    for col in columns:
        std = rename.get(col)
        if std in ("price", "size", "gross_value", "home_notional",
                   "foreign_notional"):
            dtypes[col] = "float64"
        elif std in ("side", "tick_direction", "trade_id"):
            dtypes[col] = "string"

    df = pd.read_csv(csv_gz, compression="gzip", dtype=dtypes,
                     low_memory=False)
    df = df.rename(columns=rename)

    if "ts" not in df.columns:
        raise ValueError(f"{csv_gz.name}: timestamp列が見つかりません "
                         f"(列: {columns})")

    # timestampは秒(小数付き)で入っていることが多い。
    # 桁数から秒/ミリ秒を自動判定する
    ts = pd.to_numeric(df["ts"], errors="coerce")
    unit = "ms" if ts.max() > 1e12 else "s"
    df["ts"] = pd.to_datetime(ts, unit=unit, utc=True)

    # 売買方向は文字列。カテゴリ型にしてメモリを削る
    for col in ("side", "tick_direction"):
        if col in df.columns:
            df[col] = df[col].astype("category")

    return df.set_index("ts").sort_index()


def to_parquet(csv_gz: Path, out_dir: Path = PARQUET_DIR,
               delete_source: bool = False) -> ParseResult:
    """日次CSVをParquetに変換する。"""
    symbol = csv_gz.parent.name
    out_sym = out_dir / symbol
    out_sym.mkdir(parents=True, exist_ok=True)

    day = csv_gz.stem.replace(symbol, "").replace(".csv", "")
    out = out_sym / f"{symbol}_{day}.parquet"

    if out.exists() and out.stat().st_size > 0:
        return ParseResult(out, 0, [], [])

    df = parse_csv(csv_gz)
    columns = peek_header(csv_gz)
    _, unmapped = build_rename_map(columns)

    tmp = out.with_suffix(".tmp")
    df.to_parquet(tmp, compression="zstd")
    tmp.replace(out)

    if delete_source:
        csv_gz.unlink()

    return ParseResult(out, len(df), list(df.columns), unmapped)


def convert_all(symbol: str, raw_dir: Path = Path("data/public/raw"),
                out_dir: Path = PARQUET_DIR,
                delete_source: bool = False,
                verbose: bool = True) -> dict:
    files = sorted((raw_dir / symbol).glob("*.csv.gz"))
    if not files:
        return {"error": f"{symbol}: CSVが見つかりません"}

    total_rows = 0
    converted = 0
    csv_bytes = 0
    parquet_bytes = 0

    for i, f in enumerate(files, 1):
        csv_bytes += f.stat().st_size
        res = to_parquet(f, out_dir, delete_source)
        if res.rows:
            converted += 1
            total_rows += res.rows
        parquet_bytes += res.path.stat().st_size if res.path.exists() else 0

        if verbose and i % 20 == 0:
            print(f"  {i}/{len(files)} … {total_rows:,}行")

    ratio = (parquet_bytes / csv_bytes) if csv_bytes else 0
    return {
        "symbol": symbol,
        "ファイル数": len(files),
        "変換": converted,
        "総ティック数": total_rows,
        "CSV(MB)": round(csv_bytes / 1024 / 1024, 1),
        "Parquet(MB)": round(parquet_bytes / 1024 / 1024, 1),
        "圧縮率": f"{ratio:.1%}",
    }


def load_range(symbol: str, start: date, end: date,
               parquet_dir: Path = PARQUET_DIR) -> pd.DataFrame:
    """期間を指定してParquetを読み込む。"""
    files = []
    for p in sorted((parquet_dir / symbol).glob("*.parquet")):
        day_str = p.stem.split("_")[-1]
        try:
            d = date.fromisoformat(day_str)
        except ValueError:
            continue
        if start <= d <= end:
            files.append(p)

    if not files:
        return pd.DataFrame()
    return pd.concat([pd.read_parquet(p) for p in files]).sort_index()


if __name__ == "__main__":
    SYMBOL = "BTCUSDT"
    raw = Path("data/public/raw") / SYMBOL
    files = sorted(raw.glob("*.csv.gz"))

    if not files:
        raise SystemExit("先に public_data.py でダウンロードしてください")

    print("=== ファイルの列構成を確認 ===")
    cols = peek_header(files[-1])
    print(f"  {files[-1].name}")
    print(f"  列: {cols}")
    rename, unmapped = build_rename_map(cols)
    print(f"  マップ: {rename}")
    if unmapped:
        print(f"  未マップ(無視される列): {unmapped}")

    print("\n=== Parquetへ変換 ===")
    for k, v in convert_all(SYMBOL).items():
        print(f"  {k:<14}: {v}")

    print("\n=== 読み込みテスト ===")
    df = load_range(SYMBOL, files[0].stem[-10:] and
                    date.fromisoformat(files[-1].stem.replace(SYMBOL, "")
                                       .replace(".csv", "")),
                    date.fromisoformat(files[-1].stem.replace(SYMBOL, "")
                                       .replace(".csv", "")))
    if not df.empty:
        print(f"  {len(df):,}ティック  {df.index[0]} 〜 {df.index[-1]}")
        print(df.head(3).to_string())

直近のティックはAPIで補う

公開データは日次で更新されるため、当日分は含まれません。リアルタイムに近い部分はAPIまたはWebSocketで補います。

recent_ticks.pyサンプル3
"""recent_ticks.py — 直近のティックをAPI/WSで補完する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

役割分担:
  過去(前日まで) -> public.bybit.com(サンプル1・2)
  直近            -> get_public_trade_history(1000件程度)
  リアルタイム     -> WebSocket publicTrade(記事26)
"""
from __future__ import annotations

import time
from pathlib import Path

import pandas as pd

RECENT_DIR = Path("data/public/recent")


def fetch_recent_trades(http, symbol: str, category: str = "linear",
                        limit: int = 1000) -> pd.DataFrame:
    """APIで直近の約定を取得する(取得できるのは直近のみ)。"""
    res = http.get_public_trade_history(category=category, symbol=symbol,
                                        limit=min(limit, 1000))
    if res["retCode"] != 0:
        raise RuntimeError(f"get_public_trade_history失敗: {res['retMsg']}")

    rows = res["result"]["list"]
    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame([{
        "ts": pd.Timestamp(int(r["time"]), unit="ms", tz="UTC"),
        "price": float(r["price"]),
        "size": float(r["size"]),
        "side": r["side"],              # テイカーの方向
        "trade_id": r.get("execId", ""),
    } for r in rows])
    return df.set_index("ts").sort_index()


class TickRecorder:
    """WebSocketのpublicTradeを日次ファイルに記録する(記事26と連携)。

    公開データが翌日まで出ないため、当日分を自分で貯めておく。
    """

    def __init__(self, symbol: str, out_dir: Path = RECENT_DIR,
                 flush_every: int = 2000):
        self.symbol = symbol
        self.out_dir = out_dir
        self.out_dir.mkdir(parents=True, exist_ok=True)
        self.flush_every = flush_every
        self._buf: list[dict] = []
        self._seen: set[str] = set()
        self.total = 0

    def on_message(self, msg: dict) -> None:
        """WSコールバック。重い処理はしない(記事26の原則)。"""
        for t in msg.get("data", []):
            tid = t.get("i", "")
            if tid and tid in self._seen:
                continue
            if tid:
                self._seen.add(tid)
                if len(self._seen) > 100_000:
                    self._seen.clear()

            self._buf.append({
                "ts_ms": int(t["T"]), "price": float(t["p"]),
                "size": float(t["v"]), "side": t["S"],
                "tick_direction": t.get("L", ""), "trade_id": tid,
                "block_trade": bool(t.get("BT", False)),
            })

        if len(self._buf) >= self.flush_every:
            self.flush()

    def flush(self) -> None:
        if not self._buf:
            return
        df = pd.DataFrame(self._buf)
        df["ts"] = pd.to_datetime(df["ts_ms"], unit="ms", utc=True)
        df = df.drop(columns=["ts_ms"]).set_index("ts").sort_index()

        day = df.index[0].strftime("%Y-%m-%d")
        path = self.out_dir / f"{self.symbol}_{day}.parquet"

        if path.exists():
            df = pd.concat([pd.read_parquet(path), df])
            df = df[~df.index.duplicated(keep="last")].sort_index()

        tmp = path.with_suffix(".tmp")
        df.to_parquet(tmp, compression="zstd")
        tmp.replace(path)

        self.total += len(self._buf)
        self._buf.clear()


def stitch(historical: pd.DataFrame, recent: pd.DataFrame) -> pd.DataFrame:
    """公開データと直近データを継ぎ目なくつなぐ。"""
    if historical.empty:
        return recent
    if recent.empty:
        return historical

    # 重複期間は公開データを優先する(確定値のため)
    cutoff = historical.index[-1]
    tail = recent[recent.index > cutoff]
    return pd.concat([historical, tail]).sort_index()


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

    from datetime import date, timedelta

    c = make_client()
    SYMBOL = "BTCUSDT"

    recent = fetch_recent_trades(c.http, SYMBOL)
    print(f"APIで取得した直近ティック: {len(recent)}件")
    if not recent.empty:
        print(f"  {recent.index[0]} 〜 {recent.index[-1]}")
        span = (recent.index[-1] - recent.index[0]).total_seconds()
        print(f"  カバー範囲: わずか {span:.0f}秒分")
        print("  -> 長期の分析には public.bybit.com が必須")

    yesterday = date.today() - timedelta(days=1)
    hist = load_range(SYMBOL, yesterday - timedelta(days=2), yesterday)
    if not hist.empty:
        merged = stitch(hist, recent)
        print(f"\n結合後: {len(merged):,}ティック")
        print(f"  {merged.index[0]} 〜 {merged.index[-1]}")

    # WebSocketでの記録(当日分を自分で貯める)
    print("\n=== WebSocketで記録する場合 ===")
    print("  from pybit.unified_trading import WebSocket")
    print("  rec = TickRecorder('BTCUSDT')")
    print("  ws.trade_stream(symbol='BTCUSDT', callback=rec.on_message)")

情報駆動バー|tick / volume / dollar

時間で区切るローソク足は、取引が活発な時間帯と閑散な時間帯で情報量が全く違います。López de Prado は「一定の情報量ごとに区切る」バーを提案しました。

バーの種類区切る基準特徴
タイムバー経過時間情報量が不均一。リターンの正規性が低い
ティックバー約定回数情報量が均一に近い
ボリュームバー出来高大口の分割約定に強い
ドルバー売買代金価格水準の変化に頑健。長期分析に最適
information_bars.pyサンプル4
"""information_bars.py — 情報駆動バーの生成
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Lopez de Prado (2018)
      "Advances in Financial Machine Learning" Ch.2
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd


@dataclass
class BarSpec:
    kind: str            # tick / volume / dollar
    threshold: float

    def describe(self) -> str:
        unit = {"tick": "約定", "volume": "枚", "dollar": "USDT"}[self.kind]
        return f"{self.kind}バー({self.threshold:,.0f} {unit}ごと)"


def _accumulator(df: pd.DataFrame, kind: str) -> np.ndarray:
    if kind == "tick":
        return np.ones(len(df), dtype="float64")
    if kind == "volume":
        return df["size"].to_numpy(dtype="float64")
    if kind == "dollar":
        return (df["price"] * df["size"]).to_numpy(dtype="float64")
    raise ValueError(f"未知のバー種別: {kind}")


def make_bars(ticks: pd.DataFrame, spec: BarSpec) -> pd.DataFrame:
    """ティックから情報駆動バーを生成する。

    ticks は index=UTC時刻、列に price / size / side が必要。
    """
    if ticks.empty:
        return pd.DataFrame()

    df = ticks.sort_index()
    acc = _accumulator(df, spec.kind)
    price = df["price"].to_numpy(dtype="float64")
    size = df["size"].to_numpy(dtype="float64")
    is_buy = (df["side"].astype(str) == "Buy").to_numpy()
    ts = df.index.to_numpy()

    bars = []
    cum = 0.0
    start = 0

    for i in range(len(df)):
        cum += acc[i]
        if cum < spec.threshold:
            continue

        seg = slice(start, i + 1)
        p = price[seg]
        v = size[seg]
        b = is_buy[seg]
        notional = p * v

        bars.append({
            "ts": ts[i],
            "ts_open": ts[start],
            "open": p[0], "high": p.max(), "low": p.min(), "close": p[-1],
            "volume": v.sum(),
            "dollar": notional.sum(),
            "ticks": i - start + 1,
            "vwap": notional.sum() / v.sum() if v.sum() else p[-1],
            # テイカー方向の分解(需給の偏り)
            "buy_volume": v[b].sum(),
            "sell_volume": v[~b].sum(),
            "duration_sec": (ts[i] - ts[start]) / np.timedelta64(1, "s"),
        })
        cum = 0.0
        start = i + 1

    if not bars:
        return pd.DataFrame()

    out = pd.DataFrame(bars).set_index("ts")
    out["delta"] = out["buy_volume"] - out["sell_volume"]
    out["delta_ratio"] = out["delta"] / out["volume"].replace(0, np.nan)
    return out


def suggest_threshold(ticks: pd.DataFrame, kind: str,
                      target_bars_per_day: int = 288) -> float:
    """1日あたり目標本数になる閾値を推定する。

    288本 = 5分足相当。まずこの水準から始めると比較しやすい。
    """
    if ticks.empty:
        return 0.0
    acc = _accumulator(ticks, kind)
    days = max((ticks.index[-1] - ticks.index[0]).total_seconds() / 86400,
               1 / 24)
    return float(acc.sum() / days / target_bars_per_day)


if __name__ == "__main__":
    from datetime import date, timedelta

    from tick_parser import load_range

    SYMBOL = "BTCUSDT"
    end = date.today() - timedelta(days=1)
    ticks = load_range(SYMBOL, end - timedelta(days=6), end)

    if ticks.empty:
        raise SystemExit("先に public_data.py と tick_parser.py を実行してください")

    print(f"{len(ticks):,}ティック  {ticks.index[0]} 〜 {ticks.index[-1]}\n")

    for kind in ("tick", "volume", "dollar"):
        th = suggest_threshold(ticks, kind, target_bars_per_day=288)
        spec = BarSpec(kind=kind, threshold=th)
        bars = make_bars(ticks, spec)

        if bars.empty:
            print(f"{spec.describe()}: 生成できませんでした")
            continue

        days = (ticks.index[-1] - ticks.index[0]).total_seconds() / 86400
        print(f"{spec.describe()}")
        print(f"  本数        : {len(bars):,}(1日あたり {len(bars)/days:.0f})")
        print(f"  平均所要時間 : {bars['duration_sec'].mean():.0f}秒 "
              f"(最短 {bars['duration_sec'].min():.0f}秒 / "
              f"最長 {bars['duration_sec'].max():.0f}秒)")
        print(f"  平均ティック数: {bars['ticks'].mean():.0f}")
        print(f"  デルタの平均  : {bars['delta_ratio'].mean():+.3f}")
        print()

    print("※ ドルバーは所要時間のばらつきが大きい=")
    print("  『相場が動くときは細かく、静かなときは粗く』区切れている。")

時間足との統計的性質の比較

「ドルバーのほうが良い」を実データで確認します。正規性・自己相関・分散の安定性で比較します。

bar_stats.pyサンプル5
"""bar_stats.py — タイムバーと情報駆動バーの統計的性質を比較する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

比較する性質:
  1) 正規性     — Jarque-Bera統計量(小さいほど正規に近い)
  2) 系列相関   — Ljung-Box(小さいほど独立に近い)
  3) 分散の安定 — 期間を分割したときの分散のばらつき
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd
from scipy import stats


@dataclass
class BarQuality:
    label: str
    n_bars: int
    jarque_bera: float
    jb_pvalue: float
    skew: float
    kurtosis: float
    acf1: float
    ljung_box_p: float
    var_stability: float      # 分散のばらつき(小さいほど安定)

    def line(self) -> str:
        return (f"{self.label:<22}{self.n_bars:>7}"
                f"{self.jarque_bera:>12.1f}{self.skew:>+9.3f}"
                f"{self.kurtosis:>9.2f}{self.acf1:>+9.3f}"
                f"{self.ljung_box_p:>9.3f}{self.var_stability:>10.3f}")


def ljung_box(x: np.ndarray, lags: int = 10) -> float:
    """Ljung-Box検定のp値(自作・statsmodels不要)。

    p値が大きいほど「系列相関がない」という帰無仮説が棄却されない。
    """
    n = len(x)
    if n < lags + 5:
        return float("nan")
    x = x - x.mean()
    denom = (x ** 2).sum()
    if denom == 0:
        return float("nan")

    q = 0.0
    for k in range(1, lags + 1):
        r = (x[k:] * x[:-k]).sum() / denom
        q += r ** 2 / (n - k)
    q *= n * (n + 2)
    return float(1 - stats.chi2.cdf(q, df=lags))


def variance_stability(x: np.ndarray, blocks: int = 10) -> float:
    """期間をブロックに分けたときの分散の変動係数。

    小さいほど「どの期間でも分散が同じ」=安定している。
    """
    if len(x) < blocks * 10:
        return float("nan")
    chunks = np.array_split(x, blocks)
    variances = np.array([c.var(ddof=1) for c in chunks if len(c) > 1])
    if variances.size < 2 or variances.mean() == 0:
        return float("nan")
    return float(variances.std(ddof=1) / variances.mean())


def assess(returns: np.ndarray, label: str) -> BarQuality:
    r = returns[np.isfinite(returns)]
    if len(r) < 30:
        raise ValueError(f"{label}: サンプル不足 ({len(r)})")

    jb, jb_p = stats.jarque_bera(r)
    acf1 = float(np.corrcoef(r[:-1], r[1:])[0, 1]) if len(r) > 2 else 0.0

    return BarQuality(
        label=label, n_bars=len(r),
        jarque_bera=float(jb), jb_pvalue=float(jb_p),
        skew=float(stats.skew(r)),
        kurtosis=float(stats.kurtosis(r, fisher=False)),
        acf1=acf1,
        ljung_box_p=ljung_box(r),
        var_stability=variance_stability(r),
    )


def bar_returns(bars: pd.DataFrame) -> np.ndarray:
    return np.log(bars["close"] / bars["close"].shift(1)).dropna().to_numpy()


def time_bars_from_ticks(ticks: pd.DataFrame, freq: str) -> pd.DataFrame:
    """ティックからタイムバーを作る(比較の基準)。"""
    agg = ticks.resample(freq).agg(
        open=("price", "first"), high=("price", "max"),
        low=("price", "min"), close=("price", "last"),
        volume=("size", "sum"),
    ).dropna(subset=["open"])
    return agg


if __name__ == "__main__":
    from datetime import date, timedelta

    from information_bars import BarSpec, make_bars, suggest_threshold
    from tick_parser import load_range

    SYMBOL = "BTCUSDT"
    end = date.today() - timedelta(days=1)
    ticks = load_range(SYMBOL, end - timedelta(days=13), end)

    if ticks.empty:
        raise SystemExit("先にデータを取得してください")

    print(f"{len(ticks):,}ティック / "
          f"{(ticks.index[-1] - ticks.index[0]).days}日分\n")

    results: list[BarQuality] = []

    # タイムバー(比較の基準)
    for freq, label in (("5min", "タイムバー(5分)"),
                        ("15min", "タイムバー(15分)"),
                        ("1h", "タイムバー(1時間)")):
        tb = time_bars_from_ticks(ticks, freq)
        if len(tb) > 40:
            results.append(assess(bar_returns(tb), label))

    # 情報駆動バー(本数を揃えて公平に比較する)
    for kind in ("tick", "volume", "dollar"):
        th = suggest_threshold(ticks, kind, target_bars_per_day=288)
        bars = make_bars(ticks, BarSpec(kind, th))
        if len(bars) > 40:
            results.append(assess(bar_returns(bars), f"{kind}バー"))

    print(f"{'バー種別':<22}{'本数':>7}{'JB統計量':>12}{'歪度':>9}"
          f"{'尖度':>9}{'ACF(1)':>9}{'LB p値':>9}{'分散安定':>10}")
    print("-" * 88)
    for r in results:
        print(r.line())

    print("\n--- 読み方 ---")
    print("  JB統計量が小さい  = 正規分布に近い(統計手法が使いやすい)")
    print("  尖度が3に近い     = 極端な値が少ない")
    print("  ACF(1)が0に近い   = 系列相関が小さい(独立に近い)")
    print("  LB p値が大きい    = 系列相関の帰無仮説が棄却されない")
    print("  分散安定が小さい  = どの期間でもばらつきが同程度")
    print()
    print("  一般にドルバーはタイムバーより正規性が高く分散も安定する。")
    print("  ただし『必ずそうなる』わけではないので、自分のデータで確認すること。")

売買方向の推定|Lee-Readyルール

Bybitの公開データには side(テイカーの方向)が含まれますが、方向が記録されていないデータでは推定が必要です。Lee & Ready (1991) のティックルールが標準的な手法です。

trade_direction.pyサンプル6
"""trade_direction.py — 売買方向の推定と、記録された方向との照合
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Lee, C. & Ready, M. (1991)
      "Inferring Trade Direction from Intraday Data"

Bybitのデータには side が含まれるため推定は不要だが、
  - 推定の精度を検証できる(貴重な検証機会)
  - 方向が無い他のデータソースに応用できる
という価値がある。
"""
from __future__ import annotations

import numpy as np
import pandas as pd


def tick_rule(prices: pd.Series) -> pd.Series:
    """ティックルール。前回より高ければ買い、安ければ売り。

    同値の場合は直前の方向を引き継ぐ(zero tick)。
    """
    diff = prices.diff()
    sign = np.sign(diff)
    # 0(同値)は直前の符号を引き継ぐ
    sign = sign.replace(0, np.nan).ffill().fillna(1)
    return sign.astype("int8")


def quote_rule(prices: pd.Series, bid: pd.Series,
               ask: pd.Series) -> pd.Series:
    """クオートルール。ミッドより上なら買い、下なら売り。

    板のスナップショットが必要(記事09・28で取得できる)。
    """
    mid = (bid + ask) / 2
    sign = pd.Series(0, index=prices.index, dtype="int8")
    sign[prices > mid] = 1
    sign[prices < mid] = -1
    return sign


def lee_ready(prices: pd.Series, bid: pd.Series | None = None,
              ask: pd.Series | None = None) -> pd.Series:
    """Lee-Readyアルゴリズム。

    クオートルールを優先し、ミッド価格ちょうどの場合のみ
    ティックルールで補う。板が無ければティックルールのみ。
    """
    tick = tick_rule(prices)
    if bid is None or ask is None:
        return tick

    quote = quote_rule(prices, bid, ask)
    # クオートルールで判定できない(ミッドちょうど)部分をティックルールで埋める
    result = quote.copy()
    result[quote == 0] = tick[quote == 0]
    return result


def evaluate_accuracy(ticks: pd.DataFrame,
                      estimated: pd.Series) -> dict:
    """推定した方向と、記録された side の一致率を測る。"""
    if "side" not in ticks.columns:
        return {"error": "side列がありません"}

    actual = ticks["side"].astype(str).map({"Buy": 1, "Sell": -1})
    valid = actual.notna() & estimated.notna()
    if valid.sum() == 0:
        return {"error": "比較できるデータがありません"}

    a = actual[valid]
    e = estimated[valid]
    match = (a == e)

    # 出来高加重の一致率(大口の判定精度のほうが重要)
    if "size" in ticks.columns:
        w = ticks.loc[valid, "size"]
        weighted = float((match * w).sum() / w.sum())
    else:
        weighted = float(match.mean())

    return {
        "サンプル数": int(valid.sum()),
        "一致率": f"{match.mean():.2%}",
        "出来高加重一致率": f"{weighted:.2%}",
        "買いの再現率": f"{match[a == 1].mean():.2%}" if (a == 1).any() else "-",
        "売りの再現率": f"{match[a == -1].mean():.2%}" if (a == -1).any() else "-",
    }


def volume_imbalance(ticks: pd.DataFrame,
                     direction: pd.Series | None = None,
                     window: str = "5min") -> pd.DataFrame:
    """符号付き出来高(買い − 売り)の時系列。需給の偏りを見る。"""
    if direction is None:
        if "side" in ticks.columns:
            direction = ticks["side"].astype(str).map(
                {"Buy": 1, "Sell": -1}).fillna(0)
        else:
            direction = tick_rule(ticks["price"])

    signed = ticks["size"] * direction
    out = pd.DataFrame({
        "buy_volume": ticks["size"].where(direction > 0, 0),
        "sell_volume": ticks["size"].where(direction < 0, 0),
        "signed_volume": signed,
        "notional": ticks["price"] * ticks["size"],
    })
    agg = out.resample(window).sum()
    agg["imbalance"] = (agg["signed_volume"]
                        / (agg["buy_volume"] + agg["sell_volume"])
                        .replace(0, np.nan))
    return agg


if __name__ == "__main__":
    from datetime import date, timedelta

    from tick_parser import load_range

    SYMBOL = "BTCUSDT"
    end = date.today() - timedelta(days=1)
    ticks = load_range(SYMBOL, end - timedelta(days=1), end)

    if ticks.empty:
        raise SystemExit("先にデータを取得してください")

    print(f"{len(ticks):,}ティックで検証\n")

    print("=== ティックルールの精度(記録されたsideと比較) ===")
    est = tick_rule(ticks["price"])
    for k, v in evaluate_accuracy(ticks, est).items():
        print(f"  {k:<18}: {v}")

    print("\n※ ティックルールの一致率は通常70〜85%程度。")
    print("  板情報を併用するLee-Readyのほうが精度は上がるが、")
    print("  Bybitのデータには side があるので推定は不要。")
    print("  この検証は『他のデータソースで推定する場合の精度の目安』になる。")

    print("\n=== 需給の偏り(5分ごと) ===")
    imb = volume_imbalance(ticks, window="5min")
    print(imb.tail(8)[["buy_volume", "sell_volume", "imbalance"]]
          .to_string(float_format=lambda v: f"{v:,.3f}"))
    print(f"\n  期間全体の偏り: {imb['imbalance'].mean():+.3f}")
    print("  (プラスなら買いが攻めている。記事09のOFIと合わせて使う)")

大容量データの扱い|DuckDBとPolars

pandasで全期間を読むとメモリが足りない

BTCUSDTの5年分は数十億ティックになり、pandasで一度に読むのは不可能です。DuckDB(SQLで直接Parquetを読む)かPolars(遅延評価)を使ってください。

bigdata_query.pyサンプル7
"""bigdata_query.py — 数十GBのティックデータをメモリに載せずに集計する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pip install duckdb polars
"""
from __future__ import annotations

from pathlib import Path

PARQUET_GLOB = "data/public/parquet/{symbol}/*.parquet"


# ---- DuckDB: SQLで直接Parquetを読む ------------------------------------
def duckdb_daily_stats(symbol: str = "BTCUSDT") -> None:
    import duckdb

    src = PARQUET_GLOB.format(symbol=symbol)
    q = f"""
    SELECT
        date_trunc('day', ts)                       AS day,
        count(*)                                    AS ticks,
        sum(size)                                   AS volume,
        sum(price * size)                           AS notional,
        min(price)                                  AS low,
        max(price)                                  AS high,
        arg_min(price, ts)                          AS open,
        arg_max(price, ts)                          AS close,
        sum(CASE WHEN side = 'Buy'  THEN size ELSE 0 END) AS buy_volume,
        sum(CASE WHEN side = 'Sell' THEN size ELSE 0 END) AS sell_volume
    FROM read_parquet('{src}')
    GROUP BY day
    ORDER BY day DESC
    LIMIT 15
    """
    print("=== 日次サマリ(DuckDB) ===")
    print(duckdb.sql(q))


def duckdb_hourly_liquidity(symbol: str = "BTCUSDT") -> None:
    """時間帯ごとの取引活発度。執行のタイミング設計に使う(記事19)。"""
    import duckdb

    src = PARQUET_GLOB.format(symbol=symbol)
    q = f"""
    WITH t AS (
        SELECT
            extract('hour' FROM ts)  AS hour_utc,
            size, price * size       AS notional
        FROM read_parquet('{src}')
    )
    SELECT
        hour_utc,
        (hour_utc + 9) % 24          AS hour_jst,
        count(*)                     AS ticks,
        round(sum(notional) / 1e9, 2) AS notional_bn,
        round(avg(notional), 1)      AS avg_trade_usd
    FROM t
    GROUP BY hour_utc
    ORDER BY hour_utc
    """
    print("\n=== 時間帯別の活発度(DuckDB) ===")
    print(duckdb.sql(q))


def duckdb_large_trades(symbol: str = "BTCUSDT",
                        min_notional: float = 1_000_000) -> None:
    """大口約定の抽出。マーケットインパクトの分析に使う(記事09)。"""
    import duckdb

    src = PARQUET_GLOB.format(symbol=symbol)
    q = f"""
    SELECT ts, side, price, size, round(price * size) AS notional
    FROM read_parquet('{src}')
    WHERE price * size >= {min_notional}
    ORDER BY notional DESC
    LIMIT 15
    """
    print(f"\n=== {min_notional:,.0f} USDT以上の大口約定 ===")
    print(duckdb.sql(q))


# ---- Polars: 遅延評価でストリーム処理 ----------------------------------
def polars_dollar_bars(symbol: str = "BTCUSDT",
                       threshold: float = 5_000_000) -> None:
    """Polarsの遅延評価でドルバーを作る(メモリに全部載せない)。"""
    import polars as pl

    src = PARQUET_GLOB.format(symbol=symbol)

    lf = (
        pl.scan_parquet(src)
        .select(["ts", "price", "size", "side"])
        .with_columns(
            (pl.col("price") * pl.col("size")).alias("notional"))
        .with_columns(
            # 累積売買代金を閾値で割って、バー番号を振る
            (pl.col("notional").cum_sum() / threshold)
            .floor().cast(pl.Int64).alias("bar_id"))
    )

    bars = (
        lf.group_by("bar_id")
        .agg([
            pl.col("ts").first().alias("ts_open"),
            pl.col("ts").last().alias("ts_close"),
            pl.col("price").first().alias("open"),
            pl.col("price").max().alias("high"),
            pl.col("price").min().alias("low"),
            pl.col("price").last().alias("close"),
            pl.col("size").sum().alias("volume"),
            pl.col("notional").sum().alias("dollar"),
            pl.len().alias("ticks"),
            pl.col("size").filter(pl.col("side") == "Buy")
              .sum().alias("buy_volume"),
        ])
        .sort("bar_id")
        .collect(streaming=True)      # メモリに載せずに処理
    )

    print(f"\n=== ドルバー(Polars・{threshold:,.0f} USDTごと) ===")
    print(f"  {len(bars):,}本を生成")
    print(bars.tail(5))


if __name__ == "__main__":
    SYMBOL = "BTCUSDT"
    src_dir = Path(f"data/public/parquet/{SYMBOL}")

    if not src_dir.exists() or not any(src_dir.glob("*.parquet")):
        raise SystemExit("先に public_data.py と tick_parser.py を実行してください")

    files = list(src_dir.glob("*.parquet"))
    total_mb = sum(f.stat().st_size for f in files) / 1024 / 1024
    print(f"対象: {len(files)}ファイル / {total_mb:,.0f} MB\n")

    duckdb_daily_stats(SYMBOL)
    duckdb_hourly_liquidity(SYMBOL)
    duckdb_large_trades(SYMBOL)

    try:
        polars_dollar_bars(SYMBOL)
    except ImportError:
        print("\n(polarsが未インストールのためスキップ)")

    print("\n※ DuckDBもPolarsも、Parquetを直接読むためメモリに全部載せない。")
    print("  数十GBでもノートPCで集計できる。")
verify_data.pyサンプル8
"""verify_data.py — 取得したティックデータの品質を検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

検証:
  1) 日付の欠損(取り逃したファイルがないか)
  2) ティックからOHLCVを再構成し、APIのklineと一致するか
  3) 時刻の単調性・重複
"""
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path

import numpy as np
import pandas as pd

PARQUET_DIR = Path("data/public/parquet")


@dataclass
class QualityReport:
    symbol: str
    files: int = 0
    missing_days: list[str] = field(default_factory=list)
    duplicated_ts: int = 0
    non_monotonic: int = 0
    ohlcv_diff: dict = field(default_factory=dict)
    notes: list[str] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return not self.missing_days and self.non_monotonic == 0

    def report(self) -> str:
        lines = [f"{self.symbol}: {self.files}ファイル"]
        if self.missing_days:
            lines.append(f"  [!] 欠損日 {len(self.missing_days)}日: "
                         f"{self.missing_days[:5]}"
                         + (" …" if len(self.missing_days) > 5 else ""))
        else:
            lines.append("  日付の欠損: なし")
        lines.append(f"  重複タイムスタンプ: {self.duplicated_ts:,}"
                     f"(同一ミリ秒の約定は正常)")
        lines.append(f"  時刻の逆転: {self.non_monotonic}")
        if self.ohlcv_diff:
            lines.append("  APIのklineとの差:")
            for k, v in self.ohlcv_diff.items():
                lines.append(f"    {k:<8} {v}")
        for n in self.notes:
            lines.append(f"  ! {n}")
        return "\n".join(lines)


def check_missing_days(symbol: str, start: date, end: date,
                       parquet_dir: Path = PARQUET_DIR) -> list[str]:
    have = set()
    for p in (parquet_dir / symbol).glob("*.parquet"):
        try:
            have.add(date.fromisoformat(p.stem.split("_")[-1]))
        except ValueError:
            continue

    missing = []
    cur = start
    while cur <= end:
        if cur not in have:
            missing.append(str(cur))
        cur += timedelta(days=1)
    return missing


def rebuild_ohlcv(ticks: pd.DataFrame, freq: str = "1h") -> pd.DataFrame:
    """ティックからOHLCVを再構成する(APIのklineと照合するため)。"""
    return ticks.resample(freq).agg(
        open=("price", "first"), high=("price", "max"),
        low=("price", "min"), close=("price", "last"),
        volume=("size", "sum"),
    ).dropna(subset=["open"])


def compare_with_kline(rebuilt: pd.DataFrame,
                       kline: pd.DataFrame) -> dict:
    """再構成した足と、APIのklineを比較する。"""
    common = rebuilt.index.intersection(kline.index)
    if len(common) < 3:
        return {"error": "比較できる期間がありません"}

    a = rebuilt.loc[common]
    b = kline.loc[common]

    out = {}
    for col in ("open", "high", "low", "close", "volume"):
        if col not in a.columns or col not in b.columns:
            continue
        diff = (a[col] - b[col]).abs()
        rel = (diff / b[col].replace(0, np.nan)).dropna()
        out[col] = (f"最大 {diff.max():.6f} / "
                    f"相対 {rel.max():.4%} / 一致 {(rel < 1e-6).mean():.1%}")
    out["比較本数"] = len(common)
    return out


def verify(symbol: str, days: int = 7,
           http=None) -> QualityReport:
    from tick_parser import load_range

    end = date.today() - timedelta(days=1)
    start = end - timedelta(days=days - 1)

    rep = QualityReport(symbol=symbol)
    rep.files = len(list((PARQUET_DIR / symbol).glob("*.parquet")))
    rep.missing_days = check_missing_days(symbol, start, end)

    ticks = load_range(symbol, start, end)
    if ticks.empty:
        rep.notes.append("データが読み込めません")
        return rep

    rep.duplicated_ts = int(ticks.index.duplicated().sum())
    rep.non_monotonic = int((ticks.index.to_series().diff()
                             < pd.Timedelta(0)).sum())

    if http is not None:
        from kline_fetch import fetch_klines

        kl = fetch_klines(http, symbol, interval="60",
                          start=start.isoformat(), verbose=False)
        if not kl.empty:
            rebuilt = rebuild_ohlcv(ticks, "1h")
            rep.ohlcv_diff = compare_with_kline(rebuilt, kl)

    return rep


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

    c = make_client()
    rep = verify("BTCUSDT", days=7, http=c.http)
    print(rep.report())

    if rep.ok:
        print("\nデータ品質は良好です。バックテストに使用できます。")
    else:
        print("\n[!] 問題があります。欠損日を再ダウンロードしてください。")

    print("\n※ ティックから再構成したOHLCVがAPIのklineとほぼ一致すれば、")
    print("  データの正確性が確認できる。乖離が大きい場合は、")
    print("  タイムゾーンや足の境界(記事07)の扱いを確認すること。")
参考文献・データソース
  • 公開ヒストリカルデータ: public.bybit.com/trading/
  • Get Public Trading History(直近の補完): /v5/market/recent-trade
  • WebSocket publicTrade(リアルタイム): /websocket/public/trade
  • López de Prado, M. (2018) Advances in Financial Machine Learning Ch.2(Financial Data Structures / Information-Driven Bars)
  • Lee, C. & Ready, M. (1991) Inferring Trade Direction from Intraday Data, Journal of Finance
  • Easley, D., López de Prado, M. & O'Hara, M. (2012) Flow Toxicity and Liquidity in a High-Frequency World — ボリュームバーを前提としたVPIN

よくある疑問Q&A

公開データは本当に無料で、制限もありませんか?
APIキー不要のHTTPダウンロードで、APIのレート制限(記事05)の対象外です。ただし相手はCDNなので、過度な並列ダウンロードは避けてください。サンプル1では並列4に抑えています。
またダウンロードは1回だけにしてParquetに変換・保存すれば、以後は再取得が不要です。何度も取り直すのは帯域の無駄です。
どれくらいの期間を取得すべきですか?
用途次第です。マーケットメイクのパラメータ推定(記事37)なら数週間〜数か月、Kyleのλの安定推定(記事09)なら数か月、戦略のバックテストなら1年以上が目安です。
ただしティックレベルのバックテストは計算量が膨大です。まず1時間足(記事07)で検証し、有望なものだけティックで精査する二段構えが現実的です。全期間を先に取得する必要はありません。
ドルバーを使えば成績が上がりますか?
直接は上がりません。バーの種類は「データの表現方法」であって、エッジそのものではありません。
効果があるのは統計手法や機械学習を使う場合です。多くの手法はリターンの正規性と独立性を前提にしており、タイムバーはその前提を満たしません(サンプル5で確認できます)。ドルバーのほうが前提に近いため、手法が本来の性能を出しやすくなるという間接的な効果です。単純なテクニカル戦略では差が出にくいでしょう。
ティックデータでバックテストすれば正確になりますか?
「同じ足で高値と安値のどちらが先に付いたか」という曖昧さは解消されるので、TP/SLを併用する戦略の検証精度は明確に上がります(記事31の課題)。
ただし板の情報がないため、指値の約定判定は依然として推定です。自分の注文が板のどこに並んだかは分かりません。約定確率のモデル化(記事31の maker_fill_prob)は引き続き必要です。ティックデータは「万能の正確さ」を与えるものではありません。
列名が記事と違うファイルがあります
銘柄や時期によって列構成が変わることがあります。サンプル2の peek_header() と build_rename_map() は実際のヘッダを読んで対応する実装なので、多少の違いは吸収できます。
それでも timestamp 列が見つからない場合は例外を投げるようにしてあります。未マップの列が出たら COLUMN_ALIASES に追加してください。列名を決め打ちしないのがこの種のデータを扱う基本です。
同じタイムスタンプのティックが大量にあります
正常です。1つの成行注文が複数の指値を食うと、同一ミリ秒に複数の約定が記録されます。これは「大口が一度に入った」ことを示す重要な情報です。
重複を削除してはいけません。むしろ同一タイムスタンプの約定をまとめると「1回の注文の規模」が分かります。マーケットインパクトの分析(記事09のKyleのλ)ではこの単位が意味を持ちます。
現物のティックデータも取得できますか?
取得できます。public.bybit.com/trading/ には現物ペアも含まれています。ただし無期限先物と現物では出来高の桁が違うため、同じ閾値でバーを作ると本数が大きく変わります。サンプル4の suggest_threshold() で銘柄ごとに閾値を推定してください。
現物と無期限の両方を取得すれば、ベーシスの高頻度な推移が分析でき、資金調達率アービトラージ(記事35)の執行タイミング設計に使えます。

注意事項・免責事項

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

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

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

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

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

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

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