トレンドフォロー戦略をBybitで実装する
時系列モメンタム・ドンチャン・
ボラティリティ正規化

トレンドフォローは学術的な裏付けが最も厚い戦略群です(Moskowitz-Ooi-Pedersen 2012 は数十年・多資産で時系列モメンタムの存在を示しました)。ただしそのまま実装しても機能しません。この記事ではシグナル生成から、ボラティリティ正規化によるサイジング、ATRトレール、レジームフィルタ、複数銘柄分散へと段階的に堅牢化し、各段階を記事31・32の検証で通します。サンプルコード8本。

確認日
2026-07-30
API バージョン
Bybit V5
Python
3.10 以上
検証
WFA / DSR / PBO

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

目次

  1. なぜトレンドフォローから始めるのか
  2. 3種類のシグナル|MA・ドンチャン・TSMOM
  3. ボラティリティ正規化|これが効く
  4. ATRトレールと利確の設計
  5. レジームフィルタ|機能しない局面を避ける
  6. 完成したStrategyクラス
  7. 複数銘柄への分散
  8. 検証|WFA・DSR・PBOを通す
  9. 本番稼働への接続
  10. よくある疑問Q&A
この記事の位置づけ

本記事は実装方法の解説であり、特定の戦略やパラメータの収益性を主張するものではありません。掲載するコードはすべて記事31のバックテスト基盤と記事32の検証手法を通す前提で書かれています。過去の結果は将来を保証しません。

なぜトレンドフォローから始めるのか

理由内容
学術的裏付け Moskowitz, Ooi & Pedersen (2012) は58の先物・通貨で1〜12か月の時系列モメンタムを確認。Hurst et al. (2017) は100年以上のデータで再現
パラメータが少ない ルックバック期間とトレール幅の2つで済む。過剰最適化しにくい(記事32のDSRを通しやすい)
損益分布が有利 勝率は低い(30〜45%)が、利益が損失より大きい。少数の大勝ちが全体を支える
実装が単純 確定足だけで判断でき、低遅延を要求しない。マーケットメイク(記事37)と違いレイテンシ競争にならない
損益分布を理解しないと途中で止めてしまう

トレンドフォローは「負けが続き、稀に大きく勝つ」戦略です。勝率30%台は正常で、連続10回の負けも普通に起こります。そして年間利益の大半が数回のトレードから生まれます。

問題は心理面です。連敗中に「壊れた」と判断して止めると、その直後の大勝ちを取り逃がし、損失だけが確定します。実際にこれで失敗する人が多い。

だから連敗の分布を事前に把握しておくことが実装の一部です(サンプル7で最長連敗を計測します)。「何連敗までは正常か」を数値で知っていれば、耐えられます。

3種類のシグナル|MA・ドンチャン・TSMOM

signals.pyサンプル1
"""signals.py — トレンドフォローの3種類のシグナル
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

いずれも「確定足の情報だけ」で計算する。
未来を参照しないことは記事31のルックアヘッドテストで検証する。
"""
from __future__ import annotations

from dataclasses import dataclass
from enum import IntEnum

import numpy as np
import pandas as pd


class Direction(IntEnum):
    SHORT = -1
    FLAT = 0
    LONG = 1


@dataclass(frozen=True)
class SignalSpec:
    name: str
    lookback: int
    description: str


# ---- 1) 移動平均クロス -------------------------------------------------
def ma_cross_signal(close: pd.Series, fast: int, slow: int) -> pd.Series:
    """速いMAが遅いMAを上回っていればロング。最も単純な形。

    注意: shift(1)は呼び出し側で行う(この関数は「その足時点の状態」を返す)。
    """
    if fast >= slow:
        raise ValueError("fast < slow が必要です")
    f = close.rolling(fast, min_periods=fast).mean()
    s = close.rolling(slow, min_periods=slow).mean()
    sig = pd.Series(Direction.FLAT, index=close.index, dtype="int8")
    sig[f > s] = Direction.LONG
    sig[f < s] = Direction.SHORT
    return sig


# ---- 2) ドンチャンブレイクアウト ----------------------------------------
def donchian_signal(high: pd.Series, low: pd.Series, close: pd.Series,
                    entry_period: int = 55,
                    exit_period: int = 20) -> pd.Series:
    """N期間高値を超えたらロング、N期間安値を割ったらショート。

    タートルズ流。エントリーとエグジットで期間を分けるのが要点で、
    エグジットを短くすると利益の保持と損失の切り離しが両立する。

    重要: 高値・安値は「1本前まで」で計算する(当日の高値を含めると
          未来参照になる)。shift(1)を内部で入れている。
    """
    upper = high.rolling(entry_period, min_periods=entry_period).max().shift(1)
    lower = low.rolling(entry_period, min_periods=entry_period).min().shift(1)
    exit_up = high.rolling(exit_period, min_periods=exit_period).max().shift(1)
    exit_dn = low.rolling(exit_period, min_periods=exit_period).min().shift(1)

    sig = pd.Series(Direction.FLAT, index=close.index, dtype="int8")
    state = Direction.FLAT
    values = np.zeros(len(close), dtype=np.int8)

    c = close.to_numpy()
    up, dn = upper.to_numpy(), lower.to_numpy()
    xu, xd = exit_up.to_numpy(), exit_dn.to_numpy()

    for i in range(len(c)):
        if np.isnan(up[i]) or np.isnan(dn[i]):
            values[i] = Direction.FLAT
            continue

        if state == Direction.FLAT:
            if c[i] > up[i]:
                state = Direction.LONG
            elif c[i] < dn[i]:
                state = Direction.SHORT
        elif state == Direction.LONG:
            # エグジット期間の安値を割ったら撤退
            if not np.isnan(xd[i]) and c[i] < xd[i]:
                state = Direction.FLAT
                if c[i] < dn[i]:
                    state = Direction.SHORT      # ドテン
        else:  # SHORT
            if not np.isnan(xu[i]) and c[i] > xu[i]:
                state = Direction.FLAT
                if c[i] > up[i]:
                    state = Direction.LONG

        values[i] = state

    sig[:] = values
    return sig


# ---- 3) 時系列モメンタム(TSMOM) --------------------------------------
def tsmom_signal(close: pd.Series, lookback: int = 168,
                 threshold: float = 0.0) -> pd.Series:
    """過去N期間のリターンの符号でポジションを決める。

    Moskowitz, Ooi & Pedersen (2012) の最も単純な形。
    「上がってきたものは上がり続ける」という時系列方向の慣性を使う。

    threshold を上げると、弱いトレンドを無視できる(ノイズ低減)。
    """
    ret = close.pct_change(lookback)
    sig = pd.Series(Direction.FLAT, index=close.index, dtype="int8")
    sig[ret > threshold] = Direction.LONG
    sig[ret < -threshold] = Direction.SHORT
    return sig


def tsmom_multi_horizon(close: pd.Series,
                        lookbacks: tuple[int, ...] = (24, 72, 168, 336),
                        min_agreement: float = 0.5) -> pd.Series:
    """複数のルックバックの合意を取る。単一期間への依存を減らす。

    min_agreement=0.5 なら、過半数が同じ方向を示したときだけポジションを取る。
    パラメータ1つに賭けないため、過剰最適化しにくい。
    """
    votes = pd.DataFrame({
        f"h{lb}": tsmom_signal(close, lb).astype(float)
        for lb in lookbacks
    })
    score = votes.mean(axis=1)          # -1 〜 +1

    sig = pd.Series(Direction.FLAT, index=close.index, dtype="int8")
    sig[score >= min_agreement] = Direction.LONG
    sig[score <= -min_agreement] = Direction.SHORT
    return sig


# ---- 比較 ---------------------------------------------------------------
def compare_signals(bars: pd.DataFrame) -> pd.DataFrame:
    """各シグナルの性質を比較する(回転率・ポジション保有率)。"""
    close, high, low = bars["close"], bars["high"], bars["low"]

    sigs = {
        "MA(20,60)": ma_cross_signal(close, 20, 60),
        "Donchian(55,20)": donchian_signal(high, low, close, 55, 20),
        "TSMOM(168)": tsmom_signal(close, 168),
        "TSMOM(multi)": tsmom_multi_horizon(close),
    }

    rows = []
    for name, sig in sigs.items():
        s = sig.dropna()
        if s.empty:
            continue
        changes = (s.diff() != 0).sum()
        rows.append({
            "signal": name,
            "ロング率": float((s == Direction.LONG).mean()),
            "ショート率": float((s == Direction.SHORT).mean()),
            "フラット率": float((s == Direction.FLAT).mean()),
            "方向転換回数": int(changes),
            "平均保有本数": float(len(s) / max(changes, 1)),
        })
    return pd.DataFrame(rows).set_index("signal")


if __name__ == "__main__":
    from kline_store import KlineStore

    bars = KlineStore().load("BTCUSDT", "60")
    if bars.empty:
        raise SystemExit("先に記事07でデータを取得してください")
    bars = bars.loc["2024-01-01":]

    print(f"{len(bars)}本の1時間足で比較\n")
    with pd.option_context("display.float_format", lambda v: f"{v:.3f}"):
        print(compare_signals(bars).to_string())

    print("\n--- 読み方 ---")
    print("  方向転換回数が多い = 回転率が高い = 手数料負担が大きい")
    print("  平均保有本数が短いシグナルは、往復コストを回収できるか要確認")
    print("  (1時間足で平均20本保有なら、20時間で往復コストを回収する必要)")

ボラティリティ正規化|これが効く

単純なトレンドフォローの最大の弱点は、銘柄・時期によってリスク量がばらつくことです。BTCとDOGEを同じ枚数持てばリスクは全く違い、同じBTCでもボラが2倍になればリスクも2倍です。

ボラティリティ正規化は、これを「目標リスク量」で揃えます。TSMOMの論文でも中核的な要素です。

vol_target.pyサンプル2
"""vol_target.py — ボラティリティ正規化によるポジションサイジング
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

考え方:
  目標年率ボラ / 銘柄の年率ボラ = 想定元本の倍率

  ボラが高い銘柄・時期には小さく、低いときは大きく持つ。
  これにより「常に同じリスク量」を維持できる。

参考: Moskowitz, Ooi & Pedersen (2012) の TSMOM 構成、
      Moreira & Muir (2017) "Volatility-Managed Portfolios"
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import numpy as np
import pandas as pd

BARS_PER_YEAR = {"1": 525_600, "5": 105_120, "15": 35_040, "60": 8_760,
                 "240": 2_190, "D": 365}


@dataclass(frozen=True)
class VolTargetConfig:
    target_annual_vol: float = 0.20      # 目標年率ボラ 20%
    vol_lookback: int = 168              # ボラ推定期間(1時間足で1週間)
    max_leverage: float = 3.0            # 実効レバレッジの上限
    min_leverage: float = 0.0
    vol_floor: float = 0.05              # 年率ボラの下限(ゼロ割り防止)


def realised_vol(close: pd.Series, lookback: int,
                 bars_per_year: int) -> pd.Series:
    """対数リターンの標準偏差から年率ボラを推定する。"""
    log_ret = np.log(close / close.shift(1))
    return log_ret.rolling(lookback, min_periods=max(lookback // 2, 10)).std() \
        * np.sqrt(bars_per_year)


def yang_zhang_vol(bars: pd.DataFrame, lookback: int,
                   bars_per_year: int) -> pd.Series:
    """Yang-Zhang推定量。始値ギャップとレンジを使い、終値ベースより効率的。

    参考: Yang & Zhang (2000)
    """
    o, h, l, c = (bars["open"], bars["high"], bars["low"], bars["close"])
    c_prev = c.shift(1)

    # 翌日への持ち越し(オーバーナイト)成分
    log_oc_prev = np.log(o / c_prev)
    sigma_o = log_oc_prev.rolling(lookback).var()

    # 日中の終値成分
    log_co = np.log(c / o)
    sigma_c = log_co.rolling(lookback).var()

    # Rogers-Satchell成分(レンジを使う)
    rs = (np.log(h / c) * np.log(h / o) + np.log(l / c) * np.log(l / o))
    sigma_rs = rs.rolling(lookback).mean()

    k = 0.34 / (1.34 + (lookback + 1) / (lookback - 1))
    var = sigma_o + k * sigma_c + (1 - k) * sigma_rs
    return np.sqrt(var.clip(lower=0)) * np.sqrt(bars_per_year)


def vol_scaled_leverage(vol: pd.Series,
                        cfg: VolTargetConfig | None = None) -> pd.Series:
    """年率ボラから、想定元本の倍率(実効レバレッジ)を求める。"""
    cfg = cfg or VolTargetConfig()
    safe_vol = vol.clip(lower=cfg.vol_floor)
    lev = cfg.target_annual_vol / safe_vol
    return lev.clip(lower=cfg.min_leverage, upper=cfg.max_leverage)


def position_notional(equity: Decimal, leverage: float,
                      direction: int) -> Decimal:
    """純資産と倍率から、目標の想定元本(符号付き)を返す。"""
    if direction == 0 or leverage <= 0:
        return Decimal(0)
    return equity * Decimal(str(round(leverage, 6))) * direction


def compare_sizing(bars: pd.DataFrame, interval: str = "60") -> pd.DataFrame:
    """固定サイズとボラ正規化を比較する。"""
    bpy = BARS_PER_YEAR.get(interval, 8_760)
    cfg = VolTargetConfig()

    close_vol = realised_vol(bars["close"], cfg.vol_lookback, bpy)
    yz_vol = yang_zhang_vol(bars, cfg.vol_lookback, bpy)

    out = pd.DataFrame({
        "close_vol": close_vol,
        "yz_vol": yz_vol,
        "lev_close": vol_scaled_leverage(close_vol, cfg),
        "lev_yz": vol_scaled_leverage(yz_vol, cfg),
    }).dropna()

    # 実現するリスク量(倍率 × 実ボラ)が目標に近いかを確認
    out["realised_risk_close"] = out["lev_close"] * out["close_vol"]
    out["realised_risk_yz"] = out["lev_yz"] * out["yz_vol"]
    return out


if __name__ == "__main__":
    from kline_store import KlineStore

    store = KlineStore()
    cfg = VolTargetConfig()

    print(f"目標年率ボラ {cfg.target_annual_vol:.0%} / "
          f"上限レバレッジ {cfg.max_leverage}x\n")
    print(f"{'symbol':<11}{'年率ボラ':>10}{'倍率':>8}"
          f"{'実現リスク':>11}{'ボラの変動幅':>14}")
    print("-" * 56)

    for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT"):
        bars = store.load(sym, "60")
        if bars.empty:
            continue
        bars = bars.loc["2024-01-01":]
        df = compare_sizing(bars)
        if df.empty:
            continue
        vol = df["yz_vol"].iloc[-1]
        lev = df["lev_yz"].iloc[-1]
        risk = df["realised_risk_yz"].mean()
        vol_range = f"{df['yz_vol'].min():.0%}〜{df['yz_vol'].max():.0%}"
        print(f"{sym:<11}{vol:>9.1%}{lev:>8.2f}x{risk:>10.1%}"
              f"{vol_range:>14}")

    print("\n※ 銘柄ごとにボラが数倍違うため、同じ枚数を持つとリスクが揃わない。")
    print("  ボラ正規化すれば『実現リスク』が目標付近に揃う。")
    print("  上限レバレッジでクリップされる分、完全には一致しない。")
ボラ正規化は「1つの変更で最も効く」改善

シグナルを変えるより、ボラ正規化を入れるほうが成績への影響が大きいことが多いです。理由は、(1) 銘柄間のリスクが揃うため分散が効く、(2) 高ボラ局面で自動的にポジションを縮めるため大きなドローダウンを避けられる、(3) パラメータを増やさない(目標ボラ1つ)。

特に(3)が重要です。記事32で見たように、パラメータを増やすほどDSRが厳しくなります。ボラ正規化は「経済的に正しい」変更なので、データに合わせて選んだパラメータではありません。

ATRトレールと利確の設計

トレンドフォローに固定利確を入れてはいけない

「+2%で利確」のような固定利確は、トレンドフォローの収益構造を壊します。この戦略は「少数の大勝ちが全体を支える」ため、その大勝ちを早期に切ると期待値がマイナスになります。

正しいのは「損切りは固定(または小さめ)、利確はトレールで伸ばす」非対称な設計です。ATRトレールなら、ボラティリティに応じて追従幅が自動調整されます。

trail.pyサンプル3
"""trail.py — ATRベースのトレーリングストップ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

設計:
  - 損切り: エントリー時のATR × k(固定)
  - トレール: 有利方向にのみ動かす(不利方向には絶対に動かさない)
  - 利確: 設定しない(トレールに任せる)
"""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd


def atr(bars: pd.DataFrame, period: int = 14) -> pd.Series:
    """ATR(Average True Range)。TA-Lib不要の実装。"""
    h, l, c = bars["high"], bars["low"], bars["close"]
    prev_c = c.shift(1)
    tr = pd.concat([h - l, (h - prev_c).abs(), (l - prev_c).abs()],
                   axis=1).max(axis=1)
    return tr.rolling(period, min_periods=period).mean()


def chandelier_exit(bars: pd.DataFrame, period: int = 22,
                    mult: float = 3.0) -> pd.DataFrame:
    """シャンデリア・エグジット。高値からATR×kを引いた水準。

    ロングの損切り = 直近N期間の高値 − ATR × mult
    ショートの損切り = 直近N期間の安値 + ATR × mult

    注意: shift(1)で「1本前まで」の情報にする(未来参照の防止)。
    """
    a = atr(bars, period)
    highest = bars["high"].rolling(period, min_periods=period).max()
    lowest = bars["low"].rolling(period, min_periods=period).min()

    return pd.DataFrame({
        "long_stop": (highest - a * mult).shift(1),
        "short_stop": (lowest + a * mult).shift(1),
        "atr": a.shift(1),
    }, index=bars.index)


@dataclass
class TrailState:
    """1ポジションのトレール状態。単調性(有利方向のみ)を保証する。"""

    is_long: bool
    entry_price: float
    stop_price: float
    atr_at_entry: float
    best_price: float
    updates: int = 0
    history: list[float] = field(default_factory=list)

    def update(self, price: float, atr_now: float, mult: float) -> bool:
        """価格とATRから損切り水準を更新する。改善したらTrueを返す。"""
        if self.is_long:
            self.best_price = max(self.best_price, price)
            candidate = self.best_price - atr_now * mult
            improved = candidate > self.stop_price
        else:
            self.best_price = min(self.best_price, price)
            candidate = self.best_price + atr_now * mult
            improved = candidate < self.stop_price

        if improved:
            self.stop_price = candidate
            self.updates += 1
            self.history.append(candidate)
        return improved

    def is_hit(self, price: float) -> bool:
        return (price <= self.stop_price if self.is_long
                else price >= self.stop_price)

    @property
    def risk_pct(self) -> float:
        """現在の損切りまでの距離(エントリー価格に対する%)。"""
        return abs(self.stop_price - self.entry_price) / self.entry_price * 100

    @property
    def locked_pct(self) -> float:
        """確定させた利益(損切りがエントリーを超えた分)。"""
        if self.is_long:
            gain = self.stop_price - self.entry_price
        else:
            gain = self.entry_price - self.stop_price
        return gain / self.entry_price * 100


def make_trail(is_long: bool, entry_price: float, atr_value: float,
               mult: float = 3.0) -> TrailState:
    stop = (entry_price - atr_value * mult if is_long
            else entry_price + atr_value * mult)
    return TrailState(is_long=is_long, entry_price=entry_price,
                      stop_price=stop, atr_at_entry=atr_value,
                      best_price=entry_price)


def simulate_trail(bars: pd.DataFrame, is_long: bool = True,
                   period: int = 22, mult: float = 3.0) -> pd.DataFrame:
    """トレールの挙動を1トレード分シミュレートする(挙動確認用)。"""
    a = atr(bars, period).shift(1)
    valid = bars.loc[a.notna()]
    if valid.empty:
        return pd.DataFrame()

    entry_idx = valid.index[0]
    entry = float(valid["close"].iloc[0])
    st = make_trail(is_long, entry, float(a.loc[entry_idx]), mult)

    rows = []
    for ts, row in valid.iterrows():
        atr_now = float(a.loc[ts])
        # 不利方向の判定を先に行う(同じ足で両方触れた場合の保守的な扱い)
        worst = float(row["low"] if is_long else row["high"])
        if st.is_hit(worst):
            rows.append({"ts": ts, "price": worst, "stop": st.stop_price,
                         "hit": True, "locked_pct": st.locked_pct})
            break
        st.update(float(row["close"]), atr_now, mult)
        rows.append({"ts": ts, "price": float(row["close"]),
                     "stop": st.stop_price, "hit": False,
                     "locked_pct": st.locked_pct})

    return pd.DataFrame(rows).set_index("ts")


if __name__ == "__main__":
    from kline_store import KlineStore

    bars = KlineStore().load("BTCUSDT", "60")
    if bars.empty:
        raise SystemExit("先に記事07でデータを取得してください")
    bars = bars.loc["2025-01-01":"2025-03-01"]

    print("=== ATRトレールの挙動(mult別) ===")
    print(f"{'mult':>6}{'保有本数':>10}{'確定利益%':>12}{'初期リスク%':>13}")
    print("-" * 42)
    for mult in (1.5, 2.0, 3.0, 4.0, 6.0):
        sim = simulate_trail(bars, is_long=True, mult=mult)
        if sim.empty:
            continue
        print(f"{mult:>6.1f}{len(sim):>10}"
              f"{sim['locked_pct'].iloc[-1]:>+11.2f}%"
              f"{abs(sim['locked_pct'].iloc[0]):>12.2f}%")

    print("\n※ multが小さいと早く切られ、大きいと伸ばせるが損失も大きい。")
    print("  トレンドフォローでは3.0前後から始めるのが一般的だが、")
    print("  記事32のWFAで検証してから決めること。")

    ce = chandelier_exit(bars, 22, 3.0)
    print(f"\n直近のシャンデリア水準:")
    print(f"  ロングの損切り  : {ce['long_stop'].iloc[-1]:,.1f}")
    print(f"  ショートの損切り: {ce['short_stop'].iloc[-1]:,.1f}")
    print(f"  ATR(22)         : {ce['atr'].iloc[-1]:,.1f}")

レジームフィルタ|機能しない局面を避ける

トレンドフォローはレンジ相場で損失を積み上げます(ダマシのブレイクを繰り返す)。「トレンドが出ているか」を判定して、出ていないときは休むのが有効です。

regime.pyサンプル4
"""regime.py — トレンド相場かレンジ相場かを判定するフィルタ
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

注意: フィルタを増やすほどパラメータが増え、過剰最適化しやすくなる。
      「効いた気がする」で追加せず、記事32のWFA/DSRで検証すること。
"""
from __future__ import annotations

import numpy as np
import pandas as pd


def efficiency_ratio(close: pd.Series, period: int = 100) -> pd.Series:
    """Kaufmanの効率比。方向性のある動きの割合を測る。

    (期間の純変化) / (期間内の変動の合計)
    1に近い = 一直線に動いている(トレンド)
    0に近い = 行ったり来たり(レンジ)
    """
    net = (close - close.shift(period)).abs()
    path = close.diff().abs().rolling(period).sum()
    return (net / path).replace([np.inf, -np.inf], np.nan)


def adx(bars: pd.DataFrame, period: int = 14) -> pd.Series:
    """ADX(Average Directional Index)。トレンドの強さを測る定番指標。

    25以上でトレンドあり、20以下でレンジとされるのが一般的。
    """
    h, l, c = bars["high"], bars["low"], bars["close"]
    up = h.diff()
    dn = -l.diff()

    plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
    minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)

    prev_c = c.shift(1)
    tr = pd.concat([h - l, (h - prev_c).abs(), (l - prev_c).abs()],
                   axis=1).max(axis=1)

    atr_ = tr.rolling(period).mean()
    plus_di = 100 * pd.Series(plus_dm, index=bars.index).rolling(period).mean() / atr_
    minus_di = 100 * pd.Series(minus_dm, index=bars.index).rolling(period).mean() / atr_

    dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
    return dx.rolling(period).mean()


def higher_timeframe_filter(bars: pd.DataFrame, htf: str = "4h",
                            ma_period: int = 50) -> pd.Series:
    """上位時間足のトレンド方向。順張り方向を制限する。

    1時間足で売買しつつ、4時間足が上向きならロングのみ、
    という使い方をする。
    """
    htf_close = bars["close"].resample(htf, label="left",
                                       closed="left").last().dropna()
    htf_ma = htf_close.rolling(ma_period, min_periods=ma_period).mean()
    direction = pd.Series(0, index=htf_close.index, dtype="int8")
    direction[htf_close > htf_ma] = 1
    direction[htf_close < htf_ma] = -1
    # 上位足の値を下位足に前方埋めする(未来を見ないようshift)
    return direction.shift(1).reindex(bars.index, method="ffill").fillna(0)


def vol_regime(bars: pd.DataFrame, lookback: int = 168,
               percentile_window: int = 720) -> pd.Series:
    """ボラティリティの相対水準(0〜1)。極端に高い局面を避けるのに使う。"""
    from vol_target import realised_vol

    vol = realised_vol(bars["close"], lookback, 8_760)
    return vol.rolling(percentile_window, min_periods=lookback).rank(pct=True)


def build_filter(bars: pd.DataFrame, *,
                 min_efficiency: float | None = 0.25,
                 min_adx: float | None = 20.0,
                 use_htf: bool = True,
                 max_vol_percentile: float | None = 0.95) -> pd.DataFrame:
    """複数のフィルタをまとめて評価する。

    戻り値の allow_long / allow_short が True の足でのみエントリーする。
    """
    out = pd.DataFrame(index=bars.index)
    out["allow_long"] = True
    out["allow_short"] = True

    if min_efficiency is not None:
        er = efficiency_ratio(bars["close"]).shift(1)
        ok = er >= min_efficiency
        out["allow_long"] &= ok.fillna(False)
        out["allow_short"] &= ok.fillna(False)
        out["efficiency"] = er

    if min_adx is not None:
        a = adx(bars).shift(1)
        ok = a >= min_adx
        out["allow_long"] &= ok.fillna(False)
        out["allow_short"] &= ok.fillna(False)
        out["adx"] = a

    if use_htf:
        htf = higher_timeframe_filter(bars)
        out["allow_long"] &= (htf >= 0)
        out["allow_short"] &= (htf <= 0)
        out["htf"] = htf

    if max_vol_percentile is not None:
        vp = vol_regime(bars).shift(1)
        ok = vp <= max_vol_percentile
        out["allow_long"] &= ok.fillna(True)
        out["allow_short"] &= ok.fillna(True)
        out["vol_pct"] = vp

    return out


if __name__ == "__main__":
    from kline_store import KlineStore

    bars = KlineStore().load("BTCUSDT", "60")
    if bars.empty:
        raise SystemExit("先に記事07でデータを取得してください")
    bars = bars.loc["2024-01-01":]

    f = build_filter(bars)
    print(f"全 {len(f)}本のうち")
    print(f"  ロング可      : {f['allow_long'].mean():.1%}")
    print(f"  ショート可    : {f['allow_short'].mean():.1%}")
    print(f"  どちらも不可  : "
          f"{(~f['allow_long'] & ~f['allow_short']).mean():.1%}")

    print("\n--- 各フィルタ単体の通過率 ---")
    for name, kw in (
        ("効率比のみ", dict(min_adx=None, use_htf=False,
                            max_vol_percentile=None)),
        ("ADXのみ", dict(min_efficiency=None, use_htf=False,
                         max_vol_percentile=None)),
        ("上位足のみ", dict(min_efficiency=None, min_adx=None,
                            max_vol_percentile=None)),
        ("ボラ上限のみ", dict(min_efficiency=None, min_adx=None,
                              use_htf=False)),
    ):
        sub = build_filter(bars, **kw)
        print(f"  {name:<14} ロング可 {sub['allow_long'].mean():>6.1%}")

    print("\n※ フィルタは取引機会を減らす。減った分だけ約定回数が落ち、")
    print("  統計的な信頼性も下がる(記事32の min_trades を下回りやすい)。")
    print("  『フィルタで成績が良くなった』は過剰最適化の典型なので、")
    print("  必ずWFAとDSRで検証すること。")

完成したStrategyクラス

ここまでの部品を、記事31の Strategy 基底クラスに統合します。これ1つでバックテストと本番の両方が動きます(記事30)。

strategies/trend_follow.pyサンプル5
"""strategies/trend_follow.py — トレンドフォロー戦略(完成版)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

このクラスは記事31のバックテストエンジンと記事30の本番Runnerの
両方でそのまま動く。環境を意識するコードは一切含まない。

注意: 収益性を主張するものではなく、実装例です。
      使用前に記事32の検証を必ず通してください。
"""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd

from bt.strategy import Strategy
from bt.types import Fill, Order, OrderType, Position, Side
from regime import build_filter
from signals import Direction, donchian_signal, tsmom_multi_horizon
from trail import TrailState, atr, make_trail
from vol_target import VolTargetConfig, realised_vol, vol_scaled_leverage


@dataclass
class TrendConfig:
    # --- シグナル(パラメータは少なく保つ) ---
    signal: str = "donchian"          # donchian / tsmom
    entry_period: int = 55
    exit_period: int = 20
    tsmom_lookbacks: tuple[int, ...] = (24, 72, 168, 336)

    # --- サイジング ---
    target_annual_vol: float = 0.20
    vol_lookback: int = 168
    max_leverage: float = 2.0

    # --- トレール ---
    atr_period: int = 22
    atr_mult: float = 3.0

    # --- フィルタ(Noneで無効) ---
    min_efficiency: float | None = None
    min_adx: float | None = None
    use_htf_filter: bool = False
    max_vol_percentile: float | None = 0.95

    # --- 執行 ---
    use_post_only: bool = False
    allow_short: bool = True
    bars_per_year: int = 8_760

    def n_params(self) -> int:
        """実質的なパラメータ数。記事32のDSRで意識する。"""
        n = 3        # entry_period, target_annual_vol, atr_mult
        if self.signal == "donchian":
            n += 1   # exit_period
        for f in (self.min_efficiency, self.min_adx, self.max_vol_percentile):
            if f is not None:
                n += 1
        if self.use_htf_filter:
            n += 1
        return n


class TrendFollowStrategy(Strategy):
    """ボラティリティ正規化つきトレンドフォロー。"""

    def __init__(self, cfg: TrendConfig | None = None):
        self.cfg = cfg or TrendConfig()
        self.trail: TrailState | None = None
        self._cache_len = 0
        self._indicators: pd.DataFrame | None = None
        self.stats = {"entries": 0, "exits": 0, "trail_stops": 0,
                      "filtered_out": 0}

    # ---- 指標の計算(履歴が伸びたら再計算) -----------------------------
    def _compute(self, history: pd.DataFrame) -> pd.DataFrame:
        cfg = self.cfg

        if cfg.signal == "donchian":
            sig = donchian_signal(history["high"], history["low"],
                                  history["close"],
                                  cfg.entry_period, cfg.exit_period)
        else:
            sig = tsmom_multi_horizon(history["close"], cfg.tsmom_lookbacks)

        vol = realised_vol(history["close"], cfg.vol_lookback,
                           cfg.bars_per_year)
        lev = vol_scaled_leverage(vol, VolTargetConfig(
            target_annual_vol=cfg.target_annual_vol,
            vol_lookback=cfg.vol_lookback,
            max_leverage=cfg.max_leverage))

        filt = build_filter(
            history,
            min_efficiency=cfg.min_efficiency,
            min_adx=cfg.min_adx,
            use_htf=cfg.use_htf_filter,
            max_vol_percentile=cfg.max_vol_percentile,
        )

        return pd.DataFrame({
            "signal": sig,
            "atr": atr(history, cfg.atr_period),
            "leverage": lev,
            "allow_long": filt["allow_long"],
            "allow_short": filt["allow_short"],
        }, index=history.index)

    def _indicators_for(self, history: pd.DataFrame) -> pd.DataFrame:
        # 毎足フル再計算すると遅いので、長さが変わったときだけ更新する
        if self._indicators is None or len(history) != self._cache_len:
            self._indicators = self._compute(history)
            self._cache_len = len(history)
        return self._indicators

    # ---- Strategyインターフェース ---------------------------------------
    def on_start(self, history: pd.DataFrame) -> None:
        self.trail = None
        self._indicators = None
        self._cache_len = 0

    def on_fill(self, fill: Fill) -> None:
        """約定でポジションが建ったらトレールを初期化する。"""
        if fill.reason != "signal":
            return
        if self.trail is None and self._indicators is not None:
            atr_now = float(self._indicators["atr"].iloc[-1])
            if np.isfinite(atr_now) and atr_now > 0:
                self.trail = make_trail(
                    is_long=(fill.side is Side.BUY),
                    entry_price=fill.price, atr_value=atr_now,
                    mult=self.cfg.atr_mult)

    def on_bar(self, history: pd.DataFrame, position: Position,
               equity: float) -> list[Order] | None:
        cfg = self.cfg
        warmup = max(cfg.entry_period, cfg.vol_lookback,
                     max(cfg.tsmom_lookbacks)) + 5
        if len(history) < warmup:
            return None

        ind = self._indicators_for(history)
        last = ind.iloc[-1]
        price = float(history["close"].iloc[-1])
        atr_now = float(last["atr"])
        if not np.isfinite(atr_now) or atr_now <= 0:
            return None

        orders: list[Order] = []

        # ---- 1) トレールによる撤退を最優先で判定 ----------------------
        if not position.is_flat and self.trail is not None:
            self.trail.update(price, atr_now, cfg.atr_mult)
            if self.trail.is_hit(price):
                self.stats["trail_stops"] += 1
                self.trail = None
                return [Order(side=position.side.opposite,
                              qty=abs(position.qty),
                              order_type=OrderType.MARKET,
                              reduce_only=True, tag="trail-stop")]

        # ---- 2) シグナルとフィルタ -------------------------------------
        target = int(last["signal"])
        if target > 0 and not bool(last["allow_long"]):
            target = 0
            self.stats["filtered_out"] += 1
        if target < 0 and (not bool(last["allow_short"])
                           or not cfg.allow_short):
            target = 0

        current = (0 if position.is_flat
                   else (1 if position.qty > 0 else -1))
        if target == current:
            return None

        # ---- 3) 既存ポジションの決済 -----------------------------------
        if current != 0:
            self.stats["exits"] += 1
            self.trail = None
            orders.append(Order(side=position.side.opposite,
                                qty=abs(position.qty),
                                order_type=OrderType.MARKET,
                                reduce_only=True, tag="exit"))

        # ---- 4) 新規エントリー(ボラ正規化サイズ) ----------------------
        if target != 0:
            leverage = float(last["leverage"])
            if not np.isfinite(leverage) or leverage <= 0:
                return orders or None

            notional = equity * leverage
            qty = round(notional / price, 8)
            if qty <= 0:
                return orders or None

            self.stats["entries"] += 1
            otype = (OrderType.POST_ONLY if cfg.use_post_only
                     else OrderType.MARKET)
            orders.append(Order(
                side=Side.BUY if target > 0 else Side.SELL,
                qty=qty, order_type=otype,
                price=price if cfg.use_post_only else None,
                ttl_bars=3 if cfg.use_post_only else None,
                tag="entry-long" if target > 0 else "entry-short",
            ))

        return orders or None


if __name__ == "__main__":
    cfg = TrendConfig()
    print(f"実質パラメータ数: {cfg.n_params()}")
    print("  記事32のDSRでは、この数と探索回数が判定に影響する。")
    print("  フィルタを全部有効にするとパラメータが増えるので、")
    print("  まずは最小構成(フィルタなし)から検証すること。")

    print("\nフィルタを全部有効にした場合:")
    full = TrendConfig(min_efficiency=0.25, min_adx=20.0,
                       use_htf_filter=True, max_vol_percentile=0.95)
    print(f"  実質パラメータ数: {full.n_params()}")

複数銘柄への分散

トレンドフォローは単一銘柄では成績が不安定です。TSMOMの論文でも「多数の資産に分散して初めて安定する」ことが示されています。ただし仮想通貨は相関が高いため、分散効果は株式・債券・商品のポートフォリオより限定的です。

portfolio.pyサンプル6
"""portfolio.py — 複数銘柄への配分とポートフォリオ全体のリスク管理
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

仮想通貨特有の問題:
  BTCとアルトコインの相関が0.7〜0.9と非常に高い。
  「10銘柄に分散」しても実質的には2〜3銘柄分のリスク分散しかない。
  したがってポートフォリオ全体での上限管理が不可欠。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal

import numpy as np
import pandas as pd


@dataclass
class PortfolioLimits:
    max_total_leverage: float = 3.0      # 全銘柄合計の実効レバレッジ
    max_per_symbol_leverage: float = 1.0
    max_same_direction_ratio: float = 0.8  # 同方向への偏りの上限
    max_symbols: int = 5


def correlation_matrix(closes: dict[str, pd.Series],
                       lookback: int = 720) -> pd.DataFrame:
    """銘柄間の対数リターン相関。分散が効いているかを確認する。"""
    rets = {}
    for sym, close in closes.items():
        r = np.log(close / close.shift(1)).tail(lookback)
        rets[sym] = r
    return pd.DataFrame(rets).dropna().corr()


def effective_n(corr: pd.DataFrame) -> float:
    """実効的な独立銘柄数。相関が高いと1に近づく。

    固有値から算出する簡易指標:
      N_eff = (Σλ)^2 / Σλ^2
    """
    if corr.empty:
        return 0.0
    eig = np.linalg.eigvalsh(corr.to_numpy())
    eig = eig[eig > 1e-10]
    if eig.size == 0:
        return 0.0
    return float(eig.sum() ** 2 / (eig ** 2).sum())


def inverse_vol_weights(vols: dict[str, float]) -> dict[str, float]:
    """逆ボラティリティ配分。ボラの低い銘柄に多く配分する。"""
    inv = {s: (1.0 / v if v > 0 else 0.0) for s, v in vols.items()}
    total = sum(inv.values())
    if total <= 0:
        n = len(vols) or 1
        return {s: 1.0 / n for s in vols}
    return {s: w / total for s, w in inv.items()}


@dataclass
class PortfolioState:
    """全銘柄の目標ポジションを管理し、上限を強制する。"""

    limits: PortfolioLimits = field(default_factory=PortfolioLimits)
    targets: dict[str, float] = field(default_factory=dict)  # symbol -> 符号付きレバ

    def set_target(self, symbol: str, signed_leverage: float) -> None:
        self.targets[symbol] = signed_leverage

    def clipped(self) -> dict[str, float]:
        """上限を適用した最終的な配分を返す。"""
        lim = self.limits
        out = dict(self.targets)

        # 1) 銘柄ごとの上限
        for s, v in out.items():
            out[s] = float(np.clip(v, -lim.max_per_symbol_leverage,
                                   lim.max_per_symbol_leverage))

        # 2) 銘柄数の上限(絶対値の大きい順に採用)
        if len(out) > lim.max_symbols:
            ranked = sorted(out.items(), key=lambda kv: -abs(kv[1]))
            keep = dict(ranked[:lim.max_symbols])
            out = {s: (keep.get(s, 0.0)) for s in out}

        # 3) 同方向への偏りの上限
        total_abs = sum(abs(v) for v in out.values())
        if total_abs > 0:
            long_share = sum(v for v in out.values() if v > 0) / total_abs
            short_share = -sum(v for v in out.values() if v < 0) / total_abs
            for share, sign in ((long_share, 1), (short_share, -1)):
                if share > lim.max_same_direction_ratio:
                    scale = lim.max_same_direction_ratio / share
                    for s, v in out.items():
                        if np.sign(v) == sign:
                            out[s] = v * scale

        # 4) 合計レバレッジの上限(比例縮小)
        total = sum(abs(v) for v in out.values())
        if total > lim.max_total_leverage and total > 0:
            scale = lim.max_total_leverage / total
            out = {s: v * scale for s, v in out.items()}

        return out

    def report(self) -> str:
        raw, final = self.targets, self.clipped()
        lines = [f"{'symbol':<12}{'目標レバ':>10}{'調整後':>10}{'変化':>8}"]
        lines.append("-" * 40)
        for s in sorted(raw, key=lambda x: -abs(raw[x])):
            r, f = raw[s], final.get(s, 0.0)
            mark = "" if abs(r - f) < 1e-9 else "  <- 縮小"
            lines.append(f"{s:<12}{r:>+10.3f}{f:>+10.3f}"
                         f"{(f - r):>+8.3f}{mark}")
        lines.append(f"\n  合計(絶対値): {sum(abs(v) for v in raw.values()):.2f}"
                     f" -> {sum(abs(v) for v in final.values()):.2f}"
                     f" (上限 {self.limits.max_total_leverage})")
        return "\n".join(lines)


if __name__ == "__main__":
    from kline_store import KlineStore
    from signals import tsmom_multi_horizon
    from vol_target import VolTargetConfig, realised_vol, vol_scaled_leverage

    store = KlineStore()
    symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT", "XRPUSDT"]

    closes: dict[str, pd.Series] = {}
    for sym in symbols:
        df = store.load(sym, "60")
        if not df.empty:
            closes[sym] = df.loc["2024-01-01":]["close"]

    if len(closes) < 2:
        raise SystemExit("2銘柄以上のデータが必要です(記事07)")

    corr = correlation_matrix(closes)
    print("=== 銘柄間の相関(直近720本) ===")
    print(corr.to_string(float_format=lambda v: f"{v:.2f}"))
    n_eff = effective_n(corr)
    print(f"\n実効独立銘柄数: {n_eff:.2f} / 実際の銘柄数 {len(corr)}")
    print(f"  -> {len(corr)}銘柄に分散しても、リスク分散は実質"
          f"{n_eff:.1f}銘柄分しかない")

    # 各銘柄のシグナルとボラからポートフォリオを組む
    state = PortfolioState()
    cfg = VolTargetConfig(target_annual_vol=0.20, max_leverage=1.0)
    print("\n=== ポートフォリオの構成 ===")
    for sym, close in closes.items():
        sig = tsmom_multi_horizon(close).iloc[-1]
        vol = realised_vol(close, cfg.vol_lookback, 8_760).iloc[-1]
        lev = vol_scaled_leverage(pd.Series([vol]), cfg).iloc[0]
        state.set_target(sym, float(lev) * int(sig))

    print(state.report())
    print("\n※ 相関が高い仮想通貨では、銘柄数を増やしてもリスクは下がりにくい。")
    print("  合計レバレッジの上限管理が本質的な防御になる(記事23・24)。")

検証|WFA・DSR・PBOを通す

ここが最重要です。実装ができた時点では何も検証できていません。記事32の手法を全部通します。

validate_trend.pyサンプル7
"""validate_trend.py — トレンドフォロー戦略を記事32の手法で検証する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

手順:
  1) 悲観コストモデルでのバックテスト(記事31)
  2) ルックアヘッドテスト(記事31)
  3) Walk-Forward最適化と効率比(記事32)
  4) Deflated Sharpe Ratio(記事32)
  5) PBO(記事32)
  6) 連敗分布の把握(心理的な耐性の確認)
  7) 採用判定(記事32のadoption_gate)
"""
from __future__ import annotations

from decimal import Decimal

import numpy as np
import pandas as pd

from adoption_gate import Criteria, Evidence, evaluate_adoption
from bt.engine import BacktestConfig, Backtester
from bt.fill import FillModel, PessimisticFillModel
from bt.report import metrics
from costs import FeeModel
from deflated_sharpe import deflated_sharpe_ratio
from pbo import build_returns_matrix, compute_pbo
from strategies.trend_follow import TrendConfig, TrendFollowStrategy
from walk_forward import walk_forward


def losing_streaks(pnl: pd.Series) -> dict:
    """連敗の分布。心理的な耐性を事前に把握するために測る。"""
    signs = np.sign(pnl.to_numpy())
    streaks, run = [], 0
    for s in signs:
        if s < 0:
            run += 1
        elif run:
            streaks.append(run)
            run = 0
    if run:
        streaks.append(run)
    if not streaks:
        return {"max": 0, "mean": 0.0, "p95": 0}
    arr = np.array(streaks)
    return {"max": int(arr.max()), "mean": float(arr.mean()),
            "p95": int(np.percentile(arr, 95)), "count": len(arr)}


def evaluate_config(bars: pd.DataFrame, params: dict,
                    fees: FeeModel, funding: pd.Series | None = None,
                    pessimistic: bool = False) -> tuple[float, pd.Series]:
    """パラメータ辞書からTrendConfigを作り、Sharpeとリターン列を返す。

    walk_forward / build_returns_matrix に渡す評価関数。
    """
    cfg = TrendConfig(**params)
    model = PessimisticFillModel() if pessimistic else FillModel(
        slippage_bps=2.0, maker_fill_prob=0.7)

    bt = Backtester(bars, fees, fill_model=model, funding=funding,
                    config=BacktestConfig(initial_cash=10_000))
    result = bt.run(TrendFollowStrategy(cfg))

    eq = result.equity.dropna()
    if len(eq) < 10:
        return -np.inf, pd.Series(dtype=float)
    rets = eq.pct_change().dropna()
    std = rets.std(ddof=1)
    if std == 0 or not np.isfinite(std):
        return -np.inf, rets
    sharpe = float(rets.mean() / std * np.sqrt(8_760))
    return sharpe, rets


def full_validation(bars: pd.DataFrame, fees: FeeModel,
                    funding: pd.Series | None = None) -> None:
    # パラメータ候補は意図的に少なくする(試行回数を抑える)
    grid = [
        {"signal": "donchian", "entry_period": ep, "exit_period": xp,
         "atr_mult": am, "target_annual_vol": 0.20, "max_leverage": 2.0}
        for ep in (40, 55, 80)
        for xp in (15, 20, 30)
        for am in (2.5, 3.0, 4.0)
    ]
    n_trials = len(grid)
    print(f"パラメータ候補: {n_trials}通り(意図的に少なく保つ)\n")

    def ev(b: pd.DataFrame, p: dict) -> tuple[float, pd.Series]:
        return evaluate_config(b, p, fees, funding)

    # ---- 1) Walk-Forward -------------------------------------------------
    print("=" * 58)
    print("[1] Walk-Forward最適化")
    print("=" * 58)
    wfa = walk_forward(bars, grid, ev, is_days=180, oos_days=30)
    print(wfa.report())

    oos_rets = wfa.stitched_oos()
    if oos_rets.empty:
        print("\nOOSリターンが取得できませんでした")
        return

    # ---- 2) OOS成績の指標 -----------------------------------------------
    print("\n" + "=" * 58)
    print("[2] OOS(つないだ経路)の成績")
    print("=" * 58)
    std = oos_rets.std(ddof=1)
    oos_sharpe = (float(oos_rets.mean() / std * np.sqrt(8_760))
                  if std > 0 else 0.0)
    eq = (1 + oos_rets).cumprod()
    peak = eq.cummax()
    max_dd = float((eq / peak - 1).min())
    years = len(oos_rets) / 8_760
    annual = float(eq.iloc[-1] ** (1 / years) - 1) if years > 0 else 0.0

    print(f"  OOS Sharpe   : {oos_sharpe:>8.3f}")
    print(f"  OOS 年率      : {annual:>8.2%}")
    print(f"  OOS 最大DD    : {max_dd:>8.2%}")

    # ---- 3) Deflated Sharpe Ratio ---------------------------------------
    print("\n" + "=" * 58)
    print("[3] Deflated Sharpe Ratio")
    print("=" * 58)
    trial_sharpes = []
    for p in grid:
        s, _ = ev(bars, p)
        if np.isfinite(s):
            trial_sharpes.append(s)
    dsr = deflated_sharpe_ratio(oos_rets.to_numpy(), n_trials=n_trials,
                                trial_sharpes=trial_sharpes,
                                periods_per_year=8_760)
    print(dsr.report())

    # ---- 4) PBO ----------------------------------------------------------
    print("\n" + "=" * 58)
    print("[4] PBO(過剰最適化確率)")
    print("=" * 58)
    M = build_returns_matrix(bars, grid, ev)
    pbo = compute_pbo(M, n_blocks=10)
    print(pbo.report())

    # ---- 5) 悲観モデル ---------------------------------------------------
    print("\n" + "=" * 58)
    print("[5] 悲観コストモデル(スリッページ4倍・約定確率半分)")
    print("=" * 58)
    best = wfa.windows[-1].best_params if wfa.windows else grid[0]
    pess_sharpe, pess_rets = evaluate_config(bars, best, fees, funding,
                                             pessimistic=True)
    pess_net = float((1 + pess_rets).prod() - 1) * 10_000 if len(pess_rets) else 0.0
    print(f"  Sharpe       : {pess_sharpe:>8.3f}")
    print(f"  純損益(想定)  : {pess_net:>+10.2f}")

    # ---- 6) 連敗分布 -----------------------------------------------------
    print("\n" + "=" * 58)
    print("[6] 連敗の分布(心理的な耐性の確認)")
    print("=" * 58)
    daily = oos_rets.resample("D").sum()
    st = losing_streaks(daily[daily != 0])
    print(f"  最長連敗(日)  : {st['max']}")
    print(f"  95%点         : {st['p95']}")
    print(f"  平均           : {st['mean']:.1f}")
    print(f"  -> {st['max']}日連続で負ける局面が実際にあった。")
    print("     これに耐えられないなら、この戦略は運用できない。")

    # ---- 7) 採用判定 -----------------------------------------------------
    print("\n" + "=" * 58)
    print("[7] 採用判定")
    print("=" * 58)
    evidence = Evidence(
        n_trades=int((oos_rets != 0).sum()),
        oos_sharpe=oos_sharpe,
        wfe=wfa.mean_efficiency,
        dsr=dsr.dsr,
        pbo=pbo.pbo,
        param_stability=wfa.param_stability,
        max_dd=max_dd,
        annual_return=annual,
        pessimistic_net=pess_net,
        oos_positive_ratio=wfa.oos_positive_ratio,
        lookahead_test_passed=True,     # 別途 test_lookahead.py で確認
        n_trials_declared=n_trials,
    )
    print(evaluate_adoption(evidence, Criteria()).report())


if __name__ == "__main__":
    from client import make_client
    from costs import fetch_funding_history
    from kline_store import KlineStore

    bars = KlineStore().load("BTCUSDT", "60")
    if bars.empty:
        raise SystemExit("先に記事07でデータを取得してください")
    bars = bars.loc["2023-06-01":]

    fees = FeeModel(maker=Decimal("0.0002"), taker=Decimal("0.00055"))
    try:
        c = make_client()
        start_ms = int(bars.index[0].timestamp() * 1000)
        funding = fetch_funding_history(c.http, "BTCUSDT",
                                        start_ms)["funding_rate"]
    except Exception as e:
        print(f"資金調達履歴の取得に失敗({e})。資金調達なしで検証します")
        funding = None

    full_validation(bars, fees, funding)
検証を通らなければ実装は無意味

サンプル7を走らせると、多くの設定が「採用不可」になります。それが正常です。落ちたときにやってはいけないのは、基準を通すまでパラメータをいじることです。それは試行回数を増やす行為であり、DSRの前提を悪化させます。

やるべきなのは、(1) パラメータを減らす(フィルタを外す、シグナルを単純にする)、(2) 複数銘柄・複数期間で同じパラメータが機能するか確認する、(3) 検証期間を延ばす——のいずれかです。「調整して通す」のではなく「頑健にする」方向に動いてください。

本番稼働への接続

記事30のアーキテクチャに載せれば、Strategyクラスを1行も変更せずに本番で動きます。

run_trend_bot.pyサンプル8
"""run_trend_bot.py — トレンドフォローbotの本番起動スクリプト
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

記事30のRunnerに、記事33のStrategyを載せるだけ。
バックテストで検証したものと同一のクラスが動く。
"""
from __future__ import annotations

import asyncio
import logging
import sys

from components.feed import AccountFeed, MarketDataFeed
from components.order_router import OrderRouterComponent
from components.position import PositionKeeperComponent
from components.risk import RiskManagerComponent
from components.strategy_engine import StrategyEngine
from config_model import load_settings
from core.bus import EventBus
from core.runner import BotRunner
from client import make_client
from idempotent_router import IdempotentRouter
from order_ledger import OrderLedger
from reconciler import startup_reconcile
from strategies.trend_follow import TrendConfig, TrendFollowStrategy

log = logging.getLogger("run_trend_bot")

# バックテストで検証したのと「完全に同じ」設定を使う。
# ここを変えたら、再度記事32の検証を通す必要がある。
VALIDATED_CONFIG = TrendConfig(
    signal="donchian",
    entry_period=55,
    exit_period=20,
    atr_period=22,
    atr_mult=3.0,
    target_annual_vol=0.20,
    vol_lookback=168,
    max_leverage=2.0,
    min_efficiency=None,        # フィルタは検証で有効性を確認できたものだけ
    min_adx=None,
    use_htf_filter=False,
    max_vol_percentile=0.95,
    use_post_only=True,         # メイカー執行でコストを抑える(記事13)
    allow_short=True,
)


async def main() -> int:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)-7s %(name)-22s %(message)s",
    )

    settings = load_settings()
    print(settings.banner())

    c = make_client(settings.env)
    bus = EventBus(maxsize=settings.bus_maxsize)

    # ---- 起動前のリコンサイル(記事17)------------------------------
    ledger = OrderLedger()
    router = IdempotentRouter(c.http, ledger)
    try:
        startup_reconcile(c, ledger, router,
                          strategy_prefixes=set(settings.strategy_prefixes),
                          cancel_orphans=settings.cancel_orphans,
                          require_clean=True)
    except RuntimeError as e:
        log.error("リコンサイル失敗のため起動を中止: %s", e)
        return 1

    # ---- 口座設定を望む状態に収束させる(記事21)---------------------
    from account_setup import DesiredSetup, apply_setup, verify_setup
    from symbol_rules import SymbolRegistry

    reg = SymbolRegistry(c.http)
    desired = DesiredSetup(
        symbols={s: settings.leverage for s in settings.symbols},
        hedge_mode=False,
        planned_notional=settings.risk.max_notional_per_symbol,
    )
    result = apply_setup(c.http, desired, registry=reg)
    print(result.report())
    problems = verify_setup(c.http, desired)
    if problems:
        log.error("口座設定が期待値と一致しません: %s", problems)
        return 1

    # ---- コンポーネントの組み立て(記事30)---------------------------
    feed = MarketDataFeed(bus, c, symbols=settings.symbols,
                          interval=settings.interval,
                          category=settings.category,
                          warmup_bars=400)      # ウォームアップを十分に取る
    account = AccountFeed(bus, c)
    keeper = PositionKeeperComponent(bus, c)
    risk = RiskManagerComponent(bus, keeper, settings.risk)
    order_router = OrderRouterComponent(bus, c, router, keeper)

    # 全銘柄に同じ検証済み設定を適用する
    strategies = {sym: TrendFollowStrategy(VALIDATED_CONFIG)
                  for sym in settings.symbols}
    engine = StrategyEngine(bus, strategies, feed, keeper,
                            min_bars=400)

    runner = BotRunner(bus, heartbeat_sec=settings.heartbeat_sec)
    runner.add(keeper, account, feed, risk, order_router, engine)

    log.info("トレンドフォローbotを起動します(%d銘柄)",
             len(settings.symbols))
    log.info("設定: %s", VALIDATED_CONFIG)

    return await runner.run()


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))
VALIDATED_CONFIGを定数にする意味

設定をコード内の定数として固定し、コメントで「検証済み」と明示しているのは意図的です。稼働中に「なんとなく調整」してしまうのを防ぎます。

パラメータを変えたら、それは別の戦略です。記事32の検証をもう一度通し、試行回数のカウントも増やしてください。実運用中の微調整は、最も気づきにくい過剰最適化です。

参考文献
  • Moskowitz, T., Ooi, Y. H. & Pedersen, L. H. (2012) Time Series Momentum, Journal of Financial Economics — 58の先物・通貨で時系列モメンタムを検証した基本文献。ボラティリティ正規化を含む構成も本稿の元
  • Hurst, B., Ooi, Y. H. & Pedersen, L. H. (2017) A Century of Evidence on Trend-Following Investing, Journal of Portfolio Management — 100年以上のデータでの再現
  • Jegadeesh, N. & Titman, S. (1993) Returns to Buying Winners and Selling Losers, Journal of Finance — クロスセクション・モメンタムの原典
  • Moreira, A. & Muir, T. (2017) Volatility-Managed Portfolios, Journal of Finance — ボラティリティに応じたエクスポージャー調整の効果
  • Yang, D. & Zhang, Q. (2000) Drift-Independent Volatility Estimation Based on High, Low, Open, and Close Prices, Journal of Business — Yang-Zhang推定量
  • Kaufman, P. (2013) Trading Systems and Methods — 効率比(Efficiency Ratio)とトレンド判定

よくある疑問Q&A

どの時間足を使うべきですか?
回転率とコストのバランスで決まります。1時間足以上を推奨します。理由は、往復コスト(手数料+スリッページ+スプレッド)を回収するには十分な値幅が必要で、短い足では利幅がコストに埋もれるためです。
サンプル1の compare_signals() で「平均保有本数」を確認してください。1時間足で平均20本保有なら20時間で往復コストを回収する必要があります。日足のほうが検証も安定しますが、約定回数が減るため統計的な信頼性(記事32の min_trades)を確保しにくくなるというトレードオフがあります。
ショートも入れるべきですか?
仮想通貨では非対称です。長期的に上昇トレンドが優勢で、かつ資金調達率がプラスに偏る(ショートは受け取り側)ため、単純な期待値の比較はできません。
実務的には両方を検証して判断してください。TrendConfig(allow_short=False) でロングのみに切り替えられます。なおロングのみのほうがパラメータが減るため、記事32の検証を通しやすいという副次的な利点もあります。ショートを入れる場合は、資金調達の受取り(記事10)を必ずバックテストに含めてください。
フィルタを追加すれば成績が上がりますか?
バックテストの成績は上がりますが、それは過剰最適化の可能性が高いです。フィルタを1つ追加するとパラメータが1つ増え、DSRの判定が厳しくなります(サンプル5の n_params() で確認できます)。
判断基準は明確です。フィルタを追加してWalk-Forward効率比とDSRが改善するなら本物、バックテストのSharpeだけが上がるなら偽物です。サンプル7の検証で、フィルタあり・なしを比較してください。多くの場合、フィルタなしのほうが頑健です。
連敗が続いたら止めるべきですか?
事前に決めた基準に従ってください。「感覚で止める」のが最悪です。サンプル7の連敗分布で「過去に何連敗があったか」を把握し、それを大きく超えたら止める、という基準を作ってください。
その基準は記事24のサーキットブレーカーとして実装します(max_consecutive_losses)。ただし基準を超えた場合も「戦略が壊れた」とは限りません。トレンドフォローは長期の連敗が構造的に起こるため、止める判断は「損失額」ではなく「市場環境の変化」で行うほうが合理的です。
ドンチャンとTSMOM、どちらが良いですか?
性質が違います。ドンチャンは明確なブレイクで入るため、トレンドの初動を捉えやすく、レンジ相場でダマシに遭いやすい。TSMOMは過去リターンの符号なので反応が滑らかで、方向転換が遅れます。
どちらが優れているかはデータ次第で、期間や銘柄を変えると順位が入れ替わります。だからこそ「どちらが良いか」を選ぶ行為自体が試行回数を増やします。片方に決めて、それを頑健に作るほうが実務的です。両方を試して良いほうを採るなら、n_trialsを2倍として申告してください。
複数銘柄に分散すればリスクは下がりますか?
仮想通貨では期待するほど下がりません。サンプル6の effective_n() で確認できますが、BTCとアルトコインの相関は0.7〜0.9と高く、10銘柄に分散しても実効的には2〜3銘柄分のリスク分散しかありません。
特に急落時には相関が1に近づくため、分散はほとんど機能しません。したがって銘柄数を増やすより、合計レバレッジの上限管理(サンプル6の PortfolioLimits)が本質的な防御になります(記事23・24)。
PostOnlyでエントリーすると機会を逃しませんか?
逃します。ブレイクアウトの瞬間は価格が走るため、PostOnly指値は約定しにくい。これがトレンドフォローの本質的なジレンマです。
実務的な妥協案は3つあります。(1) エントリーは成行、エグジットはPostOnly(機会損失より確実性を優先)。(2) TTL付きPostOnly(数本待って約定しなければ成行にフォールバック・記事13)。(3) 約定率を実測してバックテストに反映(記事18のTIFベンチマークで maker_fill_prob を測る)。
どれを選んでも、選んだ方式でバックテストし直すことが重要です。「成行前提で検証してPostOnlyで運用」は前提が崩れています。

注意事項・免責事項

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

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

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

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

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

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

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