Bybit APIでローソク足(OHLCV)を全期間取得し
Parquetに保存するPython実装
1000本上限のページネーションと差分更新

バックテストも指標計算も、手元にデータが無ければ何も始まりません。Bybitの get_kline は1回1000本が上限で、しかも新しい順に返るため、素朴に書くと歴史を取り切れません。この記事では全期間を確実に取得するページネーション、未確定足とUTC日足境界という2つの罠、欠損検出、Parquetでの差分更新、DuckDBによる全銘柄横断集計までをサンプルコード9本で実装します。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
pybit
5.17.0

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

目次

  1. まず動かす|1000本取得してDataFrame化
  2. get_klineの仕様と4種類のローソク足
  3. 全期間を取り切る|逆順ページネーション
  4. 罠その1|最新の足は未確定
  5. 罠その2|日足の境界はUTC 00:00(JST 09:00)
  6. 欠損足の検出と扱い
  7. Parquetストアと差分更新(サンプル5)
  8. DuckDBで全銘柄を横断集計する
  9. 複数銘柄を並列取得する
  10. リサンプリングの正しい集約規則
  11. mark / index / premium ローソク足の使い分け
  12. データ品質のチェックリスト
  13. よくある疑問Q&A

この記事は記事01(pybit入門)の client.py を使いますが、ローソク足の取得はAPIキー不要なので HTTP(testnet=False) だけでも全サンプルが動きます。

まず動かす|1000本取得してDataFrame化

kline_min.pyサンプル1
# 本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
import pandas as pd
from pybit.unified_trading import HTTP

http = HTTP(testnet=False)          # 公開エンドポイントなのでキー不要

res = http.get_kline(
    category="linear",
    symbol="BTCUSDT",
    interval="60",                  # 1,3,5,15,30,60,120,240,360,720,D,W,M
    limit=1000,                     # 1〜1000(既定200)
)

# V5のklineは [startTime, open, high, low, close, volume, turnover] の文字列配列
df = pd.DataFrame(
    res["result"]["list"],
    columns=["ts", "open", "high", "low", "close", "volume", "turnover"],
)
df = df.astype(float)
df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
df = df.set_index("ts").sort_index()   # 返り値は「新しい順」なので並べ替える

print(df.tail())
print(f"\n{len(df)}本  {df.index[0]} 〜 {df.index[-1]}")
返り値は「新しい順」

V5のklineは startTimeの降順(新しい足が先頭)で返ってきます。これに気づかず時系列処理に流すと、移動平均が未来の値を含む(=ルックアヘッド)状態になり、バックテストが異常に良い成績を出します。取得直後に必ず sort_index() するか、reversed() で反転してください。この1行の有無で検証結果が根本的に変わります。

get_klineの仕様と4種類のローソク足

パラメータ必須内容
category—spot / linear / inverse(既定 linear)。オプションは非対応
symbol必須大文字のみ
interval必須1 / 3 / 5 / 15 / 30 / 60 / 120 / 240 / 360 / 720 / D / W / M
分は数値、日足以上は英字1文字
start / end—ミリ秒のUNIX時刻
limit—1〜1000(既定200)

Bybitには用途の違う4種類のローソク足があります。フィールド数が違う点に注意してください。

メソッド価格の種類フィールド使う場面
get_kline最終取引価格7個
(OHLCV+turnover)
通常の戦略・バックテスト
get_mark_price_klineマーク価格5個
(出来高なし)
清算判定の再現。損益評価もマーク価格基準
get_index_price_klineインデックス価格5個現物指数。ベーシス計算の基準
get_premium_index_price_klineプレミアム指数5個資金調達率の予測(記事10)
清算をシミュレートするならマーク価格

Bybitの強制決済はマーク価格で判定されます。最終取引価格(get_kline)の安値でストップに掛かったように見えても、マーク価格では掛かっていない(あるいは逆)ということが起こります。バックテストで清算を再現する場合(記事31)は、マーク価格のローソク足も併せて取得してください。

全期間を取り切る|逆順ページネーション

1回1000本の上限があるため、1分足で数年分(数百万本)を取るには何百回もリクエストを繰り返します。ここで重要なのは「新しい方から古い方へ遡る」方向で繰ることです。

end パラメータを指定すると、その時刻以前の直近1000本が返ります。取得した中の最古の足の時刻 − 1msを次の end にすれば、隙間なく遡れます。

kline_fetch.pyサンプル2
"""kline_fetch.py — 全期間のローソク足を取り切る(逆順ページネーション)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time
from datetime import datetime, timezone

import pandas as pd
from pybit.unified_trading import HTTP

MAX_LIMIT = 1000

# 足の長さ(ミリ秒)。欠損検出とページ送りの安全確認に使う
INTERVAL_MS: dict[str, int] = {
    "1": 60_000, "3": 180_000, "5": 300_000, "15": 900_000, "30": 1_800_000,
    "60": 3_600_000, "120": 7_200_000, "240": 14_400_000, "360": 21_600_000,
    "720": 43_200_000, "D": 86_400_000, "W": 604_800_000,
    # "M" は月ごとに長さが変わるので辞書に入れない
}

KLINE_COLS = ["ts", "open", "high", "low", "close", "volume", "turnover"]


def to_ms(dt: str | datetime) -> int:
    if isinstance(dt, str):
        dt = datetime.fromisoformat(dt)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return int(dt.timestamp() * 1000)


def fetch_klines(
    http: HTTP,
    symbol: str,
    interval: str = "60",
    start: str | datetime | None = None,
    end: str | datetime | None = None,
    category: str = "linear",
    sleep: float = 0.12,
    verbose: bool = True,
) -> pd.DataFrame:
    """startからendまでのローソク足を全件取得してDataFrameで返す。

    startを省略すると、取引所が持っている最古の足まで遡る。
    """
    start_ms = to_ms(start) if start else 0
    cursor_end = to_ms(end) if end else int(time.time() * 1000)

    chunks: list[list[list[str]]] = []
    pages = 0
    prev_oldest: int | None = None

    while True:
        res = http.get_kline(
            category=category, symbol=symbol, interval=interval,
            end=cursor_end, limit=MAX_LIMIT,
        )
        if res["retCode"] != 0:
            raise RuntimeError(f"get_kline失敗: {res['retCode']} {res['retMsg']}")

        rows = res["result"]["list"]
        if not rows:
            break                       # これ以上古いデータが無い

        # 返り値は降順なので、最後の要素が最古
        oldest = int(rows[-1][0])

        # 無限ループ防止: カーソルが進まなくなったら打ち切る
        if prev_oldest is not None and oldest >= prev_oldest:
            if verbose:
                print(f"  [stop] カーソルが進みません(oldest={oldest})")
            break
        prev_oldest = oldest

        chunks.append(rows)
        pages += 1
        if verbose and pages % 10 == 0:
            got = sum(len(c) for c in chunks)
            print(f"  {pages}ページ / {got:,}本  最古 "
                  f"{datetime.fromtimestamp(oldest / 1000, timezone.utc):%Y-%m-%d %H:%M}")

        if oldest <= start_ms or len(rows) < MAX_LIMIT:
            break

        cursor_end = oldest - 1
        time.sleep(sleep)               # IP制限(5秒600リクエスト)に配慮

    if not chunks:
        return pd.DataFrame(columns=KLINE_COLS).set_index("ts")

    flat = [r for chunk in chunks for r in chunk]
    df = pd.DataFrame(flat, columns=KLINE_COLS).astype(float)
    df["ts"] = pd.to_datetime(df["ts"].astype("int64"), unit="ms", utc=True)

    df = (df.drop_duplicates(subset="ts")     # ページ境界の重複を除去
            .set_index("ts")
            .sort_index())                    # 時系列昇順に直す

    if start_ms:
        df = df[df.index >= pd.Timestamp(start_ms, unit="ms", tz="UTC")]

    if verbose:
        print(f"[完了] {symbol} {interval} : {len(df):,}本  "
              f"{df.index[0]} 〜 {df.index[-1]}")
    return df


if __name__ == "__main__":
    http = HTTP(testnet=False)
    df = fetch_klines(http, "BTCUSDT", interval="60", start="2024-01-01")
    print(df.head())
    print(df.describe())
無限ループ防止を必ず入れる

prev_oldest のチェックは省略しがちですが、これがないと稀に無限ループします。上場が新しい銘柄や、取引が途切れている銘柄で、同じデータが返り続けるケースがあります。ループを回すコードには「進捗していなければ抜ける」ガードを必ず入れてください。数百回のリクエストを回すコードで無限ループが起きると、IP制限に到達してbot全体が止まります。

罠その1|最新の足は未確定

取得した最後の足(最新の足)はまだ形成中です。1時間足を12:30に取得すれば、12:00の足は「12:00〜12:30までの途中経過」であり、終値・高値・安値・出来高は今後変わります。

この足を含めて指標を計算し、そのままシグナル判定すると同じ足で何度も違う判断をします。バックテストでは「その足の終値」を知った状態で取引したことになり、成績が実運用と乖離します。

drop_unconfirmed.pyサンプル3
"""drop_unconfirmed.py — 未確定足の判定と除外
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import time

import pandas as pd

from kline_fetch import INTERVAL_MS


def drop_unconfirmed(df: pd.DataFrame, interval: str,
                     now_ms: int | None = None) -> pd.DataFrame:
    """まだ閉じていない足を落とす。

    足が確定しているのは「足の開始時刻 + 足の長さ <= 現在時刻」のとき。
    """
    if df.empty:
        return df
    step = INTERVAL_MS.get(interval)
    if step is None:
        # 月足など長さが可変の場合は、単純に最後の1本を落とす
        return df.iloc[:-1]

    now = now_ms if now_ms is not None else int(time.time() * 1000)
    cutoff = pd.Timestamp(now - step, unit="ms", tz="UTC")
    return df[df.index <= cutoff]


def is_bar_closed(bar_start: pd.Timestamp, interval: str) -> bool:
    step = INTERVAL_MS.get(interval)
    if step is None:
        return False
    return bar_start.value // 1_000_000 + step <= int(time.time() * 1000)


if __name__ == "__main__":
    from pybit.unified_trading import HTTP

    from kline_fetch import fetch_klines

    http = HTTP(testnet=False)
    raw = fetch_klines(http, "BTCUSDT", interval="60", start="2026-07-01",
                       verbose=False)
    clean = drop_unconfirmed(raw, "60")

    print(f"取得       : {len(raw)}本  最終足 {raw.index[-1]}")
    print(f"確定足のみ : {len(clean)}本  最終足 {clean.index[-1]}")
    print(f"除外       : {len(raw) - len(clean)}本")

罠その2|日足の境界はUTC 00:00(JST 09:00)

日本のトレーダーが最も混乱するのがここです。Bybitの日足はUTC 00:00区切りです。つまり日本時間では朝9時から翌朝9時までが「1日」になります。

Bybitの日足UTCJST(日本時間)
7月30日の足7/30 00:00 〜 7/31 00:007/30 09:00 〜 7/31 09:00
週足の開始月曜 00:00月曜 09:00
「JSTの日足」を作りたいなら自分でリサンプリングする

国内株やFXの感覚で「日足の終値」を語ると、Bybitの日足とはズレます。日本時間0時区切りの日足が必要なら、1時間足を取得してJSTでリサンプリングしてください(サンプル8)。interval="D" をそのまま使うのは「UTC日足でよい」と判断した場合だけにしてください。

なお内部の計算はすべてUTCで統一し、表示の直前だけJSTに変換するのが事故の少ない設計です。タイムゾーン付きのdatetime(tz-aware)を徹底し、naiveなdatetimeを混ぜないでください。

timezone_util.py補助
"""timezone_util.py — UTC基準で保持し、表示のみJSTへ変換する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import pandas as pd

JST = "Asia/Tokyo"


def to_jst(df: pd.DataFrame) -> pd.DataFrame:
    """表示用。indexをJSTに変換したコピーを返す(計算には使わない)。"""
    out = df.copy()
    out.index = out.index.tz_convert(JST)
    return out


def assert_utc(df: pd.DataFrame) -> None:
    """データの取り違えを早期に検出するアサーション。"""
    if not isinstance(df.index, pd.DatetimeIndex):
        raise TypeError("indexがDatetimeIndexではありません")
    if df.index.tz is None:
        raise ValueError("naiveなDatetimeIndexです。UTCを明示してください")
    if str(df.index.tz) not in ("UTC", "utc"):
        raise ValueError(f"indexがUTCではありません: {df.index.tz}")
    if not df.index.is_monotonic_increasing:
        raise ValueError("indexが昇順ではありません(sort_index()を忘れています)")

欠損足の検出と扱い

ローソク足は取引がなかった時間帯には存在しません。流動性の低いアルトコインの1分足では頻繁に欠損が起きます。欠損を無視すると、20本移動平均が実際には30分以上前のデータを含んでいた、という事故になります。

gap_check.pyサンプル4
"""gap_check.py — 欠損足の検出とレポート
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import pandas as pd

from kline_fetch import INTERVAL_MS

PANDAS_FREQ = {
    "1": "1min", "3": "3min", "5": "5min", "15": "15min", "30": "30min",
    "60": "1h", "120": "2h", "240": "4h", "360": "6h", "720": "12h",
    "D": "1D", "W": "1W",
}


def find_gaps(df: pd.DataFrame, interval: str) -> pd.DataFrame:
    """欠損している足の開始時刻を列挙する。"""
    step = INTERVAL_MS.get(interval)
    if step is None or df.empty:
        return pd.DataFrame(columns=["missing_from", "missing_to", "bars"])

    freq = PANDAS_FREQ[interval]
    full = pd.date_range(df.index[0], df.index[-1], freq=freq, tz="UTC")
    missing = full.difference(df.index)
    if len(missing) == 0:
        return pd.DataFrame(columns=["missing_from", "missing_to", "bars"])

    # 連続する欠損を1区間にまとめる
    groups, start, prev = [], missing[0], missing[0]
    for t in missing[1:]:
        if (t - prev).total_seconds() * 1000 > step:
            groups.append((start, prev))
            start = t
        prev = t
    groups.append((start, prev))

    return pd.DataFrame(
        [{"missing_from": a, "missing_to": b,
          "bars": int((b - a).total_seconds() * 1000 / step) + 1}
         for a, b in groups]
    )


def fill_gaps(df: pd.DataFrame, interval: str, method: str = "ffill") -> pd.DataFrame:
    """欠損足を埋める。用途によって埋め方を変える。

    method="ffill" : 直前の終値で埋め、出来高は0にする(指標計算向け)
    method="none"  : 埋めない(バックテストは「取引が無かった」ままが正しい)
    """
    if method == "none" or df.empty:
        return df

    out = df.reindex(pd.date_range(df.index[0], df.index[-1],
                                   freq=PANDAS_FREQ[interval], tz="UTC"))
    # 価格は直前の終値で埋める
    out["close"] = out["close"].ffill()
    for col in ("open", "high", "low"):
        out[col] = out[col].fillna(out["close"])
    # 出来高は0(取引が無かったのだから0が正しい。ffillは誤り)
    for col in ("volume", "turnover"):
        if col in out.columns:
            out[col] = out[col].fillna(0.0)
    return out


if __name__ == "__main__":
    from pybit.unified_trading import HTTP

    from kline_fetch import fetch_klines

    http = HTTP(testnet=False)
    df = fetch_klines(http, "BTCUSDT", interval="1", start="2026-07-25",
                      verbose=False)

    gaps = find_gaps(df, "1")
    print(f"取得 {len(df):,}本 / 欠損区間 {len(gaps)}件")
    if not gaps.empty:
        print(gaps.head(10).to_string())
        print(f"欠損足の合計: {gaps['bars'].sum()}本 "
              f"({gaps['bars'].sum() / (len(df) + gaps['bars'].sum()):.2%})")
出来高をffillしてはいけない

欠損を埋めるとき、価格は直前値で埋めるのが自然ですが、出来高は0で埋めるのが正しいです。取引がなかったのだから出来高は0です。ここをffillすると、出来高フィルタや出来高加重の指標が全部おかしくなります。DataFrame.ffill() を全列に一括で当てるコードは、この点で間違っています。

Parquetストアと差分更新

数百回のリクエストで取ったデータを毎回取り直すのは無駄です。Parquetで保存し、前回の最終足以降だけを追加取得する仕組みを作ります。

kline_store.pyサンプル5
"""kline_store.py — ローソク足のParquetストア(差分更新つき)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pip install pyarrow
"""
from __future__ import annotations

from pathlib import Path

import pandas as pd
from pybit.unified_trading import HTTP

from drop_unconfirmed import drop_unconfirmed
from gap_check import find_gaps
from kline_fetch import fetch_klines


class KlineStore:
    """symbol / interval ごとに1ファイル。確定足のみを保存する。"""

    def __init__(self, root: str | Path = "data/kline", category: str = "linear"):
        self.root = Path(root)
        self.category = category
        self.root.mkdir(parents=True, exist_ok=True)

    def path(self, symbol: str, interval: str) -> Path:
        return self.root / f"{self.category}_{symbol}_{interval}.parquet"

    # ---- 読み込み ------------------------------------------------------
    def load(self, symbol: str, interval: str) -> pd.DataFrame:
        p = self.path(symbol, interval)
        if not p.exists():
            return pd.DataFrame()
        return pd.read_parquet(p)

    # ---- 更新 ----------------------------------------------------------
    def update(self, http: HTTP, symbol: str, interval: str = "60",
               start: str = "2020-01-01", verbose: bool = True) -> pd.DataFrame:
        """未取得の区間だけを取得して追記する。"""
        existing = self.load(symbol, interval)

        if existing.empty:
            fetch_from = start
            if verbose:
                print(f"[新規] {symbol} {interval} を {start} から取得します")
        else:
            # 最終足の「次の足」から取得すればよい
            last = existing.index[-1]
            fetch_from = last.isoformat()
            if verbose:
                print(f"[差分] {symbol} {interval} : 既存{len(existing):,}本 "
                      f"(最終 {last}) 以降を取得")

        fresh = fetch_klines(http, symbol, interval=interval, start=fetch_from,
                             category=self.category, verbose=verbose)
        fresh = drop_unconfirmed(fresh, interval)      # 未確定足は保存しない

        if fresh.empty:
            if verbose:
                print("  新しい確定足はありません")
            return existing

        merged = (pd.concat([existing, fresh])
                    if not existing.empty else fresh)
        merged = merged[~merged.index.duplicated(keep="last")].sort_index()

        # 原子的に書き換える(書き込み中の中断で壊さない)
        p = self.path(symbol, interval)
        tmp = p.with_suffix(".parquet.tmp")
        merged.to_parquet(tmp, compression="zstd")
        tmp.replace(p)

        if verbose:
            added = len(merged) - len(existing)
            size_mb = p.stat().st_size / 1024 / 1024
            print(f"  +{added:,}本 -> 計{len(merged):,}本  ({size_mb:.1f} MB)")
        return merged

    # ---- 検査 ----------------------------------------------------------
    def inspect(self, symbol: str, interval: str) -> None:
        df = self.load(symbol, interval)
        if df.empty:
            print(f"{symbol} {interval}: データなし")
            return
        gaps = find_gaps(df, interval)
        print(f"{symbol} {interval}: {len(df):,}本  "
              f"{df.index[0]:%Y-%m-%d} 〜 {df.index[-1]:%Y-%m-%d}  "
              f"欠損区間{len(gaps)}件")


if __name__ == "__main__":
    http = HTTP(testnet=False)
    store = KlineStore()

    for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT"):
        store.update(http, sym, interval="60", start="2023-01-01")
        store.inspect(sym, "60")
一時ファイル経由で書き換える

tmp.replace(p) のパターンは重要です。Parquetを直接上書きしている途中でプロセスが落ちると、ファイルが壊れて過去データを全部失います。数時間かけて取得したデータであれば致命的です。一時ファイルに書いてからアトミックにrenameすれば、失敗しても元のファイルは無傷です。

DuckDBで全銘柄を横断集計する

Parquetファイルが数十〜数百になると、pandasで全部読むのは非効率です。DuckDBならファイル群にSQLを直接投げられます(読み込み不要・列単位で読むため高速)。

query_duckdb.pyサンプル6
"""query_duckdb.py — Parquet群にSQLを直接投げて全銘柄を横断集計する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  pip install duckdb
"""
from __future__ import annotations

import duckdb

# ファイル名から銘柄名を取り出しつつ、全Parquetを1つのテーブルとして扱う
BASE = """
    SELECT
        regexp_extract(filename, '_([A-Z0-9]+)_', 1) AS symbol,
        ts, open, high, low, close, volume, turnover
    FROM read_parquet('data/kline/linear_*_60.parquet', filename = true)
"""


def monthly_stats() -> None:
    """銘柄×月ごとのボラティリティと出来高を集計する。"""
    q = f"""
    WITH k AS ({BASE}),
    r AS (
        SELECT symbol, ts, turnover,
               ln(close / lag(close) OVER (PARTITION BY symbol ORDER BY ts)) AS ret
        FROM k
    )
    SELECT
        symbol,
        date_trunc('month', ts)                       AS month,
        count(*)                                      AS bars,
        round(stddev_samp(ret) * sqrt(24 * 365) * 100, 1) AS ann_vol_pct,
        round(sum(turnover) / 1e9, 2)                 AS turnover_bn
    FROM r
    WHERE ret IS NOT NULL
    GROUP BY symbol, month
    ORDER BY month DESC, turnover_bn DESC
    LIMIT 20
    """
    print(duckdb.sql(q))


def correlation_matrix() -> None:
    """銘柄間の日次リターン相関(ペアトレードの候補探索・記事34の前段)。"""
    q = f"""
    WITH k AS ({BASE}),
    d AS (
        SELECT symbol, date_trunc('day', ts) AS d, last(close ORDER BY ts) AS close
        FROM k GROUP BY symbol, d
    ),
    r AS (
        SELECT symbol, d,
               ln(close / lag(close) OVER (PARTITION BY symbol ORDER BY d)) AS ret
        FROM d
    )
    SELECT a.symbol AS sym_a, b.symbol AS sym_b,
           round(corr(a.ret, b.ret), 3) AS correlation,
           count(*) AS n
    FROM r a JOIN r b ON a.d = b.d AND a.symbol < b.symbol
    WHERE a.ret IS NOT NULL AND b.ret IS NOT NULL
    GROUP BY sym_a, sym_b
    HAVING count(*) >= 60
    ORDER BY correlation DESC
    LIMIT 15
    """
    print(duckdb.sql(q))


if __name__ == "__main__":
    print("=== 銘柄×月の年率ボラと売買代金 ===")
    monthly_stats()
    print("\n=== 日次リターン相関の高いペア ===")
    correlation_matrix()

複数銘柄を並列取得する

数百銘柄の履歴を取るなら並列化が必要ですが、IP制限(5秒あたり600リクエスト、超過でHTTP 403+約10分の制限)があります。並列数を上げすぎると、全部止まって最悪です。

bulk_fetch.pyサンプル7
"""bulk_fetch.py — 複数銘柄を安全に並列取得する(IP制限に配慮)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

from pybit.unified_trading import HTTP

from kline_store import KlineStore

# IP制限は5秒600リクエスト。安全側に倒して秒間30リクエストを上限にする。
# 履歴取得は急ぐ処理ではないので、余裕を持たせるほうが確実。
MAX_RPS = 30
WORKERS = 4


class RateGate:
    """秒間リクエスト数を制限する簡易ゲート(詳細な実装は記事05)。"""

    def __init__(self, rps: int):
        self.min_interval = 1.0 / rps
        self._lock = threading.Lock()
        self._next_at = 0.0

    def wait(self) -> None:
        with self._lock:
            now = time.monotonic()
            if now < self._next_at:
                time.sleep(self._next_at - now)
                now = time.monotonic()
            self._next_at = now + self.min_interval


gate = RateGate(MAX_RPS)


class GatedHTTP:
    """HTTPをラップして、全リクエストをゲート経由にする。"""

    def __init__(self, http: HTTP):
        self._http = http

    def __getattr__(self, name):
        attr = getattr(self._http, name)
        if not callable(attr):
            return attr

        def wrapped(*args, **kwargs):
            gate.wait()
            return attr(*args, **kwargs)

        return wrapped


def liquid_symbols(http: HTTP, min_turnover: float = 20_000_000) -> list[str]:
    rows = http.get_tickers(category="linear")["result"]["list"]
    rows = [r for r in rows if r["symbol"].endswith("USDT")
            and float(r["turnover24h"]) >= min_turnover]
    rows.sort(key=lambda r: float(r["turnover24h"]), reverse=True)
    return [r["symbol"] for r in rows]


def main() -> None:
    raw_http = HTTP(testnet=False)
    http = GatedHTTP(raw_http)
    store = KlineStore()

    symbols = liquid_symbols(raw_http)
    print(f"{len(symbols)}銘柄を取得します(並列{WORKERS} / 上限{MAX_RPS}rps)")

    started = time.time()
    ok, ng = 0, []

    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = {
            pool.submit(store.update, http, sym, "60", "2024-01-01", False): sym
            for sym in symbols
        }
        for i, fut in enumerate(as_completed(futures), 1):
            sym = futures[fut]
            try:
                df = fut.result()
                ok += 1
                print(f"[{i:>3}/{len(symbols)}] {sym:<12} {len(df):>7,}本")
            except Exception as e:
                ng.append((sym, f"{type(e).__name__}: {e}"))
                print(f"[{i:>3}/{len(symbols)}] {sym:<12} 失敗: {e}")

    print(f"\n完了 {ok}銘柄 / 失敗 {len(ng)}件 / {time.time() - started:.0f}秒")
    for sym, err in ng:
        print(f"  {sym}: {err}")


if __name__ == "__main__":
    main()
並列数を上げるほど速いわけではない

IP制限は並列数ではなく単位時間あたりのリクエスト数で決まります。並列16にしてもゲートで秒間30に抑えられるので速度は変わらず、403のリスクだけが上がります。上のコードのように「ゲートで流量を決め、並列数は待ち時間を隠すためだけに使う」構成にしてください。トークンバケットによる本格的な流量制御は記事05で扱います。

リサンプリングの正しい集約規則

1分足を貯めておけば、上位足は自分で作れます。ただしOHLCVは列ごとに集約方法が違います。mean() を全列に当てるのは完全な誤りです。

列集約方法理由
openfirst区間の最初の始値
highmax区間内の最高値
lowmin区間内の最安値
closelast区間の最後の終値
volume
turnover
sum合計。平均ではない
resample.pyサンプル8
"""resample.py — OHLCVを正しくリサンプリングする(JST日足の作り方も含む)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import pandas as pd

OHLCV_AGG = {
    "open": "first",
    "high": "max",
    "low": "min",
    "close": "last",
    "volume": "sum",
    "turnover": "sum",
}


def resample_ohlcv(df: pd.DataFrame, freq: str, tz: str | None = None,
                   drop_partial: bool = True) -> pd.DataFrame:
    """OHLCVを上位足に集約する。

    freq       : pandasの頻度文字列("4h", "1D", "1W" など)
    tz         : "Asia/Tokyo" を渡すとJST基準の区切りになる(日足・週足で重要)
    drop_partial: 区間が埋まっていない最後の足を落とす
    """
    if df.empty:
        return df

    work = df.copy()
    if tz:
        work.index = work.index.tz_convert(tz)

    cols = {k: v for k, v in OHLCV_AGG.items() if k in work.columns}
    out = work.resample(freq, label="left", closed="left").agg(cols)

    # 取引が無かった区間は openがNaNになる。これは「足が存在しない」ので落とす
    out = out.dropna(subset=["open"])

    if drop_partial and len(out) > 0:
        # 最後の区間が現在進行中なら落とす
        step = pd.tseries.frequencies.to_offset(freq)
        last_start = out.index[-1]
        if last_start + step > work.index[-1] + pd.Timedelta(milliseconds=1):
            out = out.iloc[:-1]

    if tz:
        out.index = out.index.tz_convert("UTC")   # 保持はUTCに戻す
    return out


if __name__ == "__main__":
    from pybit.unified_trading import HTTP

    from kline_fetch import fetch_klines

    http = HTTP(testnet=False)
    m1 = fetch_klines(http, "BTCUSDT", interval="1", start="2026-07-20",
                      verbose=False)
    print(f"1分足: {len(m1):,}本")

    h4 = resample_ohlcv(m1, "4h")
    print(f"\n4時間足: {len(h4)}本")
    print(h4.tail(3).to_string())

    # UTC日足とJST日足の違いを見る
    d_utc = resample_ohlcv(m1, "1D")
    d_jst = resample_ohlcv(m1, "1D", tz="Asia/Tokyo")
    print(f"\nUTC日足 終値: {d_utc['close'].tail(3).tolist()}")
    print(f"JST日足 終値: {d_jst['close'].tail(3).tolist()}")
    print("  -> 同じ日でも終値が違う(区切り時刻が9時間ずれるため)")

mark / index / premium ローソク足の使い分け

price_kinds.pyサンプル9
"""price_kinds.py — 4種類のローソク足を取得して比較する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
"""
from __future__ import annotations

import pandas as pd
from pybit.unified_trading import HTTP

# mark / index / premium は5フィールド(出来高なし)
PRICE_ONLY_COLS = ["ts", "open", "high", "low", "close"]
FULL_COLS = ["ts", "open", "high", "low", "close", "volume", "turnover"]


def _to_df(rows: list[list[str]]) -> pd.DataFrame:
    """フィールド数から自動判別してDataFrame化する(仕様差を吸収)。"""
    if not rows:
        return pd.DataFrame()
    cols = FULL_COLS if len(rows[0]) >= 7 else PRICE_ONLY_COLS
    df = pd.DataFrame([r[: len(cols)] for r in rows], columns=cols).astype(float)
    df["ts"] = pd.to_datetime(df["ts"].astype("int64"), unit="ms", utc=True)
    return df.set_index("ts").sort_index()


def fetch_all_price_kinds(http: HTTP, symbol: str = "BTCUSDT",
                          interval: str = "60", limit: int = 200) -> pd.DataFrame:
    kw = dict(category="linear", symbol=symbol, interval=interval, limit=limit)

    last = _to_df(http.get_kline(**kw)["result"]["list"])
    mark = _to_df(http.get_mark_price_kline(**kw)["result"]["list"])
    index = _to_df(http.get_index_price_kline(**kw)["result"]["list"])
    prem = _to_df(http.get_premium_index_price_kline(**kw)["result"]["list"])

    out = pd.DataFrame({
        "last": last["close"],
        "mark": mark["close"],
        "index": index["close"],
        "premium_idx": prem["close"],
        "volume": last["volume"],
    })
    # ベーシス: 無期限価格が現物指数からどれだけ乖離しているか
    out["basis_bps"] = (out["last"] - out["index"]) / out["index"] * 10_000
    # マーク価格と最終価格の乖離(清算判定とのズレの目安)
    out["mark_gap_bps"] = (out["last"] - out["mark"]) / out["mark"] * 10_000
    return out.dropna()


if __name__ == "__main__":
    http = HTTP(testnet=False)
    df = fetch_all_price_kinds(http)

    print(df.tail(5).to_string())
    print(f"\nベーシス(bps)      : 平均 {df['basis_bps'].mean():+.2f} / "
          f"標準偏差 {df['basis_bps'].std():.2f} / "
          f"最大 {df['basis_bps'].abs().max():.2f}")
    print(f"マーク乖離(bps)    : 平均 {df['mark_gap_bps'].mean():+.2f} / "
          f"最大 {df['mark_gap_bps'].abs().max():.2f}")
    print("\n※ マーク乖離が大きい局面では、最終価格ベースのストップ判定と"
          "実際の清算判定がズレる")

データ品質のチェックリスト

取得したデータを検証に使う前に、以下を必ず確認してください。ここを飛ばすと、後段のバックテスト(記事31)の結果がすべて無意味になります。

確認項目症状対処
時系列が昇順か指標が未来を参照するsort_index() と is_monotonic_increasing のアサーション
未確定足が入っていないか同じ足で判断が揺れるdrop_unconfirmed()(サンプル3)
タイムゾーンがUTCか日足の区切りが9時間ずれるassert_utc() で早期検出
欠損足の割合移動平均の実効期間が伸びるfind_gaps() で率を確認。多すぎる銘柄は除外
重複足がないかページ境界で二重計上index.duplicated() をチェック
high >= low かデータ破損(df.high >= df.low).all() で検査
上場前の期間を含まないか存在しない期間で取引してしまう最初の足の時刻を上場日として扱う
上場廃止銘柄を除いていないか生存者バイアスで成績が過大に現在の銘柄リストだけで検証しない(記事32)
生存者バイアスは仮想通貨で特に深刻

「現在Bybitに上場している銘柄」で過去5年をバックテストすると、途中で上場廃止になった銘柄が最初から除外されています。生き残った銘柄だけを対象にすれば、どんな戦略でも成績が良く見えます。仮想通貨は上場廃止の頻度が株式より高いため、この影響は無視できません。厳密にやるなら過去の銘柄リストを時点ごとに保存しておく必要があります。せめて「自分の検証には生存者バイアスがある」と明示的に認識することが重要です。

参考リンク(一次情報)

よくある疑問Q&A

1分足はどこまで過去まで遡れますか?
銘柄の上場時点までです。ただし1分足で数年分は数百万本になり、リクエスト回数も数千回に達します。所要時間とストレージを考えると、長期の検証は1時間足以上、高頻度の検証は必要な期間だけ1分足、というように用途に応じて粒度を分けるのが実務的です。ティックレベルの長期データが必要なら、APIより public.bybit.com の日次CSVのほうが効率的です(記事08)。
startを指定しても古いデータが返ってきません
start と limit を同時に指定すると、その範囲のうち直近1000本が返るため、範囲の古い側が切り落とされます。全期間を取るには end を使って遡る方式(サンプル2)が確実です。start は「ここまで遡ったら終了」という終了条件として使い、リクエストのカーソルには end を使う——これが正しい組み立てです。
現物と無期限で同じ銘柄の足が微妙に違います
正常です。現物(spot)と無期限先物(linear)は別の市場・別の板なので、価格も出来高も一致しません。この差がベーシスであり、資金調達率アービトラージ(記事35)の収益源です。バックテストでは「どちらの市場で取引するのか」を明確にし、category を混在させないよう注意してください。
volumeとturnoverはどちらを使うべきですか?
USDT/USDC建て(linear)では volume が基軸通貨の数量(BTC)、turnover が決済通貨の金額(USDT)です。銘柄間で流動性を比較するなら金額ベースの turnover を使ってください。価格が違う銘柄同士で volume を比べても意味がありません。なおコイン建て(inverse)では単位が逆になるので、カテゴリを混ぜて集計しないよう注意してください。
Parquet以外の保存形式はどうですか?
CSVは可読性が高い反面、サイズが数倍になり読み込みも遅く、型情報が失われます(文字列として読み込まれて数値比較が壊れる事故が起きます)。Parquetは列指向・圧縮・型保持が揃っており、DuckDBやPolarsからそのまま読めるため、この用途では最適です。数千万行規模になるとDuckDBのネイティブ形式やTimescaleDBも選択肢ですが、まずはParquetで十分です。
D足を取ったのにTradingViewのチャートと終値が違います
タイムゾーンの区切りが原因である可能性が高いです。Bybitの日足はUTC 00:00区切りですが、TradingView側の表示設定が別のタイムゾーンになっていると、同じ「7月30日の足」が別の区間を指します。比較するときは必ずUTCで揃えてください。またTradingViewは無期限先物・現物・指数のどれを表示しているかでも値が変わります。
取得中にHTTP 403が出て止まりました
IP単位のレート制限(5秒あたり600リクエスト)に到達し、約10分の制限がかかった状態です。並列数を下げ、リクエスト間にsleepを入れてください(サンプル7のゲート)。制限中はどのエンドポイントも通らないため、botを同じIPで動かしている場合は売買まで止まります。履歴取得のような重いバッチは、売買botとは別のプロセス・可能なら別IPで動かすのが安全です(記事05・記事38)。

注意事項・免責事項

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

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

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

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

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

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

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