※ Bybit V5は機能追加のペースが速いAPIです。パラメータの最終的な正解は必ず公式ドキュメントで確認してください。本記事のコードはすべてテストネットで動作確認しています。
成行注文は板の厚みを上から順に食っていきます。最良気配だけで足りなければ、次の価格帯、その次——と、注文が大きいほど平均約定価格は悪化します。
| 執行方式 | インパクト | タイミングリスク | 約定の確実性 | 向く場面 |
|---|---|---|---|---|
| 一括成行 | 最大 | なし | 確実 | 小口・緊急の損切り |
| TWAP | 小 | 中 | 高い | 出来高が読めない銘柄 |
| VWAP | 小 | 中 | 高い | 日内の出来高パターンが安定している銘柄 |
| アイスバーグ | 小 | 中 | 中 | 板から意図を隠したい場合 |
| パッシブ | 最小(+リベート) | 大 | 低い | 急がない・逆行してもよい場合 |
Almgren-Chriss の枠組みでは、執行コストは①一時的インパクト(自分の注文が板を押し下げる分。執行速度に比例)と②永続的インパクト(市場に情報が伝わり価格水準そのものが動く分。累積数量に比例)に分解されます。
これに③タイミングリスク(執行に時間をかける間に価格が動く分散)が加わり、期待コスト + λ×分散を最小化する軌道を解きます。λは執行者のリスク回避度です。
4方式に共通する「総量・期間・スライス配分・状態の永続化」を先に切り出します。方式ごとの違いはスライス配分と発注方法だけにします。
"""execution_base.py — 執行エンジンの共通基盤
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
執行アルゴリズムに共通するのは:
1. 総量を N スライスに配分する(配分方法が方式ごとの違い)
2. 各スライスを発注し、約定を記録する
3. 未約定分を次スライスに繰り越す
4. 中断・再開できるよう状態を永続化する
最後の「中断・再開」が実務では最も重要。
10分の執行の途中でbotが落ちたとき、
どこまで約定したか分からないと二重執行になる。
"""
from __future__ import annotations
import json
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from decimal import Decimal
from pathlib import Path
from client import make_client
from symbol_rules import fetch_rules
STATE_DIR = Path("./exec_state")
@dataclass
class Slice:
"""1スライス分の計画と結果。"""
index: int
planned_qty: Decimal
scheduled_at: float
filled_qty: Decimal = Decimal(0)
avg_price: Decimal = Decimal(0)
fee: Decimal = Decimal(0)
order_id: str = ""
order_link_id: str = ""
status: str = "pending" # pending / working / done / failed
@property
def remaining(self) -> Decimal:
return max(Decimal(0), self.planned_qty - self.filled_qty)
@dataclass
class ExecutionConfig:
symbol: str
side: str # "Buy" / "Sell"
total_qty: Decimal
duration_sec: float = 600.0
n_slices: int = 20
category: str = "linear"
reduce_only: bool = False
randomize_pct: float = 0.20 # スライス量を±この割合で揺らす
randomize_time_pct: float = 0.30
max_participation: float = 0.15 # 直近出来高に対する上限参加率
link_prefix: str = "exec"
@property
def slice_sec(self) -> float:
return self.duration_sec / self.n_slices
@dataclass
class ExecutionState:
"""永続化される実行状態。"""
run_id: str
config: dict
slices: list[dict] = field(default_factory=list)
arrival_price: str = "0"
started_at: float = 0.0
finished_at: float = 0.0
def path(self) -> Path:
return STATE_DIR / f"{self.run_id}.json"
def save(self) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
self.path().write_text(
json.dumps(asdict(self), ensure_ascii=False, indent=2,
default=str), encoding="utf-8")
@classmethod
def load(cls, run_id: str) -> "ExecutionState | None":
p = STATE_DIR / f"{run_id}.json"
if not p.exists():
return None
return cls(**json.loads(p.read_text(encoding="utf-8")))
class ExecutionAlgo(ABC):
"""執行アルゴリズムの基底クラス。"""
def __init__(self, http, cfg: ExecutionConfig, run_id: str = ""):
self.http = http
self.cfg = cfg
self.rules = fetch_rules(http, cfg.symbol, cfg.category)
self.run_id = run_id or f"{cfg.link_prefix}-{int(time.time())}"
self.slices: list[Slice] = []
self.arrival_price = Decimal(0)
# --- 方式ごとに実装する部分 -------------------------------------
@abstractmethod
def allocate(self) -> list[Decimal]:
"""総量をスライスに配分する。合計は total_qty と一致させる。"""
@abstractmethod
def execute_slice(self, sl: Slice) -> None:
"""1スライスを発注し、結果を sl に書き込む。"""
# --- 共通処理 ---------------------------------------------------
def mid_price(self) -> Decimal:
r = self.http.get_tickers(category=self.cfg.category,
symbol=self.cfg.symbol)
t = r["result"]["list"][0]
return (Decimal(t["bid1Price"]) + Decimal(t["ask1Price"])) / 2
def build_schedule(self) -> None:
"""配分とスケジュールを作る。"""
quantities = self.allocate()
now = time.time()
self.slices = [
Slice(index=i, planned_qty=q,
scheduled_at=now + self.cfg.slice_sec * i)
for i, q in enumerate(quantities) if q > 0
]
def total_filled(self) -> Decimal:
return sum((s.filled_qty for s in self.slices), Decimal(0))
def vwap_of_execution(self) -> Decimal:
"""自分の執行の加重平均価格。"""
filled = self.total_filled()
if filled <= 0:
return Decimal(0)
notional = sum((s.filled_qty * s.avg_price for s in self.slices),
Decimal(0))
return notional / filled
def total_fee(self) -> Decimal:
return sum((s.fee for s in self.slices), Decimal(0))
def snapshot(self) -> ExecutionState:
return ExecutionState(
run_id=self.run_id,
config={k: str(v) for k, v in asdict(self.cfg).items()},
slices=[{k: str(v) for k, v in asdict(s).items()}
for s in self.slices],
arrival_price=str(self.arrival_price),
started_at=self.slices[0].scheduled_at if self.slices else 0.0,
)
def run(self, verbose: bool = True) -> ExecutionState:
"""執行を実行する。"""
self.arrival_price = self.mid_price()
self.build_schedule()
if verbose:
print(f"=== {type(self).__name__} 執行開始 ===")
print(f" {self.cfg.symbol} {self.cfg.side} "
f"{self.cfg.total_qty} / "
f"{len(self.slices)}スライス / "
f"{self.cfg.duration_sec:.0f}秒")
print(f" 到着価格(基準): {self.arrival_price}")
for sl in self.slices:
wait = sl.scheduled_at - time.time()
if wait > 0:
time.sleep(wait)
try:
sl.status = "working"
self.execute_slice(sl)
sl.status = "done"
except Exception as e:
sl.status = "failed"
print(f" [error] slice {sl.index}: {type(e).__name__}: {e}")
self.snapshot().save() # 毎スライス保存(中断に備える)
if verbose:
done = self.total_filled()
pct = done / self.cfg.total_qty * 100
print(f" [{sl.index + 1:>3}/{len(self.slices)}] "
f"約定 {sl.filled_qty} @{sl.avg_price} "
f"(累計 {done}/{self.cfg.total_qty} = {pct:.1f}%)")
state = self.snapshot()
state.finished_at = time.time()
state.save()
if verbose:
print(f"\n 執行VWAP: {self.vwap_of_execution()}")
print(f" 手数料合計: {self.total_fee()}")
return state
def resume(self, verbose: bool = True) -> ExecutionState:
"""中断した執行を再開する。
永続化された状態から未執行のスライスだけを実行する。
二重執行を避けるため、約定済み数量は必ず取引所側で再確認する。
"""
prev = ExecutionState.load(self.run_id)
if prev is None:
raise FileNotFoundError(f"状態が見つかりません: {self.run_id}")
self.arrival_price = Decimal(prev.arrival_price)
self.slices = [
Slice(index=int(d["index"]),
planned_qty=Decimal(d["planned_qty"]),
scheduled_at=float(d["scheduled_at"]),
filled_qty=Decimal(d["filled_qty"]),
avg_price=Decimal(d["avg_price"]),
fee=Decimal(d["fee"]),
order_id=d["order_id"],
order_link_id=d["order_link_id"],
status=d["status"])
for d in prev.slices
]
pending = [s for s in self.slices if s.status in ("pending", "failed")]
if verbose:
print(f"=== 執行を再開 ({self.run_id}) ===")
print(f" 既約定 {self.total_filled()} / {self.cfg.total_qty}")
print(f" 残スライス {len(pending)}件")
# 遅延分を詰めて再スケジュール
now = time.time()
for i, sl in enumerate(pending):
sl.scheduled_at = now + self.cfg.slice_sec * i
for sl in pending:
wait = sl.scheduled_at - time.time()
if wait > 0:
time.sleep(wait)
try:
sl.status = "working"
self.execute_slice(sl)
sl.status = "done"
except Exception as e:
sl.status = "failed"
print(f" [error] slice {sl.index}: {e}")
self.snapshot().save()
state = self.snapshot()
state.finished_at = time.time()
state.save()
return state
def fetch_fill_result(http, category: str, symbol: str,
order_id: str) -> tuple[Decimal, Decimal, Decimal]:
"""注文の約定数量・平均価格・手数料を取得する。
get_open_orders では約定後の注文が取れないため、
get_order_history を使う。
"""
r = http.get_order_history(category=category, symbol=symbol,
orderId=order_id, limit=1)
if r["retCode"] != 0 or not r["result"]["list"]:
return Decimal(0), Decimal(0), Decimal(0)
o = r["result"]["list"][0]
return (Decimal(o.get("cumExecQty") or "0"),
Decimal(o.get("avgPrice") or "0"),
Decimal(o.get("cumExecFee") or "0"))
TWAP(Time-Weighted Average Price)は時間で均等に分割する最も単純な方式です。単純ゆえに他の参加者から読まれやすいため、量と時刻の両方をランダム化します。
"""twap.py — TWAP執行エンジン(ランダム化つき)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
素のTWAPは「毎分きっかり同じ量」なので予測されやすい。
数量と時刻の両方を揺らして、パターンを消す。
さらに参加率の上限(直近出来高の何%まで)を設けて、
流動性が枯れた時間帯に自分だけが板を食う状態を避ける。
"""
from __future__ import annotations
import random
import time
from decimal import Decimal
from execution_base import (ExecutionAlgo, ExecutionConfig, Slice,
fetch_fill_result)
class TwapAlgo(ExecutionAlgo):
"""時間均等分割 + ランダム化。"""
def allocate(self) -> list[Decimal]:
n = self.cfg.n_slices
base = self.cfg.total_qty / n
# ±randomize_pct で揺らす
raw = []
for _ in range(n):
jitter = Decimal(str(1 + random.uniform(
-self.cfg.randomize_pct, self.cfg.randomize_pct)))
raw.append(base * jitter)
# 合計を total_qty に合わせ、最小ロット単位に丸める
scale = self.cfg.total_qty / sum(raw)
quantities = [self.rules.round_qty(q * scale) for q in raw]
# 丸め誤差を最後のスライスで吸収する
diff = self.cfg.total_qty - sum(quantities)
if diff != 0:
quantities[-1] = self.rules.round_qty(quantities[-1] + diff)
return quantities
def build_schedule(self) -> None:
"""時刻もランダム化する。"""
super().build_schedule()
jitter_range = self.cfg.slice_sec * self.cfg.randomize_time_pct
for sl in self.slices:
sl.scheduled_at += random.uniform(-jitter_range, jitter_range)
self.slices.sort(key=lambda s: s.scheduled_at)
def recent_volume(self, seconds: float = 60.0) -> Decimal:
"""直近の出来高(参加率の分母)。"""
r = self.http.get_kline(category=self.cfg.category,
symbol=self.cfg.symbol,
interval="1", limit=2)
if r["retCode"] != 0 or not r["result"]["list"]:
return Decimal(0)
# list[0] が最新(未確定足)。確定足の list[1] を使う
row = r["result"]["list"][min(1, len(r["result"]["list"]) - 1)]
return Decimal(row[5]) # volume
def cap_by_participation(self, qty: Decimal) -> Decimal:
"""参加率の上限を超えないよう数量を抑える。"""
vol = self.recent_volume()
if vol <= 0:
return qty
cap = self.rules.round_qty(vol * Decimal(str(self.cfg.max_participation)))
if cap <= 0:
return qty
return min(qty, cap)
def execute_slice(self, sl: Slice) -> None:
qty = self.cap_by_participation(sl.planned_qty)
if qty < self.rules.min_qty:
sl.filled_qty = Decimal(0)
return
link = f"{self.run_id}-s{sl.index:03d}"
params = {
"category": self.cfg.category,
"symbol": self.cfg.symbol,
"side": self.cfg.side,
"orderType": "Market",
"qty": self.rules.format_qty(qty),
"orderLinkId": link,
}
if self.cfg.reduce_only:
params["reduceOnly"] = True
r = self.http.place_order(**params)
if r["retCode"] != 0:
raise RuntimeError(f"発注失敗: {r['retCode']} {r['retMsg']}")
sl.order_id = r["result"]["orderId"]
sl.order_link_id = link
# 成行なので即時約定するが、履歴反映に少し待つ
time.sleep(0.3)
filled, avg, fee = fetch_fill_result(
self.http, self.cfg.category, self.cfg.symbol, sl.order_id)
sl.filled_qty, sl.avg_price, sl.fee = filled, avg, fee
# 未約定分(参加率で削った分)を残りのスライスに配る
shortfall = sl.planned_qty - filled
if shortfall > 0:
pending = [s for s in self.slices
if s.index > sl.index and s.status == "pending"]
if pending:
add = shortfall / len(pending)
for s in pending:
s.planned_qty = self.rules.round_qty(s.planned_qty + add)
if __name__ == "__main__":
from client import make_client
c = make_client() # 既定 testnet=True
cfg = ExecutionConfig(
symbol="BTCUSDT", side="Buy", total_qty=Decimal("0.05"),
duration_sec=120.0, n_slices=10,
randomize_pct=0.25, max_participation=0.10)
algo = TwapAlgo(c.http, cfg)
state = algo.run()
print(f"\n 到着価格 : {algo.arrival_price}")
print(f" 執行VWAP : {algo.vwap_of_execution()}")
slippage_bps = ((algo.vwap_of_execution() - algo.arrival_price)
/ algo.arrival_price * Decimal("10000"))
if cfg.side == "Sell":
slippage_bps = -slippage_bps
print(f" スリッページ: {slippage_bps:+.2f} bps(プラスが不利)")
print(f"\n 状態ファイル: exec_state/{algo.run_id}.json")
print(" 途中で落ちても algo.resume() で続行できる。")
VWAP執行は市場の出来高パターンに合わせて配分します。出来高が多い時間帯に多く流せば、同じ量でも市場に占める割合が小さくなり、インパクトが下がります。
"""vwap.py — 出来高プロファイルから作るVWAP執行
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
手順:
1. 過去N日の同じ時間帯の出来高を集計し、日内プロファイルを作る
2. 執行期間に該当する部分だけを切り出し、正規化してウェイトにする
3. total_qty をそのウェイトで配分する
暗号資産は24時間市場だが、出来高には明確な日内パターンがある
(欧州・米国の立ち上がり、アジア時間の薄さ)。
このパターンはUTC基準で安定していることが多い。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import numpy as np
import pandas as pd
from execution_base import ExecutionConfig, Slice, fetch_fill_result
from kline_fetch import fetch_klines
from twap import TwapAlgo
def build_volume_profile(http, symbol: str, *, category: str = "linear",
days: int = 14, bucket_min: int = 5) -> pd.Series:
"""日内の出来高プロファイルを作る。
戻り値: index = 日内の分(0〜1439 を bucket_min で丸めたもの)
value = 平均出来高(正規化前)
"""
start = (datetime.now(timezone.utc) - timedelta(days=days)) \
.strftime("%Y-%m-%d")
bars = fetch_klines(http, symbol, interval=str(bucket_min),
start=start, category=category, verbose=False)
if bars.empty:
raise RuntimeError("Kline取得に失敗しました")
minute_of_day = bars.index.hour * 60 + bars.index.minute
bucket = (minute_of_day // bucket_min) * bucket_min
profile = bars.groupby(bucket)["volume"].mean()
# 欠損バケットを埋める(取引が無かった時間帯)
full = pd.Series(0.0, index=range(0, 1440, bucket_min))
full.update(profile)
# ゼロ割りを避けるため、最小値で下限を切る
floor = full[full > 0].min() * 0.1 if (full > 0).any() else 1.0
return full.clip(lower=floor)
def slice_weights(profile: pd.Series, start_ts: float,
duration_sec: float, n_slices: int) -> list[float]:
"""執行期間に対応するウェイトを取り出して正規化する。"""
bucket_min = int(profile.index[1] - profile.index[0])
weights = []
for i in range(n_slices):
t = start_ts + duration_sec * (i + 0.5) / n_slices
dt = datetime.fromtimestamp(t, tz=timezone.utc)
minute = dt.hour * 60 + dt.minute
key = (minute // bucket_min) * bucket_min
weights.append(float(profile.get(key, profile.mean())))
total = sum(weights)
return [w / total for w in weights] if total > 0 else \
[1.0 / n_slices] * n_slices
class VwapAlgo(TwapAlgo):
"""出来高プロファイルに沿って配分する。発注方法はTWAPと同じ。"""
def __init__(self, http, cfg: ExecutionConfig, run_id: str = "",
profile_days: int = 14):
super().__init__(http, cfg, run_id)
self.profile = build_volume_profile(
http, cfg.symbol, category=cfg.category, days=profile_days)
self.weights: list[float] = []
def allocate(self) -> list[Decimal]:
self.weights = slice_weights(
self.profile, time.time(),
self.cfg.duration_sec, self.cfg.n_slices)
raw = [self.cfg.total_qty * Decimal(str(w)) for w in self.weights]
quantities = [self.rules.round_qty(q) for q in raw]
diff = self.cfg.total_qty - sum(quantities)
if diff != 0:
# 最もウェイトの大きいスライスで誤差を吸収する
k = int(np.argmax(self.weights))
quantities[k] = self.rules.round_qty(quantities[k] + diff)
return quantities
def build_schedule(self) -> None:
"""VWAPは時刻を揺らさない(出来高の山に合わせる意味が薄れる)。"""
quantities = self.allocate()
now = time.time()
self.slices = [
Slice(index=i, planned_qty=q,
scheduled_at=now + self.cfg.slice_sec * i)
for i, q in enumerate(quantities) if q > 0
]
def market_vwap_since_start(self) -> Decimal:
"""執行期間中の市場VWAP。ベンチマークとして使う。"""
minutes = max(1, int(self.cfg.duration_sec / 60) + 1)
r = self.http.get_kline(category=self.cfg.category,
symbol=self.cfg.symbol,
interval="1", limit=minutes)
if r["retCode"] != 0 or not r["result"]["list"]:
return Decimal(0)
notional = Decimal(0)
volume = Decimal(0)
for row in r["result"]["list"]:
# [start, open, high, low, close, volume, turnover]
volume += Decimal(row[5])
notional += Decimal(row[6])
return notional / volume if volume > 0 else Decimal(0)
if __name__ == "__main__":
from client import make_client
c = make_client()
symbol = "BTCUSDT"
print("=== 出来高プロファイル(UTC時間帯別) ===")
profile = build_volume_profile(c.http, symbol, days=14, bucket_min=60)
normalized = profile / profile.sum() * 100
for minute, pct in normalized.items():
hour = minute // 60
bar = "#" * int(pct * 4)
print(f" {hour:02d}:00 UTC {pct:>5.2f}% {bar}")
peak = int(normalized.idxmax()) // 60
trough = int(normalized.idxmin()) // 60
print(f"\n 出来高のピーク: {peak:02d}:00 UTC "
f"({normalized.max():.2f}%)")
print(f" 出来高の谷 : {trough:02d}:00 UTC "
f"({normalized.min():.2f}%)")
print(f" ピーク/谷 比率: {normalized.max() / normalized.min():.1f}倍")
cfg = ExecutionConfig(
symbol=symbol, side="Buy", total_qty=Decimal("0.05"),
duration_sec=180.0, n_slices=12, max_participation=0.10)
algo = VwapAlgo(c.http, cfg)
state = algo.run()
market_vwap = algo.market_vwap_since_start()
my_vwap = algo.vwap_of_execution()
print(f"\n 市場VWAP : {market_vwap}")
print(f" 執行VWAP : {my_vwap}")
if market_vwap > 0:
diff_bps = (my_vwap - market_vwap) / market_vwap * Decimal("10000")
if cfg.side == "Sell":
diff_bps = -diff_bps
print(f" VWAP差 : {diff_bps:+.2f} bps(プラスが不利)")
print("\n※ VWAP執行の評価基準は『市場VWAPに勝てたか』。")
print(" 到着価格基準(IS)とは別の指標なので混同しないこと。")
アイスバーグは表示数量を小さくして板に置き、約定するたびに補充する方式です。板から本来の意図が読まれにくくなります。BybitにはネイティブなIceberg機能が無いため、自前で補充ロジックを組みます。
"""iceberg.py — アイスバーグ執行(表示数量を小さく分割して補充)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
仕組み:
- 総量のうち display_qty だけを指値で板に置く
- 約定したら、残量から次の display_qty を補充する
- 価格が離れたら注文を追従させる(追いすぎるとテイカー化するので注意)
Bybitにネイティブなアイスバーグ機能は無いため、
約定監視 → 補充 のループを自前で回す。
WebSocket(記事27)で約定を受けると補充が速くなる。
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from decimal import Decimal
from execution_base import (ExecutionAlgo, ExecutionConfig, Slice,
fetch_fill_result)
@dataclass
class IcebergConfig:
display_ratio: Decimal = Decimal("0.05") # 総量の何%を表示するか
offset_ticks: int = 1 # 最良気配から何ティック内側に置くか
reprice_ticks: int = 3 # 価格がこれだけ離れたら置き直す
poll_sec: float = 1.0
timeout_sec: float = 600.0
market_fallback: bool = True # 期限内に終わらなければ成行で残りを処理
class IcebergAlgo(ExecutionAlgo):
"""アイスバーグ執行。スライスの概念を使わず残量ベースで回す。"""
def __init__(self, http, cfg: ExecutionConfig,
ice: IcebergConfig = IcebergConfig(), run_id: str = ""):
super().__init__(http, cfg, run_id)
self.ice = ice
self.current_order_id = ""
self.current_price = Decimal(0)
self.filled_total = Decimal(0)
self.notional_total = Decimal(0)
self.fee_total = Decimal(0)
def allocate(self) -> list[Decimal]:
"""残量ベースで動くため、計画配分は表示数量の繰り返しになる。"""
display = self.rules.round_qty(
self.cfg.total_qty * self.ice.display_ratio)
display = max(display, self.rules.min_qty)
quantities, remaining = [], self.cfg.total_qty
while remaining > 0:
q = min(display, remaining)
quantities.append(self.rules.round_qty(q))
remaining -= q
return quantities
def best_price(self) -> Decimal:
"""自分の側の最良気配から offset_ticks 内側の価格。"""
r = self.http.get_orderbook(category=self.cfg.category,
symbol=self.cfg.symbol, limit=1)
if r["retCode"] != 0:
raise RuntimeError(f"板取得失敗: {r['retCode']} {r['retMsg']}")
ob = r["result"]
bid = Decimal(ob["b"][0][0])
ask = Decimal(ob["a"][0][0])
tick = self.rules.tick_size
if self.cfg.side == "Buy":
# 買いは bid に並ぶ。offset分だけ有利側(下)に置くと約定しにくい
return bid + tick * self.ice.offset_ticks
return ask - tick * self.ice.offset_ticks
def _place_display(self, qty: Decimal, price: Decimal,
tag: str) -> str:
params = {
"category": self.cfg.category,
"symbol": self.cfg.symbol,
"side": self.cfg.side,
"orderType": "Limit",
"qty": self.rules.format_qty(qty),
"price": self.rules.format_price(price),
"timeInForce": "PostOnly", # メイカーを保証(テイカー化なら拒否)
"orderLinkId": f"{self.run_id}-{tag}",
}
if self.cfg.reduce_only:
params["reduceOnly"] = True
r = self.http.place_order(**params)
if r["retCode"] == 0:
return r["result"]["orderId"]
# 110001系: PostOnlyが拒否された(価格が板を跨いだ)
if r["retCode"] in (110094, 30208):
return ""
raise RuntimeError(f"発注失敗: {r['retCode']} {r['retMsg']}")
def _cancel_current(self) -> None:
if not self.current_order_id:
return
r = self.http.cancel_order(category=self.cfg.category,
symbol=self.cfg.symbol,
orderId=self.current_order_id)
if r["retCode"] not in (0, 110001):
print(f" [warn] キャンセル失敗: {r['retCode']} {r['retMsg']}")
self.current_order_id = ""
def _collect_fill(self) -> Decimal:
"""現在の注文の約定分を集計に取り込む。戻り値は今回の約定量。"""
if not self.current_order_id:
return Decimal(0)
filled, avg, fee = fetch_fill_result(
self.http, self.cfg.category, self.cfg.symbol,
self.current_order_id)
if filled > 0:
self.filled_total += filled
self.notional_total += filled * avg
self.fee_total += fee
return filled
def execute_slice(self, sl: Slice) -> None:
"""1つの表示ロットを約定させる。"""
deadline = time.time() + self.ice.timeout_sec / len(self.slices)
remaining = sl.planned_qty
tag = f"i{sl.index:03d}"
while remaining > 0 and time.time() < deadline:
price = self.best_price()
need_replace = (
not self.current_order_id
or abs(price - self.current_price)
>= self.rules.tick_size * self.ice.reprice_ticks)
if need_replace:
if self.current_order_id:
self._cancel_current()
got = self._collect_fill()
remaining -= got
sl.filled_qty += got
if remaining <= 0:
break
oid = self._place_display(remaining, price, tag)
if not oid:
time.sleep(self.ice.poll_sec)
continue
self.current_order_id = oid
self.current_price = price
sl.order_id = oid
time.sleep(self.ice.poll_sec)
filled, avg, fee = fetch_fill_result(
self.http, self.cfg.category, self.cfg.symbol,
self.current_order_id)
if filled >= remaining:
self.filled_total += filled
self.notional_total += filled * avg
self.fee_total += fee
sl.filled_qty += filled
sl.avg_price = avg
sl.fee = fee
self.current_order_id = ""
remaining = Decimal(0)
# タイムアウト時の後始末
if remaining > 0:
self._cancel_current()
got = self._collect_fill()
remaining -= got
sl.filled_qty += got
if remaining > 0 and self.ice.market_fallback:
print(f" [fallback] 残 {remaining} を成行で処理")
r = self.http.place_order(
category=self.cfg.category, symbol=self.cfg.symbol,
side=self.cfg.side, orderType="Market",
qty=self.rules.format_qty(remaining),
orderLinkId=f"{self.run_id}-{tag}-mkt")
if r["retCode"] == 0:
time.sleep(0.3)
filled, avg, fee = fetch_fill_result(
self.http, self.cfg.category, self.cfg.symbol,
r["result"]["orderId"])
self.filled_total += filled
self.notional_total += filled * avg
self.fee_total += fee
sl.filled_qty += filled
if sl.filled_qty > 0 and sl.avg_price == 0:
sl.avg_price = self.notional_total / self.filled_total
def vwap_of_execution(self) -> Decimal:
if self.filled_total <= 0:
return Decimal(0)
return self.notional_total / self.filled_total
def total_filled(self) -> Decimal:
return self.filled_total
def total_fee(self) -> Decimal:
return self.fee_total
if __name__ == "__main__":
from client import make_client
c = make_client()
cfg = ExecutionConfig(
symbol="BTCUSDT", side="Buy", total_qty=Decimal("0.05"),
duration_sec=180.0, n_slices=10)
ice = IcebergConfig(display_ratio=Decimal("0.1"),
offset_ticks=1, reprice_ticks=3,
timeout_sec=180.0)
algo = IcebergAlgo(c.http, cfg, ice)
algo.run()
print(f"\n 表示数量: 総量の {ice.display_ratio:.0%} "
f"= {cfg.total_qty * ice.display_ratio}")
print(f" 約定合計: {algo.total_filled()} / {cfg.total_qty}")
print(f" 執行VWAP: {algo.vwap_of_execution()}")
print(f" 手数料 : {algo.total_fee()}")
print("\n※ PostOnlyなのでメイカー手数料(マイナスの場合もある)。")
print(" ただし約定しないまま時間切れになるリスクがある。")
print(" market_fallback=False にすると未執行のまま終わる。")
"""passive.py — PostOnly指値を板に追従させ、期限切れで成行に切り替える
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
パッシブ執行は「メイカー手数料で執行したい」という動機で使う。
Bybitのメイカー手数料は多くの銘柄でテイカーより低く、
往復で数bpsの差がつく。ただし約定しないリスクを負う。
設計の要点:
- 板の最良気配に並ぶが、追いすぎるとスプレッドを跨いでしまう
- 「どこまで不利な価格を許容するか」を price_limit で明示する
- 期限が近づいたら段階的に攻撃的にする(urgency ramp)
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from decimal import Decimal
from execution_base import (ExecutionAlgo, ExecutionConfig, Slice,
fetch_fill_result)
@dataclass
class PassiveConfig:
poll_sec: float = 1.0
reprice_ticks: int = 1 # 最良気配がこれだけ動いたら追従
max_adverse_bps: float = 20.0 # 到着価格からこれ以上不利なら追わない
ramp_start: float = 0.6 # 残時間がこの割合を切ったら攻撃的に
cross_at_end: bool = True # 最後は成行で確実に終わらせる
class PassiveAlgo(ExecutionAlgo):
"""PostOnly指値で板に張り付き、期限切れで成行フォールバック。"""
def __init__(self, http, cfg: ExecutionConfig,
pcfg: PassiveConfig = PassiveConfig(), run_id: str = ""):
super().__init__(http, cfg, run_id)
self.p = pcfg
self.filled_total = Decimal(0)
self.notional_total = Decimal(0)
self.fee_total = Decimal(0)
self.maker_qty = Decimal(0)
self.taker_qty = Decimal(0)
def allocate(self) -> list[Decimal]:
"""パッシブは1本の注文を追従させるだけなので1スライス。"""
return [self.cfg.total_qty]
def _book(self) -> tuple[Decimal, Decimal]:
r = self.http.get_orderbook(category=self.cfg.category,
symbol=self.cfg.symbol, limit=1)
if r["retCode"] != 0:
raise RuntimeError(f"板取得失敗: {r['retCode']} {r['retMsg']}")
return (Decimal(r["result"]["b"][0][0]),
Decimal(r["result"]["a"][0][0]))
def _target_price(self, aggression: float) -> Decimal:
"""aggression 0.0=最良気配に並ぶ / 1.0=反対側に踏み込む。"""
bid, ask = self._book()
tick = self.rules.tick_size
spread_ticks = max(1, int((ask - bid) / tick))
step = int(spread_ticks * aggression)
if self.cfg.side == "Buy":
return self.rules.round_price(bid + tick * step)
return self.rules.round_price(ask - tick * step)
def _within_limit(self, price: Decimal) -> bool:
"""到着価格から許容範囲内かを判定する。"""
if self.arrival_price <= 0:
return True
drift_bps = (price - self.arrival_price) / self.arrival_price \
* Decimal("10000")
if self.cfg.side == "Sell":
drift_bps = -drift_bps
return drift_bps <= Decimal(str(self.p.max_adverse_bps))
def execute_slice(self, sl: Slice) -> None:
deadline = time.time() + self.cfg.duration_sec
remaining = sl.planned_qty
order_id, order_price = "", Decimal(0)
def collect(oid: str) -> Decimal:
filled, avg, fee = fetch_fill_result(
self.http, self.cfg.category, self.cfg.symbol, oid)
if filled > 0:
self.filled_total += filled
self.notional_total += filled * avg
self.fee_total += fee
return filled
while remaining > 0 and time.time() < deadline:
elapsed_ratio = 1 - (deadline - time.time()) / self.cfg.duration_sec
# 残時間が減るほど攻撃的にする
aggression = 0.0 if elapsed_ratio < self.p.ramp_start else \
(elapsed_ratio - self.p.ramp_start) / (1 - self.p.ramp_start)
price = self._target_price(aggression)
if not self._within_limit(price):
print(f" [hold] 価格 {price} は許容範囲外。待機")
time.sleep(self.p.poll_sec)
continue
need_replace = (
not order_id
or abs(price - order_price)
>= self.rules.tick_size * self.p.reprice_ticks)
if need_replace:
if order_id:
r = self.http.cancel_order(
category=self.cfg.category, symbol=self.cfg.symbol,
orderId=order_id)
if r["retCode"] in (0, 110001):
got = collect(order_id)
remaining -= got
self.maker_qty += got
order_id = ""
if remaining <= 0:
break
r = self.http.place_order(
category=self.cfg.category, symbol=self.cfg.symbol,
side=self.cfg.side, orderType="Limit",
qty=self.rules.format_qty(remaining),
price=self.rules.format_price(price),
timeInForce="PostOnly",
orderLinkId=f"{self.run_id}-p{int(time.time() * 1000) % 100000}")
if r["retCode"] == 0:
order_id, order_price = r["result"]["orderId"], price
elif r["retCode"] in (110094, 30208):
# PostOnlyが板を跨いだので拒否された。少し待つ
time.sleep(self.p.poll_sec)
continue
else:
raise RuntimeError(
f"発注失敗: {r['retCode']} {r['retMsg']}")
time.sleep(self.p.poll_sec)
filled, avg, fee = fetch_fill_result(
self.http, self.cfg.category, self.cfg.symbol, order_id)
if filled >= remaining:
self.filled_total += filled
self.notional_total += filled * avg
self.fee_total += fee
self.maker_qty += filled
remaining = Decimal(0)
order_id = ""
# 期限切れの処理
if order_id:
self.http.cancel_order(category=self.cfg.category,
symbol=self.cfg.symbol, orderId=order_id)
got = collect(order_id)
remaining -= got
self.maker_qty += got
if remaining > 0 and self.p.cross_at_end:
print(f" [cross] 残 {remaining} を成行で処理(テイカー)")
r = self.http.place_order(
category=self.cfg.category, symbol=self.cfg.symbol,
side=self.cfg.side, orderType="Market",
qty=self.rules.format_qty(remaining),
orderLinkId=f"{self.run_id}-cross")
if r["retCode"] == 0:
time.sleep(0.3)
got = collect(r["result"]["orderId"])
self.taker_qty += got
sl.filled_qty = self.filled_total
sl.avg_price = self.vwap_of_execution()
sl.fee = self.fee_total
def vwap_of_execution(self) -> Decimal:
if self.filled_total <= 0:
return Decimal(0)
return self.notional_total / self.filled_total
def total_filled(self) -> Decimal:
return self.filled_total
def total_fee(self) -> Decimal:
return self.fee_total
def maker_ratio(self) -> float:
total = self.maker_qty + self.taker_qty
return float(self.maker_qty / total) if total > 0 else 0.0
if __name__ == "__main__":
from client import make_client
c = make_client()
cfg = ExecutionConfig(
symbol="BTCUSDT", side="Buy", total_qty=Decimal("0.02"),
duration_sec=180.0, n_slices=1)
algo = PassiveAlgo(c.http, cfg,
PassiveConfig(max_adverse_bps=15.0, ramp_start=0.7))
algo.run()
print(f"\n メイカー比率: {algo.maker_ratio():.1%}")
print(f" 手数料 : {algo.total_fee()}")
print(f" 執行VWAP : {algo.vwap_of_execution()}")
print("\n※ メイカー比率が高いほど手数料は有利だが、")
print(" 約定するまでの時間が長く、その間の価格変動リスクを負う。")
print(" 『手数料で得して価格で損する』のは典型的な失敗。")
print(" 必ずIS(次節)で総コストを見ること。")
ここまでの配分は経験則でした。Almgren-Chriss は期待コストと分散のトレードオフを解いて、最適な残高軌道を閉じた形で与えます。
"""almgren_chriss.py — Almgren-Chriss最適執行軌道
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
参考: Almgren, R. & Chriss, N. (2000)
"Optimal Execution of Portfolio Transactions", Journal of Risk 3(2)
モデル:
総量 X を時間 T で N 期に分けて執行する。
価格過程: S_k = S_{k-1} + σ√τ ξ_k − τ g(n_k/τ) (永続的インパクト)
約定価格: S̃_k = S_{k-1} − h(n_k/τ) (一時的インパクト)
線形インパクトを仮定: g(v) = γv, h(v) = ε·sgn(v) + ηv
最小化: E[コスト] + λ·Var[コスト]
解(残高軌道):
x_k = X · sinh(κ(T − t_k)) / sinh(κT)
κ² ≈ λσ² / η̃ , η̃ = η − γτ/2
λ→0 なら κ→0 で x_k は直線 = TWAP。
λが大きいほど前倒しになる(リスクを早く落としたい)。
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import numpy as np
@dataclass(frozen=True)
class AcParams:
total_qty: float # X: 執行総量
duration_sec: float # T: 執行時間(秒)
n_slices: int # N
sigma: float # σ: ボラティリティ(価格単位 / √秒)
eta: float # η: 一時的インパクト係数
gamma: float # γ: 永続的インパクト係数
epsilon: float = 0.0 # ε: 固定コスト(スプレッドの半分など)
risk_aversion: float = 1e-6 # λ
@property
def tau(self) -> float:
return self.duration_sec / self.n_slices
@property
def eta_tilde(self) -> float:
"""η̃ = η − γτ/2。これが負になるとモデルが破綻する。"""
return self.eta - self.gamma * self.tau / 2
@property
def kappa(self) -> float:
"""κ: 執行の『急ぎ度』。大きいほど前倒し。"""
et = self.eta_tilde
if et <= 0:
raise ValueError(
f"η̃={et:.3e} が非正です。"
"スライス数を増やすかηを大きくしてください")
kappa_sq = self.risk_aversion * self.sigma ** 2 / et
# 離散版の補正
kappa_tilde_sq = 2 / self.tau ** 2 * (
np.cosh(np.sqrt(kappa_sq) * self.tau) - 1) \
if kappa_sq > 0 else 0.0
return float(np.sqrt(max(kappa_sq, kappa_tilde_sq)))
def optimal_trajectory(p: AcParams) -> np.ndarray:
"""残高軌道 x_k (k=0..N) を返す。x_0 = X, x_N = 0。"""
N, T, kappa = p.n_slices, p.duration_sec, p.kappa
t = np.linspace(0, T, N + 1)
if kappa * T < 1e-9:
# λ→0 の極限は線形(TWAP)
return p.total_qty * (1 - t / T)
return p.total_qty * np.sinh(kappa * (T - t)) / np.sinh(kappa * T)
def optimal_slices(p: AcParams) -> np.ndarray:
"""各期の執行量 n_k = x_{k-1} − x_k を返す。"""
x = optimal_trajectory(p)
return np.diff(-x) # 正の値になる
def expected_cost(p: AcParams) -> dict:
"""期待コストと分散(価格単位×数量)。
E[C] = ε·Σ|n_k| + η̃/τ · Σ n_k² + γ/2 · X²
V[C] = σ² · τ · Σ x_k²
"""
n = optimal_slices(p)
x = optimal_trajectory(p)[1:] # x_1..x_N
fixed = p.epsilon * float(np.abs(n).sum())
temporary = p.eta_tilde / p.tau * float((n ** 2).sum())
permanent = p.gamma / 2 * p.total_qty ** 2
variance = p.sigma ** 2 * p.tau * float((x ** 2).sum())
return {
"固定コスト": fixed,
"一時的インパクト": temporary,
"永続的インパクト": permanent,
"期待コスト合計": fixed + temporary + permanent,
"分散": variance,
"標準偏差": float(np.sqrt(variance)),
"効用": fixed + temporary + permanent + p.risk_aversion * variance,
}
def calibrate_from_market(mid_price: float, spread: float,
annual_vol: float, depth_qty: float,
depth_impact_bps: float,
total_qty: float, duration_sec: float,
n_slices: int,
risk_aversion: float = 1e-6) -> AcParams:
"""観測できる市場量からパラメータを推定する。
σ : 年率ボラ(記事12)を秒あたりに換算し、価格単位にする
ε : スプレッドの半分(テイカーの最低コスト)
η : 「depth_qty を執行すると depth_impact_bps 動く」から逆算
γ : 永続的インパクトは一時的の 1/2 程度とする経験則
"""
seconds_per_year = 365 * 24 * 3600
sigma = annual_vol * mid_price / np.sqrt(seconds_per_year)
# η: 一時的インパクト。v = depth_qty/秒 のとき Δ = depth_impact_bps
impact_price = mid_price * depth_impact_bps / 10_000
eta = impact_price / max(depth_qty, 1e-12)
return AcParams(
total_qty=total_qty, duration_sec=duration_sec, n_slices=n_slices,
sigma=sigma, eta=eta, gamma=eta / 2,
epsilon=spread / 2, risk_aversion=risk_aversion)
def compare_risk_aversion(base: AcParams,
lambdas: tuple[float, ...] = (0.0, 1e-7, 1e-6, 1e-5)
) -> None:
"""リスク回避度を変えて軌道の形を比較する。"""
print(f"{'λ':>10}{'κ':>12}{'前半50%到達':>14}"
f"{'期待コスト':>14}{'標準偏差':>13}")
print("-" * 66)
for lam in lambdas:
p = AcParams(**{**base.__dict__, "risk_aversion": lam})
try:
x = optimal_trajectory(p)
except ValueError as e:
print(f"{lam:>10.1e} {e}")
continue
# 残高が半分になる時点(早いほど前倒し)
half = float(np.argmax(x <= p.total_qty / 2)) / p.n_slices
cost = expected_cost(p)
print(f"{lam:>10.1e}{p.kappa:>12.6f}{half:>13.1%}"
f"{cost['期待コスト合計']:>14.2f}{cost['標準偏差']:>13.2f}")
if __name__ == "__main__":
# BTCUSDT を想定した数値例
p = calibrate_from_market(
mid_price=120_000.0,
spread=1.0, # 1 USDT のスプレッド
annual_vol=0.60, # 年率60%(記事12で実測すること)
depth_qty=5.0, # 5 BTC 執行すると
depth_impact_bps=8.0, # 8bps 動く(板から実測: 記事09)
total_qty=10.0, # 10 BTC を
duration_sec=1800.0, # 30分で
n_slices=30,
risk_aversion=1e-6)
print("=== 推定パラメータ ===")
print(f" σ (価格/√秒) : {p.sigma:.6f}")
print(f" η (一時的) : {p.eta:.6f}")
print(f" γ (永続的) : {p.gamma:.6f}")
print(f" ε (固定) : {p.epsilon:.4f}")
print(f" τ (1期の秒) : {p.tau:.1f}")
print(f" κ : {p.kappa:.6f}")
print("\n=== 最適スライス配分(先頭10期) ===")
slices = optimal_slices(p)
twap = p.total_qty / p.n_slices
for i, q in enumerate(slices[:10], 1):
ratio = q / twap
bar = "#" * int(ratio * 20)
print(f" 期{i:>2}: {q:>7.4f} BTC (TWAP比 {ratio:>5.2f}) {bar}")
print(f" ...")
print(f" 合計: {slices.sum():.4f} BTC")
print("\n=== 期待コストの内訳 ===")
for k, v in expected_cost(p).items():
print(f" {k:<18}: {v:>14.2f}")
print("\n=== リスク回避度による軌道の変化 ===")
compare_risk_aversion(p)
print("\n【解釈】")
print(" λ=0 なら κ=0 で完全なTWAP(リスク中立)。")
print(" λが大きいほど κ が大きくなり、前倒しで執行する。")
print(" 『早く終わらせてリスクを落とす』代わりに")
print(" インパクトコストを多く払う、というトレードオフ。")
print("\n※ η と γ の推定が全て。板の実測(記事09のKyleのλ)から")
print(" 自分の銘柄・サイズで較正しないと、数値に意味は無い。")
執行アルゴリズムは「測らなければ良し悪しが分からない」領域です。Perold (1988) の Implementation Shortfall を軸に、4方式を同条件で比較します。
"""exec_quality.py — 執行品質の測定(Implementation Shortfall ほか)
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
参考: Perold, A. (1988) "The Implementation Shortfall: Paper vs. Reality"
Implementation Shortfall (IS):
「意思決定した瞬間の価格で全量約定できた場合」との差。
IS = (執行VWAP − 到着価格) × 数量 × sign + 手数料 + 未執行機会損失
他の指標:
実効スプレッド = 2 × |約定価格 − 仲値| / 仲値
参加率 (POV) = 自分の出来高 / 市場出来高
VWAP差 = 執行VWAP − 市場VWAP(VWAP執行の評価基準)
重要: IS と VWAP差 は別物。
IS は「決めた時に買えていたら」を基準にする絶対評価。
VWAP差 は「市場の平均に対して」という相対評価。
VWAP差だけを見ると、期間中ずっと価格が上がっていても
「市場平均より安く買えた」と評価されてしまう。
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
BPS = Decimal("10000")
@dataclass
class ExecutionQuality:
algo: str
side: str
target_qty: Decimal
filled_qty: Decimal
arrival_price: Decimal
exec_vwap: Decimal
market_vwap: Decimal
final_price: Decimal
fee: Decimal
elapsed_sec: float
market_volume: Decimal = Decimal(0)
@property
def sign(self) -> Decimal:
"""買いなら +1、売りなら −1。コストを正で表すため。"""
return Decimal(1) if self.side == "Buy" else Decimal(-1)
@property
def fill_rate(self) -> Decimal:
if self.target_qty <= 0:
return Decimal(0)
return self.filled_qty / self.target_qty
@property
def price_shortfall_bps(self) -> Decimal:
"""約定分の価格劣化(到着価格基準)。"""
if self.arrival_price <= 0 or self.filled_qty <= 0:
return Decimal(0)
return ((self.exec_vwap - self.arrival_price) / self.arrival_price
* self.sign * BPS)
@property
def fee_bps(self) -> Decimal:
notional = self.filled_qty * self.exec_vwap
if notional <= 0:
return Decimal(0)
return self.fee / notional * BPS
@property
def opportunity_bps(self) -> Decimal:
"""未執行分の機会損失。
執行できなかった数量について、
到着価格から最終価格までの変動が損失(または利益)になる。
"""
unfilled = self.target_qty - self.filled_qty
if unfilled <= 0 or self.arrival_price <= 0:
return Decimal(0)
move = ((self.final_price - self.arrival_price) / self.arrival_price
* self.sign * BPS)
return move * (unfilled / self.target_qty)
@property
def implementation_shortfall_bps(self) -> Decimal:
"""総合コスト。これが最終的な評価指標。"""
return (self.price_shortfall_bps * self.fill_rate
+ self.fee_bps * self.fill_rate
+ self.opportunity_bps)
@property
def vwap_diff_bps(self) -> Decimal:
if self.market_vwap <= 0 or self.filled_qty <= 0:
return Decimal(0)
return ((self.exec_vwap - self.market_vwap) / self.market_vwap
* self.sign * BPS)
@property
def participation_rate(self) -> Decimal:
if self.market_volume <= 0:
return Decimal(0)
return self.filled_qty / self.market_volume
def report(self) -> str:
return "\n".join([
f"[{self.algo}] {self.side} "
f"{self.filled_qty}/{self.target_qty} "
f"({self.fill_rate:.1%})",
f" 到着価格 : {self.arrival_price}",
f" 執行VWAP : {self.exec_vwap}",
f" 市場VWAP : {self.market_vwap}",
f" ── コスト分解 (bps, 正が不利) ──",
f" 価格劣化 : {self.price_shortfall_bps:>+8.2f}",
f" 手数料 : {self.fee_bps:>+8.2f}",
f" 未執行機会損失 : {self.opportunity_bps:>+8.2f}",
f" Implementation SF : {self.implementation_shortfall_bps:>+8.2f}",
f" ── 参考 ──",
f" VWAP差 : {self.vwap_diff_bps:>+8.2f}",
f" 参加率 : {self.participation_rate:>8.2%}",
f" 所要時間 : {self.elapsed_sec:>8.0f}秒",
])
def measure(algo, state, market_vwap: Decimal,
market_volume: Decimal = Decimal(0)) -> ExecutionQuality:
"""執行エンジンの結果から品質指標を作る。"""
final = algo.mid_price()
elapsed = (state.finished_at - state.started_at
if state.finished_at else 0.0)
return ExecutionQuality(
algo=type(algo).__name__,
side=algo.cfg.side,
target_qty=algo.cfg.total_qty,
filled_qty=algo.total_filled(),
arrival_price=algo.arrival_price,
exec_vwap=algo.vwap_of_execution(),
market_vwap=market_vwap,
final_price=final,
fee=algo.total_fee(),
elapsed_sec=elapsed,
market_volume=market_volume,
)
def compare(results: list[ExecutionQuality]) -> None:
"""複数方式を並べて比較表示する。"""
print(f"\n{'方式':<14}{'約定率':>8}{'価格劣化':>10}{'手数料':>9}"
f"{'機会損失':>10}{'IS計':>10}{'時間':>8}")
print("-" * 70)
for q in sorted(results,
key=lambda x: x.implementation_shortfall_bps):
print(f"{q.algo:<14}"
f"{q.fill_rate:>7.1%}"
f"{q.price_shortfall_bps:>+10.2f}"
f"{q.fee_bps:>+9.2f}"
f"{q.opportunity_bps:>+10.2f}"
f"{q.implementation_shortfall_bps:>+10.2f}"
f"{q.elapsed_sec:>7.0f}s")
best = min(results, key=lambda x: x.implementation_shortfall_bps)
worst = max(results, key=lambda x: x.implementation_shortfall_bps)
diff = worst.implementation_shortfall_bps - best.implementation_shortfall_bps
print(f"\n 最良: {best.algo} / 最悪: {worst.algo} / 差 {diff:.2f}bps")
print(" ※ 1回の試行では偶然の要素が大きい。")
print(" 最低でも数十回繰り返して中央値で判断すること。")
if __name__ == "__main__":
# 測定ロジックの動作確認(実発注なし)
q = ExecutionQuality(
algo="TwapAlgo", side="Buy",
target_qty=Decimal("10"), filled_qty=Decimal("10"),
arrival_price=Decimal("120000"),
exec_vwap=Decimal("120060"),
market_vwap=Decimal("120050"),
final_price=Decimal("120200"),
fee=Decimal("660"), elapsed_sec=1800.0,
market_volume=Decimal("250"))
print(q.report())
q2 = ExecutionQuality(
algo="PassiveAlgo", side="Buy",
target_qty=Decimal("10"), filled_qty=Decimal("7"),
arrival_price=Decimal("120000"),
exec_vwap=Decimal("119990"),
market_vwap=Decimal("120050"),
final_price=Decimal("120200"),
fee=Decimal("-84"), elapsed_sec=1800.0,
market_volume=Decimal("250"))
print("\n" + q2.report())
compare([q, q2])
print("\n【この例が示すこと】")
print(" パッシブは価格も手数料も有利(リベート)だが、")
print(" 30%が未執行のまま価格が上昇したため機会損失が発生。")
print(" 『安く買えた分』より『買えなかった分』の損失が大きい。")
print(" 約定率を無視した執行評価は必ず間違う。")
"""exec_ab_test.py — 4方式のA/Bテストランナー
本コードは本人利用に限り自由に使用可。再配布・商用利用・販売は禁止。
同一銘柄・同一サイズで4方式を順番に実行し、
Implementation Shortfall で比較する。
注意:
- 順番に実行するので市場環境が異なる。
公平にするには (1) 順序をランダム化 (2) 多数回繰り返す。
- testnet では板が薄く約定が現実と乖離する。
比較の「傾向」を見る用途に留め、絶対値は信用しない。
- 本番で実行する場合、往復のポジションが立つ。
buy → sell を対にして中立に戻す設計にしてある。
"""
from __future__ import annotations
import json
import random
import statistics
import time
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
from almgren_chriss import AcParams, calibrate_from_market, optimal_slices
from client import make_client
from exec_quality import ExecutionQuality, compare, measure
from execution_base import ExecutionConfig, Slice
from iceberg import IcebergAlgo, IcebergConfig
from passive import PassiveAlgo, PassiveConfig
from twap import TwapAlgo
from vwap import VwapAlgo
class AlmgrenAlgo(TwapAlgo):
"""Almgren-Chrissの最適配分で流す。発注方法はTWAPと同じ。"""
def __init__(self, http, cfg: ExecutionConfig,
params: AcParams, run_id: str = ""):
self.ac = params
super().__init__(http, cfg, run_id)
def allocate(self) -> list[Decimal]:
raw = optimal_slices(self.ac)
scale = self.cfg.total_qty / Decimal(str(float(raw.sum())))
quantities = [self.rules.round_qty(Decimal(str(float(q))) * scale)
for q in raw]
diff = self.cfg.total_qty - sum(quantities)
if diff != 0:
quantities[0] = self.rules.round_qty(quantities[0] + diff)
return quantities
@dataclass
class AbConfig:
symbol: str = "BTCUSDT"
qty: Decimal = Decimal("0.02")
duration_sec: float = 120.0
n_slices: int = 8
rounds: int = 3
cooldown_sec: float = 20.0
unwind: bool = True # 各試行後にポジションを閉じる
out_path: Path = Path("./exec_ab_results.json")
def market_stats(http, symbol: str, minutes: int) -> tuple[Decimal, Decimal]:
"""期間中の市場VWAPと出来高。"""
r = http.get_kline(category="linear", symbol=symbol,
interval="1", limit=max(1, minutes))
if r["retCode"] != 0 or not r["result"]["list"]:
return Decimal(0), Decimal(0)
volume = notional = Decimal(0)
for row in r["result"]["list"]:
volume += Decimal(row[5])
notional += Decimal(row[6])
return (notional / volume if volume > 0 else Decimal(0)), volume
def unwind_position(http, symbol: str) -> None:
"""試行で作ったポジションを成行で閉じる。"""
r = http.get_positions(category="linear", symbol=symbol)
if r["retCode"] != 0:
return
for p in r["result"]["list"]:
size = Decimal(p["size"] or "0")
if size <= 0:
continue
close_side = "Sell" if p["side"] == "Buy" else "Buy"
http.place_order(category="linear", symbol=symbol,
side=close_side, orderType="Market",
qty=str(size), reduceOnly=True)
print(f" ポジション {size} を解消")
def build_algos(http, cfg: ExecutionConfig, ac: AcParams) -> list:
"""毎ラウンド新しいインスタンスを作る(状態を持ち越さない)。"""
stamp = int(time.time() * 1000) % 1_000_000
return [
TwapAlgo(http, cfg, run_id=f"ab-twap-{stamp}"),
VwapAlgo(http, cfg, run_id=f"ab-vwap-{stamp}"),
IcebergAlgo(http, cfg,
IcebergConfig(display_ratio=Decimal("0.15"),
timeout_sec=cfg.duration_sec),
run_id=f"ab-ice-{stamp}"),
PassiveAlgo(http, cfg,
PassiveConfig(max_adverse_bps=20.0),
run_id=f"ab-psv-{stamp}"),
AlmgrenAlgo(http, cfg, ac, run_id=f"ab-ac-{stamp}"),
]
def run_ab(ab: AbConfig = AbConfig()) -> dict:
c = make_client()
if not getattr(c, "is_testnet", True):
raise RuntimeError(
"本番では実行しないでください(実際に売買されます)")
r = c.http.get_tickers(category="linear", symbol=ab.symbol)
t = r["result"]["list"][0]
mid = (Decimal(t["bid1Price"]) + Decimal(t["ask1Price"])) / 2
spread = Decimal(t["ask1Price"]) - Decimal(t["bid1Price"])
ac = calibrate_from_market(
mid_price=float(mid), spread=float(spread),
annual_vol=0.60, depth_qty=5.0, depth_impact_bps=8.0,
total_qty=float(ab.qty), duration_sec=ab.duration_sec,
n_slices=ab.n_slices, risk_aversion=1e-6)
cfg = ExecutionConfig(
symbol=ab.symbol, side="Buy", total_qty=ab.qty,
duration_sec=ab.duration_sec, n_slices=ab.n_slices,
max_participation=0.20)
by_algo: dict[str, list[ExecutionQuality]] = {}
for rnd in range(1, ab.rounds + 1):
algos = build_algos(c.http, cfg, ac)
random.shuffle(algos) # 順序効果を消す
print(f"\n{'=' * 60}")
print(f"ラウンド {rnd}/{ab.rounds}")
print(f"{'=' * 60}")
for algo in algos:
name = type(algo).__name__
print(f"\n--- {name} ---")
try:
state = algo.run(verbose=False)
mv, vol = market_stats(
c.http, ab.symbol, int(ab.duration_sec / 60) + 1)
q = measure(algo, state, mv, vol)
by_algo.setdefault(name, []).append(q)
print(f" IS = {q.implementation_shortfall_bps:+.2f}bps "
f"(約定率 {q.fill_rate:.1%})")
except Exception as e:
print(f" [error] {type(e).__name__}: {e}")
if ab.unwind:
unwind_position(c.http, ab.symbol)
time.sleep(ab.cooldown_sec)
# 集計
print(f"\n{'=' * 60}")
print("集計(各方式のIS、単位bps・正が不利)")
print(f"{'=' * 60}")
print(f"\n{'方式':<14}{'試行':>6}{'中央値':>10}{'平均':>10}"
f"{'最良':>10}{'最悪':>10}{'約定率':>9}")
print("-" * 70)
summary = {}
for name, qs in by_algo.items():
values = [float(q.implementation_shortfall_bps) for q in qs]
fills = [float(q.fill_rate) for q in qs]
summary[name] = {
"n": len(values),
"median": statistics.median(values),
"mean": statistics.mean(values),
"best": min(values), "worst": max(values),
"fill_rate": statistics.mean(fills),
}
s = summary[name]
print(f"{name:<14}{s['n']:>6}{s['median']:>+10.2f}"
f"{s['mean']:>+10.2f}{s['best']:>+10.2f}"
f"{s['worst']:>+10.2f}{s['fill_rate']:>8.1%}")
if by_algo:
best = min(summary.items(), key=lambda kv: kv[1]["median"])
print(f"\n 中央値が最良: {best[0]} "
f"({best[1]['median']:+.2f}bps)")
ab.out_path.write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"\n 結果を保存: {ab.out_path}")
print("\n【判断の注意】")
print(f" 試行数 {ab.rounds} 回では統計的にほぼ何も言えない。")
print(" 執行コストの差は数bpsで、市場のノイズより小さいことが多い。")
print(" 記事32の観点で、差が偶然かどうかを検定してから採用すること。")
return summary
if __name__ == "__main__":
run_ab(AbConfig(rounds=3, qty=Decimal("0.02"), duration_sec=120.0))
compare_risk_aversion() で軌道の形を見て決めてください。λ=0はTWAPと同じ、λが大きいほど前倒しになります。ExecutionState が毎スライス exec_state/{run_id}.json に保存しているので、algo.resume() で続行できます。orderLinkId で行い、実際の約定合計と突き合わせるのが確実です。投資は自己判断・自己責任でお願いいたします。本ブログの情報・ツール・ソフトウェアを参考・使用して、取引をされて何らかの損失を被られても、当サイトでは責任は一切負いません(間接的・付随的な障害・損失も含め一切の責任を負いません)。自己責任でご利用ください。
当サイトからダウンロードしたプログラム・EAは本人利用に限り完全無料でご利用いただけます。プログラムの全てまたは一部の複製・コピー・リバースエンジニアリング・改変・2次配布・商用利用・転売・販売等は一切禁止いたします。違反を発見した場合は法的処置を行います。
当サイト上に存在するソフトウェア・ツール・文章・画像・動画等の著作物を無断転載することを禁止します。引用の範囲を超えるものについては、法的処置を行います。
当サイトで配布するツール・ソフトウェアは無料提供ですのでサポートなどは行っておりません(逆に丁寧にサポートしているサイトはほとんど違法サイトですのでご注意ください。投資助言・代理業登録なしでサポート・助言することは法律で禁止されています)。万が一、ツールやソフトウェアに不具合や問題があっても当サイトでは修正・対応などができない場合があります。ご利用の環境でトラブルが発生しても当サイトでは助言することはできませんので自己責任でご利用ください。検索するなどして自分で解決してください。
当サイトでは取引所・証券会社へのリンクを貼っていますが、これは情報として提供するものであり、勧誘行為などは一切行っておりません。そのため、取引所・証券会社の登録方法・使い方・オススメ金融機関・オススメ金融商品などを説明した記事はサイト内にありませんので、ご自身で調べた上で判断ください(本当はサイト内で丁寧に説明したいのですが、仲介・取次ぎにあたる可能性があり第一種金融商品取引業登録を行っていない場合、違法となる可能性があるため出来ません。ご容赦ください)。
当サイトからリンクやバナーなどによって他のサイトに移動された場合、移動先サイトで提供される情報、サービス等について一切の責任を負いません。当サイト掲載情報やリンク先の掲載情報の合法性、正確性、道徳性、最新性、適切性、著作権の許諾や有無など、その内容については一切の保証を致しかねます。
当サイトは、ご訪問いただいた時間、訪問者のアクセス元のホスト名、IPアドレス、OSとブラウザのバージョン、リンク元のURL、モニターの情報等のパソコン環境情報を収集し、アクセス解析をしております。この目的は訪問者が検索サイトからご訪問いただいた場合、どのような検索で当サイトに来訪いただいたかの解析にのみ使用しており、第3者に公開はいたしません。また、個人を特定するものではありません。