Bot

開発記録#186(2025/4/20)MMbot開発ログ5「Bybit MMbot:未約定キャンセル+ログ&PnL集計まで実装【Python×pybotters×loguru】」

2025年4月20日

2025年4月20日

前回の記事では 「本番 API 署名/証拠金チェック」 を整え、
板監視 → スプレッド検知 → 指値発注 → 約定ポーリング → Slack 報告まで到達しました。
今回はさらに リスク管理と運用ログの土台 を作り込み、Bot の“実戦仕様”へ一段引き上げた開発ログです。


🎯 今回のゴール

  1. 未約定キャンセル
    /v5/order/cancel を組み込み、タイムアウトした注文を自動で取り消す。
  2. ログ基盤 (loguru)
    コンソール+日次ローテーションファイル、7 日保持。
  3. 取引履歴 & P n L
    約定結果を SQLite に保存し、サイクル単位で損益を即時計算。

🛠️ 実装ハイライト

機能追加・変更点コア関数
未約定キャンセルwait_for_fill() がタイムアウト時に cancel_order() を呼び出しcancel_order()
日次ログlogurumm_bot_YYYYMMDD.log を自動生成・ZIP 圧縮で 7 日保持logger.add()
P n L 集計SQLite テーブル trades(ts, symbol, side, qty, price) を作成し、Buy/Sell ペアで差額計算record_trade() / calc_pnl()
環境スイッチEXCHANGE_MODE = "mainnet"|"testnet" で一括切替先頭の定数

🔍 未約定キャンセルの流れ

flowchart TD
  A[place_limit_order()] --> B(wait_for_fill)
  B --> |Filled| C[record_trade()]
  B --> |Timeout| D[cancel_order()]
  D --> E[Slack: Canceled] --> F[next cycle]

30 秒以内に Filled にならないと即キャンセル。部分約定は次フェーズで対応予定。


📝 loguru 設定抜粋

logger.remove()
logger.add(lambda m: print(m, end=""))             # コンソール
logger.add("mm_bot_{time:YYYYMMDD}.log",
           rotation="00:00",
           retention="7 days",
           compression="zip")
  • rotation:UTC0 時に日付ロール
  • retention:ZIP 圧縮後 7 日で自動削除
  • levelDEBUG 以上をファイルへ、INFO 以上をコンソールへ(デフォルト設定)

💾 SQLite スキーマ

CREATE TABLE IF NOT EXISTS trades(
  ts     INTEGER,   -- Unix ms
  symbol TEXT,
  side   TEXT,      -- 'Buy' / 'Sell'
  qty    REAL,
  price  REAL
);
  • 記録:Filled ごとに 1 行 INSERT
  • PnL:直近 2 行が Buy & Sell ペアなら (sell - buy) * qty を即計算
  • バックアップmmtrades.db を Git 管理外に置く

🐛 つまずきポイントと対処

エラー / 症状原因解決
retCode‑110007 InsufficientAB証拠金不足、本番口座で USDT 残高ゼロ1. テストネットに切替
2. Faucet で資金追加
3. --lot を 0.001 など最小に
例外スタックが毎サイクル表示logger.exception() が巨大な traceback を吐く運用時は logger.error(str(e)) に変更、ファイルのみフルログ
KeyboardInterrupt で DB 未 closeGraceful shutdown 未実装finally: conn.close() を追加予定
ログファイルが肥大化DEBUG 量が多い本番では logger.level("DEBUG").remove() で抑制

🏃‍♂️ テスト手順チェックリスト

  • 1.テストネットモード
EXCHANGE_MODE = "testnet"
  • 2.API キー & 残高
    • Key に Trade 権限
    • テスト USDT をチャージ
  • 3.超小ロットで発注
python MMbotbybit.py --lot 0.001
  • 4.Filled → PnL
    • mmtrades.db に 2 行 (Buy/Sell)
    • CONSOLE: cycle end pnl=…
    • Slack に埋まり状況+PnL
  • 5.ログ確認
    • mm_bot_YYYYMMDD.log が生成
    • 翌日ローテーション+ZIP 圧縮

✅ ここまでで動く機能

機能状態
WS 板監視/スプレッド検知
指値 2 本発注
約定ポーリング
未約定キャンセル
Slack 実行レポート
ファイルログ(loguru)
PnL 集計(SQLite)

🔜 次フェーズ予定

フェーズ強化内容
ヘッジ片側だけ Filled 時に成行 or 逆指値でクローズ
モニタリングPrometheus + Grafana で Bot 稼働状況を可視化
パラメータ最適化スプレッド閾値 / ロット量を動的変更 (Volatility Aware)
バックテスト互換リアルタイムロジックを backtest モジュールへスワップ可能に

✍️ まとめ

  • タイムアウト → 自動キャンセル が入ったことで、板が薄いときの“置きっぱなしリスク”を排除。
  • loguru + SQLite で「いつ何が起きたか」「いくら稼いだか」を即座に追跡可能に。
  • まずはテストネットで Filled → PnL が流れるまでを反復し、本番投入は余力とレバレッジを厳守する。

次回は 部分約定ヘッジGrafana ダッシュボード を追加し、運用監視まで一気に仕上げる予定です。

👇疑問点の整理と確認

質問リストの整理表

#カテゴリ質問内容(要約)キーワード目的/掘り下げポイント
1リスク管理未約定キャンセルのコード構造とスクリプト全体での役割wait_for_fill / cancel_order / timeout① Fill ⇄ Cancel 判定ロジック
② 発注レイヤとの関係性
③ タイムアウト設計指針
2ロギングloguru のログ出力メカニズムとライブラリの立ち位置logger.add / rotation / retention① 標準 logging との違い
② ローテーション・圧縮・JSON 出力
③ 観測性スタック(Loki 等)との連携
3データベース基礎SQLite とは何かembedded RDB / serverless / ACID① ファイル一体型の仕組み
② トランザクション保証範囲
③ WAL モードの特徴
4DB 選定理由なぜ SQLite を採用したか/他の選択肢Postgres / InfluxDB / Redis① 単一 Bot × 軽量運用の適合性
② データ量・同時書込の限界
③ 将来スケール時の移行戦略

見落としがちな追加視点 ✔️

  1. Fill‑Cancel Race
    同時刻で Filled → Canceled が返る競合。キャンセル返却後に再照会して二重確認。
  2. Idempotency (orderLinkId)
    API 再送で重複注文が入らないよう、各リクエストに UUID を添付。
  3. Rate‑Limit & Backoff
    v5 REST 100 req/分上限。指数バックオフまたはバケットアルゴリズムで整流。
  4. Clock Sync
    署名 timestamp が±1 s 以上ズレると認証失敗。起動時に /v5/public/time 参照か NTPd。
  5. PnL 精度(手数料・資金調達費)
    取引手数料とファンディングを DB 列へ追加し、純損益を算出。
  6. Durability 設定
    PRAGMA journal_mode=WAL + synchronous=NORMAL で停電耐性と速度をバランス。
  7. Observability パイプライン
    loguru → Loki → Grafana でメトリクス/アラートを一本化する青写真。
  8. 水平展開時の共通 DB
    Bot が複数台になる場合、PostgreSQL へ移行 or “1 Bot = 1 SQLite ファイル” でローカル保持。

未約定キャンセル」 ・ 「loguru ログ基盤」 ・ 「SQLite 取引履歴 & PnL」 を、
コード断片と設計思想の両面から“根っこ”に遡って解説します。
最後に「見落としやすい追加観点」も列挙したので、再学習ノートとして活用してください。


1. 未約定キャンセル ―― リスク管理の“最後の砦”

1‑1 コードの粒度で追う

async def wait_for_fill(...):
    for _ in range(int(timeout / interval)):
        # ❶ 注文ステータスをポーリング
        if status == "Filled":
            return True, price      # ❺ 正常約定
        await asyncio.sleep(interval)
    # ❷ タイムアウト(=未約定)
    await cancel_order(...)         # ❸ キャンセル発火
    return False, 0.0               # ❹ 呼び出し元へ失敗通知
  • ❶→❺:Filled まで busy‑wait せずに interval ごとに休憩。
  • timeout はプライシング α(板厚・流動性)に合わせて設計。
  • cancel_order()/v5/order/cancel POST – 純粋な副作用関数で戻り値は bool(失敗しても上位を止めない)。
  • :戻り値は run_loop() に伝搬 → DB 記録・Slack 通知に利用。

1‑2 スクリプト全体での役割

signal検知 → place_limit_order() ─┐
                                  │   (約定監視レイヤ)
                                  ├─ wait_for_fill() ──┬─ Filled → record_trade()
                                  │                    └─ Timeout → cancel_order()
                                  │
                                  └→ Slack / PnL / 次サイクル
  • “発注したら必ず出口を決める”――マーケットメイク最大の原則。
  • タイムアウト値は 板流動性 × スプレッド幅 × 約定率 の経験値で決定。
  • ロジックを 1 関数に閉じ込めることで、後で「部分約定ヘッジ」や「IOC 注文」と差し替えても呼び出し側は無変更( インタフェース分離原則 )。

2. loguru ―― “標準 logging の 80 % を 1 行で”

2‑1 loguru とは

項目logurulogging(標準)
セットアップlogger.add("file.log") 1 行getLogger()/handlers/formatters
段階的ローテーション時刻・サイズ・関数任意独自ハンドラを書く必要
Sinks(出力先)ファイル, 標準出力, HTTP, λ基本は Handler 実装
構造化ログserialize=True で JSON自前フォーマット
Backtrace直近エラーの遡及ログ(診断用)なし

= “logging の日常的ボイラープレートを封殺する syntactic‑sugar ライブラリ”

2‑2 今回の設定ポイント

logger.add("mm_bot_{time}.log",
           rotation="00:00",       # 毎日 0:00
           retention="7 days",     # 7日後自動削除
           compression="zip")      # 古いログを ZIP
  • rotationretention が 1 行で済む便利さが採用理由。
  • λ sinkprint() 同等の即時コンソール出力を追加。
  • 将来 Promtail → Grafana Loki へ流す場合も serialize=True だけで JSON に切り替え可。

3. SQLite ―― “組み込み型”リレーショナル DB

3‑1 そもそも何か?

  • ヘッダファイル+C ライブラリで完結する サーバーレス RDBMS
  • データは 単一ファイル.db)に格納。
  • 1 プロセス・1 ユーザー前提で ACID 保証(WAL モードで軽い同時書込も可)。

3‑2 MMbot に採用した理由

要求SQLite が丁度いい
超軽量バイナリ≒600 KiB。Docker イメージ肥大しない
ゼロ運用MySQL/PostgreSQL の起動・認証不要
ACID 必須Filled ペアがトランザクションで一貫性記録
持ち運びテストネット ↔ 本番で単にファイルコピー

3‑3 他の選択肢

代替いつ使うか
PostgreSQL複数 Bot/複数サーバで共有、JOIN が複雑、同時書込多い
TimescaleDB / InfluxDB高頻度タイムシリーズ(ms 板データを永続化)
RedisK‑line キャッシュや発注キューなど“揮発 + 高速”用途
Parquet + DuckDB後処理 (オフライン解析) にカラム圧縮で高効率

4. 見落としがちな追加視点 🧐

視点なぜ重要かヒント
“Fill‑Cancel Race”同一ミリ秒で Filled→Canceled が来る可能性cancel_order() 受信後に再度 /v5/order/realtime 確認
Idempotency再送で二重注文を防ぐorderLinkId を UUID で付与し、重複 POST を無害化
Rate Limit & Backoffv5 REST 100 req/分asyncio.sleep(random.expovariate()) で指数待ち
Out‑of‑Sync Clock署名 timestamp が±1 s 以上ズレるNTPd or GET /v5/public/time で起動時補正
PnL 精度部分約定・手数料・資金調達費DB スキーマに execQty/fee 列を追加して正味計算
Durability電源断で WAL が消えるPRAGMA journal_mode=WAL, synchronous=NORMAL
Observability24/7 運用の死活監視loguru → Loki + Grafana で AlertManager を設定
Concurrency将来 Bot を水平展開DB を PostgreSQL に移行する、または「1 Bot = 1 DB ファイル」方式で分散

まとめ & 次アクション

  1. 未約定キャンセル損失限定 の核心。タイムアウト値とキャンセル後の“二度引っ掛け”を必ず検証。
  2. loguru は“⾃動ローテーション付き print”と捉えると導入コストが激減。構造化 JSON 出力まで視野に。
  3. SQLite は“単一プロセス × 少数トランザクション”なら無敵。
    • リモート複数 Bot に拡大する前に PostgreSQL + Timescale 等への移行計画を持つ。
  4. 追加視点リストのうち IdempotencyClock Sync は早期に対応すると後が楽。

👇ラジオで話したこと

🎙️ Yodaka Radio ― 開発記録 #186MMbot開発ログ5「未約定キャンセル+ログ&PnL集計まで実装」


0. オープニング 

こんにちは、仮想通貨bot開発ラジオ、 Yodaka です。
前回は「本番 API 署名」と「証拠金チェック」まで整え、
板監視 → スプレッド検知 → 指値発注 → 約定ポーリング → Slack 報告
という“ひとまず回る”ところまで来ました。

でも―― 「発注しっぱなし」「損益が見えない」「ログが散らかる」
今日はこの3つを一気に片づけます。


1. 今日のゴール

  1. 未約定キャンセル
    30 秒で埋まらない指値は自動で取り消し。
  2. ちゃんとしたログ基盤
    loguru日次ローテーション+7日保持+ZIP 圧縮
  3. 取引履歴とリアルタイム PnL
    約定ごとに SQLite に保存し、その場で損益を計算。

これで Bot が「放し飼い」できるレベルに引き上がります。


2. 未約定キャンセル ―― リスク管理の最後の砦

2‑1 なぜ必要か

  • 板が薄いとき、指値が刺さらず 置きっぱなし になると
    反対側の注文だけ約定して 裸ポジション が残る。
  • スプレッドが閉じたり、相場が反転した途端に 一方的な損失

結論:「埋まらないなら即キャンセル」で傷を浅くする。

2‑2 コードで追う

async def wait_for_fill(order_id, timeout=30, interval=2):
    for _ in range(int(timeout / interval)):
        status = await fetch_order_status(order_id)
        if status == "Filled":
            return True           # 約定したら即終了
        await asyncio.sleep(interval)
    # --- タイムアウト ---
    await cancel_order(order_id)  # 取り消し API
    return False
  1. 2 秒ごとに オーダーステータスを問い合わせ。
  2. 30 秒 超えたら /v5/order/cancel でキャンセル。
  3. 成功でも失敗でも True/False を返して 上位ロジックに委ねる

2‑3 フローチャート(口頭イメージ)

発注 → wait_for_fill
Filled なら DB 記録 → Slack「Filled!」
Timeout なら cancel_order → Slack「Canceled」→ 次のサイクルへ。

ポイントは “発注側の関数を汚さない” こと。
この粒度で切っておくと「部分約定ヘッジ」や「IOC 注文」へ差し替えるときに、
他の層を一切触らずに済みます。


3. loguru ―― 3 行で“運用可能な”ログを作る

3‑1 なぜ loguru?

  • 標準 loggingハンドラフォーマッタ の設定が長い。
  • ローテーションや圧縮を自前で書くのも面倒。

logurulogger.add() 1行で

  • 出力先
  • ローテーション条件
  • 保持期間
  • 圧縮形式

まで指定できます。

3‑2 実際の設定

from loguru import logger

# 1) コンソール(INFO 以上)
logger.remove()
logger.add(lambda m: print(m, end=""), level="INFO")

# 2) ファイル(日次ローテーション → ZIP → 7日保持)
logger.add(
    "mm_bot_{time:YYYYMMDD}.log",
    rotation="00:00",
    retention="7 days",
    compression="zip",
    level="DEBUG"
)
  • rotation 00:00 → 毎日 UTC 0 時でファイルを切り替え
  • retention 7 days → 古いログは ZIP して 7 日で削除
  • コンソールはスッキリ INFO だけ、ファイルには DEBUG まで。

3‑3 運用 Tips

  • もし Prometheus + Loki + Grafana で可視化したくなったら
    serialize=True を追加して JSON ログ にするだけで流し込めます。
  • 巨大な traceback がウザいときは
    logger.error(str(e)) でメッセージだけに落とす、などフィルタも簡単。

4. SQLite で即時計算する PnL

4‑1 なぜ SQLite?

要件SQLite がピッタリ
単一 Bot・軽量運用サーバーレス、ファイル1本
取引後すぐ損益計算SQL でペア照合が楽
ACID 必須トランザクションあり
テスト↔本番コピー.db ファイルを移すだけ

PostgreSQL や InfluxDB は「複数 Bot」「同時書込多発」になってからで OK。

4‑2 スキーマとロジック

CREATE TABLE IF NOT EXISTS trades(
  ts     INTEGER,  -- Unix ms
  symbol TEXT,
  side   TEXT,     -- 'Buy' or 'Sell'
  qty    REAL,
  price  REAL
);
  1. 約定ごとに1行 INSERT
  2. 直近2行BuySell のペアなら PnL=(Sell−Buy)×qty \text{PnL} = (\text{Sell} - \text{Buy}) \times \text{qty}PnL=(Sell−Buy)×qty を計算して Slack に報告。
  3. 手数料やファンディングは 列を追加 すれば精度アップできます。

4‑3 Graceful Shutdown

Ctrl‑C を叩いたときに

finally:
    conn.close()
    logger.info("DB closed gracefully")

を入れておくと WAL が壊れて起動不能 という事故を防げます。


5. つまずきポイントと対処

症状原因処置
retCode 110007 InsufficientABUSDT 残高ゼロテストネットに切替 → Faucet で資金追加
traceback が毎サイクル出るlogger.exception(e)運用時は logger.error(str(e)) に切替
DB 未 close でロック残りKeyboardInterruptfinally: conn.close() を徹底
ログ巨大化DEBUG 多すぎ本番では logger.level("DEBUG").remove()

6. テストネットでの動かし方

  • 1.モード切替
EXCHANGE_MODE = "testnet"
  • 2.API キーに Trade 権限、残高チャージ。
  • 3.超小ロット
python MMbotbybit.py --lot 0.001
  • 4.Filled → PnL がコンソールと Slack に流れるか確認。
  • 5.ログ ファイルが日付ごとに生成、翌日 ZIP 圧縮されるか確認。

7. まとめ & 次フェーズ

  • 未約定キャンセル で “置きっぱなしリスク” を排除。
  • loguru で「見やすい・消えない・膨らまない」ログを実現。
  • SQLite + 即席 PnL で「今いくら勝ってる?」が一目で分かる。

次回は

  1. 部分約定ヘッジ ― 片側だけ Filled したときに逆指値で逃げる。
  2. Grafana ダッシュボード ― CPU・メモリ・PnL をリアルタイム表示。

Bot が 24/7 で走り、見守り・損切り・報告まで自動――
ここをゴールに、もう一段ギアを上げていきます。

🎙️ Q&Aコーナー — “30 秒待ちは鈍すぎるのでは?”問題

HFT(いわゆる板貼り・ミリ秒勝負の世界)では
「未約定を 5 秒も放置=負けフラグ」は事実です。
では 30 秒タイムアウトの MMbot が利益を出せる余地はあるのか?
結論から言うと “条件を選べばまだ勝負になる” パターンがいくつか存在します。


1. 30 秒でも通用する3つの典型シナリオ

パターンどうして 30 秒でも損しにくい?代表例
① リベート狙いの完全メイカー約定=すべてメイカー側で手数料リベートが入る。
多少の逆行はリベートで相殺できる。
Bybit 現物 0.01% rebate
② スプレッド極太のアルト対板に厚みがなくスプレッドが50–100 bp開く。
30 秒放置しても逆行幅よりスプレッド利が勝つ。
深夜・超マイナー銘柄
③ ニュートラル・ヘッジ戦略発注と同時に CEX 側で先にヘッジ済み。
未約定でも市場リスクがフラットなので焦らない。
DEX(CFMM) ↔ Bybit ヘッジ

2. 具体的に利益が出た“実戦イメージ”

※金額はイメージ。手数料は Bybit メイカー 0.02%割引相当で計算。

ケース A — 深夜の流動性真空地帯

  • BTCUSDT‐PERP、スプレッド 0.6 %
  • 0.02 BTC を Bid −1 USDT / Ask +1 USDT に置く
  • 約定まで 11 秒
  • 片側利幅 ≈ 25 USDT、往復 50 USDT
  • 逆行 40 USDT 食らう → それでも +10 USDT + メイカーリベート 2 USDT

ケース B — 小型アルト “XZY” 現物

  • スプレッド 1.2 %、板枚数スカスカ
  • 30 秒で刺さらずキャンセル→直後に 0.0005 USDT 上下シフトし再発注
  • 4サイクルで両側約定、合計 PnL ≈ +0.8 %
  • 途中逆行は 0.3 %、リベートで相殺

3. それでも速い方がいい:改良イメージ

改良点目的目標値
WebSocket push で約定検知REST ポーリングをゼロに≦ 100 ms
cancel/replace 連打1 秒ごとに価格を追随5 rpm → 60 rpm
Adaptive timeoutATR × 板厚で動的タイムアウト30 s → 1–10 s

こうした高速化は Prometheus で逆行損 vs. 約定タイム を計測し、
曲線が“コスト<リターン”になる位置で落ち着かせるのが王道です。


4. まとめ ── 30 秒モデルの“生き残り方”

  1. Maker リベート×広スプレッド のニッチを狙う
  2. ヘッジ済みニュートラル戦略 なら時間的リスクを切り離せる
  3. とはいえ “5 秒理論” は正しいので、将来的には 1–5 秒 cancel/replace へ進化させる
  4. まずは テストネットで逆行幅ヒートマップ を取り、
    “あなたの銘柄・時間帯” で最適タイムアウトをデータで決めよう

これで 「30 秒は絶対NG?」→「ケースバイケースでまだ戦える」 という答えが見えるはずです。
次回放送では 部分約定ヘッジ を絡めつつ、より高速な注文サイクルへ踏み込んでいきます。

それでは! Yodaka でした。

現在のコード:自分用ログ

"""
MMBot v1.1  (2025‑04‑20)
────────────────────────────────────────────────────────────────────
機能
1. スプレッド検出で指値 2 本   … place_limit_order()
2. タイムアウトで自動キャンセル … wait_for_fill() → cancel_order()
3. loguru でファイル & コンソールログ (日次ローテーション 7 日保持)
4. 約定結果を SQLite に保存し、サイクル毎に累計 PnL を計算
"""

from __future__ import annotations
import asyncio, json, time, hmac, hashlib, sqlite3
from argparse import ArgumentParser
from pathlib import Path
from urllib.parse import urlencode

import aiohttp, pybotters
from loguru import logger

# ────── 0. 設定 ───────────────────────────────────────────────────
EXCHANGE_MODE = "mainnet"      # ← testnet / mainnet で REST & WS エンドポイントを分岐
DB_FILE       = Path("mmtrades.db")
LOG_FILE      = "mm_bot_{time:YYYYMMDD}.log"

REST_BASE = {
    "mainnet": "https://api.bybit.com",
    "testnet": "https://api-testnet.bybit.com",
}[EXCHANGE_MODE]

WS_PUBLIC = {
    "mainnet": "wss://stream.bybit.com/v5/public/linear",
    "testnet": "wss://stream-testnet.bybit.com/v5/public/linear",
}[EXCHANGE_MODE]

# ────── 1. 共通ユーティリティ ────────────────────────────────────
def create_signature(config: dict, body: str = "", params: dict | None = None) -> dict:
    timestamp   = str(int(time.time() * 1000))
    api_key     = config["bybit"]["apiKey"]
    secret      = config["bybit"]["secret"]
    recv_window = config.get("recv_window", "5000")
    payload     = urlencode(params) if params else body or ""
    to_sign     = timestamp + api_key + recv_window + payload
    signature   = hmac.new(secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()

    return {
        "Content-Type":      "application/json",
        "X-BAPI-API-KEY":    api_key,
        "X-BAPI-TIMESTAMP":  timestamp,
        "X-BAPI-SIGN":       signature,
        "X-BAPI-RECV-WINDOW": recv_window,
    }

async def place_limit_order(session, config, symbol, side, qty, price) -> str:
    path = "/v5/order/create"
    body = json.dumps({
        "category": "linear",
        "symbol": symbol,
        "side": side,
        "orderType": "Limit",
        "qty": str(qty),
        "price": str(price),
        "timeInForce": "GTC",
    })
    headers = create_signature(config, body, None)
    async with session.post(REST_BASE + path, headers=headers, data=body) as res:
        data = await res.json()
        if data.get("retCode") != 0:
            raise RuntimeError(data)
        return data["result"]["orderId"]

async def cancel_order(session, config, symbol, order_id) -> bool:
    path = "/v5/order/cancel"
    body = json.dumps({"category": "linear", "symbol": symbol, "orderId": order_id})
    headers = create_signature(config, body, None)
    async with session.post(REST_BASE + path, headers=headers, data=body) as res:
        j = await res.json()
        ok = j.get("retCode") == 0
        logger.info(f"order.cancel  id={order_id} ok={ok}  raw={j}")
        return ok

async def wait_for_fill(session, config, symbol, order_id, timeout=30, interval=1.0) -> tuple[bool, float]:
    """Filled → (True, price) / Timeout → cancel → (False, 0)."""
    path = "/v5/order/realtime"
    for _ in range(int(timeout / interval)):
        params   = {"symbol": symbol, "orderId": order_id}
        headers  = create_signature(config, "", params)
        async with session.get(REST_BASE + path, headers=headers, params=params) as r:
            d = await r.json()
            status = d["result"]["data"][0]
            state  = status["orderStatus"]
            price  = float(status["price"])
            logger.debug(f"status {order_id}: {state}")
            if state == "Filled":
                return True, price
        await asyncio.sleep(interval)

    await cancel_order(session, config, symbol, order_id)
    return False, 0.0

async def notify_slack(webhook_url: str, message: str):
    async with aiohttp.ClientSession() as s:
        await s.post(webhook_url, json={"text": message})

# ────── 2. DB (SQLite) ────────────────────────────────────────────
def init_db():
    conn = sqlite3.connect(DB_FILE)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS trades("
        "ts INTEGER, symbol TEXT, side TEXT, qty REAL, price REAL)"
    )
    conn.commit()
    return conn

def record_trade(conn, ts: int, symbol: str, side: str, qty: float, price: float):
    conn.execute("INSERT INTO trades VALUES (?,?,?,?,?)", (ts, symbol, side, qty, price))
    conn.commit()

def calc_pnl(conn, symbol: str) -> float:
    """
    Σ( sell – buy )×qty  を単純計算  
    ※ 片側しか埋まってない場合は 0、部分約定対応は別途。
    """
    cur = conn.cursor()
    cur.execute(
        "SELECT side, qty, price FROM trades WHERE symbol=? ORDER BY ts DESC LIMIT 2",
        (symbol,),
    )
    rows = cur.fetchall()
    if len(rows) == 2 and {"Buy", "Sell"} == {r[0] for r in rows}:
        sell = next(p for s, q, p in rows if s == "Sell")
        buy  = next(p for s, q, p in rows if s == "Buy")
        qty  = rows[0][1]  # 同量前提
        return (sell - buy) * qty
    return 0.0

# ────── 3. メインループ ───────────────────────────────────────────
async def run_loop(symbol: str, qty: float, s_entry: float, config: dict, interval: int):
    logger.info(f"MMBot start  mode={EXCHANGE_MODE}")

    conn = init_db()
    orderbook: dict[str, float] = {}

    async with pybotters.Client(base_url=WS_PUBLIC) as ws:
        async with aiohttp.ClientSession() as httpsess:
            await ws.ws_connect(
                WS_PUBLIC,
                send_json=[{"op": "subscribe", "args": [f"orderbook.1.{symbol}"]}],
                hdlr_json=lambda m, w: (
                    orderbook.update({"ask": float(m["data"]["a"][0][0])})
                    if "data" in m and m["data"].get("a")
                    else None,
                    orderbook.update({"bid": float(m["data"]["b"][0][0])})
                    if "data" in m and m["data"].get("b")
                    else None,
                ),
            )
            while "bid" not in orderbook:
                await asyncio.sleep(0.5)

            while True:
                bid, ask   = orderbook["bid"], orderbook["ask"]
                spread_pct = (ask - bid) / bid
                logger.debug(f"spread={spread_pct:.6f}")

                if spread_pct > s_entry:
                    logger.info("spread OK → send orders")
                    buy_price  = int(bid - 1)
                    sell_price = int(ask + 1)

                    try:
                        buy_id  = await place_limit_order(httpsess, config, symbol, "Buy",  qty, buy_price)
                        sell_id = await place_limit_order(httpsess, config, symbol, "Sell", qty, sell_price)
                    except Exception as e:
                        logger.exception(f"order error: {e}")
                        await notify_slack(config["slack_webhook_url"], f"Order error: {e}")
                        await asyncio.sleep(interval)
                        continue

                    filled_buy,  fprice_buy  = await wait_for_fill(httpsess, config, symbol, buy_id)
                    filled_sell, fprice_sell = await wait_for_fill(httpsess, config, symbol, sell_id)

                    ts = int(time.time() * 1000)
                    if filled_buy:
                        record_trade(conn, ts, symbol, "Buy",  qty, fprice_buy)
                    if filled_sell:
                        record_trade(conn, ts, symbol, "Sell", qty, fprice_sell)

                    pnl = calc_pnl(conn, symbol)
                    logger.success(f"cycle end  pnl={pnl:.4f}")

                    msg = (
                        f"📈 *MMBot Report*\n"
                        f"> Pair: `{symbol}`  Spread: `{spread_pct:.5f}`\n"
                        f"> 🟢 Buy  @{buy_price} ➡️ {'Filled' if filled_buy else 'Canceled/Pending'}\n"
                        f"> 🔴 Sell @{sell_price} ➡️ {'Filled' if filled_sell else 'Canceled/Pending'}\n"
                        f"> PnL (last cycle): `{pnl:.4f}`"
                    )
                    await notify_slack(config["slack_webhook_url"], msg)
                    await asyncio.sleep(interval)
                else:
                    await asyncio.sleep(1)

# ────── 4. CLI ──────────────────────────────────────────────────
def cli():
    p = ArgumentParser()
    p.add_argument("--config", default="config.json")
    p.add_argument("--symbol", default="BTCUSDT")
    p.add_argument("--lot",    default=0.01, type=float)
    p.add_argument("--s_entry", default=0.000001, type=float)
    p.add_argument("--interval", default=10, type=int)
    return p.parse_args()

# ────── 5. エントリポイント ─────────────────────────────────────
if __name__ == "__main__":
    # — loguru —
    logger.remove()
    logger.add(lambda msg: print(msg, end=""))  # console
    logger.add(LOG_FILE, rotation="00:00", retention="7 days", compression="zip")

    args = cli()
    with open(args.config) as f:
        cfg = json.load(f)

    asyncio.run(
        run_loop(
            args.symbol,
            args.lot,
            args.s_entry,
            cfg,
            args.interval,
        )C
    )

-Bot