Avellaneda-Stoikovマーケットメイクbotを実装する
最適スプレッドと在庫制御

「メイカーなら手数料で稼げる」——この理解でマーケットメイクを始めると、ほぼ確実に負けます。両側に指値を置けば、価格が動く方向の指値だけが約定し、常に不利な側の在庫を持たされるからです。Avellaneda-Stoikov (2008) はこの在庫リスクを数式で扱い、予約価格と最適スプレッドを導きます。この記事ではその実装と、逆選択リスクの検知までをサンプルコード9本で作ります。

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

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

目次

  1. マーケットメイクの現実
  2. Avellaneda-Stoikovの中核
  3. 約定強度A・kの推定
  4. 気配価格の計算と在庫スキュー
  5. 気配の高速更新
  6. 在庫管理と強制フラット化
  7. 逆選択の検知
  8. 損益分解
  9. 完成コードと運用手順
  10. よくある疑問Q&A

マーケットメイクの現実

まずこの戦略で何が起きるのかを正確に把握します。ここを誤解したまま実装すると、動くコードが完成した瞬間から損失が始まります。

収益源内容現実
スプレッド収入 買い気配で買い、売り気配で売る差 唯一の実質的な収益源
メイカー手数料 銘柄・ティアによりリベートまたは低率 補助的。これ目当ては破綻する
損失源内容影響
逆選択(有害フロー) 情報を持つ相手にだけ約定させられる 最大の損失源。これに負けると他は無意味
在庫リスク 片側に偏った在庫が逆行する 価格が一方向に動くと必ず発生
強制解消コスト 在庫上限で成行フラット化 テイカー手数料 + スリッページ
機会損失 気配が遠すぎて約定しない スプレッドを広げすぎたとき
「メイカーリベートで無条件に稼げる」は誤り

両側に指値を置くと、価格が動く方向の指値だけが約定します。上昇すれば売り気配が食われて売り在庫を抱え、そのまま価格は上がり続けます。
これは偶然ではなく構造です。あなたの指値を食う相手は「今買うべきだ」と判断した相手なので、平均的にあなたは不利な側を取らされます。これが逆選択(adverse selection)です。
メイカー手数料が数bpsのリベートでも、逆選択による損失はその何倍にもなります。スプレッド収入が逆選択と在庫損を上回って初めてこの戦略は成立します。

RPIオーダーは使えない

Bybitには一般参加者から隔離されたRPI(Retail Price Improvement)オーダーがありますが、timeInForce="RPI" は承認されたマーケットメイカーのみが使えます。一般の利用者が指定すると 110119 / 170355 で拒否されます。
つまり個人は逆選択から保護される仕組みを使えません。サンプル7の検知と撤退が唯一の防御です。

Avellaneda-Stoikovの中核

as_model.pyサンプル1
"""as_model.py — Avellaneda-Stoikov の予約価格と最適スプレッド
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Avellaneda, M. & Stoikov, S. (2008)
        "High-frequency Trading in a Limit Order Book",
        Quantitative Finance 8(3), 217-224
      Guéant, O., Lehalle, C.-A. & Fernandez-Tapia, J. (2013)
        "Dealing with the Inventory Risk", Mathematics and
        Financial Economics 7(4)

  モデルの2本の柱:

  1. 予約価格(reservation price)
       r = s − q·γ·σ²·(T − t)

       s : 現在の仲値
       q : 在庫(正=ロング, 負=ショート)
       γ : リスク回避係数
       σ : ボラティリティ
       T−t : 残り時間

     在庫がロングなら r は s より下にずれる。
     → 両側の気配が下にずれ、売りが約定しやすくなり在庫が減る。
     これが在庫制御の中核。

  2. 最適スプレッド
       δ_total = γ·σ²·(T − t) + (2/γ)·ln(1 + γ/k)

       k : 板の流動性(約定強度の減衰率)

     第1項は在庫リスクへの対価、第2項は約定確率とのトレードオフ。
     γが大きいほど(リスク回避的なほど)スプレッドは広くなる。

  実装上の注意:
    元論文は「有限期間 T で在庫をゼロにする」設定。
    24時間動かすbotでは T−t の扱いが問題になる。
    実務では「T−t を一定に固定する(無限期間近似)」か、
    「セッションを区切って T をリセットする」のどちらかを使う。
    本実装は前者を既定とし、後者も選べるようにしてある。
"""
from __future__ import annotations

import math
from dataclasses import dataclass, field


@dataclass(frozen=True)
class AsParams:
    gamma: float = 0.1          # リスク回避係数
    sigma: float = 0.0          # ボラティリティ(価格単位/√秒)
    k: float = 1.5              # 約定強度の減衰率
    A: float = 1.0              # 約定強度のスケール(診断用)
    horizon_sec: float = 600.0  # 残り時間 T−t(無限期間近似では固定)
    use_finite_horizon: bool = False

    @property
    def inventory_risk_term(self) -> float:
        """γσ²(T−t)。在庫1単位あたりの価格調整量。"""
        return self.gamma * self.sigma ** 2 * self.horizon_sec

    @property
    def spread_base_term(self) -> float:
        """(2/γ)ln(1 + γ/k)。約定確率とのトレードオフ項。"""
        if self.gamma <= 0 or self.k <= 0:
            return 0.0
        return (2 / self.gamma) * math.log(1 + self.gamma / self.k)


@dataclass
class Quote:
    """算出された両側の気配。"""
    mid: float
    reservation_price: float
    bid: float
    ask: float
    half_spread: float
    inventory: float
    skew: float                # 予約価格の仲値からのズレ

    @property
    def total_spread(self) -> float:
        return self.ask - self.bid

    @property
    def total_spread_bps(self) -> float:
        return self.total_spread / self.mid * 10_000 if self.mid > 0 else 0.0

    @property
    def skew_bps(self) -> float:
        return self.skew / self.mid * 10_000 if self.mid > 0 else 0.0

    @property
    def bid_distance_bps(self) -> float:
        return (self.mid - self.bid) / self.mid * 10_000 if self.mid > 0 else 0.0

    @property
    def ask_distance_bps(self) -> float:
        return (self.ask - self.mid) / self.mid * 10_000 if self.mid > 0 else 0.0

    def line(self) -> str:
        return (f"  mid={self.mid:>12,.2f}  "
                f"r={self.reservation_price:>12,.2f} "
                f"({self.skew_bps:>+6.2f}bps)  "
                f"bid={self.bid:>12,.2f}  ask={self.ask:>12,.2f}  "
                f"spread={self.total_spread_bps:>6.2f}bps  q={self.inventory:>+7.4f}")


def reservation_price(mid: float, inventory: float,
                      params: AsParams,
                      elapsed_sec: float = 0.0) -> float:
    """予約価格 r = s − q·γ·σ²·(T−t)

    在庫がロング(q>0)なら r < s になり、
    両側の気配が下方向にずれる。
    → 売りが約定しやすく、買いは約定しにくくなる → 在庫が減る
    """
    remaining = params.horizon_sec
    if params.use_finite_horizon:
        remaining = max(0.0, params.horizon_sec - elapsed_sec)

    return mid - inventory * params.gamma * params.sigma ** 2 * remaining


def optimal_spread(params: AsParams, elapsed_sec: float = 0.0) -> float:
    """最適な合計スプレッド δ_bid + δ_ask"""
    remaining = params.horizon_sec
    if params.use_finite_horizon:
        remaining = max(0.0, params.horizon_sec - elapsed_sec)

    risk_term = params.gamma * params.sigma ** 2 * remaining
    return risk_term + params.spread_base_term


def compute_quote(mid: float, inventory: float, params: AsParams,
                  elapsed_sec: float = 0.0,
                  min_half_spread: float = 0.0,
                  max_half_spread: float = float("inf")) -> Quote:
    """両側の気配を計算する。

    予約価格を中心に、最適スプレッドの半分を両側に置く。
    在庫の偏りは予約価格のズレとして反映される。
    """
    r = reservation_price(mid, inventory, params, elapsed_sec)
    spread = optimal_spread(params, elapsed_sec)
    half = spread / 2

    half = max(min_half_spread, min(half, max_half_spread))

    return Quote(
        mid=mid, reservation_price=r,
        bid=r - half, ask=r + half,
        half_spread=half, inventory=inventory,
        skew=r - mid)


def inventory_at_which_quote_crosses_mid(params: AsParams) -> float:
    """気配の片側が仲値を跨ぐ在庫水準。

    在庫がこれを超えると、片側の気配が仲値の反対側に行き、
    実質的に「テイカーになろうとする」状態になる。
    PostOnly なら拒否されるため、在庫上限の目安になる。
    """
    risk = params.inventory_risk_term
    if risk <= 0:
        return float("inf")
    return optimal_spread(params) / 2 / risk


if __name__ == "__main__":
    params = AsParams(gamma=0.1, sigma=0.02, k=1.5, horizon_sec=600.0)

    print("=== パラメータ ===")
    print(f"  γ (リスク回避) : {params.gamma}")
    print(f"  σ (ボラ)       : {params.sigma}")
    print(f"  k (流動性)     : {params.k}")
    print(f"  T−t            : {params.horizon_sec}秒")
    print(f"\n  在庫リスク項 γσ²(T−t) : {params.inventory_risk_term:.6f}")
    print(f"  スプレッド基本項       : {params.spread_base_term:.6f}")
    print(f"  最適スプレッド         : {optimal_spread(params):.6f}")

    mid = 120_000.0

    print(f"\n=== 在庫による気配の変化 (mid={mid:,.0f}) ===")
    print(f"{'在庫q':>8}{'予約価格':>14}{'スキュー':>10}"
          f"{'bid':>13}{'ask':>13}{'spread':>9}")
    print("-" * 68)
    for q in (-2.0, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0):
        quote = compute_quote(mid, q, params)
        print(f"{q:>+8.1f}{quote.reservation_price:>14,.2f}"
              f"{quote.skew_bps:>+9.2f}b"
              f"{quote.bid:>13,.2f}{quote.ask:>13,.2f}"
              f"{quote.total_spread_bps:>8.2f}b")

    print("\n  ※ 在庫がロング(q>0)だと予約価格が下がり、")
    print("    両側の気配が下方向にずれる。")
    print("    売りが約定しやすくなり、在庫が自然に減る。")

    print("\n=== γ(リスク回避)の影響 ===")
    print(f"{'γ':>8}{'spread(bps)':>14}{'q=1のskew(bps)':>18}")
    print("-" * 42)
    for gamma in (0.01, 0.05, 0.1, 0.5, 1.0):
        p = AsParams(gamma=gamma, sigma=params.sigma, k=params.k,
                     horizon_sec=params.horizon_sec)
        q0 = compute_quote(mid, 0.0, p)
        q1 = compute_quote(mid, 1.0, p)
        print(f"{gamma:>8.2f}{q0.total_spread_bps:>14.2f}"
              f"{q1.skew_bps:>18.2f}")

    print("\n  γが大きい = リスク回避的 = スプレッドが広く在庫調整も強い")
    print("  γが小さい = 約定を優先 = 薄いスプレッドだが在庫が溜まりやすい")

    limit = inventory_at_which_quote_crosses_mid(params)
    print(f"\n  気配が仲値を跨ぐ在庫: ±{limit:.3f}")
    print("  この水準を在庫上限の目安にする(サンプル6)")

約定強度A・kの推定

モデルの k は「仲値からどれだけ離すと、約定率がどれだけ下がるか」を表します。実データから推定しないと数式に意味がありません。

as_calibrate.pyサンプル2
"""as_calibrate.py — 約定強度 A, k をティックデータから推定する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  モデルの仮定:
    仲値から δ 離れた指値の約定強度(単位時間あたりの約定率)は
      λ(δ) = A · exp(−k·δ)

    A : δ=0(仲値に置いた場合)の約定率
    k : 離すほど約定しにくくなる度合い(大きいほど急に減る)

  推定手順:
    1. 約定履歴(記事08の公開データ or WSのtrade)を取得
    2. 各約定について「その時点の仲値からの距離 δ」を求める
    3. δ を階級に分け、階級ごとの約定件数/時間 = λ(δ) を数える
    4. ln λ = ln A − k·δ を線形回帰して A, k を得る

  注意:
    ここで測るのは「市場全体の約定強度」であって、
    「自分の指値が約定する確率」ではない。
    自分の注文は板の後ろに並ぶため、実際の約定率はこれより低い。
    したがって k は保守的(大きめ)に見積もるのが安全。
"""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd


@dataclass
class IntensityFit:
    A: float
    k: float
    r_squared: float
    n_trades: int
    n_bins: int
    duration_sec: float
    bins: pd.DataFrame = field(default_factory=pd.DataFrame)

    @property
    def valid(self) -> bool:
        return self.k > 0 and self.A > 0 and self.r_squared > 0.5

    def intensity_at(self, delta: float) -> float:
        """距離 δ における約定強度 λ(δ)。"""
        return self.A * np.exp(-self.k * delta)

    def fill_probability(self, delta: float,
                         duration_sec: float = 1.0) -> float:
        """δ に置いた指値が duration_sec 以内に約定する確率。

        ポアソン過程を仮定: P = 1 − exp(−λ·t)
        """
        lam = self.intensity_at(delta)
        return float(1 - np.exp(-lam * duration_sec))

    def report(self) -> str:
        return "\n".join([
            f"  A (δ=0の約定率)  : {self.A:.6f} 件/秒",
            f"  k (減衰率)       : {self.k:.6f}",
            f"  R²               : {self.r_squared:.4f}",
            f"  約定件数         : {self.n_trades:,}",
            f"  階級数           : {self.n_bins}",
            f"  観測時間         : {self.duration_sec / 60:.1f}分",
            f"  {'推定は妥当です' if self.valid else '推定が不安定です'}",
        ])


def fit_intensity(trades: pd.DataFrame, mid_prices: pd.Series,
                  n_bins: int = 15,
                  max_delta_bps: float = 20.0,
                  min_trades_per_bin: int = 5) -> IntensityFit:
    """約定データから λ(δ) = A·exp(−kδ) をフィットする。

    trades   : index=時刻, columns=[price, size, side]
    mid_prices: index=時刻, values=仲値
    """
    if trades.empty or mid_prices.empty:
        raise ValueError("データが空です")

    # 各約定時点の仲値を最近傍で対応付ける
    aligned_mid = mid_prices.reindex(
        trades.index, method="nearest", tolerance=pd.Timedelta("5s"))
    valid = aligned_mid.notna()
    trades = trades[valid]
    aligned_mid = aligned_mid[valid]

    if len(trades) < 100:
        raise ValueError(f"有効な約定が少なすぎます ({len(trades)}件)")

    # 仲値からの距離(bps)。売買どちらでも絶対値で見る
    delta_bps = (trades["price"] - aligned_mid).abs() / aligned_mid * 10_000
    delta_bps = delta_bps[delta_bps <= max_delta_bps]

    duration = (trades.index[-1] - trades.index[0]).total_seconds()
    if duration <= 0:
        raise ValueError("観測時間がゼロです")

    # 階級ごとの約定率を数える
    edges = np.linspace(0, max_delta_bps, n_bins + 1)
    counts, _ = np.histogram(delta_bps, bins=edges)
    centers = (edges[:-1] + edges[1:]) / 2

    # λ(δ) = 「δ 以上離れた位置での約定率」なので累積で数える
    # (δ に置いた指値は、それより遠くまで価格が動けば約定する)
    cumulative = counts[::-1].cumsum()[::-1]
    lam = cumulative / duration

    mask = (cumulative >= min_trades_per_bin) & (lam > 0)
    if mask.sum() < 4:
        raise ValueError("有効な階級が不足しています")

    x = centers[mask]
    y = np.log(lam[mask])

    design = np.column_stack([np.ones(len(x)), x])
    coef, *_ = np.linalg.lstsq(design, y, rcond=None)
    ln_a, neg_k = float(coef[0]), float(coef[1])

    predicted = design @ coef
    ss_res = float(((y - predicted) ** 2).sum())
    ss_tot = float(((y - y.mean()) ** 2).sum())
    r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0

    bins_df = pd.DataFrame({
        "delta_bps": centers[mask],
        "trades": cumulative[mask],
        "lambda": lam[mask],
        "ln_lambda": y,
        "fitted": predicted,
    })

    return IntensityFit(
        A=float(np.exp(ln_a)), k=float(-neg_k), r_squared=r2,
        n_trades=int(counts.sum()), n_bins=int(mask.sum()),
        duration_sec=duration, bins=bins_df)


def fit_from_public_data(symbol: str = "BTCUSDT",
                         date: str = "2026-07-25") -> IntensityFit:
    """記事08の公開ティックデータから推定する。

    公開データには板情報が無いので、
    直近の約定価格の移動平均を仲値の代理として使う。
    精度は落ちるが、傾向を掴むには十分。
    """
    from tick_data import load_public_ticks   # 記事08のサンプル

    ticks = load_public_ticks(symbol, date)
    if ticks.empty:
        raise ValueError("ティックデータを取得できませんでした")

    trades = pd.DataFrame({
        "price": ticks["price"],
        "size": ticks["size"],
        "side": ticks["side"],
    }, index=ticks.index)

    # 仲値の代理: 前後の約定価格の中央値(未来を使うが推定目的なので許容)
    mid = trades["price"].rolling(50, center=True, min_periods=10).median()

    return fit_intensity(trades, mid.dropna())


def estimate_sigma(prices: pd.Series, seconds_per_bar: float = 1.0) -> float:
    """AS モデル用の σ(価格単位 / √秒)を推定する。

    記事12の年率ボラとは単位が違うので注意。
    AS モデルでは価格の絶対単位で扱う。
    """
    returns = prices.diff().dropna()
    if returns.empty:
        return 0.0
    return float(returns.std() / np.sqrt(seconds_per_bar))


def suggest_gamma(fit: IntensityFit, sigma: float, horizon_sec: float,
                  target_spread_bps: float, mid: float) -> float:
    """目標スプレッドになる γ を逆算する。

    γ は「リスク回避度」という抽象的な量で直感が働かない。
    「スプレッドを何bpsにしたいか」から逆算するほうが実務的。
    """
    from scipy.optimize import brentq

    from as_model import AsParams, optimal_spread

    target = target_spread_bps / 10_000 * mid

    def objective(gamma: float) -> float:
        p = AsParams(gamma=gamma, sigma=sigma, k=fit.k,
                     horizon_sec=horizon_sec)
        return optimal_spread(p) - target

    try:
        return float(brentq(objective, 1e-6, 100.0))
    except ValueError:
        return 0.1     # 収束しない場合の既定値


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

    print("=== σ の推定 ===")
    c = make_client()
    bars = fetch_klines(c.http, "BTCUSDT", interval="1",
                        limit_bars=1440, verbose=False)
    sigma = estimate_sigma(bars["close"], seconds_per_bar=60.0)
    mid = float(bars["close"].iloc[-1])
    print(f"  σ = {sigma:.6f} (価格単位/√秒)")
    print(f"  参考: 1分間の標準偏差 = {sigma * np.sqrt(60):.2f}")
    print(f"        = {sigma * np.sqrt(60) / mid * 10_000:.2f} bps")

    print("\n=== 約定強度の推定 ===")
    try:
        fit = fit_from_public_data("BTCUSDT", "2026-07-25")
        print(fit.report())

        print("\n  距離別の約定確率(10秒以内):")
        for delta_bps in (0.5, 1.0, 2.0, 3.0, 5.0, 10.0):
            delta = delta_bps / 10_000 * mid
            prob = fit.fill_probability(delta_bps, duration_sec=10.0)
            print(f"    {delta_bps:>5.1f}bps ({delta:>8.2f}): "
                  f"{prob:>6.1%}")

        print("\n=== γ の逆算 ===")
        for target_bps in (2.0, 5.0, 10.0):
            gamma = suggest_gamma(fit, sigma, 600.0, target_bps, mid)
            print(f"  スプレッド {target_bps:>5.1f}bps → γ = {gamma:.6f}")

    except Exception as e:
        print(f"  推定できませんでした: {e}")
        print("  記事08の公開データ取得が動作するか確認してください。")

    print("\n※ ここで測るのは市場全体の約定強度。")
    print("  自分の指値は板の後ろに並ぶため、実際の約定率はこれより低い。")
    print("  k は保守的(大きめ)に見積もること。")

気配価格の計算と在庫スキュー

理論値をそのまま発注することはできません。ティックサイズへの丸め、PostOnlyの制約、最小スプレッドの下限を通して、実際に置ける価格にします。

quote_engine.pyサンプル3
"""quote_engine.py — 理論気配を発注可能な価格に変換する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  理論値と実務のギャップ:
    1. ティックサイズに丸める必要がある(記事03)
    2. PostOnly は板を跨ぐと拒否される
       → 買いは best_ask 未満、売りは best_bid 超 でなければならない
    3. スプレッドが手数料を下回ると、約定するほど損をする
       → 最小スプレッドの下限を設ける
    4. 在庫が上限に近いと片側だけを出す(片側撤退)

  マイクロプライス(記事28)を仲値の代わりに使うと、
  板の偏りを織り込んだ中心が得られる。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal

from as_model import AsParams, Quote, compute_quote


@dataclass(frozen=True)
class QuoteConfig:
    symbol: str
    category: str = "linear"

    # スプレッドの制約
    min_spread_bps: float = 2.0        # これ未満にはしない
    max_spread_bps: float = 50.0
    fee_maker_bps: float = 2.0         # メイカー手数料(往復で2倍)

    # 在庫の制約
    max_inventory: Decimal = Decimal("0.05")
    skew_only_ratio: float = 0.7       # 在庫がこの割合を超えたら片側撤退

    # 気配の基準
    use_micro_price: bool = True

    # サイズ
    quote_size: Decimal = Decimal("0.001")
    size_decay: float = 0.5            # 在庫が偏った側のサイズを減らす率


@dataclass
class ExecutableQuote:
    """発注可能な形に丸めた気配。"""
    bid_price: Decimal = Decimal(0)
    ask_price: Decimal = Decimal(0)
    bid_size: Decimal = Decimal(0)
    ask_size: Decimal = Decimal(0)
    theoretical: Quote | None = None
    reasons: list = field(default_factory=list)

    @property
    def has_bid(self) -> bool:
        return self.bid_price > 0 and self.bid_size > 0

    @property
    def has_ask(self) -> bool:
        return self.ask_price > 0 and self.ask_size > 0

    @property
    def is_two_sided(self) -> bool:
        return self.has_bid and self.has_ask

    def line(self) -> str:
        bid = f"{self.bid_price}×{self.bid_size}" if self.has_bid else "---"
        ask = f"{self.ask_price}×{self.ask_size}" if self.has_ask else "---"
        note = f"  ({'; '.join(self.reasons)})" if self.reasons else ""
        return f"  bid {bid:>26}   ask {ask:>26}{note}"


class QuoteEngine:
    """理論気配 → 発注可能な気配。"""

    def __init__(self, http, cfg: QuoteConfig, params: AsParams):
        from symbol_rules import fetch_rules

        self.http = http
        self.cfg = cfg
        self.params = params
        self.rules = fetch_rules(http, cfg.symbol, cfg.category)

    def _center_price(self, book) -> float:
        """気配の中心にする価格を決める。

        マイクロプライスは板の数量の偏りを織り込むため、
        単純な仲値より短期の将来価格に近い(記事28)。
        """
        if not self.cfg.use_micro_price:
            return book.mid

        bid_size, ask_size = book.best_bid_size, book.best_ask_size
        total = bid_size + ask_size
        if total <= 0:
            return book.mid
        return (book.best_bid * ask_size + book.best_ask * bid_size) / total

    def _min_half_spread(self, mid: float) -> float:
        """手数料を考慮した最小の片側スプレッド。

        往復のメイカー手数料を回収できないスプレッドは意味がない。
        """
        fee_based = self.cfg.fee_maker_bps / 10_000 * mid
        floor_based = self.cfg.min_spread_bps / 20_000 * mid
        tick = float(self.rules.tick_size)
        return max(fee_based, floor_based, tick)

    def build(self, book, inventory: Decimal,
              elapsed_sec: float = 0.0) -> ExecutableQuote:
        """発注可能な気配を作る。"""
        result = ExecutableQuote()

        if not book.ready:
            result.reasons.append("板が未同期")
            return result

        mid = self._center_price(book)
        if mid <= 0:
            result.reasons.append("仲値が不正")
            return result

        theoretical = compute_quote(
            mid, float(inventory), self.params, elapsed_sec,
            min_half_spread=self._min_half_spread(mid),
            max_half_spread=self.cfg.max_spread_bps / 20_000 * mid)
        result.theoretical = theoretical

        # ティックサイズに丸める(有利側に丸めて、跨がないようにする)
        bid = self.rules.round_price(Decimal(str(theoretical.bid)))
        ask = self.rules.round_price(Decimal(str(theoretical.ask)))

        best_bid = Decimal(str(book.best_bid))
        best_ask = Decimal(str(book.best_ask))
        tick = self.rules.tick_size

        # PostOnly が拒否されないよう、板を跨がない位置に収める
        if bid >= best_ask:
            bid = best_ask - tick
            result.reasons.append("買い気配を板の内側に補正")
        if ask <= best_bid:
            ask = best_bid + tick
            result.reasons.append("売り気配を板の内側に補正")

        # 補正の結果、bid >= ask になったら気配を出さない
        if bid >= ask:
            result.reasons.append("スプレッドが確保できないため見送り")
            return result

        # サイズの決定と在庫による片側撤退
        base_size = self.cfg.quote_size
        limit = self.cfg.max_inventory
        skew_threshold = limit * Decimal(str(self.cfg.skew_only_ratio))

        bid_size = base_size
        ask_size = base_size

        if inventory >= limit:
            bid_size = Decimal(0)
            result.reasons.append(f"在庫上限({limit})により買いを停止")
        elif inventory >= skew_threshold:
            bid_size = self.rules.round_qty(
                base_size * Decimal(str(self.cfg.size_decay)))
            result.reasons.append("在庫過多により買いサイズを縮小")

        if inventory <= -limit:
            ask_size = Decimal(0)
            result.reasons.append(f"在庫下限(-{limit})により売りを停止")
        elif inventory <= -skew_threshold:
            ask_size = self.rules.round_qty(
                base_size * Decimal(str(self.cfg.size_decay)))
            result.reasons.append("在庫過少により売りサイズを縮小")

        # 最小ロット未満なら出さない
        if 0 < bid_size < self.rules.min_qty:
            bid_size = Decimal(0)
        if 0 < ask_size < self.rules.min_qty:
            ask_size = Decimal(0)

        result.bid_price = bid if bid_size > 0 else Decimal(0)
        result.ask_price = ask if ask_size > 0 else Decimal(0)
        result.bid_size = bid_size
        result.ask_size = ask_size

        return result

    def widen(self, factor: float) -> None:
        """スプレッドを広げる(逆選択の検知時に使う。サンプル7)。"""
        object.__setattr__(
            self.cfg, "min_spread_bps",
            self.cfg.min_spread_bps * factor)


if __name__ == "__main__":
    from decimal import Decimal as D

    from as_model import AsParams
    from book_manager import BookConfig, BookManager
    from client import make_client

    import time

    c = make_client()
    symbol = "BTCUSDT"

    manager = BookManager([symbol], BookConfig(depth=50))
    manager.start()

    print("  板の同期を待機中...")
    for _ in range(30):
        book = manager.book(symbol)
        if book and book.ready:
            break
        time.sleep(1)

    book = manager.book(symbol)
    if not book or not book.ready:
        print("  板を同期できませんでした。")
        manager.stop()
        raise SystemExit(1)

    params = AsParams(gamma=0.05, sigma=2.0, k=1.5, horizon_sec=600.0)
    cfg = QuoteConfig(symbol=symbol, min_spread_bps=3.0,
                      max_inventory=D("0.01"), quote_size=D("0.001"))
    engine = QuoteEngine(c.http, cfg, params)

    print(f"\n=== 在庫別の発注可能な気配 ===")
    print(f"  現在の板: bid={book.best_bid:,.2f} ask={book.best_ask:,.2f} "
          f"(spread {book.spread_bps:.2f}bps)\n")

    for inventory in (D("-0.010"), D("-0.007"), D("0"),
                      D("0.007"), D("0.010")):
        quote = engine.build(book, inventory)
        print(f"  在庫 {inventory:>+8}:")
        print(quote.line())
        if quote.theoretical:
            t = quote.theoretical
            print(f"    理論値: bid={t.bid:,.2f} ask={t.ask:,.2f} "
                  f"skew={t.skew_bps:+.2f}bps")
        print()

    manager.stop()

    print("※ 在庫が上限に達すると片側だけを出す(片側撤退)。")
    print("  両側を出し続けると在庫が際限なく積み上がる。")

気配の高速更新

価格が動くたびにキャンセル→再発注していては、レート制限を食い潰し、板から消えている時間も長くなります。amend による更新が正解です。

quote_manager.pyサンプル4
"""quote_manager.py — 気配の発注・更新・撤退を管理する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  更新戦略:
    - 目標価格と現在の注文価格の差が閾値未満なら「何もしない」
      (無駄な更新はレートの浪費)
    - 差が閾値以上なら amend で価格だけ更新(記事16)
    - サイズが変わる場合も amend で対応
    - 片側だけ出す場合、不要な側はキャンセル

  なぜ amend か:
    キャンセル+再発注 = 2リクエスト、しかも間に板から消える。
    amend = 1リクエストで、注文の同一性も保たれる。
    ただし amend で価格を大きく動かすと
    キューの優先順位は失われる(新規と同じ扱い)。
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

from quote_engine import ExecutableQuote


@dataclass(frozen=True)
class QuoteManagerConfig:
    symbol: str
    category: str = "linear"
    reprice_threshold_ticks: int = 1     # これ以上ずれたら更新
    resize_threshold_ratio: float = 0.25  # サイズがこの割合変わったら更新
    max_updates_per_sec: float = 5.0
    link_prefix: str = "mm"


@dataclass
class LiveOrder:
    side: str
    order_id: str = ""
    order_link_id: str = ""
    price: Decimal = Decimal(0)
    size: Decimal = Decimal(0)
    placed_at: float = 0.0
    amend_count: int = 0

    @property
    def is_live(self) -> bool:
        return bool(self.order_id)

    @property
    def age_sec(self) -> float:
        return time.time() - self.placed_at if self.placed_at else 0.0


@dataclass
class QuoteManager:
    """両側の気配注文を維持する。"""
    http: object
    config: QuoteManagerConfig
    rules: object = None

    bid: LiveOrder = field(default_factory=lambda: LiveOrder("Buy"))
    ask: LiveOrder = field(default_factory=lambda: LiveOrder("Sell"))

    placed_count: int = 0
    amended_count: int = 0
    cancelled_count: int = 0
    skipped_count: int = 0
    error_count: int = 0
    _last_update: float = 0.0

    def __post_init__(self) -> None:
        if self.rules is None:
            from symbol_rules import fetch_rules
            self.rules = fetch_rules(self.http, self.config.symbol,
                                     self.config.category)

    # --- 判定 ------------------------------------------------------

    def _needs_update(self, order: LiveOrder, target_price: Decimal,
                      target_size: Decimal) -> bool:
        if not order.is_live:
            return target_size > 0
        if target_size <= 0:
            return True      # 撤退が必要

        tick = self.rules.tick_size
        price_diff = abs(order.price - target_price)
        if price_diff >= tick * self.config.reprice_threshold_ticks:
            return True

        if order.size > 0:
            size_diff = abs(order.size - target_size) / order.size
            if size_diff >= Decimal(str(self.config.resize_threshold_ratio)):
                return True

        return False

    def _rate_ok(self) -> bool:
        interval = 1.0 / self.config.max_updates_per_sec
        return time.time() - self._last_update >= interval

    # --- 操作 ------------------------------------------------------

    def _place(self, order: LiveOrder, price: Decimal,
               size: Decimal) -> bool:
        link = (f"{self.config.link_prefix}-{order.side[0].lower()}"
                f"-{int(time.time() * 1000) % 1_000_000}")
        r = self.http.place_order(
            category=self.config.category, symbol=self.config.symbol,
            side=order.side, orderType="Limit",
            qty=self.rules.format_qty(size),
            price=self.rules.format_price(price),
            timeInForce="PostOnly", orderLinkId=link)

        if r["retCode"] == 0:
            order.order_id = r["result"]["orderId"]
            order.order_link_id = link
            order.price = price
            order.size = size
            order.placed_at = time.time()
            order.amend_count = 0
            self.placed_count += 1
            return True

        # PostOnly が板を跨いで拒否された。次の周回で再試行する
        if r["retCode"] in (110094, 30208):
            self.skipped_count += 1
            return False

        self.error_count += 1
        print(f"  [warn] 発注失敗 {order.side}: "
              f"{r['retCode']} {r['retMsg']}")
        return False

    def _amend(self, order: LiveOrder, price: Decimal,
               size: Decimal) -> bool:
        params = {
            "category": self.config.category,
            "symbol": self.config.symbol,
            "orderId": order.order_id,
            "price": self.rules.format_price(price),
        }
        if size != order.size:
            params["qty"] = self.rules.format_qty(size)

        r = self.http.amend_order(**params)

        if r["retCode"] == 0:
            order.price = price
            order.size = size
            order.amend_count += 1
            self.amended_count += 1
            return True

        # 110001: 注文が存在しない(約定またはキャンセル済み)
        if r["retCode"] in (110001, 110004):
            order.order_id = ""
            return self._place(order, price, size)

        # PostOnly違反
        if r["retCode"] in (110094, 30208):
            self.skipped_count += 1
            return False

        self.error_count += 1
        print(f"  [warn] 修正失敗 {order.side}: "
              f"{r['retCode']} {r['retMsg']}")
        return False

    def _cancel(self, order: LiveOrder) -> bool:
        if not order.is_live:
            return True
        r = self.http.cancel_order(
            category=self.config.category, symbol=self.config.symbol,
            orderId=order.order_id)
        order.order_id = ""
        order.price = Decimal(0)
        order.size = Decimal(0)
        if r["retCode"] in (0, 110001):
            self.cancelled_count += 1
            return True
        self.error_count += 1
        return False

    # --- 公開API --------------------------------------------------

    def sync(self, quote: ExecutableQuote) -> dict:
        """目標の気配に合わせる。"""
        if not self._rate_ok():
            return {"skipped": "レート制限"}

        actions = {}

        for order, price, size in (
                (self.bid, quote.bid_price, quote.bid_size),
                (self.ask, quote.ask_price, quote.ask_size)):

            if not self._needs_update(order, price, size):
                actions[order.side] = "変更なし"
                continue

            if size <= 0:
                self._cancel(order)
                actions[order.side] = "撤退"
            elif order.is_live:
                ok = self._amend(order, price, size)
                actions[order.side] = "修正" if ok else "修正失敗"
            else:
                ok = self._place(order, price, size)
                actions[order.side] = "発注" if ok else "発注失敗"

        self._last_update = time.time()
        return actions

    def mark_filled(self, side: str) -> None:
        """約定の通知を受けたら呼ぶ(private WS 経由。記事27)。"""
        order = self.bid if side == "Buy" else self.ask
        order.order_id = ""
        order.price = Decimal(0)
        order.size = Decimal(0)

    def withdraw_all(self) -> None:
        """全ての気配を引き上げる。"""
        r = self.http.cancel_all_orders(
            category=self.config.category, symbol=self.config.symbol)
        if r["retCode"] == 0:
            self.cancelled_count += len(r["result"].get("list", []))
        self.bid = LiveOrder("Buy")
        self.ask = LiveOrder("Sell")

    def report(self) -> str:
        total = self.placed_count + self.amended_count
        amend_rate = self.amended_count / total if total else 0.0
        return (f"  発注 {self.placed_count} / 修正 {self.amended_count} "
                f"(修正率 {amend_rate:.1%}) / "
                f"取消 {self.cancelled_count} / "
                f"見送り {self.skipped_count} / エラー {self.error_count}\n"
                f"  現在: bid {self.bid.price} × {self.bid.size} "
                f"(修正{self.bid.amend_count}回) / "
                f"ask {self.ask.price} × {self.ask.size} "
                f"(修正{self.ask.amend_count}回)")


if __name__ == "__main__":
    from decimal import Decimal as D

    from as_model import AsParams
    from book_manager import BookConfig, BookManager
    from client import make_client
    from quote_engine import QuoteConfig, QuoteEngine

    c = make_client()
    symbol = "BTCUSDT"

    manager = BookManager([symbol], BookConfig(depth=50))
    manager.start()
    for _ in range(30):
        book = manager.book(symbol)
        if book and book.ready:
            break
        time.sleep(1)

    params = AsParams(gamma=0.05, sigma=2.0, k=1.5, horizon_sec=600.0)
    engine = QuoteEngine(c.http, QuoteConfig(
        symbol=symbol, min_spread_bps=5.0,
        max_inventory=D("0.005"), quote_size=D("0.001")), params)

    quotes = QuoteManager(c.http, QuoteManagerConfig(symbol=symbol))

    print("\n=== 気配を60秒間維持 ===\n")
    try:
        for i in range(20):
            book = manager.book(symbol)
            quote = engine.build(book, Decimal(0))
            actions = quotes.sync(quote)
            print(f"[{i + 1:>2}] {quote.line()}")
            print(f"     {actions}")
            time.sleep(3)
    except KeyboardInterrupt:
        pass
    finally:
        quotes.withdraw_all()
        manager.stop()

    print(f"\n{quotes.report()}")
    print("\n※ 修正率が高いほど効率的。")
    print("  キャンセル+再発注は2リクエストかかり、")
    print("  その間は板から消えている。")

在庫管理と強制フラット化

inventory_control.pyサンプル5
"""inventory_control.py — 在庫の追跡と強制フラット化
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  在庫制御の階層:
    1. 予約価格のスキュー(サンプル1)— 自然に在庫を減らす
    2. サイズの縮小(サンプル3)      — 偏った側を細くする
    3. 片側撤退(サンプル3)          — 上限に達したら片側を止める
    4. 強制フラット化(ここ)         — 最終手段。テイカーで解消

  4 は必ずコストがかかる(テイカー手数料 + スリッページ)。
  1〜3 が機能していれば、4 の発動は稀であるべき。
  頻発するなら γ が小さすぎるか、在庫上限が甘い。

  保有時間の上限も設ける:
    在庫が小さくても、長時間持ち続けると
    それは「マーケットメイク」ではなく「方向性の賭け」になる。
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

from margin_snapshot import d


@dataclass(frozen=True)
class InventoryConfig:
    symbol: str
    category: str = "linear"
    max_inventory: Decimal = Decimal("0.05")
    hard_limit_ratio: float = 1.5        # この倍率を超えたら即フラット化
    max_holding_sec: float = 1800.0      # 在庫の保有時間上限
    flatten_chunk_ratio: float = 0.5     # 1回で解消する割合
    flatten_pause_sec: float = 1.0


@dataclass
class InventoryState:
    quantity: Decimal = Decimal(0)
    avg_entry_price: Decimal = Decimal(0)
    since: float = 0.0                   # 在庫がゼロでなくなった時刻
    peak_abs_quantity: Decimal = Decimal(0)
    flatten_count: int = 0
    flatten_cost: Decimal = Decimal(0)   # フラット化で払ったコスト

    @property
    def is_flat(self) -> bool:
        return self.quantity == 0

    @property
    def holding_sec(self) -> float:
        return time.time() - self.since if self.since else 0.0

    @property
    def side(self) -> str:
        if self.quantity > 0:
            return "long"
        if self.quantity < 0:
            return "short"
        return "flat"

    def unrealised(self, mark_price: Decimal) -> Decimal:
        if self.is_flat or self.avg_entry_price <= 0:
            return Decimal(0)
        return (mark_price - self.avg_entry_price) * self.quantity


@dataclass
class InventoryController:
    http: object
    config: InventoryConfig
    state: InventoryState = field(default_factory=InventoryState)
    rules: object = None

    def __post_init__(self) -> None:
        if self.rules is None:
            from symbol_rules import fetch_rules
            self.rules = fetch_rules(self.http, self.config.symbol,
                                     self.config.category)

    # --- 状態の同期 ------------------------------------------------

    def refresh(self) -> InventoryState:
        """取引所から実際のポジションを取得する。

        自前の在庫追跡だけに頼ると、
        取りこぼした約定でズレが蓄積する。
        定期的に取引所の値で上書きすること。
        """
        r = self.http.get_positions(category=self.config.category,
                                    symbol=self.config.symbol)
        if r["retCode"] != 0:
            raise RuntimeError(
                f"get_positions失敗: {r['retCode']} {r['retMsg']}")

        quantity = Decimal(0)
        entry = Decimal(0)

        for p in r["result"]["list"]:
            size = d(p.get("size"))
            if size <= 0:
                continue
            signed = size if p["side"] == "Buy" else -size
            quantity += signed
            entry = d(p.get("avgPrice"))

        was_flat = self.state.is_flat
        self.state.quantity = quantity
        self.state.avg_entry_price = entry

        if quantity == 0:
            self.state.since = 0.0
        elif was_flat:
            self.state.since = time.time()

        abs_q = abs(quantity)
        if abs_q > self.state.peak_abs_quantity:
            self.state.peak_abs_quantity = abs_q

        return self.state

    # --- 判定 ------------------------------------------------------

    def needs_flatten(self) -> tuple:
        """強制フラット化が必要か。(必要か, 理由)"""
        q = abs(self.state.quantity)
        limit = self.config.max_inventory
        hard = limit * Decimal(str(self.config.hard_limit_ratio))

        if q >= hard:
            return True, (f"在庫 {self.state.quantity} が"
                          f"ハードリミット ±{hard} に到達")

        if (not self.state.is_flat
                and self.state.holding_sec > self.config.max_holding_sec):
            return True, (f"在庫を {self.state.holding_sec / 60:.1f}分 "
                          f"保有(上限 "
                          f"{self.config.max_holding_sec / 60:.0f}分)")

        return False, ""

    def utilization(self) -> float:
        """在庫の使用率。1.0で上限。"""
        if self.config.max_inventory <= 0:
            return 0.0
        return float(abs(self.state.quantity) / self.config.max_inventory)

    # --- 実行 ------------------------------------------------------

    def flatten(self, full: bool = False,
                dry_run: bool = False) -> dict:
        """在庫を成行で解消する。

        full=False なら flatten_chunk_ratio の割合だけ減らす。
        一度に全部解消すると、その分スリッページが大きくなる。
        """
        self.refresh()
        if self.state.is_flat:
            return {"closed": Decimal(0), "reason": "在庫なし"}

        quantity = abs(self.state.quantity)
        ratio = Decimal(1) if full else Decimal(
            str(self.config.flatten_chunk_ratio))
        target = self.rules.round_qty(quantity * ratio)

        if target < self.rules.min_qty:
            target = min(quantity, self.rules.min_qty)
        target = min(target, quantity)

        close_side = "Sell" if self.state.quantity > 0 else "Buy"

        if dry_run:
            return {"closed": Decimal(0), "planned": target,
                    "side": close_side, "reason": "dry_run"}

        # 気配を引き上げてから解消する(自己約定の防止)
        self.http.cancel_all_orders(category=self.config.category,
                                    symbol=self.config.symbol)
        time.sleep(0.2)

        r = self.http.place_order(
            category=self.config.category, symbol=self.config.symbol,
            side=close_side, orderType="Market",
            qty=self.rules.format_qty(target), reduceOnly=True,
            orderLinkId=f"flat-{int(time.time() * 1000) % 1_000_000}")

        if r["retCode"] != 0:
            return {"closed": Decimal(0), "error":
                    f"{r['retCode']} {r['retMsg']}"}

        self.state.flatten_count += 1
        time.sleep(self.config.flatten_pause_sec)

        # 実際のコストを記録する
        order_id = r["result"]["orderId"]
        history = self.http.get_order_history(
            category=self.config.category, symbol=self.config.symbol,
            orderId=order_id, limit=1)
        if history["retCode"] == 0 and history["result"]["list"]:
            o = history["result"]["list"][0]
            fee = d(o.get("cumExecFee"))
            self.state.flatten_cost += fee

        self.refresh()
        return {"closed": target, "side": close_side,
                "remaining": self.state.quantity}

    def flatten_until_safe(self, max_iterations: int = 5) -> list:
        """安全な水準まで繰り返し解消する。"""
        log = []
        for i in range(max_iterations):
            needed, reason = self.needs_flatten()
            if not needed:
                break
            print(f"  [フラット化 {i + 1}] {reason}")
            result = self.flatten()
            log.append(result)
            if result.get("error"):
                print(f"    失敗: {result['error']}")
                break
            print(f"    {result.get('closed')} を解消 "
                  f"→ 残 {result.get('remaining')}")
        return log

    def report(self, mark_price: Decimal = Decimal(0)) -> str:
        s = self.state
        lines = [
            f"  在庫       : {s.quantity:>+10} ({s.side})",
            f"  平均建値   : {s.avg_entry_price:>10}",
            f"  使用率     : {self.utilization():>10.1%} "
            f"(上限 ±{self.config.max_inventory})",
            f"  保有時間   : {s.holding_sec / 60:>10.1f}分",
            f"  ピーク在庫 : {s.peak_abs_quantity:>10}",
            f"  フラット化 : {s.flatten_count}回 "
            f"(コスト {s.flatten_cost})",
        ]
        if mark_price > 0:
            lines.append(f"  含み損益   : {s.unrealised(mark_price):>+10.4f}")
        needed, reason = self.needs_flatten()
        if needed:
            lines.append(f"  !! 要フラット化: {reason}")
        return "\n".join(lines)


if __name__ == "__main__":
    from decimal import Decimal as D

    from client import make_client

    c = make_client()
    controller = InventoryController(
        c.http, InventoryConfig(
            symbol="BTCUSDT", max_inventory=D("0.01"),
            max_holding_sec=600.0))

    controller.refresh()
    print("=== 在庫の状態 ===")
    print(controller.report())

    needed, reason = controller.needs_flatten()
    if needed:
        print(f"\n=== フラット化(試算) ===")
        print(f"  理由: {reason}")
        print(f"  {controller.flatten(dry_run=True)}")
        print("\n  dry_run=False にすると実行されます。")
    else:
        print("\n  フラット化は不要です。")

    print("\n※ フラット化が頻発するなら、")
    print("  γ が小さすぎる(在庫調整が弱い)か、")
    print("  在庫上限が甘い(quote_size に対して大きすぎる)。")
    print("  在庫制御は『予約価格のスキュー』が主役であるべきで、")
    print("  成行フラット化は最終手段。")

逆選択の検知

この戦略の生死を分ける部分です。情報を持つ相手にだけ約定させられている状態を検知し、気配を広げるか撤退します。

toxicity.pyサンプル6
"""toxicity.py — 有害フロー(逆選択)を検知する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

参考: Easley, D., López de Prado, M. & O'Hara, M. (2012)
        "Flow Toxicity and Liquidity in a High-frequency World",
        Review of Financial Studies 25(5)
      Cont, R., Kukanov, A. & Stoikov, S. (2014)
        "The Price Impact of Order Book Events"

  3つの検知手法:

  1. VPIN (Volume-Synchronized Probability of Informed Trading)
     出来高バケットごとの「買い量と売り量の偏り」の平均。
     偏りが大きい = 一方向の情報を持つ取引が多い = 有害。

  2. OFI (Order Flow Imbalance)
     板イベントの符号付き流量(記事28)。
     短期の価格圧力を捉える。

  3. 実測の逆選択コスト(マークアウト)
     自分の約定後 N 秒の価格変化。
     買った直後に下がる、売った直後に上がるなら逆選択されている。
     ← 最も直接的で信頼できる指標

  対応:
     軽度  → スプレッドを広げる
     中程度 → 片側だけ出す(不利な側を止める)
     重度  → 完全撤退して様子を見る
"""
from __future__ import annotations

import time
from collections import deque
from dataclasses import dataclass, field
from enum import IntEnum

import numpy as np


class ToxicityLevel(IntEnum):
    NORMAL = 0
    ELEVATED = 1
    HIGH = 2
    SEVERE = 3

    @property
    def label(self) -> str:
        return {
            ToxicityLevel.NORMAL: "正常",
            ToxicityLevel.ELEVATED: "やや有害",
            ToxicityLevel.HIGH: "有害",
            ToxicityLevel.SEVERE: "極めて有害",
        }[self]

    @property
    def action(self) -> str:
        return {
            ToxicityLevel.NORMAL: "通常運転",
            ToxicityLevel.ELEVATED: "スプレッドを1.5倍に",
            ToxicityLevel.HIGH: "スプレッドを3倍に、サイズを半減",
            ToxicityLevel.SEVERE: "全撤退して様子見",
        }[self]

    @property
    def spread_multiplier(self) -> float:
        return {ToxicityLevel.NORMAL: 1.0, ToxicityLevel.ELEVATED: 1.5,
                ToxicityLevel.HIGH: 3.0, ToxicityLevel.SEVERE: 10.0}[self]


@dataclass(frozen=True)
class ToxicityConfig:
    vpin_buckets: int = 50
    vpin_bucket_volume: float = 0.0    # 0なら自動(平均出来高から)
    vpin_elevated: float = 0.35
    vpin_high: float = 0.50

    ofi_window: int = 100
    ofi_elevated: float = 0.30
    ofi_high: float = 0.50

    markout_horizon_sec: float = 30.0
    markout_elevated_bps: float = -1.0   # 負が損(逆選択されている)
    markout_high_bps: float = -3.0
    markout_min_samples: int = 10


@dataclass
class Markout:
    """自分の約定1件の事後価格変化。"""
    ts: float
    side: str            # 自分が Buy か Sell か
    price: float
    size: float
    mark_price: float = 0.0     # horizon 後の価格
    resolved: bool = False

    def pnl_bps(self) -> float:
        """約定後の価格変化による損益(bps)。

        買った後に上がれば正(有利)、下がれば負(逆選択された)。
        """
        if not self.resolved or self.price <= 0:
            return 0.0
        change = (self.mark_price - self.price) / self.price * 10_000
        return change if self.side == "Buy" else -change


@dataclass
class ToxicityDetector:
    config: ToxicityConfig = field(default_factory=ToxicityConfig)

    # VPIN 用
    _buy_volume: float = 0.0
    _sell_volume: float = 0.0
    _bucket_target: float = 0.0
    _vpin_history: deque = field(default_factory=lambda: deque(maxlen=200))

    # OFI 用(記事28の OfiTracker を使ってもよい)
    _ofi_events: deque = field(default_factory=lambda: deque(maxlen=1000))

    # マークアウト用
    markouts: deque = field(default_factory=lambda: deque(maxlen=500))

    level: ToxicityLevel = ToxicityLevel.NORMAL
    level_changed_at: float = 0.0

    # --- VPIN ------------------------------------------------------

    def set_bucket_volume(self, avg_volume_per_min: float) -> None:
        """バケットの大きさを設定する。1分あたりの出来高を基準にする。"""
        self._bucket_target = (self.config.vpin_bucket_volume
                               or avg_volume_per_min)

    def on_trade(self, size: float, is_buyer_maker: bool) -> None:
        """市場の約定を受け取る(WSのtradeトピック)。

        is_buyer_maker=True → テイカーは売り手
        """
        if is_buyer_maker:
            self._sell_volume += size
        else:
            self._buy_volume += size

        total = self._buy_volume + self._sell_volume
        if self._bucket_target > 0 and total >= self._bucket_target:
            imbalance = abs(self._buy_volume - self._sell_volume) / total
            self._vpin_history.append(imbalance)
            self._buy_volume = 0.0
            self._sell_volume = 0.0

    def vpin(self) -> float:
        """直近バケットの平均不均衡。0〜1。"""
        recent = list(self._vpin_history)[-self.config.vpin_buckets:]
        return float(np.mean(recent)) if recent else 0.0

    # --- OFI -------------------------------------------------------

    def on_ofi(self, value: float) -> None:
        self._ofi_events.append(value)

    def normalized_ofi(self) -> float:
        recent = list(self._ofi_events)[-self.config.ofi_window:]
        if len(recent) < 10:
            return 0.0
        total_abs = sum(abs(v) for v in recent)
        return abs(sum(recent)) / total_abs if total_abs > 0 else 0.0

    # --- マークアウト ----------------------------------------------

    def on_own_fill(self, side: str, price: float, size: float) -> None:
        """自分の約定を記録する(private WS の execution)。"""
        self.markouts.append(Markout(time.time(), side, price, size))

    def resolve_markouts(self, current_price: float) -> int:
        """horizon を過ぎたマークアウトを確定させる。"""
        now = time.time()
        resolved = 0
        for m in self.markouts:
            if m.resolved:
                continue
            if now - m.ts >= self.config.markout_horizon_sec:
                m.mark_price = current_price
                m.resolved = True
                resolved += 1
        return resolved

    def average_markout_bps(self) -> float:
        """確定したマークアウトの平均。負なら逆選択されている。"""
        values = [m.pnl_bps() for m in self.markouts if m.resolved]
        if len(values) < self.config.markout_min_samples:
            return 0.0
        return float(np.mean(values))

    def markout_by_side(self) -> dict:
        """買い・売りそれぞれのマークアウト。

        片側だけが悪いなら、その側の気配だけを引くべき。
        """
        out = {}
        for side in ("Buy", "Sell"):
            values = [m.pnl_bps() for m in self.markouts
                      if m.resolved and m.side == side]
            out[side] = (float(np.mean(values)) if values else 0.0,
                         len(values))
        return out

    # --- 総合判定 --------------------------------------------------

    def assess(self) -> ToxicityLevel:
        """3つの指標を統合して有害度を判定する。"""
        scores = []

        v = self.vpin()
        if v >= self.config.vpin_high:
            scores.append(ToxicityLevel.HIGH)
        elif v >= self.config.vpin_elevated:
            scores.append(ToxicityLevel.ELEVATED)
        else:
            scores.append(ToxicityLevel.NORMAL)

        o = self.normalized_ofi()
        if o >= self.config.ofi_high:
            scores.append(ToxicityLevel.HIGH)
        elif o >= self.config.ofi_elevated:
            scores.append(ToxicityLevel.ELEVATED)
        else:
            scores.append(ToxicityLevel.NORMAL)

        m = self.average_markout_bps()
        if m != 0.0:
            if m <= self.config.markout_high_bps:
                # マークアウトが悪いのは決定的な証拠
                scores.append(ToxicityLevel.SEVERE)
            elif m <= self.config.markout_elevated_bps:
                scores.append(ToxicityLevel.HIGH)
            else:
                scores.append(ToxicityLevel.NORMAL)

        new_level = max(scores) if scores else ToxicityLevel.NORMAL

        if new_level != self.level:
            self.level = new_level
            self.level_changed_at = time.time()

        return self.level

    def report(self) -> str:
        by_side = self.markout_by_side()
        lines = [
            f"  有害度: [{self.level.label}] → {self.level.action}",
            f"    VPIN          : {self.vpin():.4f} "
            f"(閾値 {self.config.vpin_elevated}/{self.config.vpin_high})",
            f"    正規化OFI     : {self.normalized_ofi():.4f}",
            f"    マークアウト  : {self.average_markout_bps():>+7.3f}bps "
            f"({sum(1 for m in self.markouts if m.resolved)}件)",
            f"      買い側      : {by_side['Buy'][0]:>+7.3f}bps "
            f"({by_side['Buy'][1]}件)",
            f"      売り側      : {by_side['Sell'][0]:>+7.3f}bps "
            f"({by_side['Sell'][1]}件)",
        ]
        return "\n".join(lines)


if __name__ == "__main__":
    import random

    detector = ToxicityDetector()
    detector.set_bucket_volume(10.0)

    print("=== 正常な市場(買いと売りが拮抗)===")
    rng = random.Random(42)
    for _ in range(2000):
        detector.on_trade(rng.uniform(0.01, 0.5), rng.random() < 0.5)
        detector.on_ofi(rng.gauss(0, 1))

    for i in range(30):
        detector.on_own_fill("Buy" if i % 2 == 0 else "Sell",
                             120_000 + rng.gauss(0, 5), 0.001)
    for m in detector.markouts:
        m.mark_price = 120_000 + rng.gauss(0, 5)
        m.resolved = True

    print(detector.report())
    print(f"  判定: {detector.assess().label}\n")

    print("=== 有害な市場(一方向のフロー)===")
    toxic = ToxicityDetector()
    toxic.set_bucket_volume(10.0)

    for _ in range(2000):
        # 85% が買い
        toxic.on_trade(rng.uniform(0.01, 0.5), rng.random() < 0.15)
        toxic.on_ofi(abs(rng.gauss(2, 1)))

    # 自分は売り気配を食われ続け、その後価格が上がる
    for i in range(30):
        toxic.on_own_fill("Sell", 120_000 + i * 2, 0.001)
    for i, m in enumerate(toxic.markouts):
        m.mark_price = 120_000 + i * 2 + 8    # 売った後に上昇
        m.resolved = True

    print(toxic.report())
    print(f"  判定: {toxic.assess().label}")

    print("\n※ マークアウトが最も信頼できる指標。")
    print("  VPINとOFIは『市場が偏っている』ことしか言わないが、")
    print("  マークアウトは『自分が損をしている』ことを直接示す。")
    print("  片側だけ悪いなら、その側の気配だけを引けばよい。")

損益分解

MM botの損益は「なぜ勝ったか/負けたか」を分解できなければ改善できません。スプレッド収入・在庫損益・手数料の3つに分けます。

mm_pnl.pyサンプル7
"""mm_pnl.py — マーケットメイクの損益を要因分解する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  分解:
    総損益 = スプレッド収入 + 在庫損益 + 手数料

    スプレッド収入 : 各約定が仲値からどれだけ有利な価格だったか
                     Σ (仲値 − 約定価格) × 数量 × 符号
    在庫損益       : 保有中の価格変動による損益
    手数料         : メイカー手数料(リベートなら正)

  この分解が重要な理由:
    「トータルで負けている」だけでは何を直せばよいか分からない。
    - スプレッド収入がマイナス → 気配が近すぎる or 逆選択
    - 在庫損益が大きくマイナス → 在庫制御が効いていない
    - 手数料がマイナスで大きい → テイカー化している(PostOnly失敗)
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

from margin_snapshot import d


@dataclass
class Fill:
    """自分の約定1件。"""
    ts: float
    side: str
    price: Decimal
    size: Decimal
    fee: Decimal
    is_maker: bool
    mid_at_fill: Decimal = Decimal(0)
    exec_id: str = ""

    @property
    def signed_size(self) -> Decimal:
        return self.size if self.side == "Buy" else -self.size

    def spread_capture(self) -> Decimal:
        """仲値からの有利分。

        買いなら (仲値 − 約定価格) × 数量。
        仲値より安く買えていれば正。
        """
        if self.mid_at_fill <= 0:
            return Decimal(0)
        edge = (self.mid_at_fill - self.price if self.side == "Buy"
                else self.price - self.mid_at_fill)
        return edge * self.size

    def spread_capture_bps(self) -> Decimal:
        if self.mid_at_fill <= 0 or self.size <= 0:
            return Decimal(0)
        return (self.spread_capture() / (self.mid_at_fill * self.size)
                * Decimal("10000"))


@dataclass
class PnlBreakdown:
    spread_income: Decimal = Decimal(0)
    inventory_pnl: Decimal = Decimal(0)
    fees: Decimal = Decimal(0)
    realized_pnl: Decimal = Decimal(0)
    unrealized_pnl: Decimal = Decimal(0)

    n_fills: int = 0
    n_maker_fills: int = 0
    n_taker_fills: int = 0
    volume: Decimal = Decimal(0)

    @property
    def total(self) -> Decimal:
        return self.spread_income + self.inventory_pnl + self.fees

    @property
    def maker_ratio(self) -> float:
        return self.n_maker_fills / self.n_fills if self.n_fills else 0.0

    @property
    def spread_per_fill_bps(self) -> Decimal:
        if self.volume <= 0:
            return Decimal(0)
        return self.spread_income / self.volume * Decimal("10000")

    def report(self, notional_traded: Decimal = Decimal(0)) -> str:
        lines = [
            "=== 損益分解 ===",
            f"  スプレッド収入 : {self.spread_income:>+14.6f}",
            f"  在庫損益       : {self.inventory_pnl:>+14.6f}",
            f"  手数料         : {self.fees:>+14.6f}",
            "  " + "-" * 32,
            f"  合計           : {self.total:>+14.6f}",
            "",
            f"  約定件数       : {self.n_fills} "
            f"(メイカー {self.n_maker_fills} / "
            f"テイカー {self.n_taker_fills})",
            f"  メイカー比率   : {self.maker_ratio:>13.1%}",
            f"  約定数量       : {self.volume}",
        ]

        if notional_traded > 0:
            lines.append("")
            lines.append(
                f"  取引額あたり   : "
                f"{self.total / notional_traded * 10_000:>+13.3f}bps")

        lines.append("")
        lines.append("  【診断】")
        if self.spread_income < 0:
            lines.append("    ! スプレッド収入がマイナス。")
            lines.append("      気配が近すぎるか、逆選択されています(サンプル6)")
        if self.inventory_pnl < -abs(self.spread_income):
            lines.append("    ! 在庫損がスプレッド収入を上回っています。")
            lines.append("      γを大きくするか在庫上限を下げてください")
        if self.maker_ratio < 0.9 and self.n_fills > 10:
            lines.append("    ! メイカー比率が低いです。")
            lines.append("      PostOnly が拒否されているか、")
            lines.append("      フラット化が頻発しています")
        if self.fees < 0 and abs(self.fees) > abs(self.spread_income) * Decimal("0.5"):
            lines.append("    ! 手数料負担が大きいです。")

        return "\n".join(lines)


class PnlTracker:
    """約定を記録して損益を分解する。"""

    def __init__(self, symbol: str, category: str = "linear"):
        self.symbol = symbol
        self.category = category
        self.fills: list = []
        self.inventory = Decimal(0)
        self.avg_cost = Decimal(0)
        self.realized = Decimal(0)
        self._seen: set = set()

    def on_fill(self, fill: Fill) -> None:
        """約定を1件取り込む(private WS の execution から)。"""
        if fill.exec_id and fill.exec_id in self._seen:
            return
        if fill.exec_id:
            self._seen.add(fill.exec_id)

        self.fills.append(fill)

        signed = fill.signed_size
        new_inventory = self.inventory + signed

        # 実現損益の計算(平均法)
        if self.inventory != 0 and (self.inventory > 0) != (signed > 0):
            # 反対売買。決済された分の損益を実現する
            closed = min(abs(self.inventory), abs(signed))
            direction = Decimal(1) if self.inventory > 0 else Decimal(-1)
            self.realized += (fill.price - self.avg_cost) * closed * direction

        if new_inventory == 0:
            self.avg_cost = Decimal(0)
        elif (self.inventory >= 0) == (signed >= 0) or self.inventory == 0:
            # 積み増し。平均取得単価を更新
            total_cost = self.avg_cost * abs(self.inventory) \
                + fill.price * abs(signed)
            self.avg_cost = total_cost / abs(new_inventory)
        # 部分決済で残った分の単価は変わらない

        self.inventory = new_inventory

    def breakdown(self, mark_price: Decimal) -> PnlBreakdown:
        """現在の損益を分解する。"""
        result = PnlBreakdown()

        for fill in self.fills:
            result.spread_income += fill.spread_capture()
            result.fees -= fill.fee      # feeは支払いが正なので符号を反転
            result.volume += fill.size
            result.n_fills += 1
            if fill.is_maker:
                result.n_maker_fills += 1
            else:
                result.n_taker_fills += 1

        result.realized_pnl = self.realized
        if self.inventory != 0 and self.avg_cost > 0:
            result.unrealized_pnl = (mark_price - self.avg_cost) * self.inventory

        # 在庫損益 = 実現 + 含み − スプレッド収入
        # (スプレッド収入は実現損益の一部として既に含まれるため差し引く)
        result.inventory_pnl = (result.realized_pnl + result.unrealized_pnl
                                - result.spread_income)

        return result

    def load_from_api(self, http, hours: float = 24.0) -> int:
        """APIから約定履歴を取り込む。

        get_executions は7日以内の範囲のみ(記事18)。
        """
        start_ms = int((time.time() - hours * 3600) * 1000)
        cursor = ""
        loaded = 0

        while True:
            params = {"category": self.category, "symbol": self.symbol,
                      "startTime": start_ms, "limit": 100}
            if cursor:
                params["cursor"] = cursor

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

            for e in r["result"]["list"]:
                self.on_fill(Fill(
                    ts=int(e.get("execTime", 0)) / 1000,
                    side=e.get("side", ""),
                    price=d(e.get("execPrice")),
                    size=d(e.get("execQty")),
                    fee=d(e.get("execFee")),
                    is_maker=(e.get("isMaker") is True),
                    # 約定時の仲値は履歴に無いので、約定価格で代用
                    # 正確に測るには自前で記録する必要がある
                    mid_at_fill=d(e.get("markPrice")) or d(e.get("execPrice")),
                    exec_id=e.get("execId", "")))
                loaded += 1

            cursor = r["result"].get("nextPageCursor", "")
            if not cursor:
                break
            time.sleep(0.06)

        self.fills.sort(key=lambda f: f.ts)
        return loaded


if __name__ == "__main__":
    from decimal import Decimal as D

    from client import make_client

    c = make_client()
    symbol = "BTCUSDT"

    tracker = PnlTracker(symbol)
    try:
        n = tracker.load_from_api(c.http, hours=24)
        print(f"  約定 {n}件を取り込みました\n")
    except RuntimeError as e:
        print(f"  取り込み失敗: {e}\n")

    if not tracker.fills:
        print("約定履歴がないため、合成データで動作を示します。\n")
        mid = D("120000")
        for i in range(40):
            side = "Buy" if i % 2 == 0 else "Sell"
            # 仲値より有利な位置で約定している想定
            edge = D("3") if side == "Buy" else D("-3")
            tracker.on_fill(Fill(
                ts=time.time() - (40 - i) * 60,
                side=side, price=mid - edge, size=D("0.001"),
                fee=D("-0.00007"),      # メイカーリベート
                is_maker=True, mid_at_fill=mid,
                exec_id=f"demo-{i}"))
            mid += D("2") if i % 3 == 0 else D("-1")

    r = c.http.get_tickers(category="linear", symbol=symbol)
    mark = D(r["result"]["list"][0]["lastPrice"])

    breakdown = tracker.breakdown(mark)
    notional = breakdown.volume * mark
    print(breakdown.report(notional))

    print(f"\n  現在の在庫: {tracker.inventory} @ {tracker.avg_cost}")

    print("\n※ 約定時の仲値はAPI履歴に含まれないため、")
    print("  正確なスプレッド収入を測るには、")
    print("  発注時点の板を自前で記録しておく必要がある(記事28)。")

完成コードと運用手順

mm_bot.pyサンプル8
"""mm_bot.py — マーケットメイクbot(全構成要素の統合)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  使用する記事:
    記事28 ローカル板       → 仲値・マイクロプライス・OFI
    記事27 private WS       → 約定の即時検知
    記事16 バッチ/amend     → 気配の高速更新
    記事24 サーキットブレーカー → 停止条件
    記事12 ボラ推定         → σ の更新

  ループ:
    1. ローカル板から仲値を取得
    2. 在庫を確認(必要ならフラット化)
    3. 有害度を評価(必要ならスプレッドを広げる/撤退)
    4. AS モデルで気配を計算
    5. amend で更新
    6. 損益を記録

  この bot は「動く」が「儲かる」とは限らない。
  必ず testnet で数週間動かし、
  サンプル7の損益分解で挙動を理解してから本番に進むこと。
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from decimal import Decimal

from as_model import AsParams
from book_manager import BookConfig, BookManager
from client import make_client
from inventory_control import InventoryConfig, InventoryController
from mm_pnl import Fill, PnlTracker
from quote_engine import QuoteConfig, QuoteEngine
from quote_manager import QuoteManager, QuoteManagerConfig
from toxicity import ToxicityConfig, ToxicityDetector, ToxicityLevel


@dataclass(frozen=True)
class MmBotConfig:
    symbol: str = "BTCUSDT"
    category: str = "linear"

    # AS パラメータ
    gamma: float = 0.05
    k: float = 1.5
    horizon_sec: float = 600.0
    sigma_update_sec: float = 300.0

    # 気配
    quote_size: Decimal = Decimal("0.001")
    min_spread_bps: float = 4.0
    max_spread_bps: float = 60.0

    # 在庫
    max_inventory: Decimal = Decimal("0.01")
    max_holding_sec: float = 1800.0

    # ループ
    loop_sec: float = 1.0
    status_interval_sec: float = 30.0
    pnl_refresh_sec: float = 120.0


class MmBot:
    def __init__(self, cfg: MmBotConfig):
        self.cfg = cfg
        self.client = make_client()
        self.http = self.client.http

        self.params = AsParams(gamma=cfg.gamma, sigma=0.0, k=cfg.k,
                               horizon_sec=cfg.horizon_sec)

        self.books = BookManager([cfg.symbol], BookConfig(
            category=cfg.category, depth=50))

        self.engine = QuoteEngine(self.http, QuoteConfig(
            symbol=cfg.symbol, category=cfg.category,
            min_spread_bps=cfg.min_spread_bps,
            max_spread_bps=cfg.max_spread_bps,
            max_inventory=cfg.max_inventory,
            quote_size=cfg.quote_size), self.params)

        self.quotes = QuoteManager(self.http, QuoteManagerConfig(
            symbol=cfg.symbol, category=cfg.category))

        self.inventory = InventoryController(self.http, InventoryConfig(
            symbol=cfg.symbol, category=cfg.category,
            max_inventory=cfg.max_inventory,
            max_holding_sec=cfg.max_holding_sec))

        self.toxicity = ToxicityDetector(ToxicityConfig())
        self.pnl = PnlTracker(cfg.symbol, cfg.category)

        self.base_min_spread = cfg.min_spread_bps
        self.started_at = time.time()
        self.last_sigma_update = 0.0
        self.last_status = 0.0
        self.last_pnl_refresh = 0.0
        self.running = False

    # --- パラメータ更新 --------------------------------------------

    def update_sigma(self) -> None:
        """σ を実データから更新する(記事12)。"""
        from as_calibrate import estimate_sigma
        from kline_fetch import fetch_klines

        bars = fetch_klines(self.http, self.cfg.symbol, interval="1",
                            limit_bars=240, category=self.cfg.category,
                            verbose=False)
        sigma = estimate_sigma(bars["close"], seconds_per_bar=60.0)

        self.params = AsParams(
            gamma=self.params.gamma, sigma=sigma, k=self.params.k,
            horizon_sec=self.params.horizon_sec)
        self.engine.params = self.params
        self.last_sigma_update = time.time()
        print(f"  [param] σ を更新: {sigma:.6f}")

    def apply_toxicity(self, level: ToxicityLevel) -> bool:
        """有害度に応じて気配を調整する。撤退すべきなら False。"""
        if level is ToxicityLevel.SEVERE:
            self.quotes.withdraw_all()
            print(f"  [toxic] {level.label} → 全撤退")
            return False

        multiplier = level.spread_multiplier
        object.__setattr__(self.engine.cfg, "min_spread_bps",
                           self.base_min_spread * multiplier)
        return True

    # --- ループ ----------------------------------------------------

    def step(self) -> None:
        book = self.books.book(self.cfg.symbol)
        if book is None or not book.ready:
            return

        # σ の定期更新
        if time.time() - self.last_sigma_update > self.cfg.sigma_update_sec:
            try:
                self.update_sigma()
            except Exception as e:
                print(f"  [warn] σ更新に失敗: {e}")

        # 在庫の確認
        state = self.inventory.refresh()
        needed, reason = self.inventory.needs_flatten()
        if needed:
            print(f"  [inventory] {reason}")
            self.quotes.withdraw_all()
            self.inventory.flatten_until_safe()
            return

        # 有害度の評価
        self.toxicity.resolve_markouts(book.mid)
        level = self.toxicity.assess()
        if not self.apply_toxicity(level):
            return

        # 気配の計算と更新
        elapsed = time.time() - self.started_at
        quote = self.engine.build(book, state.quantity, elapsed)
        self.quotes.sync(quote)

    def refresh_pnl(self) -> None:
        try:
            self.pnl.load_from_api(self.http, hours=6)
        except Exception as e:
            print(f"  [warn] 損益取得に失敗: {e}")
        self.last_pnl_refresh = time.time()

    def print_status(self) -> None:
        book = self.books.book(self.cfg.symbol)
        mark = Decimal(str(book.mid)) if book and book.ready else Decimal(0)

        print(f"\n{'=' * 64}")
        print(f"稼働 {(time.time() - self.started_at) / 60:.1f}分 "
              f"| {self.cfg.symbol}")
        if book and book.ready:
            print(f"  板: bid={book.best_bid:,.2f} ask={book.best_ask:,.2f} "
                  f"({book.spread_bps:.2f}bps)")
        print(self.inventory.report(mark))
        print(self.toxicity.report())
        print(self.quotes.report())
        if self.pnl.fills and mark > 0:
            breakdown = self.pnl.breakdown(mark)
            print(f"  損益: スプレッド {breakdown.spread_income:+.6f} / "
                  f"在庫 {breakdown.inventory_pnl:+.6f} / "
                  f"手数料 {breakdown.fees:+.6f} "
                  f"= {breakdown.total:+.6f}")
        print(f"{'=' * 64}\n")
        self.last_status = time.time()

    def run(self) -> None:
        print(f"=== マーケットメイクbot ({self.cfg.symbol}) ===")
        print(f"  γ={self.cfg.gamma} k={self.cfg.k} "
              f"在庫上限=±{self.cfg.max_inventory} "
              f"サイズ={self.cfg.quote_size}")
        print("  Ctrl+C で停止\n")

        self.books.start()
        print("  板の同期を待機中...")
        for _ in range(30):
            book = self.books.book(self.cfg.symbol)
            if book and book.ready:
                break
            time.sleep(1)

        try:
            self.update_sigma()
        except Exception as e:
            print(f"  [warn] 初回のσ推定に失敗: {e}")

        self.running = True
        try:
            while self.running:
                try:
                    self.step()
                except Exception as e:
                    print(f"  [error] {type(e).__name__}: {e}")

                now = time.time()
                if now - self.last_pnl_refresh > self.cfg.pnl_refresh_sec:
                    self.refresh_pnl()
                if now - self.last_status > self.cfg.status_interval_sec:
                    self.print_status()

                time.sleep(self.cfg.loop_sec)

        except KeyboardInterrupt:
            print("\n停止します...")
        finally:
            self.shutdown()

    def shutdown(self) -> None:
        self.running = False
        print("  気配を引き上げます...")
        self.quotes.withdraw_all()

        state = self.inventory.refresh()
        if not state.is_flat:
            print(f"  在庫 {state.quantity} が残っています。")
            answer = input("  成行で解消しますか? [y/N] ")
            if answer.lower() == "y":
                self.inventory.flatten(full=True)

        self.books.stop()
        self.print_status()


if __name__ == "__main__":
    bot = MmBot(MmBotConfig(
        symbol="BTCUSDT",
        gamma=0.05, k=1.5,
        quote_size=Decimal("0.001"),
        max_inventory=Decimal("0.01"),
        min_spread_bps=5.0,
        max_holding_sec=900.0))

    bot.run()

    # 記事24のサーキットブレーカーと統合する場合:
    #   supervisor = Supervisor(bot.http, ...)
    #   step() の冒頭で supervisor.can_open をチェックし、
    #   False なら quotes.withdraw_all() して return する
mm_checklist.pyサンプル9
"""mm_checklist.py — 実運用前の確認と段階的な移行手順
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。

  MM bot は「動く」ことと「儲かる」ことの距離が最も遠い戦略。
  段階を飛ばすと、必ず授業料を払うことになる。

  段階:
    Phase 0: 環境の確認(レイテンシ・手数料ティア・銘柄選定)
    Phase 1: testnet で2週間(バグ出し・挙動の理解)
    Phase 2: 本番・最小ロット・1銘柄で2週間(損益分解の確認)
    Phase 3: サイズを段階的に増やす(各段階で損益分解を再確認)

  各段階で「次に進んでよいか」を判定する基準を明示する。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal


@dataclass
class CheckItem:
    name: str
    passed: bool
    detail: str = ""
    critical: bool = True

    def line(self) -> str:
        mark = "OK " if self.passed else ("NG " if self.critical else "-- ")
        return f"  [{mark}] {self.name}{'  ' + self.detail if self.detail else ''}"


def check_environment(http, symbol: str = "BTCUSDT",
                      category: str = "linear") -> list:
    """Phase 0: 環境が MM に適しているか。"""
    from symbol_rules import fetch_rules

    items = []

    # 1) レイテンシ
    try:
        import statistics
        import time
        latencies = []
        for _ in range(10):
            start = time.perf_counter()
            http.get_server_time()
            latencies.append((time.perf_counter() - start) * 1000)
            time.sleep(0.1)
        median = statistics.median(latencies)
        items.append(CheckItem(
            "レイテンシ", median < 100,
            f"中央値 {median:.0f}ms(100ms未満が望ましい)"))
    except Exception as e:
        items.append(CheckItem("レイテンシ", False, f"測定失敗: {e}"))

    # 2) 手数料ティア
    try:
        r = http.get_fee_rates(category=category, symbol=symbol)
        if r["retCode"] == 0 and r["result"]["list"]:
            fee = r["result"]["list"][0]
            maker = Decimal(fee.get("makerFeeRate", "0"))
            taker = Decimal(fee.get("takerFeeRate", "0"))
            maker_bps = maker * 10_000
            items.append(CheckItem(
                "メイカー手数料", maker <= Decimal("0.0002"),
                f"{maker_bps:+.2f}bps "
                f"(テイカー {taker * 10_000:.2f}bps)",
                critical=False))
        else:
            items.append(CheckItem("メイカー手数料", False, "取得失敗"))
    except Exception as e:
        items.append(CheckItem("メイカー手数料", False, str(e)))

    # 3) 銘柄のスプレッド
    try:
        r = http.get_tickers(category=category, symbol=symbol)
        t = r["result"]["list"][0]
        bid, ask = Decimal(t["bid1Price"]), Decimal(t["ask1Price"])
        mid = (bid + ask) / 2
        spread_bps = (ask - bid) / mid * 10_000
        items.append(CheckItem(
            "スプレッド", spread_bps >= 2,
            f"{spread_bps:.2f}bps(狭すぎると収益機会がない)"))

        turnover = Decimal(t.get("turnover24h") or "0")
        items.append(CheckItem(
            "24h売買代金", turnover > 10_000_000,
            f"{turnover / 1_000_000:.0f}M USD"))
    except Exception as e:
        items.append(CheckItem("銘柄の流動性", False, str(e)))

    # 4) 最小ロット
    try:
        rules = fetch_rules(http, symbol, category)
        r = http.get_tickers(category=category, symbol=symbol)
        price = Decimal(r["result"]["list"][0]["lastPrice"])
        min_notional = rules.min_qty * price
        items.append(CheckItem(
            "最小建玉額", min_notional < 100,
            f"{min_notional:.2f} USD(小さいほど細かく制御できる)",
            critical=False))
    except Exception as e:
        items.append(CheckItem("最小ロット", False, str(e)))

    return items


def check_testnet_results(pnl_breakdown, hours: float,
                          n_fills: int) -> list:
    """Phase 1 → Phase 2 の判定。

    testnet では約定が現実と乖離するため、
    「儲かったか」ではなく「壊れずに動いたか」を見る。
    """
    items = []

    items.append(CheckItem(
        "十分な稼働時間", hours >= 24 * 14,
        f"{hours / 24:.1f}日(14日以上)"))

    items.append(CheckItem(
        "十分な約定数", n_fills >= 200,
        f"{n_fills}件(200件以上)"))

    items.append(CheckItem(
        "メイカー比率", pnl_breakdown.maker_ratio >= 0.90,
        f"{pnl_breakdown.maker_ratio:.1%}(90%以上)"))

    items.append(CheckItem(
        "スプレッド収入がプラス",
        pnl_breakdown.spread_income > 0,
        f"{pnl_breakdown.spread_income:+.6f}"))

    items.append(CheckItem(
        "在庫損がスプレッド収入以内",
        abs(pnl_breakdown.inventory_pnl) < pnl_breakdown.spread_income * 2
        if pnl_breakdown.spread_income > 0 else False,
        f"在庫 {pnl_breakdown.inventory_pnl:+.6f} vs "
        f"スプレッド {pnl_breakdown.spread_income:+.6f}"))

    return items


def check_live_results(pnl_breakdown, hours: float,
                       max_drawdown: Decimal,
                       flatten_count: int) -> list:
    """Phase 2 → Phase 3 の判定。ここは厳しく見る。"""
    items = []

    items.append(CheckItem(
        "稼働時間", hours >= 24 * 14, f"{hours / 24:.1f}日"))

    items.append(CheckItem(
        "総損益がプラス", pnl_breakdown.total > 0,
        f"{pnl_breakdown.total:+.6f}"))

    items.append(CheckItem(
        "スプレッド収入 > 在庫損",
        pnl_breakdown.spread_income > abs(pnl_breakdown.inventory_pnl),
        "これが成立しないなら逆選択に負けています"))

    items.append(CheckItem(
        "フラット化が稀", flatten_count < hours / 24 * 3,
        f"{flatten_count}回(1日3回未満)",
        critical=False))

    items.append(CheckItem(
        "最大DDが許容範囲", max_drawdown > Decimal("-0.05"),
        f"{max_drawdown:+.2%}"))

    return items


def print_checklist(title: str, items: list) -> bool:
    print(f"\n=== {title} ===")
    for item in items:
        print(item.line())

    failed = [i for i in items if not i.passed and i.critical]
    print()
    if failed:
        print(f"  {len(failed)}件の必須項目が未達です。次に進まないでください。")
        return False
    print("  すべての必須項目を満たしています。")
    return True


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

    c = make_client()

    print("=" * 60)
    print("マーケットメイク実運用チェックリスト")
    print("=" * 60)

    items = check_environment(c.http, "BTCUSDT")
    ok = print_checklist("Phase 0: 環境の確認", items)

    print("\n" + "=" * 60)
    print("段階的な移行手順")
    print("=" * 60)
    print("""
  Phase 1: testnet で2週間
    目的: バグ出しと挙動の理解(損益は見ない)
    確認: メイカー比率90%以上、在庫制御が効いているか
    注意: testnet は板が薄く約定が現実と乖離する

  Phase 2: 本番・最小ロット・1銘柄で2週間
    目的: 実際の逆選択を測る
    確認: スプレッド収入 > 在庫損 が成立するか
    注意: この段階で負けるなら、サイズを増やしても負ける

  Phase 3: サイズを段階的に増やす
    手順: 2倍にして2週間 → 損益分解を再確認 → また2倍
    注意: サイズを増やすと約定率も逆選択も変わる。
          「小さいロットで勝てた」は大きいロットを保証しない

  各段階で必ずサンプル7の損益分解を確認すること。
  『トータルで勝った』だけでは、次の段階に進む根拠にならない。
""")

    print("=" * 60)
    print("現実的な期待値")
    print("=" * 60)
    print("""
  - 個人がレイテンシで機関投資家に勝つことはできません
  - RPIオーダー(逆選択からの保護)は個人には使えません
  - したがって狙うべきは「機関が手を出さない領域」です:
      * 流動性の低い銘柄(ただしスリッページに注意)
      * 特定の時間帯(アジア早朝など)
      * スプレッドが構造的に広い銘柄
  - それでも逆選択には常に晒されます。
    サンプル6の検知と撤退が最後の防御線です。
""")
参考リンク・文献
  • Place Order(PostOnly / timeInForce): bybit-exchange.github.io/docs/v5/order/create-order
  • Amend Order: /order/amend-order
  • Get Fee Rate: /account/fee-rate
  • Get Executions(isMaker / execFee): /order/execution
  • WebSocket Orderbook: /websocket/public/orderbook
  • Avellaneda, M. & Stoikov, S. (2008) "High-frequency Trading in a Limit Order Book", Quantitative Finance 8(3)
  • Guéant, O., Lehalle, C.-A. & Fernandez-Tapia, J. (2013) "Dealing with the Inventory Risk", Mathematics and Financial Economics 7(4)
  • Ho, T. & Stoll, H. (1981) "Optimal Dealer Pricing under Transactions and Return Uncertainty", Journal of Financial Economics 9(1)
  • Easley, D., López de Prado, M. & O'Hara, M. (2012) "Flow Toxicity and Liquidity in a High-frequency World", Review of Financial Studies 25(5)
  • Cartea, Á., Jaimungal, S. & Penalva, J. (2015) Algorithmic and High-Frequency Trading, Cambridge University Press

よくある疑問Q&A

メイカーリベートがあれば必ず勝てますか
勝てません。両側に指値を置くと、価格が動く方向の指値だけが約定します。上昇局面では売り気配が食われて売り在庫を抱え、そのまま価格は上がり続けます。これは偶然ではなく構造です。
あなたの指値を食う相手は「今買うべきだ」と判断した相手なので、平均的にあなたは不利な側を取らされます。リベート数bpsに対し、逆選択の損失はその何倍にもなります。サンプル7の損益分解で、スプレッド収入が在庫損を上回っているかを必ず確認してください。
γ(リスク回避係数)はいくつにすべきですか
γに直感は働かないので、「目標スプレッドから逆算する」のが実務的です。サンプル2の suggest_gamma() がそれを行います。
γが大きいほどスプレッドが広く在庫調整も強くなります(安全だが約定が減る)。小さいほど薄いスプレッドで約定は増えますが、在庫が溜まりやすくなります。出発点は「現在の市場スプレッドの1.5〜2倍」あたりで、サンプル5のフラット化が頻発するなら γ を上げてください。
残り時間 T−t はどう扱えばいいですか
元論文は「有限期間 T で在庫をゼロにする」設定ですが、24時間動かすbotではこれが破綻します(T に近づくとスプレッドがゼロに収束する)。
実務では「T−t を一定に固定する(無限期間近似)」のが標準です。サンプル1の use_finite_horizon=False(既定)がこれです。固定値は「在庫を解消したい時間スケール」と考え、600秒程度から始めてください。
kはどう推定しますか
サンプル2で約定履歴から λ(δ) = A·exp(−kδ) をフィットします。仲値からδ離れた位置での約定率を階級ごとに数え、対数を取って線形回帰するだけです。
ただしここで測るのは市場全体の約定強度であって、自分の指値の約定率ではありません。自分の注文は板の後ろに並ぶため実際の約定率はこれより低くなります。k は保守的(大きめ)に見積もってください。
在庫が上限に張り付いて動きません
在庫制御が機能していない状態です。原因は3つ考えられます。
①γが小さすぎる — 予約価格のスキューが弱く、在庫を減らす力が働いていません。②在庫上限が quote_size に対して大きすぎる — 上限に達するまでに何十回も約定してしまいます。③一方向のトレンド中 — この場合はどうにもなりません。撤退が正解です。
サンプル1の inventory_at_which_quote_crosses_mid() が返す値を在庫上限の目安にしてください。
PostOnlyが頻繁に拒否されます
気配が板を跨いでいます。retCode=110094 / 30208 です。
サンプル3の QuoteEngine.build() は、買い気配を best_ask − tick 以下、売り気配を best_bid + tick 以上に補正します。それでも拒否されるのは、計算から発注までの間に板が動いたケースです。次の周回で再試行すれば問題ありません。
拒否率が高いなら、更新頻度を上げるか、気配を1ティック内側に置いてください。PostOnly を外してはいけません——テイカーになれば手数料でも価格でも負けます。
逆選択されているかどうかはどう判断しますか
マークアウトが最も直接的で信頼できます。自分の約定後N秒の価格変化を測り、「買った直後に下がる/売った直後に上がる」が続いていれば、情報を持つ相手にだけ約定させられています。
サンプル6の average_markout_bps() が負で大きいなら、それが答えです。VPINやOFIは「市場が偏っている」ことしか示しませんが、マークアウトは「自分が損をしている」ことを直接示します。markout_by_side() で片側だけが悪いなら、その側の気配だけを引いてください。
個人でもマーケットメイクで勝てますか
レイテンシで機関投資家に勝つことはできません。加えて、逆選択から保護される timeInForce="RPI" は承認されたマーケットメイカー専用で、個人は使えません。
狙えるとすれば「機関が手を出さない領域」——流動性の低い銘柄、特定の時間帯、構造的にスプレッドが広い銘柄です。ただしそこでも逆選択には常に晒されます。
サンプル9のチェックリストに沿って、testnet 2週間 → 本番最小ロット2週間 → 段階的に増量という順序を守ってください。Phase 2 で負けるなら、サイズを増やしても負けます。
この記事のコードをそのまま本番で動かしていいですか
いけません。このbotは「動く」ように書かれていますが、「儲かる」ようには書かれていません。パラメータ(γ・k・在庫上限・スプレッド下限)はすべて自分の銘柄・環境で較正する必要があります。
また記事24のサーキットブレーカーを組み込んでいません。MM botは高頻度で発注するため、バグが暴走したときの被害が最も大きい戦略です。キルスイッチと日次DD上限を必ず先に実装してください。

注意事項・免責事項

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

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

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

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

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

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

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