2025年4月20日
前回の記事では 「本番 API 署名/証拠金チェック」 を整え、
板監視 → スプレッド検知 → 指値発注 → 約定ポーリング → Slack 報告まで到達しました。
今回はさらに リスク管理と運用ログの土台 を作り込み、Bot の“実戦仕様”へ一段引き上げた開発ログです。
🎯 今回のゴール
- 未約定キャンセル/v5/order/cancelを組み込み、タイムアウトした注文を自動で取り消す。
- ログ基盤 (loguru)
 コンソール+日次ローテーションファイル、7 日保持。
- 取引履歴 & P n L
 約定結果を SQLite に保存し、サイクル単位で損益を即時計算。
🛠️ 実装ハイライト
| 機能 | 追加・変更点 | コア関数 | 
|---|---|---|
| 未約定キャンセル | wait_for_fill()がタイムアウト時にcancel_order()を呼び出し | cancel_order() | 
| 日次ログ | loguruでmm_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 日で自動削除
- level:DEBUG以上をファイルへ、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 未 close | Graceful 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 ダッシュボード を追加し、運用監視まで一気に仕上げる予定です。
よし。最小限構成だが一旦形になった。後はテストネットモードに切り替えて仕上げていく。 pic.twitter.com/c3gI6PGykA
— よだか(夜鷹/yodaka) (@yodakablog) April 20, 2025
👇疑問点の整理と確認
質問リストの整理表
| # | カテゴリ | 質問内容(要約) | キーワード | 目的/掘り下げポイント | 
|---|---|---|---|---|
| 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 モードの特徴 | 
| 4 | DB 選定理由 | なぜ SQLite を採用したか/他の選択肢 | Postgres / InfluxDB / Redis | ① 単一 Bot × 軽量運用の適合性 ② データ量・同時書込の限界 ③ 将来スケール時の移行戦略 | 
見落としがちな追加視点 ✔️
- Fill‑Cancel Race
 同時刻で Filled → Canceled が返る競合。キャンセル返却後に再照会して二重確認。
- Idempotency (orderLinkId)
 API 再送で重複注文が入らないよう、各リクエストに UUID を添付。
- Rate‑Limit & Backoff
 v5 REST 100 req/分上限。指数バックオフまたはバケットアルゴリズムで整流。
- Clock Sync
 署名timestampが±1 s 以上ズレると認証失敗。起動時に/v5/public/time参照か NTPd。
- PnL 精度(手数料・資金調達費)
 取引手数料とファンディングを DB 列へ追加し、純損益を算出。
- Durability 設定PRAGMA journal_mode=WAL+synchronous=NORMALで停電耐性と速度をバランス。
- Observability パイプライン
 loguru → Loki → Grafana でメトリクス/アラートを一本化する青写真。
- 水平展開時の共通 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/cancelPOST – 純粋な副作用関数で戻り値は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 とは
| 項目 | loguru | logging(標準) | 
|---|---|---|
| セットアップ | 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
- rotation と retention が 1 行で済む便利さが採用理由。
- λ sink で print()同等の即時コンソール出力を追加。
- 将来 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 板データを永続化) | 
| Redis | K‑line キャッシュや発注キューなど“揮発 + 高速”用途 | 
| Parquet + DuckDB | 後処理 (オフライン解析) にカラム圧縮で高効率 | 
4. 見落としがちな追加視点 🧐
| 視点 | なぜ重要か | ヒント | 
|---|---|---|
| “Fill‑Cancel Race” | 同一ミリ秒で Filled→Canceled が来る可能性 | cancel_order()受信後に再度/v5/order/realtime確認 | 
| Idempotency | 再送で二重注文を防ぐ | orderLinkIdを UUID で付与し、重複 POST を無害化 | 
| Rate Limit & Backoff | v5 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 | 
| Observability | 24/7 運用の死活監視 | loguru→ Loki + Grafana で AlertManager を設定 | 
| Concurrency | 将来 Bot を水平展開 | DB を PostgreSQL に移行する、または「1 Bot = 1 DB ファイル」方式で分散 | 
まとめ & 次アクション
- 未約定キャンセルは 損失限定 の核心。タイムアウト値とキャンセル後の“二度引っ掛け”を必ず検証。
- loguru は“⾃動ローテーション付き print”と捉えると導入コストが激減。構造化 JSON 出力まで視野に。
- SQLite は“単一プロセス × 少数トランザクション”なら無敵。
- リモート複数 Bot に拡大する前に PostgreSQL + Timescale 等への移行計画を持つ。
 
- 追加視点リストのうち Idempotency と Clock Sync は早期に対応すると後が楽。
👇ラジオで話したこと
🎙️ Yodaka Radio ― 開発記録 #186MMbot開発ログ5「未約定キャンセル+ログ&PnL集計まで実装」
0. オープニング
こんにちは、仮想通貨bot開発ラジオ、 Yodaka です。
前回は「本番 API 署名」と「証拠金チェック」まで整え、
板監視 → スプレッド検知 → 指値発注 → 約定ポーリング → Slack 報告
という“ひとまず回る”ところまで来ました。
でも―― 「発注しっぱなし」「損益が見えない」「ログが散らかる」。
今日はこの3つを一気に片づけます。
1. 今日のゴール
- 未約定キャンセル
 30 秒で埋まらない指値は自動で取り消し。
- ちゃんとしたログ基盤
 loguru で 日次ローテーション+7日保持+ZIP 圧縮。
- 取引履歴とリアルタイム 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
- 2 秒ごとに オーダーステータスを問い合わせ。
- 30 秒 超えたら /v5/order/cancelでキャンセル。
- 成功でも失敗でも True/Falseを返して 上位ロジックに委ねる。
2‑3 フローチャート(口頭イメージ)
発注 → wait_for_fill →
Filled なら DB 記録 → Slack「Filled!」
Timeout なら cancel_order → Slack「Canceled」→ 次のサイクルへ。
ポイントは “発注側の関数を汚さない” こと。
この粒度で切っておくと「部分約定ヘッジ」や「IOC 注文」へ差し替えるときに、
他の層を一切触らずに済みます。
3. loguru ―― 3 行で“運用可能な”ログを作る
3‑1 なぜ loguru?
- 標準 logging は ハンドラ と フォーマッタ の設定が長い。
- ローテーションや圧縮を自前で書くのも面倒。
loguru は logger.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行 INSERT。
- 直近2行 が Buy と Sell のペアなら PnL=(Sell−Buy)×qty \text{PnL} = (\text{Sell} - \text{Buy}) \times \text{qty}PnL=(Sell−Buy)×qty を計算して Slack に報告。
- 手数料やファンディングは 列を追加 すれば精度アップできます。
4‑3 Graceful Shutdown
Ctrl‑C を叩いたときに
finally:
    conn.close()
    logger.info("DB closed gracefully")
を入れておくと WAL が壊れて起動不能 という事故を防げます。
5. つまずきポイントと対処
| 症状 | 原因 | 処置 | 
|---|---|---|
| retCode 110007 InsufficientAB | USDT 残高ゼロ | テストネットに切替 → Faucet で資金追加 | 
| traceback が毎サイクル出る | logger.exception(e) | 運用時は logger.error(str(e))に切替 | 
| DB 未 close でロック残り | KeyboardInterrupt | finally: 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 で「今いくら勝ってる?」が一目で分かる。
次回は
- 部分約定ヘッジ ― 片側だけ Filled したときに逆指値で逃げる。
- 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 timeout | ATR × 板厚で動的タイムアウト | 30 s → 1–10 s | 
こうした高速化は Prometheus で逆行損 vs. 約定タイム を計測し、
曲線が“コスト<リターン”になる位置で落ち着かせるのが王道です。
4. まとめ ── 30 秒モデルの“生き残り方”
- Maker リベート×広スプレッド のニッチを狙う
- ヘッジ済みニュートラル戦略 なら時間的リスクを切り離せる
- とはいえ “5 秒理論” は正しいので、将来的には 1–5 秒 cancel/replace へ進化させる
- まずは テストネットで逆行幅ヒートマップ を取り、
 “あなたの銘柄・時間帯” で最適タイムアウトをデータで決めよう
これで 「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
    )
							