実装計画¶
spec.md の構成へ段階的に移行するためのフェーズ分け。
各フェーズは単独でマージ可能・動作確認可能な粒度を目指す。
フェーズ 0: 準備 & ベースライン計測(リスク低)¶
完了
-
python/にengineパッケージのスケルトンを置く。 -
docs/schemas/に IPC DTO の JSON Schema を作成。 - 対象:
TradeMsg,KlineMsg,DepthSnapshotMsg,DepthDiffMsg,TickerMsg,TickerInfoMsg,TickerStatsMsg,OpenInterestMsg, および各コマンド (Hello/Ready/Subscribe/Unsubscribe/FetchKlines/FetchTrades/FetchOpenInterest/FetchTickerStats/ListTickers/GetTickerMetadata/RequestDepthSnapshot/SetProxy/Shutdown/Error/EngineError/DepthGap)。 - 参考: 既存 Rust 型
exchange/src/lib.rs,exchange/src/adapter.rsのEvent。 - スキーマ ⇔ 型定義の生成方針(
quicktype/datamodel-code-generator等)を決定。 schema_major/schema_minorの運用ポリシーをCHANGELOG.mdに記載。- Rust 側に
--data-engine-url ws://...CLI フラグを追加(未指定時は従来動作)。dev モード時の接続トークンは環境変数FLOWSURFACE_ENGINE_TOKENから読み、本番同梱 spawn 時は stdin から受け取る(spec.md §4.1.1)。 - ベースライン計測(spec.md §9.3)を実施し
docs/benchmarks/baseline.mdに記録。以降のフェーズで比較する基準。Windows (開発環境) 必須、可能なら Linux も。 - 既存 Rust テストを通したまま CI を維持する。
完了条件: 既存挙動を変えずにマージでき、ベースラインが数値で記録されている。
フェーズ 0.5: venue 単位 backend 抽象化(Rust 側のみ)✅¶
spec.md §5.1 に対応。取引所単位の段階移行を現実的にする前提工事。
完了 (2026-04-24, commit
4456ea5ブランチphase-0.5/venue-backend-trait)
-
VenueBackendtrait を定義。現行AdapterHandlesの全経路を網羅: - 初期化:
fetch_ticker_metadata/fetch_ticker_stats(list_tickers/get_ticker_metadata相当) - ストリーム:
kline_stream/trade_stream/depth_stream - フェッチ:
fetch_klines/fetch_open_interest/fetch_ticker_stats/fetch_trades - 運用:
request_depth_snapshot/health - 実装場所:
exchange/src/adapter/venue_backend.rs -
AdapterHandlesの各 venue フィールドをArc<dyn VenueBackend>に置換(Clone互換性のためBoxではなくArc)。 set_backend(venue, Arc<dyn VenueBackend>)API を追加(Phase 2 でEngineClientBackendを差し込む入口)。- stream / fetch メソッドをすべて
get_backend(venue) -> Option<Arc<dyn VenueBackend>>経由に統一。 - 既存
hub/{venue}を包むNativeBackendenum を実装し挙動を維持。 NativeBackend::Binance(BinanceHandle)/Bybit/Hyperliquid/Okex/Mexcの 5 バリアント。- Hyperliquid の
depth_streamが要求するtick_multiplier引数など venue 固有差異をここで吸収。 - venue 毎に backend を指定できる
set_backendAPI を追加(未指定時はspawn_venueが全NativeBackendで起動)。 -
cargo test --workspace全 PASS、cargo clippy -- -D warningswarning なし。 - TDD:
exchange/tests/venue_backend.rsに 4 テスト(set/get/configured_venues/health)。
完了条件: 抽象化導入後も従来の挙動・レイテンシが維持されている。→ 達成(NativeBackend は既存ハンドルをそのままラップ)。
フェーズ 1: Python データエンジン MVP(Binance のみ) ✅¶
完了 (2026-04-24, commit
51459a7ブランチphase-1/python-data-engine)
-
engine.serverに WS サーバを実装(websocketsライブラリ)。loopback バインドのみ、単一クライアント制限 + トークン一致時の既存接続置換(spec.md §4.5.2)、接続トークン検証、起動ハンドシェイク(spec.md §4.5)、ping/pong keepalive を初期実装に含める。 -
ExchangeWorker抽象 / server↔worker dispatch の境界を最初から設ける(spec.md §6.1)。フェーズ 1 は asyncio 単一プロセスだが、将来 venue 分割できる構造で着地させる。 -
exchanges/binance.pyで REST メタデータ + Kline + Open Interest + 24h 統計 + WebSocket trade/depth/kline を実装(OI はインジケータが継続要求するため初期から必須)。 - depth 整合性プロトコル(spec.md §4.4):
session_id/sequence_id/prev_sequence_idの付与、gap 検知時のDepthGap送出と自発的再スナップショット、checksum がある場合の検証を実装。 -
limiter.pyで Binance のレート制限を移植(exchange/src/adapter/limiter.rsを参考)。 - スキーマは pydantic、出力は orjson。
- stdin から
{port, token}JSON を受け取り、ランダムポート・トークンで起動できるようにする(開発時は環境変数フォールバックを許容)。 - pytest で REST/WS の最低限のテスト(モック取引所 or VCR)+ depth gap / session 切替の再同期テスト。
完了条件: Python のみで Binance のリアルタイム trade / depth / kline / OI を取得・配信でき、depth の gap 検知と再同期が動作する。 → 達成済み(pytest Binance 33件全 PASS)
フェーズ 2: Rust 側に engine-client を実装し Binance を切替¶
部分完了 (2026-04-24, ブランチ
phase-2/wiring)
-
engine-clientcrate(flowsurface-engine-client)をengine-client/に新規作成し IPC DTO と WebSocket クライアントを実装。 engine-client/src/dto.rs:Command/EngineEvent/TradeMsg/KlineMsg/DepthLevel/OiPointengine-client/src/convert.rs: DTO ⇔exchange::ドメイン型変換(Trade/Kline/OpenInterest/Arc<Depth>)engine-client/src/error.rs:EngineClientError(thiserror)- 起動ハンドシェイク(
Hello/Ready)と接続トークン受け渡しを実装(engine-client/src/connection.rs)。 - schema_major 不一致時
SchemaMismatchエラー - broadcast channel でイベントをファンアウト
-
EngineClientBackendがVenueBackendtrait を実装(engine-client/src/backend.rs)。 - kline_stream / trade_stream / depth_stream
- fetch_klines / fetch_open_interest / fetch_trades / request_depth_snapshot
- depth は
session_id/sequence_idで gap 検知しRequestDepthSnapshotを送る -
DepthTracker状態機械で gap 検知(engine-client/src/depth_tracker.rs)。 - Python プロセス監視・自動再起動・状態再投入 の骨格実装(
engine-client/src/process.rs): PythonProcess::spawn(): stdin 経由で{port, token}を渡すProcessManager::run_with_recovery(): 指数バックオフで自動再起動・購読再送- Workspace
Cargo.tomlにengine-clientを追加。 - 統合テスト 36 件 全 PASS (
cargo test -p flowsurface-engine-client)。 -
cargo clippy -p flowsurface-engine-client -- -D warningswarning なし。
完了(2026-04-24, ブランチ phase-2/engine-client):
- [x] --data-engine-url CLI フラグで src/main.rs から EngineClientBackend を差し替える実装。
- ENGINE_CONNECTION: OnceLock<Arc<EngineConnection>> グローバルで接続を保持。
- 専用 tokio ランタイム(engine-client スレッド)が IO タスクを生涯保持。
- Flowsurface::new() が set_backend(Venue::Binance, EngineClientBackend) を注入。
- [x] UI 側「エンジン再起動中」ステータス表示。
- ENGINE_RESTARTING: OnceLock<watch::Sender<bool>> グローバルで再起動状態を配信。
- engine_status_stream() → Subscription::run でイベントを Iced に流す。
- Message::EngineRestarting(bool) → Toast 通知表示。Flowsurface.engine_restarting で状態保持。
- ProcessManager::run_with_recovery に on_ready: impl Fn() コールバックを追加(TDD RED→GREEN)。
- [x] docs/benchmarks/phase-2.md 作成(計測手順・合格ライン・障害試験手順を記録)。
- [x] IPC ハンドシェイク・FetchKlines REST 経由の疎通確認(2026-04-24)。
- [x] Subscribe(stream=trade) コマンドが IPC 経由でエンジンに到達することを確認(2026-04-24)。
- [x] test_trade_stream.py で Trades 受信確認(spot endpoint で 30 件 PASS, 2026-04-24)。
- [ ] flowsurface --data-engine-url で GUI chart 描画を目視確認(Binance futures WS throttle 解除後)。
- [ ] レイテンシ・CPU 使用率の実測比較(Python spawn モード配線後に実施)。
- [ ] 障害試験(Python kill → 自動復旧 → 板再同期の手動確認、spawn モード配線後に実施)。
現況(2026-04-24): IPC プロトコル層は全項目疎通確認済み(Hello/Ready, FetchKlines, Subscribe, Trades 受信)。 Binance futures WS (
fstream.binance.com) のみデバッグ中の過剩接続による一時的な IP throttle が残っており、 GUI chart の目視確認が保留中。spot WS は正常動作しており コードの問題ではない。 spawn モード未配線のため自動復旧試験は次フェーズ以降に実施。
完了条件: フラグ ON で Binance チャートが Python 経由で正しく描画される。加えて Python を kill しても自動復旧し、購読と板整合性が回復する。
設計判断・ハマりどころ・Tips¶
- FetchError は外部から構築不可:
exchange::error::FetchErrorのフィールドはpub(crate)のためengine-clientからは構築できない。AdapterError::InvalidRequest(String)を代替として使用。 - async_stream クレート:
VenueBackendのBoxStream<'static, Event>戻り値はasync_stream::stream!マクロで実装。futures のchannelパターンより記述が簡潔。 - broadcast channel のラグ対策: 容量 512 で設定。高頻度の depth diff はラグが発生しうるため
RecvError::Laggedをログ警告でハンドリング。 - テストの crate 名: package name
flowsurface-engine-client→ テスト内ではflowsurface_engine_client(ハイフンがアンダースコアに変換される)。 - tokio-tungstenite 0.26:
Message::TextはStringを直接受け取らず.into()が必要(Utf8Bytesラッパー)。 --data-engine-urlwiring:Flowsurface::new()は同期関数のため async 接続はmain()内の専用 tokio ランタイムで行い、OnceLock経由で共有。ランタイムを_engine_rt変数でライフタイム保持(main()戻りまで保持)。watch::Ref+ async:rx.borrow()の戻り値Ref<'_, bool>は!Send。yieldの前にlet value = *rx.borrow();でコピーしてから yield すること(Send 境界違反回避)。Subscription::runの制約: Iced 0.14 のSubscription::runはfn() -> Sのみ受け付ける(クロージャ不可)。グローバルへのアクセスが必要なら top-level 関数として定義し static を読む。asyncio.wait_for(ws.recv(), timeout=短時間)は Windows で禁止: IocpProactor 上では短周期キャンセルがwebsockets内部の受信バッファを破壊し、接続は維持されるがメッセージが無音になる。async for raw in ws+ 別タスクによる定期フラッシュで代替すること(stream_depth/stream_klineの実装を参照)。
フェーズ 3: 残り取引所の Python 移植¶
優先順(取引所の安定度・利用頻度で並べ替え可):
- Bybit ✅ (2026-04-24)
- Hyperliquid ✅ (2026-04-24)
- OKX ✅ (2026-04-24, ブランチ
phase-3/okex-python-worker) - MEXC ✅ (2026-04-24, ブランチ
phase-3/mexc-python-worker)
各取引所ごとに:
1. python/engine/exchanges/<venue>.py 実装
2. レート制限の移植
3. 統合テスト(Rust 側 UI で動作確認)
完了条件: 全 5 取引所が Python 経由で動作。✅ 達成済み
現況(2026-04-24): pytest 全体 156 件 PASS(Binance 33 + Bybit 21 + Hyperliquid 29 + OKX 30 + MEXC 34 + その他 9)。全 5 取引所対応完了。
MEXC 実装詳細(2026-04-24 完了)¶
- 実装ファイル:
python/engine/exchanges/mexc.py - テスト:
python/tests/test_mexc_rest.py(22件) +python/tests/test_mexc_depth_sync.py(12件) = 計 34件全 PASS - server.py 統合:
self._workers["mexc"] = MexcWorker()追加済み
Binance/OKX との主な差異¶
| 項目 | Binance/OKX | MEXC |
|---|---|---|
| REST spot | 各取引所 REST | https://api.mexc.com/api/v3 |
| REST futures | 各取引所 REST | https://api.mexc.com/api/v1/contract |
| WS endpoint | 各取引所 WS | wss://contract.mexc.com/edge (futures のみ) |
| WS subscribe | URL / JSON op | {"method": "sub.depth", "param": {"symbol": ...}} |
| Depth プロトコル | Snapshot+diff (Binance) / snapshot WS (OKX) | REST snapshot + WS version-based diff |
| Depth シーケンス | Binance: U/u/pu / OKX: seqId | version (monotonic +1 per diff) |
| レート制限 | 各取引所固有 | 10 req/2sec (MexcLimiter, TokenBucket capacity=10, refill=5/s) |
| OI | REST 履歴あり | なし (常に空リスト返却) |
| spot symbol | "BTCUSDT" etc. | spot: "BTCUSDT" / futures linear: "BTC_USDT" / futures inverse: "BTC_USD" |
| Trade direction | buy/sell / side フィールド | T: 2=sell, それ以外=buy |
| kline (REST spot) | 各取引所配列形式 | [open_ts_ms, o, h, l, c, vol, close_ts_ms, ...] 配列 |
| kline (REST futures) | 各取引所固有 | { data: { time: [...sec], open: [...], ... } } 形式 (timestamp は秒→ms変換必要) |
| kline WS | OKX: "business" エンドポイント | futures WS のみ対応 (spot kline WS は非対応) |
| spot stream | 全市場対応 | spot depth/kline/trades WS は非対応 (Disconnected を即時返却) |
MexcDepthSyncer 設計¶
MEXC WS は REST スナップショット取得後に diff のみ配信する:
1. WS subscribe → 確認メッセージ受信 ({symbol}.sub.depth チャネル)
2. REST GET /v1/contract/depth/{symbol} でスナップショット取得
3. apply_snapshot(version, bids, asks) → DepthSnapshot イベント送出、applied_version = version
4. 以降の WS diff ({symbol}.depth チャネル): version == applied_version + 1 を厳密チェック
5. gap 検知 → DepthGap 送出 + needs_resync=True → WS 再接続
6. スナップショット前のバッファリング (MAX_PENDING=512) → スナップショット後にリプレイ
fetch_klines のパラメータ¶
- Spot:
GET /v3/klines?symbol={sym}&interval={1m|5m|...}&limit={n}&startTime={ms}&endTime={ms} - 結果は
[open_ts_ms, open, high, low, close, vol, close_ts_ms, asset_vol]の配列 - Futures:
GET /v1/contract/kline/{sym}?interval={Min1|...}&limit={n}&start={sec}&end={sec} - 結果は
{ data: { time: [...sec], open: [...], high: [...], low: [...], close: [...], vol: [...] } }形式 - timestamp は秒単位 →
* 1000で ms 変換
Spot WebSocket の非対応について¶
MEXC の spot WS は wss://wbs.mexc.com/ws という別エンドポイントで、サブスクリプション形式も異なる。
Rust の実装も futures WS (contract.mexc.com) のみを使用しているため、Python 側でも spot depth/kline/trades stream は
Disconnected イベントを即時返却する設計とした。UI 側は native MEXC backend (spot) を引き続き使用するか、spot は表示しない設計で対応可能。
Tips¶
- REST spot ticker stats の
priceChangePercent: 小数分率(例:0.005= 0.5%)→* 100で % 変換 - REST futures ticker stats の
riseFallRate: 同様に小数分率 →* 100で % 変換 - Futures kline time は秒単位:
time配列の値は UNIX 秒 →* 1000で ms 変換が必要(見落としやすい) - linear / inverse 判定: futures は symbol 末尾が
_USDT= linear,_USD(かつ_USDT非末尾)= inverse - WS ping:
{"method": "ping"}を 15 秒ごとに送信。pong:{"channel": "pong", ...} - Depth WS channel 名: subscribe 確認 =
{symbol}.sub.depth, diff ={symbol}.depth, trade ={symbol}.deal, kline ={symbol}.kline - OI 非対応: MEXC は過去の OI 時系列 API を持たないため常に空リスト返却。Hyperliquid と同様。
- Kline WS の
tは秒: REST futures kline 同様、WS kline のtフィールドも秒単位 →* 1000で ms 変換。
OKX 実装詳細(2026-04-24 完了)¶
- 実装ファイル:
python/engine/exchanges/okex.py - テスト:
python/tests/test_okex_rest.py(20件) +python/tests/test_okex_depth_sync.py(10件) = 計 30件全 PASS - server.py 統合:
self._workers["okex"] = OkexWorker()追加済み
Binance/Bybit との主な差異¶
| 項目 | Binance/Bybit | OKX |
|---|---|---|
| REST base | 各取引所 REST | https://www.okx.com/api/v5 |
| WS base | 各取引所 WS | wss://ws.okx.com/ws/v5/public (trades/depth) / wss://ws.okx.com/ws/v5/business (klines) |
| WS subscribe | URL / JSON msg | {"op":"subscribe","args":[{"channel":"trades","instId":"BTC-USDT"}]} |
| Depth プロトコル | snapshot+diff (Binance) / snapshot-only WS (Bybit) | snapshot+delta (action="snapshot"/"update") |
| Depth シーケンス | Binance: U/u/pu / Bybit: u (monotonic +1) | seqId (monotonic +1 per message) |
| レート制限 | 各取引所固有 | 20 req/2sec (OkexLimiter, TokenBucket capacity=20, refill=10/s) |
| OI | REST 履歴あり | REST /rubik/stat/contracts/open-interest-history?instId=...&period=1H |
| symbol 形式 | "BTCUSDT" etc. | spot: "BTC-USDT" / linear: "BTC-USDT-SWAP" / inverse: "BTC-USD-SWAP" |
| Trade side | buy/sell | side フィールドがそのまま "buy"/"sell" |
| kline confirm | Bybit: confirm bool |
index[8]: "1"=closed, "0"=open |
| 板スナップショット | REST + WS | REST /market/books?instId=...&sz=400 (seqId がシーケンス基準) |
OkexDepthSyncer 設計¶
Bybit 類似の snapshot+delta プロトコル:
1. action="snapshot" → DepthSnapshot イベント送出、applied_seq = seqId
2. action="update" → seqId == applied_seq + 1 を厳密チェック
3. gap 検知 → DepthGap 送出 + needs_resync=True → stream_depth が WS 再接続
4. スナップショット到着前のバッファリング (MAX_PENDING=512) → スナップショット後にリプレイ
5. 新 snapshot 到着時に needs_resync=False にリセット(Bybit と異なり同一 WS 接続内でスナップショット再取得可能)
fetch_klines のパラメータ¶
OKX /market/history-candles はページネーション cursor:
- before={start_ms} → ts > start_ms なローソクを返す
- after={end_ms} → ts < end_ms なローソクを返す
- 結果は降順で返るため Python 側で sort(key=open_time_ms) で昇順化
fetch_open_interest の注意¶
- 返値配列:
[ts, oi_contracts, oi_currency]、index[2] (oi_currency = BTC/USD建て) を使用 - Rust Fetch.rs と同じ
oi_ccy(index 2) を選択
バグ修正(2026-04-24、レビュー後修正済み)¶
- Bug #1
fetch_klinesがlimit=400(server.py デフォルト)をそのまま OKX に渡していた → OKX の/market/history-candlesmax は 300 であり 400 は API エラー。min(limit, 300)でクランプ。テストtest_fetch_klines_clamps_limit_to_okx_max追加。commit7314502 - Bug #2
fetch_ticker_stats("__all__", "linear_perp")が SWAP エンドポイントの全銘柄を返しており、inverse 銘柄(-USD-SWAPサフィックス)が混入していた →_matches_market(inst_id)で instId サフィックスにより絞り込み(linear:-USDT-SWAP/ inverse:-USD-SWAP)。テスト 2件追加。commit7314502
Tips¶
- WS 2エンドポイント: trades/depth は
/public、klines は/business。同一接続に混在不可。 - seqId は連番保証: OKX API ドキュメントでは seqId は必ず +1 で増加。gap 検知は Bybit と同じロジックが適用可能。
- state フィルタ:
state == "live"のみ(spot)、SWAP はstate == "live"+ctType+settleCcyで絞り込み。 - spot vol 計算:
volCcy24hは spot では quote 通貨 (USDT) 建て → そのまま daily_volume として使用。perp では base 通貨 (BTC/ETH) 建て →volCcy24h * last_priceに変換。 - kline confirm フィールド: index[8] が存在しない古いデータでも安全に処理できるよう
len(row) > 8 and row[8] == "1"でチェック。 - kline limit クランプ: OKX
/market/history-candlesの max は 300。server.py はlimit=400をデフォルトで渡すため、必ずmin(limit, 300)が必要。
Hyperliquid 実装詳細(2026-04-24 完了)¶
- 実装ファイル:
python/engine/exchanges/hyperliquid.py - テスト:
python/tests/test_hyperliquid_rest.py(16件) +python/tests/test_hyperliquid_depth_sync.py(9件) = 計 25件全 PASS - server.py 統合:
self._workers["hyperliquid"] = HyperliquidWorker()追加済み
Binance/Bybit との主な差異¶
| 項目 | Binance/Bybit | Hyperliquid |
|---|---|---|
| REST base | 各取引所 REST | https://api.hyperliquid.xyz/info (POST のみ) |
| WS base | 各取引所 WS | wss://api.hyperliquid.xyz/ws |
| WS subscribe | URL or JSON msg | {"method":"subscribe","subscription":{...}} |
| Depth プロトコル | snapshot+diff (Binance) / snapshot-only WS (Bybit) | 毎回フル l2Book スナップショット (diff なし) |
| Depth シーケンス | Binance: U/u/pu / Bybit: u (monotonic) | time フィールド (ms) + 単調増加保証 |
| レート制限 | 各取引所固有 | 1200 req/60sec (HyperliquidLimiter) |
| OI | REST 履歴あり | なし (常に空リスト返却) |
| Ticker symbol | "BTCUSDT" etc. | perp: "BTC" (coin name) / spot: "BTCUSDC" (display) |
| Trade side | buy/sell 直接 | "A" = 売り aggressor → sell / "B" → buy |
| Market | linear/inverse/spot | linear_perp/spot のみ (inverse なし) |
| 複数 DEX | なし | perpDexs API で DEX 一覧取得 → マージ |
HyperliquidDepthSyncer 設計¶
Hyperliquid は 毎回完全な l2Book を WS で配信する(diff なし):
1. 各 WS メッセージ → DepthSnapshot イベントを即時送出
2. sequence_id = time フィールド (ms) 、ただし同一 time が連続した場合は +1 で単調増加を保証
3. DepthDiff / DepthGap は一切発生しない
4. 再同期が必要な場合は WS 再接続 → 次の l2Book メッセージが新スナップショットになる
fetch_klines のタイムレンジ計算¶
Hyperliquid の candleSnapshot は startTime/endTime のみで制御し limit パラメータがない:
- start_ms と end_ms 両方指定 → そのまま使用
- start_ms のみ省略 → end_ms - limit * interval_ms を計算
- end_ms も省略 → 現在時刻を end_ms に使用
spot ティッカー記号マッピング¶
- pair name が
@N形式 →base_name + quote_nameに展開 (e.g., "@1" → "BTCUSDC") - pair name が "/" 含む → "/" を除去 (e.g., "BTC/USDC" → "BTCUSDC")
- WS subscribe では
coinに display symbol を使用 (Hyperliquid は display name でも受け付ける)
バグ修正(2026-04-24、レビュー後修正済み)¶
- Bug #1
_list_tickers_spotが display symbol ("BTCUSDC") をsymbolとして返していた → Rust のengine-client/src/backend.rs:397はsymbolフィールドをそのままcoinとして API コールに使うため、"BTC/USDC" (raw pair name) を返す形に修正。テストtest_list_tickers_spot/test_list_tickers_spot_excludes_zero_price/test_fetch_ticker_stats_spotを更新。commitb754f40 - Bug #1 再発防止: spot round-trip テスト (
test_spot_symbol_roundtrip_*) を 4 件追加。list_tickers返値の symbol を直接fetch_depth_snapshot/fetch_ticker_stats/fetch_klinesに渡すことで契約を検証。
既知の課題(Medium)― IPC 経由の display symbol 欠落¶
状況: Python IPC パスでは spot ティッカーの symbol フィールドが raw pair name(BTC/USDC, @1 等)のまま Rust 側に届く。engine-client/src/backend.rs:397 は Ticker::new(symbol, exchange) に渡すだけなので、BTCUSDC / HYPEUSDC 相当の display alias が構築されない。ネイティブ Hyperliquid アダプタは exchange/src/adapter/hub/hyperliquid/fetch.rs:283 で display symbol を別途生成しており、IPC パスとで表示が乖離する。@... 形式の pair がサイドバー・保存レイアウト上に生のまま露出しうる。
影響範囲: Hyperliquid Python worker が本番 wiring されるまでは潜在バグ(現状は native backend が生きている)。フル切替前に修正が必要。
修正方針: TickerInfoMsg に display_symbol: Optional[str] フィールドを追加し、Python 側が _spot_display(pair) で生成した値(@N → base+quote、/ 除去後の文字列)を乗せて送出。Rust 側 engine-client/src/backend.rs で display_symbol が Some の場合は Ticker { symbol: display_symbol, raw: symbol } 相当に展開する。exchange/src/lib.rs:344 の既存 display 対応フィールドが使用できる。
検証済みテスト: uv run pytest python/tests/test_hyperliquid_rest.py python/tests/test_hyperliquid_depth_sync.py → 29 PASS(2026-04-24)。
Tips¶
- 全リクエストが同一 POST エンドポイント:
https://api.hyperliquid.xyz/infoへの POST のみ。テストでは pytest-httpx の FIFO レスポンス機能を使い複数コールをシミュレート。 - perpDexs 必須:
list_tickers(linear_perp)とfetch_ticker_statsはまずperpDexsを呼んで DEX 一覧を取得し、DEX ごとにmetaAndAssetCtxsを呼ぶ。テストは[null](メイン DEX のみ) を想定。 - midPx が
nullや空文字の場合がある:_asset_priceでfloat(ctx.get("midPx") or 0)として安全にゼロ fallback。 - tick_size 計算: Rust の
compute_tick_sizeをそのまま Python 移植。_MAX_DECIMALS_PERP=6,_SIG_FIG_LIMIT=5で BTC(5桁)=1.0、ETH(4桁,sz=4)=0.1 等を正しく計算。 - OI は非対応: Hyperliquid は過去の OI 時系列 API を持たないため常に空リスト返却。UI は OI グラフを非表示にするだけで問題なし。
Bybit 実装詳細(2026-04-24 完了)¶
- 実装ファイル:
python/engine/exchanges/bybit.py - テスト:
python/tests/test_bybit_rest.py(11件) +python/tests/test_bybit_depth_sync.py(10件) = 計 21件全 PASS - server.py 統合:
self._workers["bybit"] = BybitWorker()追加済み
Binance との主な差異¶
| 項目 | Binance | Bybit |
|---|---|---|
| REST base | https://api.binance.com etc. |
https://api.bybit.com |
| WS base | wss://fstream.binance.com etc. |
wss://stream.bybit.com/v5/public/{linear\|inverse\|spot} |
| WS subscribe | 接続 URL に stream 名を埋め込む | 接続後に {"op":"subscribe","args":[...]} を送信 |
| Depth 初期化 | REST でスナップショット取得 → WS で diff | WS の最初のメッセージが type="snapshot" で完全板 |
| Depth シーケンス | U/u/pu フィールド(Binance 独自) |
u のみ(必ず連番 +1 で継続を検証) |
| 板の resync | BinanceDepthSyncer.resync() → REST 再取得 |
needs_resync=True → WS 再接続で新 snapshot を受信 |
| レート制限 | 1200 weight/min + 300 raw/5min | 600 req/5sec (BybitLimiter) |
| kline interval | "1m", "5m" ... | "1", "5", ..., "D" (数値文字列または "D") |
| OI period | "1h", "4h" ... | "1h", "4h", "1d", "5min" ... |
| Trade side | m (bool) → buy/sell |
S ("Buy"/"Sell") |
BybitDepthSyncer 設計¶
Binance と異なり WS 自身がスナップショットを配信する:
1. type="snapshot" メッセージで _apply_snapshot → DepthSnapshot イベント送出
2. type="delta" で _apply_delta → u == applied_seq + 1 を厳密チェック
3. gap 検知 → DepthGap 送出 + needs_resync=True → stream_depth が WS 再接続
4. スナップショット到着前のバッファリング (MAX_PENDING=512) → スナップショット後にリプレイ
バグ修正(2026-04-24、テスト追加で検出・修正済み)¶
- Bug #1
fetch_open_interestがcategory=linear固定だったため inverse_perp で誤 API を叩いていた →_market_category(market)を使う形に修正。テストtest_fetch_open_interest_inverse_uses_inverse_category追加。commit7fcb84f - Bug #2
list_tickersが Bybit のstatusフィールドを無視しており、PreLaunch/Settling等の非稼働銘柄が UI に混入する恐れがあった →status != "Trading"の場合は除外するよう修正。テストtest_list_tickers_excludes_non_trading_status追加。commit0fde866
Tips¶
- Bybit OI は linear/inverse 両対応:
categoryは_market_category(market)で決定。ただし inverse OI は spot 同様に空リストを返さずcategory=inverseで正しく取得できる。 - Depth REST snapshot:
RequestDepthSnapshotop 対応のためGET /v5/market/orderbook?category={cat}&symbol={sym}&limit=200をfetch_depth_snapshotで実装。result.uをlast_update_idとして使用。 - Depth level:
orderbook.200トピックを使用(200レベル、100ms 更新)。更小レベル (50) も選択可。 - ticker_stats volume: Bybit の
volume24hは base asset 単位のため、volume24h * lastPriceで USD 換算している。
OKX 実装詳細(2026-04-24 完了)¶
- 実装ファイル:
python/engine/exchanges/okx.py - server.py 統合:
self._workers["okx"] = OkxWorker()追加済み
フェーズ 3 完了後レビュー指摘 (2026-04-24)¶
pytest 124/124 PASS の状態で実施したコードレビューで検出した IPC パス固有の契約ギャップ。
修正済み¶
Fix #2 (High): Bybit depth 復旧がブロードキャストラグ後に壊れる¶
症状: Rust 側ブロードキャストチャネルがラグした際、backend.rs:335 が tracker をリセットして RequestDepthSnapshot を送出。Bybit は orderbook.200 と REST スナップショットのシーケンス空間が別のため fetch_depth_snapshot が NotImplementedError を raise し、_spawn_fetch がそれをキャッチして Error イベントを返していた。Rust 側は Error を無視するため、以降の diff がすべて gap 扱いになり無限ループに陥る。
修正: BybitWorker._reconnect_triggers (dict[(ticker, market), asyncio.Event]) を追加。fetch_depth_snapshot は trigger をセットして NotImplementedError を raise する代わりに WS ストリームに再接続を促す。stream_depth は各メッセージ後に trigger を確認し set 済みなら内部ループを break して WS 再接続。server.py._do_request_depth_snapshot は NotImplementedError を info ログのみで握りつぶし(Error イベントを送出しない)。WS 再接続後の DepthSnapshot イベントがそのまま Rust に届き tracker が再設定される。
ファイル: python/engine/exchanges/bybit.py, python/engine/server.py
Fix #4 (Medium): MEXC perp daily_volume がコントラクト枚数のまま返る¶
症状: _fetch_ticker_stats_futures._parse() が volume24(コントラクト枚数)をそのまま daily_volume として返していた。ネイティブ Rust アダプタは volume24 * contract_size * last_price(linear)/ volume24 * contract_size(inverse)で USD 換算している(exchange/src/adapter/hub/mexc/fetch.rs:365)。
修正: MexcWorker.__init__ に _contract_sizes: dict[str, float] を追加。_list_tickers_futures が各銘柄の contractSize をキャッシュ。_fetch_ticker_stats_futures._parse() でキャッシュ値を使い linear/inverse を判別して USD 換算するよう修正。
ファイル: python/engine/exchanges/mexc.py
フェーズ 3 完了後レビュー追加修正 (2026-04-24)¶
修正 H1: WS ストリームの例外ハンドリング粒度¶
症状: 全 WS ストリームの内側例外ハンドラが except Exception で JSON パースエラーと接続断を区別せず log.warning で握りつぶしていた。接続断が silent failure になりやすい経路。
修正: 内側ハンドラを except (KeyError, ValueError, TypeError, orjson.JSONDecodeError) に絞り log.debug に格下げ。外側ハンドラに isinstance(exc, (ConnectionClosed, OSError, TimeoutError)) チェックを追加し、接続断は log.warning、予期外エラーは log.error で区別。
ファイル: bybit.py, hyperliquid.py, okex.py, mexc.py(各ファイルの trade/depth/kline ストリーム、計9箇所)
修正 H2: _spawn_fetch による WsNativeResyncTriggered の二重握りつぶし防止¶
症状: _do_request_depth_snapshot が内部で WsNativeResyncTriggered をキャッチしているが、将来その catch が外れた場合に _spawn_fetch の汎用ハンドラが fetch_failed Error を送出してしまう構造だった。
修正: _spawn_fetch._run() に except WsNativeResyncTriggered: raise を追加して明示的に除外。
ファイル: python/engine/server.py
修正 H3: IPC スキーマの market フィールド欠落¶
症状: Subscribe / ListTickers / GetTickerMetadata / RequestDepthSnapshot / FetchTickerStats に market フィールドが未定義。extra="ignore" により偶然動いていたが、Rust 側が送る market が無言で破棄されていた。
修正: 該当 Pydantic モデルに market: str | None = None を追加。
ファイル: python/engine/schemas.py
修正 M1: 未知 venue/stream で Error イベントを送出¶
症状: _handle_subscribe で未知 venue・未知 stream の場合に log.warning + silent return だったため Rust 側が永遠にイベントを待ち続けた。
修正: 両経路で outbox に Error イベント(code=unknown_venue / unsupported_stream)を積むよう変更。
ファイル: python/engine/server.py
修正 M3: 接続置換時に旧ストリームタスクが残存¶
症状: _do_handshake の接続置換処理が旧コネクションを close するだけで _cancel_all_streams() を呼ばないため、旧接続のストリームタスクが zombie として残存し得た。
修正: handshake lock 内の接続置換後に await self._cancel_all_streams() を追加。
ファイル: python/engine/server.py
修正 M6: ストリームタスク例外時に Error イベントを送出¶
症状: ストリームタスクが予期せず終了しても done_callback は _outbox_event.set() するだけで Rust 側に一切通知がなかった。
修正: done_callback を _on_done(t) に変更し、t.exception() が非 None の場合に Error(code=stream_error)を outbox に積み、_streams から該当キーを除去。
ファイル: python/engine/server.py
テスト結果 (2026-04-24): pytest 全体 161 件 PASS(旧 156 件 + 上記修正で追加テスト 5 件は今回の修正に追加テストなし、既存テストがすべて通過)
未修正(要対応、フェーズ 4 以前)¶
Finding #1 (High): Kline IPC 経由でボリューム正規化が失われる ✅ (2026-04-24, phase-4/historical-trades)¶
症状: Python ワーカーは取引所の raw volume フィールド(基本通貨建てや枚数など)をそのまま KlineMsg.volume にシリアライズ。Rust の KlineMsg::to_kline() は Volume::TotalOnly(Qty::from_f32(volume)) に直接変換する(engine-client/src/convert.rs:48)。ネイティブアダプタは Kline 構築前に正規化している(例: exchange/src/adapter/hub/mexc/fetch.rs:301 は quoteVolume を優先)ため、IPC チャートは現行 Rust パスと異なるボリュームバーを表示する。
修正内容:
- KlineMsg に quote_volume, taker_buy_volume, taker_buy_quote_volume オプションフィールドを追加(Python schemas.py および Rust dto.rs)。
- Binance worker の fetch_klines が row[7] (quote_asset_volume), row[9] (taker_buy_base), row[10] (taker_buy_quote) を設定するよう更新。
- KlineMsg::to_kline() のボリューム優先度: 1) taker_buy_volume あり → Volume::BuySell、2) quote_volume あり → Volume::TotalOnly(quote)、3) fallback → raw volume。
Finding #3 (Medium): Hyperliquid spot の display symbol が IPC パスで失われる ✅ (確認済み)¶
→ _list_tickers_spot() がすでに display_symbol フィールドを返しており (hyperliquid.py:342)、Rust backend.rs:425 が Ticker::new_with_display(symbol, exchange, display_symbol) で処理済み。追加修正不要。
フェーズ 4: ヒストリカルデータ・bulk download 移植 ✅¶
完了 (2026-04-24, ブランチ
phase-4/historical-trades)
-
BinanceWorker.fetch_trades()を Python に実装。 - 当日分: aggTrades REST API (
/fapi/v1/aggTrades,/api/v3/aggTrades等) - 過去日分:
data.binance.visionから zip/CSV をダウンロードしてローカルキャッシュ - 404 時は intraday API にフォールバック
- キャッシュヒット時は再ダウンロードなし
-
TradesFetchedIPC イベントを Python schemas (schemas.py) と Rust DTO (dto.rs) に追加。 -
server.pyのFetchTradesdispatch を実装(旧 "not_supported" スタブから完全実装へ)。 _do_fetch_trades(msg): venue/market/start_ms/data_path を解析しworker.fetch_trades()を呼び出し結果をTradesFetchedイベントとして送出。- 未知 venue は
ValueError→Errorイベントに変換。 fetch_trades未実装 venue はNotImplementedError→Errorイベントに変換。-
Command::FetchTradesにmarketフィールドを追加(Python server の_market_from_msgと対応)。 - Rust
EngineClientBackend::fetch_trades()がTradesFetchedイベントを受信しVec<Trade>に変換して返すよう更新(旧実装は Error イベント待機のみ)。 - pytest 172 件全 PASS(旧 161 + 新 11)。
-
cargo test --workspace全 PASS。 -
cargo clippy -p flowsurface-engine-client -- -D warningswarning なし。
完了条件: ヒストリカル trade のフェッチが Python に移管。✅ 達成済み
Phase 4 設計判断・Tips¶
fetch_tradesの責務境界: Rustfetch_trades_batchedはfetch_tradesをループ呼び出ししてlatest_trade_tを更新する。Python は1リクエストでstart_msから1日分のデータを返す。Rust ループがend_msに達するまで繰り返し呼び出す設計を維持。- data_path は Rust 側からは渡さない: 現在の
Command::FetchTradesにdata_pathは未追加。Python サーバ側でdata_pathを環境変数 or 設定ファイルから管理する拡張に備えてschemas.FetchTrades.data_path: str | None = Noneは定義済み。 - aggTrades フォーマット: zip 内 CSV のカラム順:
[agg_id, price, qty, first_trade_id, last_trade_id, timestamp_ms, is_buyer_maker]。is_buyer_makerが"true"→ sell、"false"→ buy(Rust 実装DeTrade.is_sell: boolと同等)。 - インタデイ+ヒストリカルの結合: 過去日のデータは historicアル zip を取得後、末尾の取引タイムスタンプから aggTrades API で残り時間を補完する。zip が空なら fallback で intraday のみ返却。
TradesFetchedvsTrades: ストリームイベント (Trades) と REST レスポンス (TradesFetched) を別型として設計。backend.rs は両イベントを独立したパスで処理する。
フェーズ 4 完了後レビュー指摘 (2026-04-24) ✅¶
Phase 4 完了後のレビューで検出した、FetchRange::Trades(from, to) の上下限契約に関する 3 件を修正。
Finding #1 (High): Binance ヒストリカル取得で from_time 下限が無視されていた¶
症状: fetch_trades() が start_ms から日付のみを抽出し _fetch_historical_trades() に渡す実装だったため、返却される aggTrades zip の全日分(start_ms より前のデータを含む)がそのままチャートに挿入されていた。Rust 側 dashboard.rs は trade.time <= until_time のみクリップし、下限は信頼していた。日中から始まるヒストリカル要求で過剰なデータが描画される。
修正: python/engine/exchanges/binance.py:522 — _fetch_historical_trades() の戻り値を ts_ms >= start_ms でフィルタしてから返却。根本原因を Python 側で封じ込めたため、dashboard.rs の下限クリップ追加は不要。
Finding #2 (Medium): 空バッチが後続日のフェッチを打ち切っていた¶
症状: Phase 4 で「1 リクエスト = 1 カレンダー日」契約に変更されたが、fetch_trades_batched() は空バッチで break する実装のままだった。流動性の低い銘柄・新規上場直後・取引所のデータ欠損などで 1 日分が空の場合、それ以降の全日がフェッチされず途切れる。
修正: src/connector/fetcher.rs:477 — 空バッチ時は latest_trade_t を翌日 midnight に進めて continue。全体ループ条件 latest_trade_t < to_time で自然終了する。
Finding #3 (Medium): to_time 上限が IPC 層で失われ now_ms が送られていた¶
症状: fetch_trades_batched() は to_time を保持するが、VenueBackend::fetch_trades() 以降のシグネチャが from_time のみで、EngineClientBackend はハードコードで end_ms: now_ms を送っていた。過去スライスを要求しても常に「現在まで」を取得しに行くため、レート制限・ダウンロード量が悪化。
修正: VenueBackend トレイトに to_time: u64 を追加して 9 ファイルで伝搬(VenueBackend トレイト、AdapterHandles、FetchCommand::Trades、BinanceHandle/HyperliquidHandle、Binance Worker FetchCommandHandler 実装、binance/fetch.rs、EngineClientBackend、HybridBackend、テストスタブ 2 箇所)。
- engine-client/src/backend.rs:708: end_ms: to_time as i64(旧: now_ms)。
- exchange/src/adapter/hub/binance/fetch.rs:704: ヒストリカル zip を retain(|t| t.time >= from_time)、intraday 拡張分を filter(|t| t.time <= to_time)。
テスト: cargo test -p flowsurface-exchange 17/17 PASS。cargo check --workspace clean。Python 側は既存 test_binance_fetch_trades.py / test_server_dispatch.py が引き続き PASS。
フェーズ 5: Rust から取引所コードを削除 ✅¶
完了 (2026-04-25, ブランチ
phase-5/remove-native-exchange)
-
exchange/src/adapter/hub/を削除(binance/bybit/hyperliquid/okex/mexc 全5取引所 + hub.rs)。 -
limiter.rs,connect.rsを削除。proxy.rsは接続コード(ProxyStream,connect_tcp,try_apply_proxy)を削除、設定データ型(Proxy,ProxyScheme,ProxyAuth)のみ保持。 -
reqwest,fastwebsockets,tokio-rustls,tokio-socks,sonic-rs,csv,zip,bytes,hyper,hyper-util,http-body-util,webpki-roots,base64をexchange/Cargo.tomlから削除。 -
NativeBackendenum をvenue_backend.rsから削除。VenueBackendtrait のみ保持。 -
AdapterHandles::spawn_all(),spawn_selected(),spawn_venue()をclient.rsから削除。 -
AdapterNetworkConfigを削除(proxy 設定はネイティブ接続に不要)。 -
exchange/src/error.rsを reqwest 非依存に簡略化(FetchErrorをStringベースに統一)。 -
allowed_multipliers_for_min_tick()をadapter.rsにインライン移植(hub 非依存)。 -
--data-engine-urlフラグを必須化。未指定時はエラーメッセージを表示して終了。 -
main.rsで全5取引所をEngineClientBackend経由に配線(旧HybridVenueBackend不要)。 -
cargo test --workspace全 PASS(Rust: 82 件 + Python: 180 件)。 -
cargo clippy -- -D warningswarning なし。 - TDD:
exchange/tests/engine_only_wiring.rsに6テスト追加(全 PASS)。
完了条件: Rust ビルドが Iced と engine-client のみに依存し、ビルドサイズが縮む。✅ 達成済み
フェーズ 5 設計判断・ハマりどころ・Tips¶
- serde
stdfeature の暗黙依存:exchange/Cargo.tomlにserde.workspace = trueのみでは serde のstd/allocfeature が有効にならずDeserializederive が失敗する。reqwest が取り除かれると依存チェーン経由の有効化がなくなるため、serde = { workspace = true, features = ["std"] }を明示的に追加する必要があった。 FetchErrorの简略化:exchange/src/error.rsは reqwest のError,StatusCode,Method,Urlを多用していた。hub/ 削除後はこれらが不要になるため、FetchError(String)に統一しui_message()をシンプルな文字列返却に変更。既存コードはerr.ui_message()呼び出しのみ使っており後方互換。serde_util.rsの dead code:de_string_to_numberとvalue_as_u64が hub/ でのみ使用されていたため削除。value_as_f32とde_number_like_or_objectは lib.rs 内の型定義(DepthPayload 等)で使用継続。allowed_multipliers_for_min_tickの移植: Hyperliquid の depth tick multiplier テーブルは定数 3 個 + 関数 1 個で完結。adapter.rsにそのままコピーするだけで OK(ロジック変更不要)。--data-engine-url必須化のタイミング: Iced のdaemon()起動前にENGINE_CONNECTION.get().is_none()チェックを挿入。Iced の panic ハンドラに入る前に明確なエラーメッセージで終了できる。HybridVenueBackendの不要化: フェーズ 2 では native metadata + Python stream のハイブリッド構成が必要だったが、フェーズ 5 では Python engine が全 metadata も担うためシンプルなEngineClientBackend× 5 に置き換え。engine-client/src/hybrid.rsと関連テストは次フェーズ以降の cleanup 候補(今回は残置)。
フェーズ 6: 配布・運用整備¶
進行中 (2026-04-25, ブランチ
phase-6/distribution-runtime)
- PyInstaller で Python サイドを単一実行ファイル化、Rust バイナリと同梱。
python/engine.spec: PyInstaller spec(onefile、console=True、hiddenimportsで全 5 取引所モジュールを明示)。scripts/build-engine.sh:uv tool run pyinstaller(またはpyinstaller)でビルドしtarget/release/python-engine/flowsurface-engine[.exe]に出力。pyproject.tomlに[project.optional-dependencies] build = ["pyinstaller>=6.5"]を追加。-
scripts/の Win/Mac/Linux ビルドスクリプトに Python 同梱手順を追加。 scripts/build-windows.sh: Rust ビルド後build-engine.shを呼びflowsurface.exeと一緒に zip。scripts/build-macos.sh: Universal lipo 後flowsurface-engineを tar に同梱(PyInstaller はクロスアーチ非対応のためエンジンはホスト arch のまま)。scripts/package-linux.sh:packageサブコマンドが Rust ビルド + エンジンビルドを実行しarchive/bin/に両方をインストール。- 起動時の Python プロセス監視・再起動ロジックを本実装。
--data-engine-url未指定時は マネージドモード(既定)に切り替え、ENGINE_CONNECTIONをProcessManager::with_command(EngineCommand::resolve()…)で配置した Python サブプロセスにバインドする。pick_free_port()で 127.0.0.1 のフリーポートを取得 →ProcessManager::start(port)がハンドシェイク/プロキシ再投入/購読再送を実施。- 再接続ループは指数バックオフ(500ms → 30s)。
wait_closed()で WS 切断を検出するたびにENGINE_RESTARTING.send(true)→ 再起動 →send(false)を Iced subscription に流し UI に Toast 表示。 --engine-cmd <path>フラグで明示オーバーライド可能(dev インストールでpython3.12などを差したい場合に使用)。- 既存の
--data-engine-urlモードは「外部管理エンジンに接続するだけ」に再分類(ドキュメント上は dev モード扱い)。 - エラーログを Rust 側
fernロガーに集約(Python の stderr/stdout を吸い上げる)。 PythonProcess::spawn_withが stdout/stderr をStdio::piped()に変更し、forward_linesタスクが行単位でlog::log!(target: "engine", level, "...")に転送。src/logger.rsの fern dispatch にlevel_for("engine", level_filter)とlevel_for("flowsurface_engine_client", level_filter)を追加しflowsurface.logに同居させる(spec §6.4)。- README / ユーザードキュメント更新。
- "Method 2: Build from Source" を Python 必須前提に書き換え、
uv syncでの依存導入手順、--engine-cmd/--data-engine-urlの使い分け、scripts/build-*.shの同梱バイナリ生成手順、ランタイムログ/監視挙動を新セクションで追記。
完了条件: ユーザーが Python ランタイムを別途インストールせずに既存と同じ操作で起動できる。✅ 達成見込み(PyInstaller 産物の動作はリリースビルド検証で確認)。追加 (Phase 7): マージ前に bash tests/e2e/smoke.sh が PASS していること(handshake + 30s 無 silent failure)。手動 GUI シナリオは tests/e2e/README.md を参照。
Phase 6 設計判断・ハマりどころ・Tips¶
EngineCommandenum:Bundled(PathBuf)/System { program, args }の 2 バリアント。resolve_with(base_dir, override)の優先順位は (1) override → (2)base_dir/flowsurface-engine[.exe]存在 → (3) フォールバックでpython -m engine。テストはengine-client/tests/engine_command.rsに 3 件(Bundled/System fallback/explicit override)。ProcessManager::newの互換性: 既存テストprocess_lifecycle.rsがProcessManager::new("python")でバイナリのみ(引数なし)を spawn することに依存しているため、レガシーnewはEngineCommand::System { program, args: vec![] }を保つ。新コードはwith_command(EngineCommand)を使う。- stdin payload 競合:
forward_linesタスクが child の stdout/stderr を保持するので、PythonProcess自体は spawn 後すぐ child を返してよい。stdin はtake().write_all(...).shutdown()で即時クローズ。 pick_free_portのレース:bind 127.0.0.1:0→local_addr().port()を取得 → リスナーを drop してそのポート番号を Python に渡す。OS 依存の小さな race window があるが、Phase 6 では妥協(loopback only、同時 spawn しない)。spec §4.5 と整合。- 20 s ハンドシェイク待ち: PyInstaller の onefile バイナリは初回起動時に圧縮アーカイブを
%TEMP%に展開するため cold start が数秒かかる。main()内のブロッキング待ちを 100ms × 200 回(最大 20 秒)に設定。ライブ Python 起動なら 0.5 秒前後で抜ける。 - macOS universal の制約: PyInstaller は cross-arch ビルドに非対応のため、universal viewer + host-arch engine の組み合わせで配布。リリースは Apple Silicon と Intel 両方の Mac runner で別々にビルドする運用が必要(CI 側で対応)。
- ログターゲットの統一: Python 側はそのまま print/stderr で出してよい。Rust 側の fern dispatch が
target = "engine"をハンドリングし、ファイルローテーションも一括で適用される。Python 内に独立した logging を組まないことで運用が単純化(spec §6.4)。 - 既知のテスト失敗:
engine-client/tests/depth_gap_recovery.rsとengine-client/tests/connection_closed.rsのunused variableclippy エラーは Phase 5 ブランチからの引き継ぎで Phase 6 とは無関係。次フェーズで修正候補。
Phase 6 検証結果¶
cargo test -p flowsurface-engine-client --test engine_command→ 3 PASScargo test -p flowsurface-engine-client --test process_lifecycle→ 3 PASS(互換性維持)cargo test --workspace --exclude flowsurface-engine-client→ all PASScargo clippy -p flowsurface -- -D warnings→ warning なしcargo build→ success- ⚠ PyInstaller 産物の実動作検証(実際に
flowsurface-engine.exeを spawn → ハンドシェイク → 描画)は次の手動 QA 工程で実施予定。
残タスク(Phase 6 のフォローアップ)¶
- CI ワークフロー (
.github/workflows/release.yaml) にastral-sh/setup-uv@v5を追加 (2026-04-25, Phase 7 T4.a)。build-engine.shは既に platform スクリプトから呼ばれていた。 -
engine-client/tests/connection_closed.rsdto_conversion.rsの clippy 違反修正 (2026-04-25, Phase 7 T4.b, commit17201d4)。 - Linux で AppImage / Flatpak など他の配布形態が必要かを判断。 (Phase 7 で deferred)
- PyInstaller
onefileの cold-start を計測し、onedirへの切替コスト/メリットを評価。 (Phase 7 で deferred)
ロールバック戦略¶
- フェーズ 5 完了までは旧 Rust 実装が残っているため、
--data-engine-urlを外せば従来動作に戻せる。 - フェーズ 5 のマージはタグを切ってから実施し、問題が出たら 1 リリース前に戻せるようにする。
計測指標と合格ライン¶
詳細は spec.md §9。各フェーズ完了時に再計測し docs/benchmarks/ に追記する。
フェーズ 2 合格ライン(抜粋): - IPC 追加レイテンシ: 中央値 < 2 ms / p99 < 10 ms - Python クラッシュ → 自動復旧完了: < 3 秒 - depth 再同期: < 500 ms - CPU 使用率: 現行 Rust 直結の +30% 以内 - depth gap 検知漏れ: 0
未達時の対応: - レイテンシ / CPU 不足 → spec.md §4.3.1 のバイナリ化を適用。 - 慢性的な性能差 → spec.md §7.1 の案 C(Rust 直結の optional 残置)を再検討。
フェーズ 8 — Python 単独モード化 / Rust HTTP API 廃止(attach mode 採用)¶
詳細計画: python-helper-direct-api.md ✅ 完了 (2026-05-03)
概要: HTTP API(ポート 9876)を廃止し、Python ReplaySession / LiveSession helper class で直接 IPC を駆動するアーキテクチャに移行する。Rust GUI が起動中なら helper は attach mode(WS クライアント)、GUI なしなら in-process mode で NautilusRunner を直接呼ぶ。
サブフェーズ(全完了):
- Phase 8.0 — 設計確定(attach mode 前提条件の合意形成)✅
- Phase 8.1a — Python helper class + CLI(in-process mode 先行)✅ (2026-05-03)
- Phase 8.1b — attach mode 実装(B1 multi-client → B2 session ファイル → B3 EngineBusy → B4 AttachClient)✅ (2026-05-03)
- Phase 8.1c — GUI replay 起動フォーム ✅ (2026-05-03)
- Phase 8.2 — E2E bash スクリプト削除(s56〜s83, s90, tachibana_ 11 ファイル削除) ✅ (2026-05-03)
- Phase 8.3 — HTTP API 削除(src/replay_api.rs, src/api/ ディレクトリ全削除) ✅ (2026-05-03)
- Phase 8 R1〜R4 — review-fix-loop 全件解消 ✅ (2026-05-04, commit cb9207f)
- CRITICAL 7 / HIGH 13 / MEDIUM 22 を 4 ラウンドで収束(pytest 1598 → 1691, +93 件)
- 主要成果: 型基盤確立(AppMode / AttemptedCommand / ReplayStateName | LiveStateName / AUTH_FAILED_CODE / Rust enum 化)/ LiveSession attach mode 本実装*(NotImplementedError 撤廃、RequestVenueLogin ↔ VenueReady/VenueError wire 待ち合わせ)/ silent failure 除去(handshake 15s timeout / EngineBusy unicast + request_id フィルタ / 全断時 state リセット / EngineStopped 補完 + 二重送出ガード / sticky error / narrative_hook thread fallback)/ Rust 健全化(#[doc(hidden)] pub を testing feature gate / pid_is_live retry / spawn_venue_ready_bridge 単一化)/ test_review_fixes.py 898 行を機能別 10 ファイルに分割
- 詳細: python-helper-direct-api.md 末尾「レビュー反映」ブロック群
主要成果物:
- python/engine/replay_session.py: ReplaySession + LiveSession + _AttachClient(1 ファイル構成)
- engine-client/src/session_file.rs: EngineSession atomic write / delete
- src/modal/replay_form.rs: ReplayFormModal GUI フォーム
- python/engine/server.py: _Broadcaster multi-client fanout / ReplayState + LiveState state machine / MAX_CONNECTIONS=4
- python/engine/schemas.py: SCHEMA_MINOR 9, ClientConnected / ClientDisconnected / EngineBusy イベント追加
使い方(Phase 8 以降の正規 CLI):