※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
まずこの戦略で何が起きるのかを正確に把握します。ここを誤解したまま実装すると、動くコードが完成した瞬間から損失が始まります。
| 収益源 | 内容 | 現実 |
|---|---|---|
| スプレッド収入 | 買い気配で買い、売り気配で売る差 | 唯一の実質的な収益源 |
| メイカー手数料 | 銘柄・ティアによりリベートまたは低率 | 補助的。これ目当ては破綻する |
| 損失源 | 内容 | 影響 |
|---|---|---|
| 逆選択(有害フロー) | 情報を持つ相手にだけ約定させられる | 最大の損失源。これに負けると他は無意味 |
| 在庫リスク | 片側に偏った在庫が逆行する | 価格が一方向に動くと必ず発生 |
| 強制解消コスト | 在庫上限で成行フラット化 | テイカー手数料 + スリッページ |
| 機会損失 | 気配が遠すぎて約定しない | スプレッドを広げすぎたとき |
両側に指値を置くと、価格が動く方向の指値だけが約定します。上昇すれば売り気配が食われて売り在庫を抱え、そのまま価格は上がり続けます。
これは偶然ではなく構造です。あなたの指値を食う相手は「今買うべきだ」と判断した相手なので、平均的にあなたは不利な側を取らされます。これが逆選択(adverse selection)です。
メイカー手数料が数bpsのリベートでも、逆選択による損失はその何倍にもなります。スプレッド収入が逆選択と在庫損を上回って初めてこの戦略は成立します。
Bybitには一般参加者から隔離されたRPI(Retail Price Improvement)オーダーがありますが、timeInForce="RPI" は承認されたマーケットメイカーのみが使えます。一般の利用者が指定すると 110119 / 170355 で拒否されます。
つまり個人は逆選択から保護される仕組みを使えません。サンプル7の検知と撤退が唯一の防御です。
"""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)")
モデルの k は「仲値からどれだけ離すと、約定率がどれだけ下がるか」を表します。実データから推定しないと数式に意味がありません。
"""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 — 理論気配を発注可能な価格に変換する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
理論値と実務のギャップ:
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 — 気配の発注・更新・撤退を管理する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
更新戦略:
- 目標価格と現在の注文価格の差が閾値未満なら「何もしない」
(無駄な更新はレートの浪費)
- 差が閾値以上なら 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 — 在庫の追跡と強制フラット化
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
在庫制御の階層:
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 — 有害フロー(逆選択)を検知する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
参考: 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 — マーケットメイクの損益を要因分解する
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
分解:
総損益 = スプレッド収入 + 在庫損益 + 手数料
スプレッド収入 : 各約定が仲値からどれだけ有利な価格だったか
Σ (仲値 − 約定価格) × 数量 × 符号
在庫損益 : 保有中の価格変動による損益
手数料 : メイカー手数料(リベートなら正)
この分解が重要な理由:
「トータルで負けている」だけでは何を直せばよいか分からない。
- スプレッド収入がマイナス → 気配が近すぎる 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 — マーケットメイク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 — 実運用前の確認と段階的な移行手順
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
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の検知と撤退が最後の防御線です。
""")
suggest_gamma() がそれを行います。use_finite_horizon=False(既定)がこれです。固定値は「在庫を解消したい時間スケール」と考え、600秒程度から始めてください。inventory_at_which_quote_crosses_mid() が返す値を在庫上限の目安にしてください。retCode=110094 / 30208 です。QuoteEngine.build() は、買い気配を best_ask − tick 以下、売り気配を best_bid + tick 以上に補正します。それでも拒否されるのは、計算から発注までの間に板が動いたケースです。次の周回で再試行すれば問題ありません。average_markout_bps() が負で大きいなら、それが答えです。VPINやOFIは「市場が偏っている」ことしか示しませんが、マークアウトは「自分が損をしている」ことを直接示します。markout_by_side() で片側だけが悪いなら、その側の気配だけを引いてください。timeInForce="RPI" は承認されたマーケットメイカー専用で、個人は使えません。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。