Almgren-Chriss最適執行をBybit APIで実装する
TWAP / VWAP / アイスバーグ発注bot

大きめのロットを一発の成行で流すと、板を食ってスリッページで負けます。かといって時間をかけて分割すれば、その間の価格変動リスクを負います。このマーケットインパクトと執行リスクのトレードオフを定式化したのがAlmgren-Chriss (2000) です。この記事ではTWAP・VWAP・アイスバーグ・パッシブの4方式を実装し、Implementation Shortfallで実効コストを実測比較します。サンプルコード8本。

確認日
2026-07-31
API バージョン
Bybit V5
Python
3.10 以上
依存
pybit / numpy / pandas

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

目次

  1. なぜ分割執行が必要なのか
  2. 執行エンジンの共通枠組み
  3. TWAP執行エンジン
  4. 出来高プロファイルで作るVWAP執行
  5. アイスバーグ執行
  6. パッシブ執行とフォールバック
  7. Almgren-Chriss最適軌道
  8. 執行品質の測定とA/B比較
  9. よくある疑問Q&A

なぜ分割執行が必要なのか

成行注文は板の厚みを上から順に食っていきます。最良気配だけで足りなければ、次の価格帯、その次——と、注文が大きいほど平均約定価格は悪化します。

執行方式インパクトタイミングリスク約定の確実性向く場面
一括成行 最大 なし 確実 小口・緊急の損切り
TWAP 小 中 高い 出来高が読めない銘柄
VWAP 小 中 高い 日内の出来高パターンが安定している銘柄
アイスバーグ 小 中 中 板から意図を隠したい場合
パッシブ 最小(+リベート) 大 低い 急がない・逆行してもよい場合
執行コストの2つの成分

Almgren-Chriss の枠組みでは、執行コストは①一時的インパクト(自分の注文が板を押し下げる分。執行速度に比例)と②永続的インパクト(市場に情報が伝わり価格水準そのものが動く分。累積数量に比例)に分解されます。
これに③タイミングリスク(執行に時間をかける間に価格が動く分散)が加わり、期待コスト + λ×分散を最小化する軌道を解きます。λは執行者のリスク回避度です。

執行エンジンの共通枠組み

4方式に共通する「総量・期間・スライス配分・状態の永続化」を先に切り出します。方式ごとの違いはスライス配分と発注方法だけにします。

execution_base.pyサンプル1
"""execution_base.py — 執行エンジンの共通基盤
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  執行アルゴリズムに共通するのは:
    1. 総量を N スライスに配分する(配分方法が方式ごとの違い)
    2. 各スライスを発注し、約定を記録する
    3. 未約定分を次スライスに繰り越す
    4. 中断・再開できるよう状態を永続化する

  最後の「中断・再開」が実務では最も重要。
  10分の執行の途中でbotが落ちたとき、
  どこまで約定したか分からないと二重執行になる。
"""
from __future__ import annotations

import json
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from decimal import Decimal
from pathlib import Path

from client import make_client
from symbol_rules import fetch_rules

STATE_DIR = Path("./exec_state")


@dataclass
class Slice:
    """1スライス分の計画と結果。"""
    index: int
    planned_qty: Decimal
    scheduled_at: float
    filled_qty: Decimal = Decimal(0)
    avg_price: Decimal = Decimal(0)
    fee: Decimal = Decimal(0)
    order_id: str = ""
    order_link_id: str = ""
    status: str = "pending"      # pending / working / done / failed

    @property
    def remaining(self) -> Decimal:
        return max(Decimal(0), self.planned_qty - self.filled_qty)


@dataclass
class ExecutionConfig:
    symbol: str
    side: str                     # "Buy" / "Sell"
    total_qty: Decimal
    duration_sec: float = 600.0
    n_slices: int = 20
    category: str = "linear"
    reduce_only: bool = False
    randomize_pct: float = 0.20   # スライス量を±この割合で揺らす
    randomize_time_pct: float = 0.30
    max_participation: float = 0.15   # 直近出来高に対する上限参加率
    link_prefix: str = "exec"

    @property
    def slice_sec(self) -> float:
        return self.duration_sec / self.n_slices


@dataclass
class ExecutionState:
    """永続化される実行状態。"""
    run_id: str
    config: dict
    slices: list[dict] = field(default_factory=list)
    arrival_price: str = "0"
    started_at: float = 0.0
    finished_at: float = 0.0

    def path(self) -> Path:
        return STATE_DIR / f"{self.run_id}.json"

    def save(self) -> None:
        STATE_DIR.mkdir(parents=True, exist_ok=True)
        self.path().write_text(
            json.dumps(asdict(self), ensure_ascii=False, indent=2,
                       default=str), encoding="utf-8")

    @classmethod
    def load(cls, run_id: str) -> "ExecutionState | None":
        p = STATE_DIR / f"{run_id}.json"
        if not p.exists():
            return None
        return cls(**json.loads(p.read_text(encoding="utf-8")))


class ExecutionAlgo(ABC):
    """執行アルゴリズムの基底クラス。"""

    def __init__(self, http, cfg: ExecutionConfig, run_id: str = ""):
        self.http = http
        self.cfg = cfg
        self.rules = fetch_rules(http, cfg.symbol, cfg.category)
        self.run_id = run_id or f"{cfg.link_prefix}-{int(time.time())}"
        self.slices: list[Slice] = []
        self.arrival_price = Decimal(0)

    # --- 方式ごとに実装する部分 -------------------------------------

    @abstractmethod
    def allocate(self) -> list[Decimal]:
        """総量をスライスに配分する。合計は total_qty と一致させる。"""

    @abstractmethod
    def execute_slice(self, sl: Slice) -> None:
        """1スライスを発注し、結果を sl に書き込む。"""

    # --- 共通処理 ---------------------------------------------------

    def mid_price(self) -> Decimal:
        r = self.http.get_tickers(category=self.cfg.category,
                                  symbol=self.cfg.symbol)
        t = r["result"]["list"][0]
        return (Decimal(t["bid1Price"]) + Decimal(t["ask1Price"])) / 2

    def build_schedule(self) -> None:
        """配分とスケジュールを作る。"""
        quantities = self.allocate()
        now = time.time()
        self.slices = [
            Slice(index=i, planned_qty=q,
                  scheduled_at=now + self.cfg.slice_sec * i)
            for i, q in enumerate(quantities) if q > 0
        ]

    def total_filled(self) -> Decimal:
        return sum((s.filled_qty for s in self.slices), Decimal(0))

    def vwap_of_execution(self) -> Decimal:
        """自分の執行の加重平均価格。"""
        filled = self.total_filled()
        if filled <= 0:
            return Decimal(0)
        notional = sum((s.filled_qty * s.avg_price for s in self.slices),
                       Decimal(0))
        return notional / filled

    def total_fee(self) -> Decimal:
        return sum((s.fee for s in self.slices), Decimal(0))

    def snapshot(self) -> ExecutionState:
        return ExecutionState(
            run_id=self.run_id,
            config={k: str(v) for k, v in asdict(self.cfg).items()},
            slices=[{k: str(v) for k, v in asdict(s).items()}
                    for s in self.slices],
            arrival_price=str(self.arrival_price),
            started_at=self.slices[0].scheduled_at if self.slices else 0.0,
        )

    def run(self, verbose: bool = True) -> ExecutionState:
        """執行を実行する。"""
        self.arrival_price = self.mid_price()
        self.build_schedule()

        if verbose:
            print(f"=== {type(self).__name__} 執行開始 ===")
            print(f"  {self.cfg.symbol} {self.cfg.side} "
                  f"{self.cfg.total_qty} / "
                  f"{len(self.slices)}スライス / "
                  f"{self.cfg.duration_sec:.0f}秒")
            print(f"  到着価格(基準): {self.arrival_price}")

        for sl in self.slices:
            wait = sl.scheduled_at - time.time()
            if wait > 0:
                time.sleep(wait)

            try:
                sl.status = "working"
                self.execute_slice(sl)
                sl.status = "done"
            except Exception as e:
                sl.status = "failed"
                print(f"  [error] slice {sl.index}: {type(e).__name__}: {e}")

            self.snapshot().save()   # 毎スライス保存(中断に備える)

            if verbose:
                done = self.total_filled()
                pct = done / self.cfg.total_qty * 100
                print(f"  [{sl.index + 1:>3}/{len(self.slices)}] "
                      f"約定 {sl.filled_qty} @{sl.avg_price} "
                      f"(累計 {done}/{self.cfg.total_qty} = {pct:.1f}%)")

        state = self.snapshot()
        state.finished_at = time.time()
        state.save()

        if verbose:
            print(f"\n  執行VWAP: {self.vwap_of_execution()}")
            print(f"  手数料合計: {self.total_fee()}")
        return state

    def resume(self, verbose: bool = True) -> ExecutionState:
        """中断した執行を再開する。

        永続化された状態から未執行のスライスだけを実行する。
        二重執行を避けるため、約定済み数量は必ず取引所側で再確認する。
        """
        prev = ExecutionState.load(self.run_id)
        if prev is None:
            raise FileNotFoundError(f"状態が見つかりません: {self.run_id}")

        self.arrival_price = Decimal(prev.arrival_price)
        self.slices = [
            Slice(index=int(d["index"]),
                  planned_qty=Decimal(d["planned_qty"]),
                  scheduled_at=float(d["scheduled_at"]),
                  filled_qty=Decimal(d["filled_qty"]),
                  avg_price=Decimal(d["avg_price"]),
                  fee=Decimal(d["fee"]),
                  order_id=d["order_id"],
                  order_link_id=d["order_link_id"],
                  status=d["status"])
            for d in prev.slices
        ]

        pending = [s for s in self.slices if s.status in ("pending", "failed")]
        if verbose:
            print(f"=== 執行を再開 ({self.run_id}) ===")
            print(f"  既約定 {self.total_filled()} / {self.cfg.total_qty}")
            print(f"  残スライス {len(pending)}件")

        # 遅延分を詰めて再スケジュール
        now = time.time()
        for i, sl in enumerate(pending):
            sl.scheduled_at = now + self.cfg.slice_sec * i

        for sl in pending:
            wait = sl.scheduled_at - time.time()
            if wait > 0:
                time.sleep(wait)
            try:
                sl.status = "working"
                self.execute_slice(sl)
                sl.status = "done"
            except Exception as e:
                sl.status = "failed"
                print(f"  [error] slice {sl.index}: {e}")
            self.snapshot().save()

        state = self.snapshot()
        state.finished_at = time.time()
        state.save()
        return state


def fetch_fill_result(http, category: str, symbol: str,
                      order_id: str) -> tuple[Decimal, Decimal, Decimal]:
    """注文の約定数量・平均価格・手数料を取得する。

    get_open_orders では約定後の注文が取れないため、
    get_order_history を使う。
    """
    r = http.get_order_history(category=category, symbol=symbol,
                               orderId=order_id, limit=1)
    if r["retCode"] != 0 or not r["result"]["list"]:
        return Decimal(0), Decimal(0), Decimal(0)
    o = r["result"]["list"][0]
    return (Decimal(o.get("cumExecQty") or "0"),
            Decimal(o.get("avgPrice") or "0"),
            Decimal(o.get("cumExecFee") or "0"))

TWAP執行エンジン

TWAP(Time-Weighted Average Price)は時間で均等に分割する最も単純な方式です。単純ゆえに他の参加者から読まれやすいため、量と時刻の両方をランダム化します。

twap.pyサンプル2
"""twap.py — TWAP執行エンジン(ランダム化つき)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  素のTWAPは「毎分きっかり同じ量」なので予測されやすい。
  数量と時刻の両方を揺らして、パターンを消す。

  さらに参加率の上限(直近出来高の何%まで)を設けて、
  流動性が枯れた時間帯に自分だけが板を食う状態を避ける。
"""
from __future__ import annotations

import random
import time
from decimal import Decimal

from execution_base import (ExecutionAlgo, ExecutionConfig, Slice,
                            fetch_fill_result)


class TwapAlgo(ExecutionAlgo):
    """時間均等分割 + ランダム化。"""

    def allocate(self) -> list[Decimal]:
        n = self.cfg.n_slices
        base = self.cfg.total_qty / n

        # ±randomize_pct で揺らす
        raw = []
        for _ in range(n):
            jitter = Decimal(str(1 + random.uniform(
                -self.cfg.randomize_pct, self.cfg.randomize_pct)))
            raw.append(base * jitter)

        # 合計を total_qty に合わせ、最小ロット単位に丸める
        scale = self.cfg.total_qty / sum(raw)
        quantities = [self.rules.round_qty(q * scale) for q in raw]

        # 丸め誤差を最後のスライスで吸収する
        diff = self.cfg.total_qty - sum(quantities)
        if diff != 0:
            quantities[-1] = self.rules.round_qty(quantities[-1] + diff)

        return quantities

    def build_schedule(self) -> None:
        """時刻もランダム化する。"""
        super().build_schedule()
        jitter_range = self.cfg.slice_sec * self.cfg.randomize_time_pct
        for sl in self.slices:
            sl.scheduled_at += random.uniform(-jitter_range, jitter_range)
        self.slices.sort(key=lambda s: s.scheduled_at)

    def recent_volume(self, seconds: float = 60.0) -> Decimal:
        """直近の出来高(参加率の分母)。"""
        r = self.http.get_kline(category=self.cfg.category,
                                symbol=self.cfg.symbol,
                                interval="1", limit=2)
        if r["retCode"] != 0 or not r["result"]["list"]:
            return Decimal(0)
        # list[0] が最新(未確定足)。確定足の list[1] を使う
        row = r["result"]["list"][min(1, len(r["result"]["list"]) - 1)]
        return Decimal(row[5])     # volume

    def cap_by_participation(self, qty: Decimal) -> Decimal:
        """参加率の上限を超えないよう数量を抑える。"""
        vol = self.recent_volume()
        if vol <= 0:
            return qty
        cap = self.rules.round_qty(vol * Decimal(str(self.cfg.max_participation)))
        if cap <= 0:
            return qty
        return min(qty, cap)

    def execute_slice(self, sl: Slice) -> None:
        qty = self.cap_by_participation(sl.planned_qty)
        if qty < self.rules.min_qty:
            sl.filled_qty = Decimal(0)
            return

        link = f"{self.run_id}-s{sl.index:03d}"
        params = {
            "category": self.cfg.category,
            "symbol": self.cfg.symbol,
            "side": self.cfg.side,
            "orderType": "Market",
            "qty": self.rules.format_qty(qty),
            "orderLinkId": link,
        }
        if self.cfg.reduce_only:
            params["reduceOnly"] = True

        r = self.http.place_order(**params)
        if r["retCode"] != 0:
            raise RuntimeError(f"発注失敗: {r['retCode']} {r['retMsg']}")

        sl.order_id = r["result"]["orderId"]
        sl.order_link_id = link

        # 成行なので即時約定するが、履歴反映に少し待つ
        time.sleep(0.3)
        filled, avg, fee = fetch_fill_result(
            self.http, self.cfg.category, self.cfg.symbol, sl.order_id)
        sl.filled_qty, sl.avg_price, sl.fee = filled, avg, fee

        # 未約定分(参加率で削った分)を残りのスライスに配る
        shortfall = sl.planned_qty - filled
        if shortfall > 0:
            pending = [s for s in self.slices
                       if s.index > sl.index and s.status == "pending"]
            if pending:
                add = shortfall / len(pending)
                for s in pending:
                    s.planned_qty = self.rules.round_qty(s.planned_qty + add)


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

    c = make_client()   # 既定 testnet=True

    cfg = ExecutionConfig(
        symbol="BTCUSDT", side="Buy", total_qty=Decimal("0.05"),
        duration_sec=120.0, n_slices=10,
        randomize_pct=0.25, max_participation=0.10)

    algo = TwapAlgo(c.http, cfg)
    state = algo.run()

    print(f"\n  到着価格   : {algo.arrival_price}")
    print(f"  執行VWAP   : {algo.vwap_of_execution()}")
    slippage_bps = ((algo.vwap_of_execution() - algo.arrival_price)
                    / algo.arrival_price * Decimal("10000"))
    if cfg.side == "Sell":
        slippage_bps = -slippage_bps
    print(f"  スリッページ: {slippage_bps:+.2f} bps(プラスが不利)")
    print(f"\n  状態ファイル: exec_state/{algo.run_id}.json")
    print("  途中で落ちても algo.resume() で続行できる。")

出来高プロファイルで作るVWAP執行

VWAP執行は市場の出来高パターンに合わせて配分します。出来高が多い時間帯に多く流せば、同じ量でも市場に占める割合が小さくなり、インパクトが下がります。

vwap.pyサンプル3
"""vwap.py — 出来高プロファイルから作るVWAP執行
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  手順:
    1. 過去N日の同じ時間帯の出来高を集計し、日内プロファイルを作る
    2. 執行期間に該当する部分だけを切り出し、正規化してウェイトにする
    3. total_qty をそのウェイトで配分する

  暗号資産は24時間市場だが、出来高には明確な日内パターンがある
  (欧州・米国の立ち上がり、アジア時間の薄さ)。
  このパターンはUTC基準で安定していることが多い。
"""
from __future__ import annotations

import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal

import numpy as np
import pandas as pd

from execution_base import ExecutionConfig, Slice, fetch_fill_result
from kline_fetch import fetch_klines
from twap import TwapAlgo


def build_volume_profile(http, symbol: str, *, category: str = "linear",
                         days: int = 14, bucket_min: int = 5) -> pd.Series:
    """日内の出来高プロファイルを作る。

    戻り値: index = 日内の分(0〜1439 を bucket_min で丸めたもの)
            value = 平均出来高(正規化前)
    """
    start = (datetime.now(timezone.utc) - timedelta(days=days)) \
        .strftime("%Y-%m-%d")
    bars = fetch_klines(http, symbol, interval=str(bucket_min),
                        start=start, category=category, verbose=False)
    if bars.empty:
        raise RuntimeError("Kline取得に失敗しました")

    minute_of_day = bars.index.hour * 60 + bars.index.minute
    bucket = (minute_of_day // bucket_min) * bucket_min

    profile = bars.groupby(bucket)["volume"].mean()
    # 欠損バケットを埋める(取引が無かった時間帯)
    full = pd.Series(0.0, index=range(0, 1440, bucket_min))
    full.update(profile)
    # ゼロ割りを避けるため、最小値で下限を切る
    floor = full[full > 0].min() * 0.1 if (full > 0).any() else 1.0
    return full.clip(lower=floor)


def slice_weights(profile: pd.Series, start_ts: float,
                  duration_sec: float, n_slices: int) -> list[float]:
    """執行期間に対応するウェイトを取り出して正規化する。"""
    bucket_min = int(profile.index[1] - profile.index[0])
    weights = []

    for i in range(n_slices):
        t = start_ts + duration_sec * (i + 0.5) / n_slices
        dt = datetime.fromtimestamp(t, tz=timezone.utc)
        minute = dt.hour * 60 + dt.minute
        key = (minute // bucket_min) * bucket_min
        weights.append(float(profile.get(key, profile.mean())))

    total = sum(weights)
    return [w / total for w in weights] if total > 0 else \
           [1.0 / n_slices] * n_slices


class VwapAlgo(TwapAlgo):
    """出来高プロファイルに沿って配分する。発注方法はTWAPと同じ。"""

    def __init__(self, http, cfg: ExecutionConfig, run_id: str = "",
                 profile_days: int = 14):
        super().__init__(http, cfg, run_id)
        self.profile = build_volume_profile(
            http, cfg.symbol, category=cfg.category, days=profile_days)
        self.weights: list[float] = []

    def allocate(self) -> list[Decimal]:
        self.weights = slice_weights(
            self.profile, time.time(),
            self.cfg.duration_sec, self.cfg.n_slices)

        raw = [self.cfg.total_qty * Decimal(str(w)) for w in self.weights]
        quantities = [self.rules.round_qty(q) for q in raw]

        diff = self.cfg.total_qty - sum(quantities)
        if diff != 0:
            # 最もウェイトの大きいスライスで誤差を吸収する
            k = int(np.argmax(self.weights))
            quantities[k] = self.rules.round_qty(quantities[k] + diff)
        return quantities

    def build_schedule(self) -> None:
        """VWAPは時刻を揺らさない(出来高の山に合わせる意味が薄れる)。"""
        quantities = self.allocate()
        now = time.time()
        self.slices = [
            Slice(index=i, planned_qty=q,
                  scheduled_at=now + self.cfg.slice_sec * i)
            for i, q in enumerate(quantities) if q > 0
        ]

    def market_vwap_since_start(self) -> Decimal:
        """執行期間中の市場VWAP。ベンチマークとして使う。"""
        minutes = max(1, int(self.cfg.duration_sec / 60) + 1)
        r = self.http.get_kline(category=self.cfg.category,
                                symbol=self.cfg.symbol,
                                interval="1", limit=minutes)
        if r["retCode"] != 0 or not r["result"]["list"]:
            return Decimal(0)

        notional = Decimal(0)
        volume = Decimal(0)
        for row in r["result"]["list"]:
            # [start, open, high, low, close, volume, turnover]
            volume += Decimal(row[5])
            notional += Decimal(row[6])
        return notional / volume if volume > 0 else Decimal(0)


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

    c = make_client()
    symbol = "BTCUSDT"

    print("=== 出来高プロファイル(UTC時間帯別) ===")
    profile = build_volume_profile(c.http, symbol, days=14, bucket_min=60)
    normalized = profile / profile.sum() * 100
    for minute, pct in normalized.items():
        hour = minute // 60
        bar = "#" * int(pct * 4)
        print(f"  {hour:02d}:00 UTC  {pct:>5.2f}%  {bar}")

    peak = int(normalized.idxmax()) // 60
    trough = int(normalized.idxmin()) // 60
    print(f"\n  出来高のピーク: {peak:02d}:00 UTC "
          f"({normalized.max():.2f}%)")
    print(f"  出来高の谷    : {trough:02d}:00 UTC "
          f"({normalized.min():.2f}%)")
    print(f"  ピーク/谷 比率: {normalized.max() / normalized.min():.1f}倍")

    cfg = ExecutionConfig(
        symbol=symbol, side="Buy", total_qty=Decimal("0.05"),
        duration_sec=180.0, n_slices=12, max_participation=0.10)

    algo = VwapAlgo(c.http, cfg)
    state = algo.run()

    market_vwap = algo.market_vwap_since_start()
    my_vwap = algo.vwap_of_execution()
    print(f"\n  市場VWAP  : {market_vwap}")
    print(f"  執行VWAP  : {my_vwap}")
    if market_vwap > 0:
        diff_bps = (my_vwap - market_vwap) / market_vwap * Decimal("10000")
        if cfg.side == "Sell":
            diff_bps = -diff_bps
        print(f"  VWAP差    : {diff_bps:+.2f} bps(プラスが不利)")
    print("\n※ VWAP執行の評価基準は『市場VWAPに勝てたか』。")
    print("  到着価格基準(IS)とは別の指標なので混同しないこと。")

アイスバーグ執行

アイスバーグは表示数量を小さくして板に置き、約定するたびに補充する方式です。板から本来の意図が読まれにくくなります。BybitにはネイティブなIceberg機能が無いため、自前で補充ロジックを組みます。

iceberg.pyサンプル4
"""iceberg.py — アイスバーグ執行(表示数量を小さく分割して補充)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  仕組み:
    - 総量のうち display_qty だけを指値で板に置く
    - 約定したら、残量から次の display_qty を補充する
    - 価格が離れたら注文を追従させる(追いすぎるとテイカー化するので注意)

  Bybitにネイティブなアイスバーグ機能は無いため、
  約定監視 → 補充 のループを自前で回す。
  WebSocket(記事27)で約定を受けると補充が速くなる。
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from decimal import Decimal

from execution_base import (ExecutionAlgo, ExecutionConfig, Slice,
                            fetch_fill_result)


@dataclass
class IcebergConfig:
    display_ratio: Decimal = Decimal("0.05")   # 総量の何%を表示するか
    offset_ticks: int = 1        # 最良気配から何ティック内側に置くか
    reprice_ticks: int = 3       # 価格がこれだけ離れたら置き直す
    poll_sec: float = 1.0
    timeout_sec: float = 600.0
    market_fallback: bool = True   # 期限内に終わらなければ成行で残りを処理


class IcebergAlgo(ExecutionAlgo):
    """アイスバーグ執行。スライスの概念を使わず残量ベースで回す。"""

    def __init__(self, http, cfg: ExecutionConfig,
                 ice: IcebergConfig = IcebergConfig(), run_id: str = ""):
        super().__init__(http, cfg, run_id)
        self.ice = ice
        self.current_order_id = ""
        self.current_price = Decimal(0)
        self.filled_total = Decimal(0)
        self.notional_total = Decimal(0)
        self.fee_total = Decimal(0)

    def allocate(self) -> list[Decimal]:
        """残量ベースで動くため、計画配分は表示数量の繰り返しになる。"""
        display = self.rules.round_qty(
            self.cfg.total_qty * self.ice.display_ratio)
        display = max(display, self.rules.min_qty)

        quantities, remaining = [], self.cfg.total_qty
        while remaining > 0:
            q = min(display, remaining)
            quantities.append(self.rules.round_qty(q))
            remaining -= q
        return quantities

    def best_price(self) -> Decimal:
        """自分の側の最良気配から offset_ticks 内側の価格。"""
        r = self.http.get_orderbook(category=self.cfg.category,
                                    symbol=self.cfg.symbol, limit=1)
        if r["retCode"] != 0:
            raise RuntimeError(f"板取得失敗: {r['retCode']} {r['retMsg']}")
        ob = r["result"]
        bid = Decimal(ob["b"][0][0])
        ask = Decimal(ob["a"][0][0])
        tick = self.rules.tick_size

        if self.cfg.side == "Buy":
            # 買いは bid に並ぶ。offset分だけ有利側(下)に置くと約定しにくい
            return bid + tick * self.ice.offset_ticks
        return ask - tick * self.ice.offset_ticks

    def _place_display(self, qty: Decimal, price: Decimal,
                       tag: str) -> str:
        params = {
            "category": self.cfg.category,
            "symbol": self.cfg.symbol,
            "side": self.cfg.side,
            "orderType": "Limit",
            "qty": self.rules.format_qty(qty),
            "price": self.rules.format_price(price),
            "timeInForce": "PostOnly",   # メイカーを保証(テイカー化なら拒否)
            "orderLinkId": f"{self.run_id}-{tag}",
        }
        if self.cfg.reduce_only:
            params["reduceOnly"] = True

        r = self.http.place_order(**params)
        if r["retCode"] == 0:
            return r["result"]["orderId"]
        # 110001系: PostOnlyが拒否された(価格が板を跨いだ)
        if r["retCode"] in (110094, 30208):
            return ""
        raise RuntimeError(f"発注失敗: {r['retCode']} {r['retMsg']}")

    def _cancel_current(self) -> None:
        if not self.current_order_id:
            return
        r = self.http.cancel_order(category=self.cfg.category,
                                   symbol=self.cfg.symbol,
                                   orderId=self.current_order_id)
        if r["retCode"] not in (0, 110001):
            print(f"  [warn] キャンセル失敗: {r['retCode']} {r['retMsg']}")
        self.current_order_id = ""

    def _collect_fill(self) -> Decimal:
        """現在の注文の約定分を集計に取り込む。戻り値は今回の約定量。"""
        if not self.current_order_id:
            return Decimal(0)
        filled, avg, fee = fetch_fill_result(
            self.http, self.cfg.category, self.cfg.symbol,
            self.current_order_id)
        if filled > 0:
            self.filled_total += filled
            self.notional_total += filled * avg
            self.fee_total += fee
        return filled

    def execute_slice(self, sl: Slice) -> None:
        """1つの表示ロットを約定させる。"""
        deadline = time.time() + self.ice.timeout_sec / len(self.slices)
        remaining = sl.planned_qty
        tag = f"i{sl.index:03d}"

        while remaining > 0 and time.time() < deadline:
            price = self.best_price()

            need_replace = (
                not self.current_order_id
                or abs(price - self.current_price)
                >= self.rules.tick_size * self.ice.reprice_ticks)

            if need_replace:
                if self.current_order_id:
                    self._cancel_current()
                    got = self._collect_fill()
                    remaining -= got
                    sl.filled_qty += got
                    if remaining <= 0:
                        break

                oid = self._place_display(remaining, price, tag)
                if not oid:
                    time.sleep(self.ice.poll_sec)
                    continue
                self.current_order_id = oid
                self.current_price = price
                sl.order_id = oid

            time.sleep(self.ice.poll_sec)

            filled, avg, fee = fetch_fill_result(
                self.http, self.cfg.category, self.cfg.symbol,
                self.current_order_id)
            if filled >= remaining:
                self.filled_total += filled
                self.notional_total += filled * avg
                self.fee_total += fee
                sl.filled_qty += filled
                sl.avg_price = avg
                sl.fee = fee
                self.current_order_id = ""
                remaining = Decimal(0)

        # タイムアウト時の後始末
        if remaining > 0:
            self._cancel_current()
            got = self._collect_fill()
            remaining -= got
            sl.filled_qty += got

            if remaining > 0 and self.ice.market_fallback:
                print(f"  [fallback] 残 {remaining} を成行で処理")
                r = self.http.place_order(
                    category=self.cfg.category, symbol=self.cfg.symbol,
                    side=self.cfg.side, orderType="Market",
                    qty=self.rules.format_qty(remaining),
                    orderLinkId=f"{self.run_id}-{tag}-mkt")
                if r["retCode"] == 0:
                    time.sleep(0.3)
                    filled, avg, fee = fetch_fill_result(
                        self.http, self.cfg.category, self.cfg.symbol,
                        r["result"]["orderId"])
                    self.filled_total += filled
                    self.notional_total += filled * avg
                    self.fee_total += fee
                    sl.filled_qty += filled

        if sl.filled_qty > 0 and sl.avg_price == 0:
            sl.avg_price = self.notional_total / self.filled_total

    def vwap_of_execution(self) -> Decimal:
        if self.filled_total <= 0:
            return Decimal(0)
        return self.notional_total / self.filled_total

    def total_filled(self) -> Decimal:
        return self.filled_total

    def total_fee(self) -> Decimal:
        return self.fee_total


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

    c = make_client()

    cfg = ExecutionConfig(
        symbol="BTCUSDT", side="Buy", total_qty=Decimal("0.05"),
        duration_sec=180.0, n_slices=10)
    ice = IcebergConfig(display_ratio=Decimal("0.1"),
                        offset_ticks=1, reprice_ticks=3,
                        timeout_sec=180.0)

    algo = IcebergAlgo(c.http, cfg, ice)
    algo.run()

    print(f"\n  表示数量: 総量の {ice.display_ratio:.0%} "
          f"= {cfg.total_qty * ice.display_ratio}")
    print(f"  約定合計: {algo.total_filled()} / {cfg.total_qty}")
    print(f"  執行VWAP: {algo.vwap_of_execution()}")
    print(f"  手数料  : {algo.total_fee()}")
    print("\n※ PostOnlyなのでメイカー手数料(マイナスの場合もある)。")
    print("  ただし約定しないまま時間切れになるリスクがある。")
    print("  market_fallback=False にすると未執行のまま終わる。")

パッシブ執行とフォールバック

passive.pyサンプル5
"""passive.py — PostOnly指値を板に追従させ、期限切れで成行に切り替える
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  パッシブ執行は「メイカー手数料で執行したい」という動機で使う。
  Bybitのメイカー手数料は多くの銘柄でテイカーより低く、
  往復で数bpsの差がつく。ただし約定しないリスクを負う。

  設計の要点:
    - 板の最良気配に並ぶが、追いすぎるとスプレッドを跨いでしまう
    - 「どこまで不利な価格を許容するか」を price_limit で明示する
    - 期限が近づいたら段階的に攻撃的にする(urgency ramp)
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from decimal import Decimal

from execution_base import (ExecutionAlgo, ExecutionConfig, Slice,
                            fetch_fill_result)


@dataclass
class PassiveConfig:
    poll_sec: float = 1.0
    reprice_ticks: int = 1        # 最良気配がこれだけ動いたら追従
    max_adverse_bps: float = 20.0  # 到着価格からこれ以上不利なら追わない
    ramp_start: float = 0.6       # 残時間がこの割合を切ったら攻撃的に
    cross_at_end: bool = True     # 最後は成行で確実に終わらせる


class PassiveAlgo(ExecutionAlgo):
    """PostOnly指値で板に張り付き、期限切れで成行フォールバック。"""

    def __init__(self, http, cfg: ExecutionConfig,
                 pcfg: PassiveConfig = PassiveConfig(), run_id: str = ""):
        super().__init__(http, cfg, run_id)
        self.p = pcfg
        self.filled_total = Decimal(0)
        self.notional_total = Decimal(0)
        self.fee_total = Decimal(0)
        self.maker_qty = Decimal(0)
        self.taker_qty = Decimal(0)

    def allocate(self) -> list[Decimal]:
        """パッシブは1本の注文を追従させるだけなので1スライス。"""
        return [self.cfg.total_qty]

    def _book(self) -> tuple[Decimal, Decimal]:
        r = self.http.get_orderbook(category=self.cfg.category,
                                    symbol=self.cfg.symbol, limit=1)
        if r["retCode"] != 0:
            raise RuntimeError(f"板取得失敗: {r['retCode']} {r['retMsg']}")
        return (Decimal(r["result"]["b"][0][0]),
                Decimal(r["result"]["a"][0][0]))

    def _target_price(self, aggression: float) -> Decimal:
        """aggression 0.0=最良気配に並ぶ / 1.0=反対側に踏み込む。"""
        bid, ask = self._book()
        tick = self.rules.tick_size
        spread_ticks = max(1, int((ask - bid) / tick))
        step = int(spread_ticks * aggression)

        if self.cfg.side == "Buy":
            return self.rules.round_price(bid + tick * step)
        return self.rules.round_price(ask - tick * step)

    def _within_limit(self, price: Decimal) -> bool:
        """到着価格から許容範囲内かを判定する。"""
        if self.arrival_price <= 0:
            return True
        drift_bps = (price - self.arrival_price) / self.arrival_price \
            * Decimal("10000")
        if self.cfg.side == "Sell":
            drift_bps = -drift_bps
        return drift_bps <= Decimal(str(self.p.max_adverse_bps))

    def execute_slice(self, sl: Slice) -> None:
        deadline = time.time() + self.cfg.duration_sec
        remaining = sl.planned_qty
        order_id, order_price = "", Decimal(0)

        def collect(oid: str) -> Decimal:
            filled, avg, fee = fetch_fill_result(
                self.http, self.cfg.category, self.cfg.symbol, oid)
            if filled > 0:
                self.filled_total += filled
                self.notional_total += filled * avg
                self.fee_total += fee
            return filled

        while remaining > 0 and time.time() < deadline:
            elapsed_ratio = 1 - (deadline - time.time()) / self.cfg.duration_sec
            # 残時間が減るほど攻撃的にする
            aggression = 0.0 if elapsed_ratio < self.p.ramp_start else \
                (elapsed_ratio - self.p.ramp_start) / (1 - self.p.ramp_start)

            price = self._target_price(aggression)

            if not self._within_limit(price):
                print(f"  [hold] 価格 {price} は許容範囲外。待機")
                time.sleep(self.p.poll_sec)
                continue

            need_replace = (
                not order_id
                or abs(price - order_price)
                >= self.rules.tick_size * self.p.reprice_ticks)

            if need_replace:
                if order_id:
                    r = self.http.cancel_order(
                        category=self.cfg.category, symbol=self.cfg.symbol,
                        orderId=order_id)
                    if r["retCode"] in (0, 110001):
                        got = collect(order_id)
                        remaining -= got
                        self.maker_qty += got
                    order_id = ""
                    if remaining <= 0:
                        break

                r = self.http.place_order(
                    category=self.cfg.category, symbol=self.cfg.symbol,
                    side=self.cfg.side, orderType="Limit",
                    qty=self.rules.format_qty(remaining),
                    price=self.rules.format_price(price),
                    timeInForce="PostOnly",
                    orderLinkId=f"{self.run_id}-p{int(time.time() * 1000) % 100000}")
                if r["retCode"] == 0:
                    order_id, order_price = r["result"]["orderId"], price
                elif r["retCode"] in (110094, 30208):
                    # PostOnlyが板を跨いだので拒否された。少し待つ
                    time.sleep(self.p.poll_sec)
                    continue
                else:
                    raise RuntimeError(
                        f"発注失敗: {r['retCode']} {r['retMsg']}")

            time.sleep(self.p.poll_sec)

            filled, avg, fee = fetch_fill_result(
                self.http, self.cfg.category, self.cfg.symbol, order_id)
            if filled >= remaining:
                self.filled_total += filled
                self.notional_total += filled * avg
                self.fee_total += fee
                self.maker_qty += filled
                remaining = Decimal(0)
                order_id = ""

        # 期限切れの処理
        if order_id:
            self.http.cancel_order(category=self.cfg.category,
                                    symbol=self.cfg.symbol, orderId=order_id)
            got = collect(order_id)
            remaining -= got
            self.maker_qty += got

        if remaining > 0 and self.p.cross_at_end:
            print(f"  [cross] 残 {remaining} を成行で処理(テイカー)")
            r = self.http.place_order(
                category=self.cfg.category, symbol=self.cfg.symbol,
                side=self.cfg.side, orderType="Market",
                qty=self.rules.format_qty(remaining),
                orderLinkId=f"{self.run_id}-cross")
            if r["retCode"] == 0:
                time.sleep(0.3)
                got = collect(r["result"]["orderId"])
                self.taker_qty += got

        sl.filled_qty = self.filled_total
        sl.avg_price = self.vwap_of_execution()
        sl.fee = self.fee_total

    def vwap_of_execution(self) -> Decimal:
        if self.filled_total <= 0:
            return Decimal(0)
        return self.notional_total / self.filled_total

    def total_filled(self) -> Decimal:
        return self.filled_total

    def total_fee(self) -> Decimal:
        return self.fee_total

    def maker_ratio(self) -> float:
        total = self.maker_qty + self.taker_qty
        return float(self.maker_qty / total) if total > 0 else 0.0


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

    c = make_client()

    cfg = ExecutionConfig(
        symbol="BTCUSDT", side="Buy", total_qty=Decimal("0.02"),
        duration_sec=180.0, n_slices=1)

    algo = PassiveAlgo(c.http, cfg,
                       PassiveConfig(max_adverse_bps=15.0, ramp_start=0.7))
    algo.run()

    print(f"\n  メイカー比率: {algo.maker_ratio():.1%}")
    print(f"  手数料      : {algo.total_fee()}")
    print(f"  執行VWAP    : {algo.vwap_of_execution()}")
    print("\n※ メイカー比率が高いほど手数料は有利だが、")
    print("  約定するまでの時間が長く、その間の価格変動リスクを負う。")
    print("  『手数料で得して価格で損する』のは典型的な失敗。")
    print("  必ずIS(次節)で総コストを見ること。")

Almgren-Chriss最適軌道

ここまでの配分は経験則でした。Almgren-Chriss は期待コストと分散のトレードオフを解いて、最適な残高軌道を閉じた形で与えます。

almgren_chriss.pyサンプル6
"""almgren_chriss.py — Almgren-Chriss最適執行軌道
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Almgren, R. & Chriss, N. (2000)
      "Optimal Execution of Portfolio Transactions", Journal of Risk 3(2)

  モデル:
    総量 X を時間 T で N 期に分けて執行する。
    価格過程: S_k = S_{k-1} + σ√τ ξ_k − τ g(n_k/τ)     (永続的インパクト)
    約定価格: S̃_k = S_{k-1} − h(n_k/τ)                  (一時的インパクト)

    線形インパクトを仮定: g(v) = γv,  h(v) = ε·sgn(v) + ηv

    最小化: E[コスト] + λ·Var[コスト]

  解(残高軌道):
    x_k = X · sinh(κ(T − t_k)) / sinh(κT)

    κ² ≈ λσ² / η̃  ,  η̃ = η − γτ/2

  λ→0 なら κ→0 で x_k は直線 = TWAP。
  λが大きいほど前倒しになる(リスクを早く落としたい)。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

import numpy as np


@dataclass(frozen=True)
class AcParams:
    total_qty: float          # X: 執行総量
    duration_sec: float       # T: 執行時間(秒)
    n_slices: int             # N
    sigma: float              # σ: ボラティリティ(価格単位 / √秒)
    eta: float                # η: 一時的インパクト係数
    gamma: float              # γ: 永続的インパクト係数
    epsilon: float = 0.0      # ε: 固定コスト(スプレッドの半分など)
    risk_aversion: float = 1e-6   # λ

    @property
    def tau(self) -> float:
        return self.duration_sec / self.n_slices

    @property
    def eta_tilde(self) -> float:
        """η̃ = η − γτ/2。これが負になるとモデルが破綻する。"""
        return self.eta - self.gamma * self.tau / 2

    @property
    def kappa(self) -> float:
        """κ: 執行の『急ぎ度』。大きいほど前倒し。"""
        et = self.eta_tilde
        if et <= 0:
            raise ValueError(
                f"η̃={et:.3e} が非正です。"
                "スライス数を増やすかηを大きくしてください")
        kappa_sq = self.risk_aversion * self.sigma ** 2 / et
        # 離散版の補正
        kappa_tilde_sq = 2 / self.tau ** 2 * (
            np.cosh(np.sqrt(kappa_sq) * self.tau) - 1) \
            if kappa_sq > 0 else 0.0
        return float(np.sqrt(max(kappa_sq, kappa_tilde_sq)))


def optimal_trajectory(p: AcParams) -> np.ndarray:
    """残高軌道 x_k (k=0..N) を返す。x_0 = X, x_N = 0。"""
    N, T, kappa = p.n_slices, p.duration_sec, p.kappa
    t = np.linspace(0, T, N + 1)

    if kappa * T < 1e-9:
        # λ→0 の極限は線形(TWAP)
        return p.total_qty * (1 - t / T)

    return p.total_qty * np.sinh(kappa * (T - t)) / np.sinh(kappa * T)


def optimal_slices(p: AcParams) -> np.ndarray:
    """各期の執行量 n_k = x_{k-1} − x_k を返す。"""
    x = optimal_trajectory(p)
    return np.diff(-x)      # 正の値になる


def expected_cost(p: AcParams) -> dict:
    """期待コストと分散(価格単位×数量)。

    E[C] = ε·Σ|n_k| + η̃/τ · Σ n_k² + γ/2 · X²
    V[C] = σ² · τ · Σ x_k²
    """
    n = optimal_slices(p)
    x = optimal_trajectory(p)[1:]     # x_1..x_N

    fixed = p.epsilon * float(np.abs(n).sum())
    temporary = p.eta_tilde / p.tau * float((n ** 2).sum())
    permanent = p.gamma / 2 * p.total_qty ** 2
    variance = p.sigma ** 2 * p.tau * float((x ** 2).sum())

    return {
        "固定コスト": fixed,
        "一時的インパクト": temporary,
        "永続的インパクト": permanent,
        "期待コスト合計": fixed + temporary + permanent,
        "分散": variance,
        "標準偏差": float(np.sqrt(variance)),
        "効用": fixed + temporary + permanent + p.risk_aversion * variance,
    }


def calibrate_from_market(mid_price: float, spread: float,
                          annual_vol: float, depth_qty: float,
                          depth_impact_bps: float,
                          total_qty: float, duration_sec: float,
                          n_slices: int,
                          risk_aversion: float = 1e-6) -> AcParams:
    """観測できる市場量からパラメータを推定する。

      σ      : 年率ボラ(記事12)を秒あたりに換算し、価格単位にする
      ε      : スプレッドの半分(テイカーの最低コスト)
      η      : 「depth_qty を執行すると depth_impact_bps 動く」から逆算
      γ      : 永続的インパクトは一時的の 1/2 程度とする経験則
    """
    seconds_per_year = 365 * 24 * 3600
    sigma = annual_vol * mid_price / np.sqrt(seconds_per_year)

    # η: 一時的インパクト。v = depth_qty/秒 のとき Δ = depth_impact_bps
    impact_price = mid_price * depth_impact_bps / 10_000
    eta = impact_price / max(depth_qty, 1e-12)

    return AcParams(
        total_qty=total_qty, duration_sec=duration_sec, n_slices=n_slices,
        sigma=sigma, eta=eta, gamma=eta / 2,
        epsilon=spread / 2, risk_aversion=risk_aversion)


def compare_risk_aversion(base: AcParams,
                          lambdas: tuple[float, ...] = (0.0, 1e-7, 1e-6, 1e-5)
                          ) -> None:
    """リスク回避度を変えて軌道の形を比較する。"""
    print(f"{'λ':>10}{'κ':>12}{'前半50%到達':>14}"
          f"{'期待コスト':>14}{'標準偏差':>13}")
    print("-" * 66)

    for lam in lambdas:
        p = AcParams(**{**base.__dict__, "risk_aversion": lam})
        try:
            x = optimal_trajectory(p)
        except ValueError as e:
            print(f"{lam:>10.1e}  {e}")
            continue

        # 残高が半分になる時点(早いほど前倒し)
        half = float(np.argmax(x <= p.total_qty / 2)) / p.n_slices
        cost = expected_cost(p)
        print(f"{lam:>10.1e}{p.kappa:>12.6f}{half:>13.1%}"
              f"{cost['期待コスト合計']:>14.2f}{cost['標準偏差']:>13.2f}")


if __name__ == "__main__":
    # BTCUSDT を想定した数値例
    p = calibrate_from_market(
        mid_price=120_000.0,
        spread=1.0,               # 1 USDT のスプレッド
        annual_vol=0.60,          # 年率60%(記事12で実測すること)
        depth_qty=5.0,            # 5 BTC 執行すると
        depth_impact_bps=8.0,     # 8bps 動く(板から実測: 記事09)
        total_qty=10.0,           # 10 BTC を
        duration_sec=1800.0,      # 30分で
        n_slices=30,
        risk_aversion=1e-6)

    print("=== 推定パラメータ ===")
    print(f"  σ (価格/√秒) : {p.sigma:.6f}")
    print(f"  η (一時的)   : {p.eta:.6f}")
    print(f"  γ (永続的)   : {p.gamma:.6f}")
    print(f"  ε (固定)     : {p.epsilon:.4f}")
    print(f"  τ (1期の秒)  : {p.tau:.1f}")
    print(f"  κ            : {p.kappa:.6f}")

    print("\n=== 最適スライス配分(先頭10期) ===")
    slices = optimal_slices(p)
    twap = p.total_qty / p.n_slices
    for i, q in enumerate(slices[:10], 1):
        ratio = q / twap
        bar = "#" * int(ratio * 20)
        print(f"  期{i:>2}: {q:>7.4f} BTC  (TWAP比 {ratio:>5.2f}) {bar}")
    print(f"  ...")
    print(f"  合計: {slices.sum():.4f} BTC")

    print("\n=== 期待コストの内訳 ===")
    for k, v in expected_cost(p).items():
        print(f"  {k:<18}: {v:>14.2f}")

    print("\n=== リスク回避度による軌道の変化 ===")
    compare_risk_aversion(p)

    print("\n【解釈】")
    print("  λ=0 なら κ=0 で完全なTWAP(リスク中立)。")
    print("  λが大きいほど κ が大きくなり、前倒しで執行する。")
    print("  『早く終わらせてリスクを落とす』代わりに")
    print("  インパクトコストを多く払う、というトレードオフ。")
    print("\n※ η と γ の推定が全て。板の実測(記事09のKyleのλ)から")
    print("  自分の銘柄・サイズで較正しないと、数値に意味は無い。")

執行品質の測定とA/B比較

執行アルゴリズムは「測らなければ良し悪しが分からない」領域です。Perold (1988) の Implementation Shortfall を軸に、4方式を同条件で比較します。

exec_quality.pyサンプル7
"""exec_quality.py — 執行品質の測定(Implementation Shortfall ほか)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Perold, A. (1988) "The Implementation Shortfall: Paper vs. Reality"

  Implementation Shortfall (IS):
    「意思決定した瞬間の価格で全量約定できた場合」との差。
    IS = (執行VWAP − 到着価格) × 数量 × sign   + 手数料 + 未執行機会損失

  他の指標:
    実効スプレッド = 2 × |約定価格 − 仲値| / 仲値
    参加率 (POV)   = 自分の出来高 / 市場出来高
    VWAP差         = 執行VWAP − 市場VWAP(VWAP執行の評価基準)

  重要: IS と VWAP差 は別物。
        IS は「決めた時に買えていたら」を基準にする絶対評価。
        VWAP差 は「市場の平均に対して」という相対評価。
        VWAP差だけを見ると、期間中ずっと価格が上がっていても
        「市場平均より安く買えた」と評価されてしまう。
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal

BPS = Decimal("10000")


@dataclass
class ExecutionQuality:
    algo: str
    side: str
    target_qty: Decimal
    filled_qty: Decimal
    arrival_price: Decimal
    exec_vwap: Decimal
    market_vwap: Decimal
    final_price: Decimal
    fee: Decimal
    elapsed_sec: float
    market_volume: Decimal = Decimal(0)

    @property
    def sign(self) -> Decimal:
        """買いなら +1、売りなら −1。コストを正で表すため。"""
        return Decimal(1) if self.side == "Buy" else Decimal(-1)

    @property
    def fill_rate(self) -> Decimal:
        if self.target_qty <= 0:
            return Decimal(0)
        return self.filled_qty / self.target_qty

    @property
    def price_shortfall_bps(self) -> Decimal:
        """約定分の価格劣化(到着価格基準)。"""
        if self.arrival_price <= 0 or self.filled_qty <= 0:
            return Decimal(0)
        return ((self.exec_vwap - self.arrival_price) / self.arrival_price
                * self.sign * BPS)

    @property
    def fee_bps(self) -> Decimal:
        notional = self.filled_qty * self.exec_vwap
        if notional <= 0:
            return Decimal(0)
        return self.fee / notional * BPS

    @property
    def opportunity_bps(self) -> Decimal:
        """未執行分の機会損失。

        執行できなかった数量について、
        到着価格から最終価格までの変動が損失(または利益)になる。
        """
        unfilled = self.target_qty - self.filled_qty
        if unfilled <= 0 or self.arrival_price <= 0:
            return Decimal(0)
        move = ((self.final_price - self.arrival_price) / self.arrival_price
                * self.sign * BPS)
        return move * (unfilled / self.target_qty)

    @property
    def implementation_shortfall_bps(self) -> Decimal:
        """総合コスト。これが最終的な評価指標。"""
        return (self.price_shortfall_bps * self.fill_rate
                + self.fee_bps * self.fill_rate
                + self.opportunity_bps)

    @property
    def vwap_diff_bps(self) -> Decimal:
        if self.market_vwap <= 0 or self.filled_qty <= 0:
            return Decimal(0)
        return ((self.exec_vwap - self.market_vwap) / self.market_vwap
                * self.sign * BPS)

    @property
    def participation_rate(self) -> Decimal:
        if self.market_volume <= 0:
            return Decimal(0)
        return self.filled_qty / self.market_volume

    def report(self) -> str:
        return "\n".join([
            f"[{self.algo}] {self.side} "
            f"{self.filled_qty}/{self.target_qty} "
            f"({self.fill_rate:.1%})",
            f"  到着価格          : {self.arrival_price}",
            f"  執行VWAP          : {self.exec_vwap}",
            f"  市場VWAP          : {self.market_vwap}",
            f"  ── コスト分解 (bps, 正が不利) ──",
            f"  価格劣化          : {self.price_shortfall_bps:>+8.2f}",
            f"  手数料            : {self.fee_bps:>+8.2f}",
            f"  未執行機会損失    : {self.opportunity_bps:>+8.2f}",
            f"  Implementation SF : {self.implementation_shortfall_bps:>+8.2f}",
            f"  ── 参考 ──",
            f"  VWAP差            : {self.vwap_diff_bps:>+8.2f}",
            f"  参加率            : {self.participation_rate:>8.2%}",
            f"  所要時間          : {self.elapsed_sec:>8.0f}秒",
        ])


def measure(algo, state, market_vwap: Decimal,
            market_volume: Decimal = Decimal(0)) -> ExecutionQuality:
    """執行エンジンの結果から品質指標を作る。"""
    final = algo.mid_price()
    elapsed = (state.finished_at - state.started_at
               if state.finished_at else 0.0)

    return ExecutionQuality(
        algo=type(algo).__name__,
        side=algo.cfg.side,
        target_qty=algo.cfg.total_qty,
        filled_qty=algo.total_filled(),
        arrival_price=algo.arrival_price,
        exec_vwap=algo.vwap_of_execution(),
        market_vwap=market_vwap,
        final_price=final,
        fee=algo.total_fee(),
        elapsed_sec=elapsed,
        market_volume=market_volume,
    )


def compare(results: list[ExecutionQuality]) -> None:
    """複数方式を並べて比較表示する。"""
    print(f"\n{'方式':<14}{'約定率':>8}{'価格劣化':>10}{'手数料':>9}"
          f"{'機会損失':>10}{'IS計':>10}{'時間':>8}")
    print("-" * 70)

    for q in sorted(results,
                    key=lambda x: x.implementation_shortfall_bps):
        print(f"{q.algo:<14}"
              f"{q.fill_rate:>7.1%}"
              f"{q.price_shortfall_bps:>+10.2f}"
              f"{q.fee_bps:>+9.2f}"
              f"{q.opportunity_bps:>+10.2f}"
              f"{q.implementation_shortfall_bps:>+10.2f}"
              f"{q.elapsed_sec:>7.0f}s")

    best = min(results, key=lambda x: x.implementation_shortfall_bps)
    worst = max(results, key=lambda x: x.implementation_shortfall_bps)
    diff = worst.implementation_shortfall_bps - best.implementation_shortfall_bps
    print(f"\n  最良: {best.algo} / 最悪: {worst.algo} / 差 {diff:.2f}bps")
    print("  ※ 1回の試行では偶然の要素が大きい。")
    print("    最低でも数十回繰り返して中央値で判断すること。")


if __name__ == "__main__":
    # 測定ロジックの動作確認(実発注なし)
    q = ExecutionQuality(
        algo="TwapAlgo", side="Buy",
        target_qty=Decimal("10"), filled_qty=Decimal("10"),
        arrival_price=Decimal("120000"),
        exec_vwap=Decimal("120060"),
        market_vwap=Decimal("120050"),
        final_price=Decimal("120200"),
        fee=Decimal("660"), elapsed_sec=1800.0,
        market_volume=Decimal("250"))
    print(q.report())

    q2 = ExecutionQuality(
        algo="PassiveAlgo", side="Buy",
        target_qty=Decimal("10"), filled_qty=Decimal("7"),
        arrival_price=Decimal("120000"),
        exec_vwap=Decimal("119990"),
        market_vwap=Decimal("120050"),
        final_price=Decimal("120200"),
        fee=Decimal("-84"), elapsed_sec=1800.0,
        market_volume=Decimal("250"))
    print("\n" + q2.report())

    compare([q, q2])

    print("\n【この例が示すこと】")
    print("  パッシブは価格も手数料も有利(リベート)だが、")
    print("  30%が未執行のまま価格が上昇したため機会損失が発生。")
    print("  『安く買えた分』より『買えなかった分』の損失が大きい。")
    print("  約定率を無視した執行評価は必ず間違う。")
exec_ab_test.pyサンプル8
"""exec_ab_test.py — 4方式のA/Bテストランナー
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  同一銘柄・同一サイズで4方式を順番に実行し、
  Implementation Shortfall で比較する。

  注意:
    - 順番に実行するので市場環境が異なる。
      公平にするには (1) 順序をランダム化 (2) 多数回繰り返す。
    - testnet では板が薄く約定が現実と乖離する。
      比較の「傾向」を見る用途に留め、絶対値は信用しない。
    - 本番で実行する場合、往復のポジションが立つ。
      buy → sell を対にして中立に戻す設計にしてある。
"""
from __future__ import annotations

import json
import random
import statistics
import time
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path

from almgren_chriss import AcParams, calibrate_from_market, optimal_slices
from client import make_client
from exec_quality import ExecutionQuality, compare, measure
from execution_base import ExecutionConfig, Slice
from iceberg import IcebergAlgo, IcebergConfig
from passive import PassiveAlgo, PassiveConfig
from twap import TwapAlgo
from vwap import VwapAlgo


class AlmgrenAlgo(TwapAlgo):
    """Almgren-Chrissの最適配分で流す。発注方法はTWAPと同じ。"""

    def __init__(self, http, cfg: ExecutionConfig,
                 params: AcParams, run_id: str = ""):
        self.ac = params
        super().__init__(http, cfg, run_id)

    def allocate(self) -> list[Decimal]:
        raw = optimal_slices(self.ac)
        scale = self.cfg.total_qty / Decimal(str(float(raw.sum())))
        quantities = [self.rules.round_qty(Decimal(str(float(q))) * scale)
                      for q in raw]
        diff = self.cfg.total_qty - sum(quantities)
        if diff != 0:
            quantities[0] = self.rules.round_qty(quantities[0] + diff)
        return quantities


@dataclass
class AbConfig:
    symbol: str = "BTCUSDT"
    qty: Decimal = Decimal("0.02")
    duration_sec: float = 120.0
    n_slices: int = 8
    rounds: int = 3
    cooldown_sec: float = 20.0
    unwind: bool = True       # 各試行後にポジションを閉じる
    out_path: Path = Path("./exec_ab_results.json")


def market_stats(http, symbol: str, minutes: int) -> tuple[Decimal, Decimal]:
    """期間中の市場VWAPと出来高。"""
    r = http.get_kline(category="linear", symbol=symbol,
                       interval="1", limit=max(1, minutes))
    if r["retCode"] != 0 or not r["result"]["list"]:
        return Decimal(0), Decimal(0)
    volume = notional = Decimal(0)
    for row in r["result"]["list"]:
        volume += Decimal(row[5])
        notional += Decimal(row[6])
    return (notional / volume if volume > 0 else Decimal(0)), volume


def unwind_position(http, symbol: str) -> None:
    """試行で作ったポジションを成行で閉じる。"""
    r = http.get_positions(category="linear", symbol=symbol)
    if r["retCode"] != 0:
        return
    for p in r["result"]["list"]:
        size = Decimal(p["size"] or "0")
        if size <= 0:
            continue
        close_side = "Sell" if p["side"] == "Buy" else "Buy"
        http.place_order(category="linear", symbol=symbol,
                         side=close_side, orderType="Market",
                         qty=str(size), reduceOnly=True)
        print(f"    ポジション {size} を解消")


def build_algos(http, cfg: ExecutionConfig, ac: AcParams) -> list:
    """毎ラウンド新しいインスタンスを作る(状態を持ち越さない)。"""
    stamp = int(time.time() * 1000) % 1_000_000
    return [
        TwapAlgo(http, cfg, run_id=f"ab-twap-{stamp}"),
        VwapAlgo(http, cfg, run_id=f"ab-vwap-{stamp}"),
        IcebergAlgo(http, cfg,
                    IcebergConfig(display_ratio=Decimal("0.15"),
                                  timeout_sec=cfg.duration_sec),
                    run_id=f"ab-ice-{stamp}"),
        PassiveAlgo(http, cfg,
                    PassiveConfig(max_adverse_bps=20.0),
                    run_id=f"ab-psv-{stamp}"),
        AlmgrenAlgo(http, cfg, ac, run_id=f"ab-ac-{stamp}"),
    ]


def run_ab(ab: AbConfig = AbConfig()) -> dict:
    c = make_client()
    if not getattr(c, "is_testnet", True):
        raise RuntimeError(
            "本番では実行しないでください(実際に売買されます)")

    r = c.http.get_tickers(category="linear", symbol=ab.symbol)
    t = r["result"]["list"][0]
    mid = (Decimal(t["bid1Price"]) + Decimal(t["ask1Price"])) / 2
    spread = Decimal(t["ask1Price"]) - Decimal(t["bid1Price"])

    ac = calibrate_from_market(
        mid_price=float(mid), spread=float(spread),
        annual_vol=0.60, depth_qty=5.0, depth_impact_bps=8.0,
        total_qty=float(ab.qty), duration_sec=ab.duration_sec,
        n_slices=ab.n_slices, risk_aversion=1e-6)

    cfg = ExecutionConfig(
        symbol=ab.symbol, side="Buy", total_qty=ab.qty,
        duration_sec=ab.duration_sec, n_slices=ab.n_slices,
        max_participation=0.20)

    by_algo: dict[str, list[ExecutionQuality]] = {}

    for rnd in range(1, ab.rounds + 1):
        algos = build_algos(c.http, cfg, ac)
        random.shuffle(algos)     # 順序効果を消す

        print(f"\n{'=' * 60}")
        print(f"ラウンド {rnd}/{ab.rounds}")
        print(f"{'=' * 60}")

        for algo in algos:
            name = type(algo).__name__
            print(f"\n--- {name} ---")
            try:
                state = algo.run(verbose=False)
                mv, vol = market_stats(
                    c.http, ab.symbol, int(ab.duration_sec / 60) + 1)
                q = measure(algo, state, mv, vol)
                by_algo.setdefault(name, []).append(q)
                print(f"  IS = {q.implementation_shortfall_bps:+.2f}bps "
                      f"(約定率 {q.fill_rate:.1%})")
            except Exception as e:
                print(f"  [error] {type(e).__name__}: {e}")

            if ab.unwind:
                unwind_position(c.http, ab.symbol)
            time.sleep(ab.cooldown_sec)

    # 集計
    print(f"\n{'=' * 60}")
    print("集計(各方式のIS、単位bps・正が不利)")
    print(f"{'=' * 60}")
    print(f"\n{'方式':<14}{'試行':>6}{'中央値':>10}{'平均':>10}"
          f"{'最良':>10}{'最悪':>10}{'約定率':>9}")
    print("-" * 70)

    summary = {}
    for name, qs in by_algo.items():
        values = [float(q.implementation_shortfall_bps) for q in qs]
        fills = [float(q.fill_rate) for q in qs]
        summary[name] = {
            "n": len(values),
            "median": statistics.median(values),
            "mean": statistics.mean(values),
            "best": min(values), "worst": max(values),
            "fill_rate": statistics.mean(fills),
        }
        s = summary[name]
        print(f"{name:<14}{s['n']:>6}{s['median']:>+10.2f}"
              f"{s['mean']:>+10.2f}{s['best']:>+10.2f}"
              f"{s['worst']:>+10.2f}{s['fill_rate']:>8.1%}")

    if by_algo:
        best = min(summary.items(), key=lambda kv: kv[1]["median"])
        print(f"\n  中央値が最良: {best[0]} "
              f"({best[1]['median']:+.2f}bps)")

    ab.out_path.write_text(
        json.dumps(summary, ensure_ascii=False, indent=2),
        encoding="utf-8")
    print(f"\n  結果を保存: {ab.out_path}")

    print("\n【判断の注意】")
    print(f"  試行数 {ab.rounds} 回では統計的にほぼ何も言えない。")
    print("  執行コストの差は数bpsで、市場のノイズより小さいことが多い。")
    print("  記事32の観点で、差が偶然かどうかを検定してから採用すること。")
    return summary


if __name__ == "__main__":
    run_ab(AbConfig(rounds=3, qty=Decimal("0.02"), duration_sec=120.0))
参考リンク・文献
  • Place Order: bybit-exchange.github.io/docs/v5/order/create-order
  • Get Order History(cumExecQty / avgPrice / cumExecFee): /order/order-list
  • Get Orderbook: /market/orderbook
  • Get Kline(turnover でVWAPを計算): /market/kline
  • Almgren, R. & Chriss, N. (2000) "Optimal Execution of Portfolio Transactions", Journal of Risk 3(2), 5–39
  • Perold, A. (1988) "The Implementation Shortfall: Paper versus Reality", Journal of Portfolio Management 14(3)
  • Almgren, R. (2003) "Optimal Execution with Nonlinear Impact Functions and Trading-Enhanced Risk", Applied Mathematical Finance 10(1)
  • Kissell, R. & Glantz, M. (2003) Optimal Trading Strategies, AMACOM
  • Bertsimas, D. & Lo, A. (1998) "Optimal Control of Execution Costs", Journal of Financial Markets 1(1)
  • Gatheral, J. (2010) "No-Dynamic-Arbitrage and Market Impact", Quantitative Finance 10(7)

よくある疑問Q&A

どのくらいのサイズから分割執行が必要ですか
目安は「最良気配の数量を超えるか」です。板の第1階層で吸収できるサイズなら、分割してもインパクトはほぼ変わらず、手間と時間リスクだけが増えます。
記事09で板の厚みを測り、自分のロットが上位数階層で吸収されるかを確認してください。BTCUSDTなら数BTC以上、アルトなら数十万USDT以上が一つの目安ですが、時間帯で大きく変わります。
TWAPとVWAPはどちらが良いですか
出来高パターンが安定している銘柄・時間帯ならVWAP、読めないならTWAPです。VWAPは過去のプロファイルが将来も成立することを前提にしており、その前提が外れると薄い時間帯に多く流すという最悪の結果になります。
サンプル3の出力で「ピーク/谷の比率」を見てください。比率が2倍程度ならTWAPで十分です。5倍以上あるならVWAPの価値があります。
Almgren-Chrissのパラメータはどう決めますか
η(一時的インパクト)の推定が全てです。サンプル6の calibrate_from_market() は「depth_qtyを執行するとdepth_impact_bps動く」という観測から逆算します。
この観測値は記事09のKyleのλ推定、または自分の過去の約定履歴(記事18)から得てください。他人の論文の数値をそのまま使っても意味がありません。銘柄・時間帯・自分のサイズで全く違う値になります。
λ(リスク回避度)はいくつにすべきですか
絶対的な正解はありません。サンプル6の compare_risk_aversion() で軌道の形を見て決めてください。λ=0はTWAPと同じ、λが大きいほど前倒しになります。
実務的な決め方は「残高が半分になる時点」を目標にすることです。「30分の執行で最初の10分で半分終わらせたい」なら、その形になるλを逆算します。理論値にこだわるより、意図した形になるかで選ぶほうが実用的です。
パッシブ執行のほうが手数料が安いので有利では
約定率を無視した比較です。サンプル7の例が示す通り、メイカーで数bps得しても、30%が未執行のまま価格が動けば機会損失のほうが大きくなります。
Implementation Shortfall は価格劣化 + 手数料 + 未執行機会損失を合算した指標で、これで比較しないと「手数料で得して価格で損する」典型的な失敗に気づけません。
執行の途中でbotが落ちました
サンプル1の ExecutionState が毎スライス exec_state/{run_id}.json に保存しているので、algo.resume() で続行できます。
ただし再開時は必ず取引所側の約定数量を再確認してください。ローカルの状態が最後の保存時点で止まっている可能性があります。記事18の約定履歴照会を orderLinkId で行い、実際の約定合計と突き合わせるのが確実です。
アイスバーグは本当に意図を隠せますか
板を目視で見る相手には有効ですが、約定履歴を分析している相手には検知されます。同じ価格で同じサイズの約定が繰り返し発生すれば、パターンとして残るためです。
サンプル4は表示数量を固定していますが、実務では表示数量と補充間隔もランダム化すべきです。ただし過度な期待は禁物で、隠蔽の効果より板を一度に食わないことによるインパクト削減のほうが実利は大きいです。
A/Bテストで方式の優劣を判定できますか
数回の試行では判定できません。執行コストの差は数bpsで、市場のノイズ(同じ2分間でも数十bps動く)よりはるかに小さいためです。
サンプル8は順序をランダム化していますが、それでも試行数が足りません。実務では数百回の執行ログを蓄積し、時間帯・ボラ水準で層別してから比較します。記事32の考え方をここでも適用し、「差があるように見える」と「差がある」を区別してください。

注意事項・免責事項

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

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

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

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

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

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

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