清算スナイプBotは、作るのも回すのも錯覚との戦いです。ログは派手に流れるし、WebSocketは安定してつながるし、たまに「でかいクラスター推定!」なんて出力も見える。──でも、/metrics が 0 のままなら、それはただの雰囲気運転。検証も再現も改善もできません。
この記事は、私が “動いてるっぽいbot” から “正しく動くbot” に寄せるためにやったことを、観測設計を軸にまとめた実録です。
対象は、Hyperliquid をはじめとする公開フィード前提の市場。つまり、他人の userEvents
や ledger
は見えない世界線です。使えるのは trades / BBO・L2 / activeAssetCtx(OI・mark 等)。この制約を正面から受け止め、複合現象としての清算を推定する設計に切り替えます。ありがちな誤用(fillLiquidation
を公開 trades
で探す、user:"all"
を期待する等)は封印。
清算は“複合現象”。単発の大口は“強い噂”にすぎない。本物の清算には複数の異常が同時に立つ。
1. BBO崩落(最良気配の厚み低下)
2. OIステップ(固定%ではなく Z-score / 分位点)
3. Markジャンプ(ブロック間の跳躍)
4. 価格ストレス(mark ⇄ oracle 乖離の“継続”)— よだか(夜鷹/yodaka) (@yodakablog) August 8, 2025
記事の主眼は3つです。
- 観測を先に固める:単一 REGISTRY・起動スモーク・段階別メトリクスで、「どこで止まっているか」を10分で切り分ける。
- 300msの世界で束ねる:同サイド限定・固定窓クラスタリング・
tid
重複排除・reorder バッファで、連打成行を“出来事”にまとめる。 - 複合で点火する:BBO崩落 / OIステップ(Z-score)/ Markジャンプ / 価格ストレスを正規化&重み付けし、スコアで意思決定する(どれか1個で常時1.0にならないように)。
その上で、実戦パラメータ(TTL=1–5s、板厚連動サイズ、SL/TPは“発火→+1s/+3s/+10s”の分布から導出)や、運用健全化(単一インスタンス・自己診断・ビルド識別・ローリング再起動)までを、最小で回る手順として提示します。
途中、実ログの「confidence 1.000 / 0.501
」をケーススタディに使い、スコアの意味を数値で担保するやり方も分解します。
これは投資助言ではありません。清算スナイプは反射を取りに行く戦略ゆえ、レイテンシ・滑り・手数料に極端に敏感です。まずは紙トレ / 極小サイズ、そして**/metrics を見ながら**小さく回し、数値で良し悪しを判断してください。
それでは、観測ファーストで組み立てる清算推定の“最短ルート”を、具体的な設計とランブックで追っていきましょう。
1. /metricsが0のままでは勝てない
「ログが流れてる」「WSはつながってる」「巨大クラスターも推定できた」——ここまでは**“動いてるっぽい”**の段階。
でも /metrics が 0 だと、
- 検証不能:良し悪しの判定軸が無い
- 再現不能:同じ条件で再度再現できない
- 改善不能:どこをいじればEVが上がるか見えない
つまり“伸びない”。
1.1 まず定義を揃える
- 観測可能性(Observability):内部状態を外から推定できるか(/metrics が基盤)。
- 再現性(Reproducibility):同じ入力で同じ出力になるか(ビルド情報・設定ハッシュで担保)。
- 検証可能性(Verifiability):仮説が数値で正誤判定できるか(段階別メトリクスでA/B)。
この3つがない清算スナイプは、偶然の当たりに依存するギャンブルと同義。
1.2 /metrics が 0 だと何が起きるか(典型)
- バグと閾値ミスの切り分け不能:配線の断線なのか、閾値が厳しすぎるのか判別できない
- ダブルカウント検知ができない:クラスタ窓またぎ・再接続バックログの誤集計に気付けない
- 分布の歪みが追えない:反発幅の母集団が季節性やボラでズレても気付けない
- A/B が回らない:TTL/サイズ/SLTP を試しても“体感”評価で終わる
1.3 10分で通す“最低限”の合格ライン(DoD)
- 単一プロセス:PIDロック or コンテナ1本。二重起動禁止。
- ビルド識別の1行ログ:
git_sha / config_hash / pid / metrics_port / registry_id
を起動時に出す。 - 共有REGISTRYで**/metricsを公開し、起動時に必ず1が出るスモーク**を置く。
- 段階別メトリクス(最小セット)が30秒で増分する:
ingress_trades_total
clusters_built_total
liquidation_estimate_fired_total
liquidation_estimate_score_bucket
(ヒストグラム)
- HTTPで直接確認できる:
curl -s http://127.0.0.1:8000/metrics | egrep 'liq_build_info|ingress_heartbeat_total'
1.4 最小実装スニペット(コピペで動く骨格)
# metrics.py(全モジュールが import する唯一のREGISTRY) from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram REGISTRY = CollectorRegistry() build_info = Gauge("liq_build_info", "build info", ["git_sha","config_hash"], registry=REGISTRY) ingress_heartbeat = Counter("ingress_heartbeat_total", "smoke", registry=REGISTRY) ingress_trades = Counter("ingress_trades_total", "ws trades ingress", registry=REGISTRY) clusters_built = Counter("clusters_built_total", "cluster creations", registry=REGISTRY) liq_fired = Counter("liquidation_estimate_fired_total", "liquidation estimates", registry=REGISTRY) score_bucket = Histogram("liquidation_estimate_score_bucket", "score hist", buckets=[.2,.4,.6,.8,1.0], registry=REGISTRY)
# main.py(起動直後に1行で識別し、スモークを必ず出す) import os, sys from prometheus_client import start_http_server from metrics import REGISTRY, build_info, ingress_heartbeat GIT_SHA = os.getenv("GIT_SHA","dev") CONFIG_HASH = os.getenv("CONFIG_HASH","dev") PORT = int(os.getenv("METRICS_PORT","8000")) start_http_server(PORT, registry=REGISTRY) build_info.labels(git_sha=GIT_SHA[:8], config_hash=CONFIG_HASH).set(1) ingress_heartbeat.inc() print(f"BOOT git={GIT_SHA[:8]} cfg={CONFIG_HASH} pid={os.getpid()} reg_id={id(REGISTRY)} port={PORT}", flush=True)
ここまで通れば、**「動いてるっぽい」→「正しく動く」**への土台は完成。以降は数値で議論できる。
初心者向け補足:/metricsって何?
- /metrics は、アプリの観測指標(metrics)を返すHTTPのパス。
- 中身は**数値の羅列(テキスト)**で、例:カウンタ(増え続ける数)、ゲージ(上下する値)、ヒストグラム(分布)。
- Prometheus がこのURLを数秒〜数十秒ごとに読みに来て、時系列として保存します。
- ログは「何が起きたか」を物語る文章、**/metricsは“今の状態を数値で”**語るもの。A/Bやアラートは基本こっちでやる。
表示例(このBotでの最小セット)
# HELP liq_build_info build info # TYPE liq_build_info gauge liq_build_info{git_sha="a1b2c3d4",config_hash="9f8e7d"} 1 # TYPE ingress_heartbeat_total counter ingress_heartbeat_total 1 # TYPE ingress_trades_total counter ingress_trades_total 1234 # TYPE clusters_built_total counter clusters_built_total 87 # TYPE liquidation_estimate_fired_total counter liquidation_estimate_fired_total 3 # TYPE liquidation_estimate_score_bucket histogram liquidation_estimate_score_bucket{le="0.2"} 85 liquidation_estimate_score_bucket{le="0.4"} 142 liquidation_estimate_score_bucket{le="0.6"} 196 liquidation_estimate_score_bucket{le="0.8"} 231 liquidation_estimate_score_bucket{le="1"} 240 liquidation_estimate_score_bucket_sum 164.3 liquidation_estimate_score_bucket_count 240
ワンミニット動作確認
curl -s http://localhost:8000/metrics | egrep 'liq_build_info|ingress_heartbeat_total'
- 何も出ない → 観測断線(ポート衝突 / REGISTRY違い / 二重起動)
- 数字が見える → 観測系OK。次に
ingress_trades_total
等が時間とともに増えるかを確認
用語ミニ辞典
- Counter:単調増加。例:受信メッセージ数
- Gauge:上下する。例:現在のキュー長
- Histogram:分布。
_bucket
が各しきい値の累積、_sum
と_count
で平均も出せる
2. 仕様と現実:見えるもの/見えないもの
清算スナイプで一番やらかしがちなのが**「見えない信号を見えると誤解する」こと。設計を守るにはデータ系の境界**をはっきり引く。
2.1 見える(公開)チャンネル
trades
:約定プリント(coin/side/px/sz/time/tid 等)。- 注意:清算フラグは来ない。プリントの連続性と大口合算で“清算っぽさ”を推定する。
- BBO / L2(オーダーブック):最良気配と板厚の変化。
- 使い所:BBO崩落、累積10–25bpの板厚急減、スロープ変化。
activeAssetCtx
:mark price / open interest / funding 等の状態。- 使い所:OIステップ(Z-score)とMarkジャンプ(ブロック間差分)。
これら公開情報のみで「清算推定」を作るのが正攻法。
2.2 見えない(他人の私的イベント)
- 他人の
userEvents
/ledger updates
:購読不可。見えるのは自分のイベントだけ。 - 他人の
fillLiquidation
:公開trades
には付かない。 - 結論:直接の“清算フラグ”に依存した設計は不可。あくまで複合現象の推定で行く。
2.3 設計方針:公開情報の複合現象で清算“推定”
清算は単発の大口ではなく、複数の異常が同時に立つ“現象”。
用いる特徴(例)
- ClusterNotional:同サイド連続プリントを 150–300ms で合算(tid重複排除、窓またぎ禁止)。
- BBO Collapse:最良気配の厚み急減/スプレッド跳躍。
- L2 Slope Change:累積10–25bpの板厚減少率。
- OI Step(Z-score):固定%ではなく「直近N分の分布からの逸脱」。
- Mark Jump:ブロック間の差分(連続ジャンプは強いシグナル)。
- Price Stress:mark↔oracle乖離の継続度。
スコア化(例)
score = σ( w1*Z_oi + w2*BBO_collapse + w3*Mark_jump + w4*Cluster_norm ) # 正規化は分位点/標準化で。どれか1つが強いだけで常に1.0にならないように。
2.4 ありがちな設計ミス(早めに潰す)
- 誤フラグ参照:
trades.fillLiquidation
を信じる(来ない)。 - 購読指定ミス:
user:"all"
で他人のイベントを取れると誤解。 - 単位ミス:notional = px*sz の quote 建て/丸め規則を取り違える。
- クラスタ暴走:再接続バックログで巨大クラスター誤検知(窓またぎ・重複未処理)。
- 固定%の罠:OI 5%や乖離2%の固定閾値でボラ regime 変化に負ける。
2.5 最小チェックリスト(記事執筆時点の“守”)
trades/BBO/L2/activeAssetCtx
だけで完結する設計か- クラスタ窓(150–300ms)・同サイド合算・tid重複排除を実装したか
- OI/Mark は統計化(Z-score/分位点)しているか
- スコアは分布で見る(
…score_bucket
を出す) /metrics
で最小セットが必ず増分しているか
次章では、この方針を実装で外さないための「パイプライン全体像」と“段階別メトリクス”の設計を掘り下げます。
3. “300msの世界”を掴む:クラスタリングの要点
清算は単発の1トレードでは滅多に見えない。まずは同サイド連打を“1つの出来事=クラスター”として束ねるところから。
3.1 同サイドのみ合算(買いと売りは混ぜない)
- ルール:
side ∈ {B, S}
を完全分離。 - 目的:両サイド相殺によるノイズを消し、一方向の成行圧だけを測る。
3.2 固定窓(150–300ms)で連続プリントをまとめる
- 基本は 固定幅(まずは 300ms 推奨)。
- 可変化の作法:直近 N=1,000 fills の到着間隔の中央値
Δt_med
からwindow_ms = clamp(150, 3×Δt_med, 400)
のように自動調整しても良い(過疎/過密への適応)。 - クラスター定義(同サイド):
- 開始:前プリントからの経過時間 >
window_ms
で新規開始 - 終了:
window_ms
経過 or サイド切り替え - 集計:
notional_sum = Σ(px*sz)
/count
/duration_ms
/price_range_bp
(高値-安値をmid比bp)
- 開始:前プリントからの経過時間 >
3.3 重複排除:tid
/sequence で dedup
seen_tids
を TTL付きLRU で保持(例:容量 1e6 / TTL 60s)。- 受信再接続時のバックログ再配信で二重加算を防止。
tid
が無い場合は(ts, px, sz, side)
の fuzzy key を作り、±1ms/±1tick で同一判定。
3.4 遅延到着対策:イベント時刻基準+reorderバッファ
- 到着時刻ではなく取引所付与のイベント時刻(
ts_event
)でクラスタリング。 - out-of-order を吸収するため、小さな reorder buffer を導入:
- 例:min-heap を
ts_event
キーで保持し、now - ts_event > buffer_ms
(100–150ms)になったものから確定処理。
- 例:min-heap を
- 図解
flowchart TD A["到着: ts=100 @ t=100"] --> RB B["到着: ts=110 @ t=110"] --> RB C["到着: ts=115 @ t=115"] --> RB D["到着: ts=180 @ t=180"] --> RB E["遅延到着: ts=130 @ t=190"] --> RB
gantt dateFormat X axisFormat %Lms title Arrival vs Event (ms) section Event time (ts) ts100 :100, 1 ts110 :110, 1 ts115 :115, 1 ts130 :130, 1 ts180 :180, 1 section Arrival time (t) t100 :100, 1 t110 :110, 1 t115 :115, 1 t180 :180, 1 t130(Late) :190, 1 section Windows Reorder 120ms :100, 120 Cluster 300ms :100, 300
3.5 検算:notional=px*sz
(quote建て/丸めの統一)
- 必ず quote 建て(USDC 等)で集計。
Decimal
(またはint
最小単位)で丸め誤差ゼロ運用。- 取引所の tick/lot(桁数)を踏まえ、正規化後に計算。
3.6 参照実装(疑似コード)
class Clusterer: def __init__(self, window_ms=300, reorder_ms=120): self.window_ms = window_ms self.reorder_ms = reorder_ms self.heap = [] # (ts_event, trade) self.seen = LruSet(capacity=1_000_000, ttl_s=60) self.active = None # {side, t0, t_last, notional, count, px_min, px_max} def ingest(self, trade): # trade: {tid, ts_event, side, px, sz} if trade.tid in self.seen: return self.seen.add(trade.tid) heappush(self.heap, (trade.ts_event, trade)) self._drain() def _drain(self): now = current_event_time_guess() while self.heap and now - self.heap[0][0] > self.reorder_ms: ts, tr = heappop(self.heap) self._consume(tr) def _consume(self, tr): if self.active is None: self.active = self._start(tr) return same_side = (tr.side == self.active["side"]) gap = tr.ts_event - self.active["t_last"] if same_side and gap <= self.window_ms: self._acc(tr) else: self._flush() self.active = self._start(tr) def _start(self, tr): return dict(side=tr.side, t0=tr.ts_event, t_last=tr.ts_event, notional=tr.px*tr.sz, count=1, px_min=tr.px, px_max=tr.px) def _acc(self, tr): a = self.active a["t_last"] = tr.ts_event a["notional"] += tr.px*tr.sz a["count"] += 1 a["px_min"] = min(a["px_min"], tr.px) a["px_max"] = max(a["px_max"], tr.px) def _flush(self): if not self.active: return a = self.active duration = a["t_last"] - a["t0"] price_range_bp = 1e4 * (a["px_max"] - a["px_min"]) / ((a["px_max"] + a["px_min"])/2) emit_cluster(a["side"], a["notional"], a["count"], duration, price_range_bp) self.active = None
3.7 テスト観点(最小)
- 窓またぎ:
gap = window_ms + 1
で別クラスターになる - 両サイド:B→S で必ずフラッシュ
- 追い付いた遅延:
ts_event
が前窓内に落ちたら編入される - 再接続:同一
tid
はdedup
4. 清算は“複合現象”だ:前兆フラグとスコア
単発の大口は“強い噂”にすぎない。本物の清算には複数の異常が同時に立つ。以下は“最小の四天王”。
4.1 BBO崩落(最良気配の厚み低下)
- 指標例:
best_depth_ratio = depth1_now / EMA(depth1, τ=60s)
cum10bp_ratio = cum_depth_within(10bp)_now / EMA(…, τ=60s)
- 判定:
min(best_depth_ratio, cum10bp_ratio) < θ_bbo
(例:0.4)
- 補足:スプレッド跳躍(
spread_now / EMA(spread)
) も併用可。
4.2 OIステップ(固定%ではなく Z-score / 分位点)
- 生系列:
OI_t
(activeAssetCtx.openInterest
) - 変換:
ΔOI = OI_t - OI_{t-1}
orlogdiff
- Z-score:
Z_oi = (ΔOI - μ_Δ) / σ_Δ
(μ,σ は直近 30–60 分の移動推定) - 判定:
Z_oi < -z_thr
(例:-3.0)
→ ボラ regime が変わっても自動で強弱が揃う。
4.3 Markジャンプ(ブロック間の跳躍)
- 生系列:
mark_t
(ブロック駆動) - 指標:
jump_bp = 1e4 * |mark_t - mark_{t-1}| / mark_{t-1}
- 判定:
jump_bp > θ_mark
(例:8–15bp)- 連続ジャンプ(2ブロック連続)は強い。
4.4 価格ストレス(mark ↔ oracle 乖離の“継続”)
- 瞬間より継続が重要。
- 指標:
stress = |mark - oracle| / oracle
- 平滑:
EWMA(stress, α=0.3)
がθ_stress
(例:0.5–1.0%)を k連続 超過(例:3連続)。
4.5 スコアリング:足し算ではなく“正規化+重み”
生の値はスケールがバラバラ。分位点正規化か Z-score で揃えてから重み付け。
4.5.1 正規化
- 分位点版(頑健で扱いやすい)
x̂ = clip( (x - Q50) / (Q90 - Q50 + ε), 0, 3 )
- Z版
x̂ = clip( (x - μ)/σ, -3, 3 )
(BBO崩落や OI は符号を揃える:崩落/減少を正方向に)
4.5.2 スコア例(シグモイド)
S = w1*Z_oî + w2*BBÔ + w3*MarkJump̂ + w4*ClusterNotional̂ score = 1 / (1 + exp(-S))
- 初期重みの目安:
w = [1.0, 0.8, 0.6, 0.6]
から開始。 - どれか1個が強いだけで常に1.0 にならないよう、分位点で頭打ちを入れる(clip)。
4.5.3 ガード(AND条件 & クールダウン)
- AND最小数:
(BBÔ > b0) + (Z_oî > o0) + (Mark̂ > m0) + (Cluster̂ > c0) >= 2
を満たす時だけ score を評価。 - クールダウン:発火後 30s は同アカウント由来の再清算をグルーピング(二段エントリー戦術に活用)。
4.6 メトリクス(スコアの“見える化”)
bbo_collapse_flags_total
/oi_step_flags_total
/mark_jump_flags_total
liquidation_estimate_score_bucket{le="0.2|0.4|0.6|0.8|1.0"}
- 同時発生率:
liquidation_precursor_coincidence_total{pattern="BBO+OI", ...}
→ 0.7 以上の割合が高い時間帯は“本物の相場”。
4.7 チューニング手順(最短コース)
- クラスタの分布を見る:
notional
とprice_range_bp
のヒスト - 各前兆を単体で Z/分位点化し、偽陽性の多いものを重みダウン
- score≥τ の
τ
を sweep(0.5–0.9)して、±1s/±3s/±10s の反発分布の EV が最大の点を採用 - 実売買は score≥τ & AND≥2 を条件に、TTL=1–5s から
4.8 よくある失敗
- 固定% 閾値で regime 変化に負ける → 分位点/Z に移行
- 単発の大口で即発火 → AND最小数・score閾値・クールダウン
- スコアが常に1.0 → 正規化の clip と重みの再配分
- 評価の自己満 →
/metrics
のヒストと同時発生率で客観評価
この章までで、“クラスターをどう束ねて、何を複合して、どうスコア化するか”の土台は完成。
次はこのスコアを観測できる形に落として、/metrics → 実執行へ持っていく過程を解説します。
5. 観測設計が性能を決める:Prometheus最小セット
結論:まずは“壊れにくい観測”から。単一REGISTRYを全モジュールで共有し、起動時に必ず1が出るスモークを置く。これで「配線が死んでるのか/閾値が厳しすぎるのか」を10分で切り分けられる。
5.1 単一REGISTRY + 起動スモーク
# metrics.py(共有モジュールに集約。新規CollectorRegistryはここだけ) from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram REGISTRY = CollectorRegistry() # 起動識別(1行で今走っているビルドを特定するための指標) build_info = Gauge( "liq_build_info", "build info", ["git_sha", "config_hash"], registry=REGISTRY ) # 起動スモーク(= /metrics が生きていると絶対に 1 以上になる) ingress_heartbeat = Counter( "ingress_heartbeat_total", "smoke counter", registry=REGISTRY ) # 最小メトリクス:段階別の通過数 ingress_trades = Counter("ingress_trades_total", "ws trades ingress", registry=REGISTRY) clusters_built = Counter("clusters_built_total", "cluster creations", registry=REGISTRY) liq_fired = Counter("liquidation_estimate_fired_total", "liquidation estimates", registry=REGISTRY) # スコア分布(0.2,0.4,0.6,0.8,1.0 の桶に入る回数を記録) score_bucket = Histogram( "liquidation_estimate_score_bucket", "score histogram", buckets=[.2, .4, .6, .8, 1.0], registry=REGISTRY ) # 前兆フラグ(BBO/OI/Mark などはラベルで種別を分ける) from prometheus_client import Counter as C precursor_flags = C( "liquidation_precursor_flags_total", "precursor flags", ["type"], registry=REGISTRY )
# main(起動直後) import os, sys from prometheus_client import start_http_server from metrics import REGISTRY, build_info, ingress_heartbeat GIT_SHA = os.getenv("GIT_SHA", "dev") CONFIG_HASH = os.getenv("CONFIG_HASH", "dev") PORT = int(os.getenv("METRICS_PORT", "8000")) start_http_server(PORT, registry=REGISTRY) # ← pull方式に統一 build_info.labels(git_sha=GIT_SHA[:8], config_hash=CONFIG_HASH).set(1) ingress_heartbeat.inc() # ← 必ず1以上になる print( f"BOOT git={GIT_SHA[:8]} cfg={CONFIG_HASH} pid={os.getpid()} " f"reg_id={id(REGISTRY)} port={PORT}", flush=True )
HTTPで確認(必ずHTTP経由!)curl -s localhost:8000/metrics | egrep 'liq_build_info|ingress_heartbeat_total'
- 1行も出なければ観測断線(REGISTRY/ポート/プロセスのどれかがズレてる)
ingress_heartbeat_total 1
が見えたら観測生存。次は段階別メトリクスへ。
5.2 どこで何を inc()
/ observe()
するか
- WS受信:
ingress_trades.inc()
(受信パス全てで必ず1回。フィルタ前に置く) - クラスタ確定:
clusters_built.inc()
(300ms窓でクラスターを確定した瞬間) - 前兆フラグ:
precursor_flags.labels("bbo").inc()
/"oi"
/"mark"
/"stress"
- スコア:
score_bucket.observe(score)
(0〜1に正規化した値だけ入れる) - 推定発火:
liq_fired.inc()
(score≥τ & AND条件を満たした瞬間だけ)
ラベルは低カーディナリティ徹底(type など固定集合のみ)。
実運用で time series 爆発を防ぐため、動的値(coin=…/side=…/tid=…)はラベルにしない。
5.3 10分で効く “見える化”クエリ(PromQL)
- 受信RPS:
rate(ingress_trades_total[1m])
- クラスタ生成RPS:
rate(clusters_built_total[1m])
- 発火率:
rate(liquidation_estimate_fired_total[5m])
- スコア0.8以上の割合:
sum(rate(liquidation_estimate_score_bucket_bucket{le="1.0"}[5m])) / sum(rate(liquidation_estimate_score_bucket_count[5m]))
- 前兆同時発生の強い時間帯:
sum by (type) (rate(liquidation_precursor_flags_total[5m]))
ダッシュボード作るなら:上4つ+
build_info
(テキストパネル表示)だけで十分回せる。
5.4 ありがちな事故と対処
- 別REGISTRYを増殖:各モジュールが
CollectorRegistry()
を新規生成 → 断線
→ metrics.pyのREGISTRYをimport。新規生成禁止。 - ポート衝突:旧プロセスが8000を掴んだまま
→ 起動時の BOOT行でport=
を必ず出す。lsof -iTCP:8000
で即確認。 - push/pull混在:Pushgatewayと
start_http_server
を混ぜる
→ まずはpull専用に固定。Pushは後回し。 - checkスクリプトが内部REGISTRYを参照
→ 必ずHTTPで叩く(requests.get("http://localhost:8000/metrics")
)。
6. 動かし続ける仕組み:単一プロセスと自己診断
「動く」より「同じ品質で動き続ける」が難しい。二重起動禁止・自己診断・ビルド識別・デプロイの一貫性で固める。
6.1 単一インスタンス・ロック(PID/Redis)
A) PIDファイル + fcntl(ローカル単体なら十分)
# single_instance.py import os, fcntl class SingleInstance: def __init__(self, path="/tmp/btc_liq_sniper.pid"): self.fd = os.open(path, os.O_CREAT | os.O_RDWR) try: fcntl.lockf(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB) os.write(self.fd, str(os.getpid()).encode()) except OSError: raise SystemExit("Another instance is running.")
# main.py(起動直後) from single_instance import SingleInstance SingleInstance("/tmp/btc_liquidation_sniper.pid")
B) Redisロック(分散環境)
redlock-py
などでresource="liq-sniper:lock"
を獲得。失敗したら即終了。
ポリシー:ロック失敗 = EXIT(1)。
pkill -f
で殴る運用は卒業。
6.2 自己診断(起動時に“落とすべき”は落とす)
Exporterの必須メソッドが無ければ即死
required = ["record_liquidation","record_big_trade","record_precursor"] for m in required: if not hasattr(exporter, m): raise SystemExit(f"[FATAL] Exporter missing: {m}")
設定の健全性チェック
assert 0.0 <= CONF.SCORE_THRESHOLD <= 1.0 assert 50_000 <= CONF.CLUSTER_NOTIONAL_MIN <= 5_000_000 assert 50 <= CONF.CLUSTER_WINDOW_MS <= 500
観測の生存確認(HTTP起動→スモーク)
start_http_server
→build_info.set(1)
→ingress_heartbeat.inc()
- ここで**/metricsが叩けなければ**即死(
SystemExit
)させたほうが健全。
6.3 ビルド情報を1行で出す(トラブル時の命綱)
print( f"BOOT git={GIT_SHA[:8]} cfg={CONFIG_HASH} pid={os.getpid()} " f"reg_id={id(REGISTRY)} port={PORT}", flush=True )
- git:何のコミットが走ってるか
- cfg:設定ハッシュ(YAML/ENVのダイジェスト)
- pid/port:生存確認・衝突切り分け
- reg_id:REGISTRYの実体が単一か識別
6.4 コンテナ化+ローリング再起動(“どのコードが走ってるか”を可視化)
Dockerfile(最小)
FROM python:3.12-slim WORKDIR /app ARG GIT_SHA=dev ENV GIT_SHA=$GIT_SHA COPY pyproject.toml poetry.lock ./ RUN pip install --no-cache-dir prometheus_client && pip install -e . COPY . . EXPOSE 8000 CMD ["python", "-m", "mymod.main"]
docker-compose.yml(観測と再起動)
services: liq-sniper: image: liq-sniper:${GIT_SHA} environment: - GIT_SHA=${GIT_SHA} - CONFIG_HASH=${CONFIG_HASH} - METRICS_PORT=8000 ports: ["8000:8000"] restart: always healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8000/metrics"] interval: 10s timeout: 2s retries: 5 start_period: 10s
ローリング更新(例)
- 新イメージに GIT_SHA を焼く → 差し替え
- ヘルスチェックが /metrics OK になってから古いタスクを落とす
Kubernetesなら
Deployment
のRollingUpdate
、readinessProbe
を/metrics
に向けると良い。
6.5 運用ランブック(起動→確認→落ちたらどうする)
- 起動:
make deploy TAG=$(git rev-parse --short HEAD)
- BOOT行を確認:
git/cfg/pid/port/reg_id
- /metricsスモーク:
liq_build_info
/ingress_heartbeat_total
を確認 - 段階別:
ingress_trades_total
→clusters_built_total
→…fired_total
の順に伸びるか - 異常:伸び止まりの段でログをDEBUG化し、5分だけ再現ログ採取
- 再起動:ロック解放を待たずに新プロセスが起動したら即EXIT(ロックが守ってくれる)
6.6 よくある落とし穴
- 二重起動:PID/Redisロックなし → /metricsポート衝突 → 旧プロセスのメトリクスを見てしまう
- 起動後にExporter差し替え:必須メソッド欠落 → “動いてるっぽい”まま裏で沈黙
- pkill運用:別名プロセスまで巻き添え、事故る
- healthcheckがログだけ:/metricsを見ないヘルスは意味なし
まとめ:
- 観測最小セットで「断線 or 厳しすぎ」問題を即切り分け。
- 単一プロセス&自己診断で「いつ・どのビルドが走っているか」を保証。
- ここまで固まれば、スコア最適化やTTL/サイズ調整が“数値で回る”。
次章では、/metrics の数値と実ログ(confidence 0.5/1.0)をどう突き合わせて改善するかに進む。
7. ケーススタディ:confidence 1.000 / 0.501 の裏側
実ログ:
Liquidation cluster estimated: B 493,567 USDC (25 trades, confidence: 1.000) Liquidation cluster estimated: B 293,626 USDC (23 trades, confidence: 1.000) Liquidation cluster estimated: B 100,287 USDC (7 trades, confidence: 0.501)
この3件を**“同じ手順で”検証**して、スコアの意味を担保する。
7.1 まず「現象の同時性」を見る(±3s)
各イベントのタイムスタンプ t0
を基点に、前後3秒で以下を突き合わせる:
- BBO崩落:
best_depth_ratio
,cum10bp_ratio
の落ち込み(0.4未満なら強) - OIステップ:
Z_oi
(-3σ以下なら強) - Markジャンプ:
jump_bp
(> 8–15bp なら強) - Cluster属性:
notional_sum
、count
、duration_ms
、price_range_bp
チェックが楽になる観測(PromQL 例):
rate(liquidation_precursor_flags_total{type="bbo"}[10s])
をイベント時刻で見て同時発生かliquidation_estimate_score_bucket_bucket
の 0.8 以上比率がその瞬間に跳ねているか
全部1.0ばかりなら、分位点正規化 or clip が効いていない可能性大。
0.5〜0.8帯が“そこそこ”出ているのが自然。
7.2 スコアの分解ログを出す(ブラックボックス化を避ける)
発火時に寄与度ログを必ず残す:
[score_breakdown] t=..., side=B, notional=493567, z_oi=2.4, bbo=1.8, mark=1.2, cluster=1.5, S=3.26, score=0.963, AND=3/4
- AND最小数(例:2/4 以上)とscoreの両方を満たしているか
- 100k級(0.501)のとき、何が弱いのかが一目で分かる(例:BBOが立っていない)
7.3 100k級が「0.501」になる妥当な理由
- 窓またぎ:最後のプリントが
window_ms + ε
で切れて、合算が小さくなった - 両サイド混入:dedup/同サイド制約が甘く、微小な逆サイドが混じって弱体化
- バックログ混入:再接続直後のプリントが古い ts_eventで押し寄せ、価格変化は小さいのに取引数だけ増える
- “大口の通常成行”:清算ではなく、板が厚いのに1発だけ大きい(→BBO崩落/OIステップが立たない)
対処
- クラスタの窓・同サイド・
tid
dedup を単体テストで再確認 - reorder バッファ(100–150ms)を通し、
ts_event
ベースでクラスタ - AND条件を最低2/4に固定して、単発シグナルで 1.0 にならないようにする
7.4 “真っ当な 1.000” と “疑わしい 1.000”
- 真っ当:
AND >= 3/4
、score ≥ 0.9
、±3s で BBO と OI が同時に立ち、price_range_bp
も大きい - 疑わしい:
AND=1/4
なのにscore=1.0
(=正規化の不備)。または同時性が見えない1.0(=配線/時刻整合の不備)
結論:スコアは分布&分解ログで担保する。1.0
が“気持ちいい”だけの数字にならないよう、仕組みで嘘をつけなくする。
8. 実戦パラメータの初期値(TTL/サイズ/SLTP)
清算反射は短く速い。最初は保守的に、“分布”から決める。
8.1 TTL(有効時間)
- 主軸 1–5 秒。ジャンプが大きいほど 短く(例:
TTL = clamp(1s, 5s - k * jump_bp, 5s)
) - 実装:執行スレッド側で発注から TTL 経過で強制キャンセル(未約定は捨てる)
8.2 サイズ(板厚・スリッページ連動)
目標最大スリッページ bp を決める(例:2–4bp)。
板データから bp 内累積厚みを見てサイズ上限を決める:
depth_quote(bp*) = quote volume available within bp* size_quote = min( depth_quote(target_bp) * fill_ratio, # 市場の許容 RISK.max_per_order_quote, # リスク上限 signal_cap * notional_of_cluster # シグナル強度に比例 )
fill_ratio
の初期値:0.3〜0.6target_bp
:2〜4bp- IOC takerを基本(反射を逃さない)。板が厚くて反発確度が高い場面だけmaker追加で追撃。
8.3 SL/TP(経験則ではなく分布で)
“推定発火→+1s/+3s/+10s” のプライスリターン分布を取ってから決める。
- TP:正方向の 60–70% 分位(最初は控えめ)
- SL:逆方向の 80–90% 分位(TP < SL は普通にあり得る。TTLが働く)
- 時間優先:
min(TTL, TP/SL到達)
でクローズ - 初期値の目安(BTC, 高流動時間帯・例):
- TP:8–15bp
- SL:12–25bp
- TTL:2–3s
実際はあなたの実測分布で上書きすること。
8.4 二段エントリー(部分清算→再清算)
- 初弾:
score ≥ τ1 (例: 0.75)
で 小さめサイズ(fill優先) - 追撃:30s 以内に二度目の推定(同側・同方向)→
score ≥ τ2 (例: 0.85)
なら サイズ拡大 - グルーピング:同一“清算波”を
wave_id
で束ね、総エクスポージャを制限
8.5 サーキットブレーカー
- latency_guard:WS遅延・発注遅延が閾値超過 → その波は取引禁止
- max_consecutive_losses:例:3 連敗で当日停止
- max_daily_loss:例:1,000 USDC(観測の成熟まで厳しめに)
9. 今日のランブックと明日の課題
9.1 やること
- 観測スモーク
curl -s localhost:8000/metrics | egrep 'liq_build_info|ingress_heartbeat_total'
→ 見えなければ観測断線。REGISTRY/ポート/二重起動を直す。
- 段階別の増分
curl -s localhost:8000/metrics | egrep 'ingress_trades_total|clusters_built_total|liquidation_estimate_fired_total'
ingress_trades_total > 0
かclusters_built_total
が同時に伸びているか…fired_total
が稀に増えるか
- スコア分布
curl -s localhost:8000/metrics | egrep 'liquidation_estimate_score_bucket|_count'
- 0.7 以上の比率をざっくり見る
- 仮のエントリー閾値:τ = 0.7 から開始(紙トレでもOK)
9.2 今後の課題(やる順)
- スコア重みの最適化
- 各特徴を分位点正規化→重み
w
をグリッドで sweep - 目的関数:±3s EV 最大化(ネット手数料・滑り込み反映)
- 各特徴を分位点正規化→重み
- TTL/サイズ/SLTP の再同定
- “発火→+1s/+3s/+10s” の分布から TP/SL/TTL を 自動提案
target_bp
とfill_ratio
のセットを A/B
- 再接続時バックログの誤クラスタ検知テスト
- 窓またぎ・tid重複・遅延到着の3ケースで red/green
- reorder バッファを 80/120/160ms でベンチ
【終了基準】
- スコア分布で0.5–0.8帯が十分出て、1.0 の乱発が消える
- ±3s EV が τ=0.7–0.85 のどこかで安定正
- クラスタ単体テストで誤集計ゼロ
最後に:
“confidence”は当て勘の飾りではなく、複合現象の統計的整合を示す数字。
/metrics で分布と寄与を見える化し、設計で嘘をつけない状態にしてからサイズを上げよう。これで“動いてるっぽい”は卒業できる。