メインコンテンツまでスキップ

🗄️ 歴史的文書(アーカイブ) — この文書は過去の研究フェーズの記録であり、現在の結論・手法を反映していません。現在の研究状況は解説セクションを参照してください。

Phase 4b-4 / Phase 1.5 — mm_zero_fee_inside_spread Silence Diagnosis

本ページは Phase 4b-4 / Phase 1.5 の診断レポート research_reports/phase4b4_mm_zero_fee_silence_diagnosis.md を整理したもの。 リーダーボード上位(α −¥565k、strat_pnl +¥25,967)の mm_zero_fee_inside_spread が、なぜ 62 日間で 1 フィルしかしなかったのかを特定する。結論は戦略ではなくシミュレータのバグ。元リーダーボードは leaderboard.md を参照。

  • 期間: 2026-02-17 → 2026-04-19(62 日, ETH_JPY, run tag phase1_5_s3
  • ヘッドライン: 戦略は沈黙していない — クォートを猛烈に発行している。沈黙しているのはexecutor / fill simulator、これが resting maker quote と後続 trade をマッチさせていない。

1. レポート一覧

data/derived/reports/backtest_ETH_JPY_2026-02-17_2026-04-19_mm_zero_fee_inside_spread_phase1_5_s3.json より:

FieldValue
supports_maker_quotestrue
intents20,568,443(12,033,988 events から)
maker_quote_submissions5,902,523
maker_quote_cancellations5,902,523
orders1
fills1
forced_close_orders1
forced_close_fills1
strategy_total_pnl_jpy+25,967(期末近くでアンワインドされた初期 10 ETH のみによる)

数値的: ~5.9M クォート submitted、~5.9M cancelled、ゼロ filled。唯一の orders/fills エントリは初日(Feb 17)の 06:00 JST forced-close market fallback — start_position_eth=10.0 をアンワインドする。以降 61 日フラットで走行 → +¥25,967 の「PnL」は実質初期ポジションの exit P&L。

Daily log(data/derived/reports/phase1_5_logs_s3/mm_zero_fee_inside_spread.log)もこれを反映: day 1 が pnl=+25,967 fills=1、以降のすべての日が pnl=+0 fills=0。warning なし、no-quote reason なし — 戦略のガードすらリミッティング要因ではない。

2. 根本原因: Phase 4a maker-quote path にフィル・シミュレータが存在しない

src/atc/cli/backtest.py には 2 つの並行エクゼキューション・パスがある。

Legacy single-leg path(baseline_inventory_mm, momentum 等で使用)

  • TargetPositionIntentpending_order: PendingLimitOrder をビルド
  • 全後続 TRADE イベントで(active_after_utc 後): _should_fill_limit_order(pending_order, trade_price) がフィル判定 (src/atc/cli/backtest.py:395-430, :1223)
  • クロス成功 → execution.apply_limit_fill(...) → ledger fill、fills += 1

Phase 4a two-sided maker-quote path(mm_zero_fee_inside_spread で使用)

  • QuoteIntent list → compute_quote_diff(...)to_cancel / to_submit diff 生成 (src/atc/cli/backtest.py:546-607)
  • to_submit エントリは in-memory resting_quotes: list[RestingQuote] に追加
  • to_cancel エントリはリストから drop され status="maker_quote_cancelled" マーカ記録
  • resting_quotes に対してそれ以外何もされない。 rg resting_quotes src/ によれば submit/cancel ブロックと line 269 の宣言の型アノテーションにしか出現しない。いかなる fill path にも consume されていない。

Legacy path と比較すれば: _should_fill_limit_order(pending_order, ...)(line 402)が全 trade event で呼ばれる。resting quote list に対する類似ループは存在しない。したがって戦略が emit する全 QuoteIntent は write-only: submit → 次イベントで cancel(REQUOTE_MIN_MOVE_BPS を越えて price/spread が動くため)、ad infinitum。同じコードパスは supports_maker_quotes = True を設定するあらゆる戦略を沈黙させる。

resting quote のためのフィル・シミュレータが存在しないことの確認:

$ rg 'resting_quotes|RestingQuote' src/
src/atc/execution/maker_quotes.py:23 class RestingQuote:
src/atc/execution/maker_quotes.py:48 resting: list[RestingQuote],
src/atc/execution/maker_quotes.py:52 ) -> tuple[list[RestingQuote], list[QuoteIntent]]:
src/atc/execution/maker_quotes.py:67 to_cancel: list[RestingQuote] = []
src/atc/cli/backtest.py:20 from atc.execution.maker_quotes import RestingQuote, compute_quote_diff, partition_intents
src/atc/cli/backtest.py:269 resting_quotes: list[RestingQuote] = []
src/atc/cli/backtest.py:549 if quote_intents or resting_quotes:
src/atc/cli/backtest.py:551 resting=resting_quotes,
src/atc/cli/backtest.py:560 resting_quotes = [
src/atc/cli/backtest.py:562 for r in resting_quotes
src/atc/cli/backtest.py:583 resting_quotes.append(
src/atc/cli/backtest.py:584 RestingQuote(

tests/test_maker_quotes.py は diffing、stale cancellation、TTL expiry、legacy-bypass をカバーするが、resting quote の price を trade が cross して fill がブックされるテストは無い。これは機能が半実装であることと整合する: quote bookkeeping は完成済み、fill simulation は決して wire up されなかった。

3. なぜ baseline_inventory_mm は 11,905 フィルするのか

baseline_inventory_mmsupports_maker_quotes を設定していない;legacy(レポートで supports_maker_quotes=False)。TargetPositionIntent を返す → pending_order / _should_fill_limit_order を通過(働く fill path)。legacy limit-price builder が target position を設定することで概念的に inside-spread をクォートし、これは正しくシミュレートされる。

したがって違いは パラメータではなくMIN_SPREAD_BPS=2, MAX_SPREAD_BPS=80, IMBALANCE_LEAN_THRESH=0.25 など全て妥当)、adverse-selection / RV / session-close ガードでもなく(スロットリングはするが fill をゼロアウトしない)、mm_zero_fee_inside_spread が Phase 4a two-sided パスに opt-in し、そのシミュレータが欠けているという点。

4. 戦略自身のロジックのサニティチェック

src/atc/strategies/baselines/mm_zero_fee_inside_spread.py のパラメータ:

  • MIN_SPREAD_BPS=2.0, MAX_SPREAD_BPS=80.0 — 非常に広いレンジ;ETH_JPY は典型的にこの中。
  • QUOTE_QTY_ETH=5.0, INVENTORY_MAX_ETH=10.0 — 妥当。
  • ADVERSE_VOL_500MS_THRESH=0.8 ETH/500ms, RV_GUARD_5S=5 bps — 標準ガード;observable pause は引き起こすが 62 日 100% 沈黙ではない。
  • QUOTE_TTL_SEC=5.0, REQUOTE_MIN_MOVE_BPS=1.0 — 頻繁な cancellation/requote ペアを生成(submissions == cancellations == 5.9M と整合)。

QUOTE_OFFSET_BPS_FROM_INSIDE=0.0 の下では戦略は best bid / best ask ちょうどにクォート (bid_px = bid - 0 - skew_px, ask_px = ask + 0 - skew_px)。legacy path のようにシミュレータがこれらを crossing trade にマッチしていれば fill は頻繁。戦略ロジックは本質的に正しく、downstream ランナーが intent をドロップしている。

5. 修正案

修正対象は src/atc/cli/backtest.py であって戦略ではない戦略やそのパラメータを変更してはならない。 2 つのオプション。

Option A(推奨): backtest runner に resting-quote fill ループを追加

Legacy pending_order シミュレーションを resting_quotes リスト用にミラー。各 EventType.TRADE イベントで resting_quotes を iterate:

  • BUY クォート(価格 P_b): trade_price <= P_b なら P_b でフィル(passive maker fill)。adversely-selected variant は trade_price を使うこともできるが、canonical maker convention は resting price でのフィル。
  • SELL クォート(価格 P_s): trade_price >= P_s なら P_s でフィル。
  • フィル時: execution.apply_limit_fill(...) を signed size_eth で呼ぶ、filled_orders 増加、ledger.apply_fill(...)execution.current_position_eth 更新、resting_quotes からクォート除去(status="filled_maker_quote" OrderMarker も append)。
  • 既存の active_after_utc 相当のレイテンシゲートを尊重 — 新しく submit されたクォートはその submission を引き起こしたまさにその trade でフィルすべきでない。RestingQuotefillable_after_utc = submitted_at_utc + order_latency_sec を保存し、fill チェックを event.exchange_ts_utc >= fillable_after_utc でゲート。
  • ttl_sec を従来通り尊重。
  • INVENTORY_MAX_ETH スタイルの per-position cap は既存 risk layer 経由で尊重。

Concrete insertion point: pending_order fill ブロックの直前(src/atc/cli/backtest.py:395)に、event.event_type == EventType.TRADEresting_quotes を iterate する sibling ループを追加。execution.apply_limit_fill(...) を再利用して PnL accounting が legacy path と正確に一致するようにする。

最小スケルトン(レビュー目的のみ — コミット禁止):

if (
event.event_type == EventType.TRADE
and isinstance(event.payload, TradePayload)
and resting_quotes
):
trade_price = float(event.payload.price)
survivors: list[RestingQuote] = []
for r in resting_quotes:
fillable_after = r.submitted_at_utc + timedelta(seconds=order_latency_sec)
if event.exchange_ts_utc < fillable_after:
survivors.append(r)
continue
crosses = (r.side == "BUY" and trade_price <= r.price) or (
r.side == "SELL" and trade_price >= r.price
)
if not crosses:
survivors.append(r)
continue
delta = r.size_eth if r.side == "BUY" else -r.size_eth
exec_result = execution.apply_limit_fill(
delta_qty_eth=delta,
ts=event.exchange_ts_utc,
limit_price=r.price,
reason=r.reason,
)
if exec_result.fill is not None:
filled_orders += 1
turnover += abs(exec_result.fill.qty_eth)
ledger.apply_fill(exec_result.fill)
state.current_position_eth = execution.current_position_eth
order_markers.append(
OrderMarker(
ts=event.exchange_ts_utc,
side=r.side,
delta_qty_eth=delta,
target_qty_eth=execution.current_position_eth,
reference_price=r.price,
status="filled_maker_quote",
fill_price=exec_result.fill.price,
reason=r.reason,
)
)
# Fill consumes the quote.
else:
survivors.append(r)
resting_quotes = survivors

対応するテストを tests/test_maker_quotes.py に追加 — trade が resting bid を cross して fill counters / ledger positions が更新されるランナーレベル・テスト。

Option B(フォールバック): mm_zero_fee_inside_spread を legacy path に変換

src/atc/strategies/baselines/mm_zero_fee_inside_spread.py:47supports_maker_quotes = False を設定し、quote したい側の単一 TargetPositionIntent を emit するように on_event を書き換え(例: インベントリがターゲットより下のとき bid、上のとき ask)。真の両建て挙動は失うが、現シミュレータの下で実 fill を delivery する。Option A が out of scope の場合のみ推奨。

6. パラメータ / ロジック / シミュレータのどのミスマッチ?

シミュレータ・ミスマッチ。 以下で確認:

  1. 5.9M submission → 戦略の on_event が quote-emission path に regularly 到達(ガードがブロックしていない)。
  2. 62 日 ETH_JPY テープで inside-spread クォートしても 0 fill。
  3. rg resting_quotes src/ → リストがいかなる fill path でも read されていない。
  4. Legacy-path 戦略(例 baseline_inventory_mm)は同じデータ上、非常に似た quoting intent で fill を得ている — 唯一の構造的差異は supports_maker_quotes

7. 推奨アクション

  1. src/atc/cli/backtest.pyOption A を実装(~40 LOC)。tests/test_maker_quotes.py に明示的テスト(crossing trade 後に resting quote に対して少なくとも 1 フィルがブックされることを assert)でゲート。
  2. mm_zero_fee_inside_spread の 62 日 Phase 1.5 バックテストを再ラン。
  3. その後でのみ、戦略自身のパラメータチューニングを検討。

src/atc/strategies/baselines/mm_zero_fee_inside_spread.py への変更は不要 — その quoting ロジックは内部的に一貫している。

アーティファクト

  • Report: data/derived/reports/backtest_ETH_JPY_2026-02-17_2026-04-19_mm_zero_fee_inside_spread_phase1_5_s3.json
  • Log: data/derived/reports/phase1_5_logs_s3/mm_zero_fee_inside_spread.log

関連ページ