Bot DeFi bot DEX トレードロジック 開発ログ

🛠️開発記録#272(2025/8/8)清算スナイプ実装録:仕様誤読からの脱出とパイプライン再設計

清算スナイプBotは、作るのも回すのも錯覚との戦いです。ログは派手に流れるし、WebSocketは安定してつながるし、たまに「でかいクラスター推定!」なんて出力も見える。──でも、/metrics が 0 のままなら、それはただの雰囲気運転。検証も再現も改善もできません。
この記事は、私が “動いてるっぽいbot” から “正しく動くbot” に寄せるためにやったことを、観測設計を軸にまとめた実録です。

対象は、Hyperliquid をはじめとする公開フィード前提の市場。つまり、他人の userEventsledger は見えない世界線です。使えるのは trades / BBO・L2 / activeAssetCtx(OI・mark 等)。この制約を正面から受け止め、複合現象としての清算を推定する設計に切り替えます。ありがちな誤用(fillLiquidation を公開 trades で探す、user:"all" を期待する等)は封印

記事の主眼は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)

  1. 単一プロセス:PIDロック or コンテナ1本。二重起動禁止。
  2. ビルド識別の1行ログgit_sha / config_hash / pid / metrics_port / registry_id を起動時に出す。
  3. 共有REGISTRYで**/metricsを公開し、起動時に必ず1が出るスモーク**を置く。
  4. 段階別メトリクス(最小セット)が30秒で増分する:
    • ingress_trades_total
    • clusters_built_total
    • liquidation_estimate_fired_total
    • liquidation_estimate_score_bucket(ヒストグラム)
  5. 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の板厚急減、スロープ変化。
  • activeAssetCtxmark price / open interest / funding 等の状態。
    • 使い所OIステップ(Z-score)とMarkジャンプ(ブロック間差分)。

これら公開情報のみで「清算推定」を作るのが正攻法。

2.2 見えない(他人の私的イベント)

  • 他人の userEvents / ledger updates:購読不可。見えるのは自分のイベントだけ。
  • 他人の fillLiquidation:公開tradesには付かない。
  • 結論直接の“清算フラグ”に依存した設計は不可。あくまで複合現象の推定で行く。

2.3 設計方針:公開情報の複合現象で清算“推定”

清算は単発の大口ではなく、複数の異常が同時に立つ“現象”。

用いる特徴(例)

  1. ClusterNotional:同サイド連続プリントを 150–300ms で合算(tid重複排除、窓またぎ禁止)。
  2. BBO Collapse:最良気配の厚み急減/スプレッド跳躍。
  3. L2 Slope Change:累積10–25bpの板厚減少率。
  4. OI Step(Z-score):固定%ではなく「直近N分の分布からの逸脱」。
  5. Mark Jump:ブロック間の差分(連続ジャンプは強いシグナル)。
  6. 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_tidsTTL付き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)になったものから確定処理。
  • 図解
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 が前窓内に落ちたら編入される
  • 再接続:同一 tiddedup

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_tactiveAssetCtx.openInterest
  • 変換:ΔOI = OI_t - OI_{t-1} or logdiff
  • 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 チューニング手順(最短コース)

  1. クラスタの分布を見る:notionalprice_range_bp のヒスト
  2. 各前兆を単体で Z/分位点化し、偽陽性の多いものを重みダウン
  3. score≥ττ を sweep(0.5–0.9)して、±1s/±3s/±10s の反発分布の EV が最大の点を採用
  4. 実売買は 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_serverbuild_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なら DeploymentRollingUpdatereadinessProbe/metrics に向けると良い。

6.5 運用ランブック(起動→確認→落ちたらどうする)

  1. 起動make deploy TAG=$(git rev-parse --short HEAD)
  2. BOOT行を確認:git/cfg/pid/port/reg_id
  3. /metricsスモークliq_build_info / ingress_heartbeat_total を確認
  4. 段階別ingress_trades_totalclusters_built_total…fired_total の順に伸びるか
  5. 異常:伸び止まりの段でログをDEBUG化し、5分だけ再現ログ採取
  6. 再起動ロック解放を待たずに新プロセスが起動したら即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_sumcountduration_msprice_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/4score ≥ 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.6
  • target_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 やること

  1. 観測スモーク
curl -s localhost:8000/metrics | egrep 'liq_build_info|ingress_heartbeat_total'

→ 見えなければ観測断線。REGISTRY/ポート/二重起動を直す。

  1. 段階別の増分
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稀に増える
  1. スコア分布
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_bpfill_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 で分布と寄与を見える化し、設計で嘘をつけない状態にしてからサイズを上げよう。これで“動いてるっぽい”は卒業できる。

-Bot, DeFi bot, DEX, トレードロジック, 開発ログ