前回の記事に引き続き、今回も仮想通貨botの開発状況をまとめていきます。
本記事では、仮想通貨の自動取引Bot「MMBot」の開発・検証を行った2025年4月22日の作業ログを整理しています。
今回はテストネットでの挙動確認から、本番環境での安全な実行に向けた設計改善まで、大きく進展がありました。
特に「テストネットと本番でのAPI仕様の差異」「コードが意図した通りに動いていない原因」など、初心者がつまずきやすいポイントについて詳しく書いています。
🔍 作業のタイムライン
| 時刻 | 試行内容 | 結果・問題 | 原因分析 | 対処・学び | 
|---|---|---|---|---|
| 10:05 | テストネットでMarket注文に切替 | 指値注文が継続、 history API error多発 | 実行ファイルが旧版のまま | ✅ run_loopに testnet 分岐追加✅ ログに "send market orders"で見分け可能に | 
| 10:11 | 修正版を再実行 | wait_for_fillが呼ばれ続ける | .pyc残存/未保存ファイル | ✅ grep,realpath,-uで実行ファイル特定 | 
| 10:15 | MMbot_testnet.py新規作成 | 出力なしで即終了 | 別ファイル/空ファイルを実行 | ✅ ls -l,grep,catで中身確認+保存確認 | 
| 10:17 | 「本番で検証した方が早いのでは?」という転換 | API key invalid, 指値ルート再発 | 本番設定が未反映/旧コード使用 | ✅ config_mainnet.jsonを新規用意、最小lotでMarket注文から確認する方針に変更 | 
| 10:25 | 安全ガード付きコードv1.7作成 | — | — | ✅ 追加項目: • DBファイル分離 (main/test) • 建玉上限 MAX_NOTIONAL_USD• 3回エラーで自動停止 • --max_cyclesによるサイクル制御 | 
| — | 本番用起動コマンド確定 | — | — | ✅ --lot 0.001&--max_cycles 20で安全に実行可能に | 
🎯 成功したこと
- ✅ マーケット注文のロジック検証が完了
 テストネットでAPIの挙動・署名処理・注文成功を確認。
- ✅ Slack通知 / SQLite保存 / ログ出力が一連で動作確認済
 開発環境におけるボットの基本動作は整っている。
- ✅ 安全装備を備えたv1.7コードを整備完了
 本番用にも安心して導入できる構成ができた。
- ✅ 原因切り分けフローを整理し、再現可能なデバッグ手順を明文化
 実行されているコードが想定と異なる問題をクリアに。
🧱 うまくいかなかったことと原因・対処
| 課題 | 原因 | 再発防止策 | 
|---|---|---|
| テストネットで指値ルートが残存 | 実行ファイルが旧版/保存漏れ | ✔️ ログにTAGを明示 ✔️ .pyc削除 &realpath確認 | 
| history API error: Illegal category | テストネットは /v5/order/historyのcategoryを受け付けない | ✔️ テストネットは Market注文+realtime APIのみに制限 | 
| 本番で API key is invalid | キーの設定ミス or config混在 | ✔️ 設定ファイルを物理的にmain/testで分割 ✔️ 将来的には環境変数経由で安全に読み込みたい | 
🛠 今後の開発タスク
- 本番ワンショットMarketテスト
- lot=0.001,- max_cycles=1でSlack & DBの正常動作を確認。
 
- マーケット常時運転 → Limit戦略に切替
- s_entryを調整しつつ安定稼働にシフト。
 
- ユニットテスト / CIの整備
- create_signature()、PnL計算、DB挿入などをpytestでテスト化。
 
- フェイルセーフ通知の強化
- 重大エラーをSlack→PagerDutyなどへ切り替え可能に。
 
- デプロイの自動化
- Dockerfile + GitHub Actionsでステージ別ビルド&デプロイを可能に。
 
- パフォーマンス計測・改善
- asyncio.gather化やWebSocketパーサーの最適化、注文レイテンシ測定の導入。
 
- リスク管理強化
- Redis等で未決済ポジションを管理し、非常時に即カットできる体制を整備。
 
🚀 今後のロードマップ
Step 1:本番最小構成で安全確認
→ Step 2:戦略切替(Limit化)とパフォーマンスチューニング
→ Step 3:デプロイ/CI/監視の本格運用へ拡張
✍️ 最後に
今回の作業では、「意図通りのコードが走っていない」ことに気づくまでに時間がかかりました。
一見するとプログラムミスに見える問題も、実際はキャッシュや保存漏れ、実行対象の混在によって起きていたことがわかりました。
Bot開発においては**「実行しているコードが本当にそれか?」**を確認する習慣が、
戦略ロジックと同じくらい重要だと痛感しました。
🧩 次のログでは、本番Market注文の初回結果と、Limit戦略へのスムーズな移行に挑戦する予定です!
—
✍️ 記録:2025年4月22日 / よだか
これもう本番環境で検証した方が早いな。最低限の安全稼働状態でぶん投げてしまおう。
— よだか(夜鷹/yodaka) (@yodakablog) April 22, 2025
開発者として評価(10 段階)byGPTo3
| 項目 | 評価 | コメント | 
|---|---|---|
| 問題切り分けのスピード | 4/10 | “動かない” 状態を長時間ループ。旧ファイル・ .pyc・保存漏れが典型的なのに初動が遅い。 | 
| 環境管理 (venv / config) | 3/10 | main/test の設定混在、API キー誤適用。本番 API 無効で気づくのは遅すぎる。 | 
| 安全設計 | 6/10 | 上限金額・エラーカウントなど最終的に追加できた点は評価。ただし最初から組み込むべき基礎。 | 
| ロギング & 可観測性 | 7/10 | loguru・Slack 通知は○。ただし“識別タグ”を事後で足している時点で設計が受動的。 | 
| テスト・自動化 | 2/10 | ユニットテスト無し。CI 無し。人力確認に依存。 | 
| 学習姿勢 | 8/10 | 問題を言語化し続け、解決策を取り込んだ点は良い。 | 
総合:5/10(辛くつけて“及第点ギリギリ”)
素質よりも、開発プロセスそのものに改善余地が大きい。
厳しめに指摘する 6 つの反省点
- 「実行バイナリ=ソース」の幻想
- 保存漏れ/キャッシュ/別ディレクトリを疑うのは基本中の基本。
- 対策:pytestに必ず “バージョンタグを出力して終わるテスト” を一つ入れ、CI に通さない限り本番デプロイしない。
 
- 設定の分離が甘い
- config_mainnet.jsonと- config_testnet.jsonを物理分離したのは事後対応。
- 対策:環境変数+.env管理、Vault 等でキー暗号化。誤適用を CI で fail させる。
 
- 安全装備が“後追い”
- 上限金額・max_cycles は事後に慌てて追加。
- 対策:仕様書の段階で失敗パスを洗い出し、実装時に “フェイルセーフ先行”。
 
- テストネットを信用しすぎ
- Bybit テストネットの仕様差を知らずに時間を浪費。
- 対策:API ドキュメント+コミュニティで「何が本番と違うか」を事前調査。検証は“実運用最小サイズ”を最初から視野に。
 
- デバッグは“勘”が多い
- print→思考→修正の反復は初学者なら仕方ないが、時間効率が悪い。
- 対策:pdb/ VS Code Debug・structured logging・テスト駆動で可観測性を高める。
 
- バージョン管理の痕跡が見えない
- コードの差分管理・タグ付けが会話に出てこない。
- 対策:GitFlow でも trunk-based でもよい。PR レビュー無しで本番に push は厳禁。
 
今後 30 日の行動プラン
| 週 | やること | 目的 | 
|---|---|---|
| 1 | Git リポジトリを再構成:main/test ブランチを分離。 .envでキー管理。 | 設定混在を根絶 | 
| 1‑2 | pytest 導入:署名関数・PnL・DB I/O など 10 本のユニットテストを書く。 | 回帰バグ即検知 | 
| 2 | GitHub Actions で CI: pytest+ruff+mypyを通さないと merge できない。 | 機械的品質担保 | 
| 2‑3 | Docker 化 & compose:redis / bot / prometheus / grafana を一括起動できる dev-stack を作る。 | dev/prod parity | 
| 3 | 本番 Canary デプロイ: lot=0.0005/max_cycles=10を cron で1日数回流す。 | 実環境の挙動を小さく観測 | 
| 3‑4 | 自動ロールバック:連続エラー3回→Bot停止→Slack警報→PagerDuty。 | フェイルセーフ強化 | 
| 4 | パフォーマンス計測:板取得→決済までの latency を Prometheus エクスポート。 | ボトルネック特定 | 
| 継続 | 毎日 30 分コードを“写経”:人気 OSS の async bot、Backtest スクリプトなど。 | コーディング筋トレ | 
意識すべきポイント
- 「コードを書く」より「仕組みを作る」
- 仕組み=テスト・CI・監視。これがないと本番では動かせない。
 
- “動く”をゴールにしない
- 再現可能、安全に失敗できる を同時に満たして初めてプロダクト。
 
- 疑う対象は常に自分の環境
- キャッシュ・設定・実行パス…。まず足元を確認してからコードを疑う。
 
- 小さく賭けて早く学ぶ
- 本番検証はロット最小、サイクル最小、期間最小で。失敗をコスト最小で回収する。
 
- AI は“補助輪”
- 解決策をもらったら手でタイプし直し、ドキュメントを熟読し、自分の言葉で再実装。
- “コピペして動いた”だけではスキルは伸びない。
 
厳しい一言まとめ
「動かない」時間の大半は、環境と手順への無理解が原因。
コードよりもまずプロセスを疑え。
ここを乗り越えられれば、個人開発者としてのレベルは確実に一段上がります。
次回はテスト・CI そして運用自動化の成果を報告できることを期待しています。
概要まとめ:凝縮版
本日の作業ログ — MMBot v1.x 開発・検証の進捗まとめ
(2025‑04‑22)
| 時刻 (JST) | 試行内容 | 結果 / 問題 | 原因分析 | 対処・学び | 
|---|---|---|---|---|
| 10:05 頃 | テストネットで「マーケット注文に切替」した改修コードを投入 | 指値 が飛び続け、 history API errorが多発 | 旧コード(Limit ルート)を実行していた ⇒ ファイル名・保存漏れ | ✅ 改修点を run_loop冒頭にif EXCHANGE_MODE=="testnet": ... continueで明示。ログ文言も “send market orders” に変更して判別容易に | 
| 10:11 頃 | 修正版を再実行 | 依然として指値ルート → wait_for_fillが呼ばれる | 実行ファイル/キャッシュ (.pyc) 混在、タブ未保存 | ✅ .pyc全削除・grepでタグ検索・-u(unbuffered) 実行で “どのコードが動いているか” を可視化 | 
| 10:15 頃 | テストネット専用最小コード を新規作成 ( MMbot_testnet.py) | 端末に何も表示されず即終了 | python MMbot_testnet.pyではなく 別ファイル か「空ファイル」を実行/入力ミス | ✅ realpath,ls -l,grepでファイル実体を確認、-u起動で強制ログ出力 | 
| 10:17 頃 | 本番環境に切替して検証した方が早いのでは? との判断 | mainnet で即実行 → API キー invalid, 指値ルートが動作 | 本番 API キー未設定・旧コードが残存 | ✅ 本番用設定ファイル config_mainnet.jsonを用意し、最小ロットで “ワンショット Market” をまず試す方針に転換 | 
| 10:25 頃 | 安全ガード付きフルコード v1.7 作成 | — | — | ✅ 以下を追加: • main/test で DB 分離 • 建玉上限 MAX_NOTIONAL_USD• 連続失敗 3 回で停止 • --max_cyclesで自動終了 | 
| — | 推奨起動コマンド (mainnet, lot 0.001, 20 cycles) 提示 | — | — | ✅ 本番へスムーズに移行できる手順を確定 | 
成功したこと ✅
- テストネットでのマーケット注文ロジック を最小コードで検証し、
 実際に Market API の署名・呼び出しテンプレを確立できた。
- ログ/Slack/SQLite の一連の動作は旧コードでも正常であることを確認。
- 安全ガード付きの本番向けコード v1.7 を完成。
- 原因切り分けフロー(grep → realpath → .pyc 削除 → -u起動)を整備し、
 コードと実行ファイル不一致のデバッグ手順を共有できた。
うまくいかなかったこと & 原因 ❌
| 課題 | 原因 | 再発防止策 | 
|---|---|---|
| テストネットで指値ルートが残存 | 旧ファイルを実行・保存漏れ | ✔️ ログに “TAG” を入れて必ず判定 ✔️ .pyc削除 & 仮想環境 python を確認 | 
| history API error: Illegal category | テストネットは /v5/order/historyがcategory不受理 | ✔️ テストネットでは Market 即約定 or realtimeだけを使う | 
| mainnet で API key is invalid | config mix‑up / API キー誤入力 | ✔️ main / test 用設定ファイルを物理的に分割、環境変数で注入も検討 | 
今後の開発タスク 🗒️
- 本番ワンショット Market テスト
- lot=0.001,- max_cycles=1、Slack & DB に約定記録が入ることを確認
 
- マーケット常時運転 → Limit 戻し
- Market で安定後、s_entryを調整して Limit 戦略へ切替
 
- Market で安定後、
- ユニットテスト / CI
- 署名関数・PnL 計算・DB 書込みを pytest で自動テスト
 
- フェイルセーフの外部化
- 連続エラー通知を Slack → PagerDuty 等に昇格できるよう設計
 
- デプロイ自動化
- Dockerfile + GitHub Actions で tag push ⇒ 本番/テスト用イメージ作成
 
- パフォーマンス計測
- asyncio gather 化・WS ハンドラ高速化、latency log を追加
 
- リスク管理
- 未決済ポジションを Redis に集約、急変時に一括手動決済できるフックを実装
 
これらをロードマップ化し、Step 1 → 2 → 3 の順で進めれば
「安全に本番へ移行しつつ、機能追加へ展開」できるはずです。
👇ラジオで話したこと
🎙️ 開発記録#191|MMBot開発ログ8「テストネットから本番環境へ。安全ガードを加えた本番運用に向けての道のり」
こんにちは、よだかです。
今回のラジオは、2025年4月22日に行った仮想通貨自動取引Bot「MMBot」の開発と検証の記録を、未来の自分への再学習としてまとめておきます。
この日は、テストネットから本番環境への移行準備というテーマで、大きな前進と、いくつかのつまずきがありました。
とくに、「コードが意図した通りに動いていないときにどう気づくか」
そして「安全に本番環境で動かすには何を備えておくべきか」
この2つが、今日のキーワードでした。
では、まずはその日の流れをざっくり時系列で振り返ってみます。
🕙 10:05
テストネットで、マーケット注文に切り替えたコードを投入。
でもなぜか、指値注文が出続け、history API error が止まらない…。
調べてみると、実行されていたのは古いコードだった。
保存漏れや実行ファイルの食い違いが原因でした。
🕚 10:11
修正したコードをもう一度実行。
しかし wait_for_fill() が呼ばれ続ける…。
よく見ると .pyc ファイルが残っていて、それが旧バージョンを保持していたようです。grep や realpath、それから python -u を使って、どのコードが動いているかを可視化できるようにしました。
🕧 10:15
「もう新しいファイルで確実に動かそう」と思い、MMbot_testnet.py を新規作成。
ところが実行しても何も表示されない。
原因は、実行していたのが空ファイルだったという初歩的ミス。
ここも、ls -l や cat で中身確認することで、すぐに修正できました。
🕐 10:17
ここで方針転換。
「テストネットでこんなに挙動が違うなら、本番で動かした方が早い」と判断。
さっそく本番用のAPIで起動…したものの、API key is invalid と怒られる。
configファイルの中身がテスト用のままだったんですね。
改めて config_mainnet.json を用意して、最小ロットでMarket注文から始めるという方針を立て直しました。
🕝 10:25
本番環境でも安心して動かせるよう、安全ガード付きのv1.7コードを作成。
以下のような保護機能を追加しています。
- 本番とテストで別々のDBファイルを使う
- 建玉の想定金額(qty × price)に上限 MAX_NOTIONAL_USDを設ける
- 注文が3回連続で失敗したら自動停止
- --max_cyclesを指定すれば、一定サイクルで自動終了
📌 成功したことをまとめると、こうなります。
- テストネット上でマーケット注文が問題なく動作したこと
- Slack通知、SQLite保存、ログ出力の一連動作が確認できたこと
- 安全ガード付きのコードを本番に向けて完成させたこと
- 実行ファイルの食い違いを特定するための手順を確立できたこと
💡 逆に、うまくいかなかったこととその対策も、忘れずに記録しておきます。
- 指値ルートが消えてない問題 → ファイル保存忘れ、.pyc残存。
 → ログにタグを入れることで判別をしやすくした。
- history API error → テストネットは /v5/order/historyの仕様が異なる。
 → Market注文+realtime APIのみに限定することで回避。
- APIキー invalid → 設定ファイルの混在。
 → mainnet / testnet を物理的に分離して明示管理。
🛠 今後の開発タスク も、整理しておきます。
- 本番環境での ワンショットMarketテスト(lot=0.001, max_cycles=1)で確認
- そこから常時運転に移行し、Limit注文戦略に戻していく
- ユニットテストやCIの整備(署名関数やPnL計算などをpytestで)
- フェイルセーフ通知の強化(Slack→PagerDutyなど)
- DockerやGitHub Actionsを使ったデプロイ自動化
- パフォーマンス改善(非同期処理や板データパーサーの高速化)
- Redisによるポジション監視と、緊急時の自動カット機能
📈 これからのロードマップは3ステップ。
Step 1:最小構成で本番のMarket注文が動くか確認
Step 2:戦略をLimitに戻してチューニング
Step 3:CI・監視・自動デプロイなど本格運用へ拡張
✍️ 最後に
今回、意図通りのコードが動いていないという状況が、
いかに気づきにくく、いかに時間を食うかを体感しました。
一見すると「プログラムミス」に見える問題も、
実際はファイル保存忘れ・キャッシュの残り・違うコードを実行していた
という、もっと根本的な環境要因だったりします。
この体験を通して、Bot開発では
「実行しているコードが、本当にそれか?」
を毎回疑うくらいでちょうどいいと思いました。
次回のログでは、いよいよ 本番Market注文の初回結果と、
Limit戦略へのスムーズな移行に挑戦する予定です。
🎙️ おまけコーナー|開発者としての自己評価と反省点
さて、ここからはちょっとおまけのコーナーとして、
今回のMMBot開発の一連の流れを経て、開発者としての自分自身を評価する時間です。
この評価は、ChatGPT──正確には私が使っている「o3」モデルから、
かなり客観的かつ厳しめにフィードバックをもらったものです。
🧮 10段階評価 by GPTo3(かなり正直)
- 問題切り分けのスピード:4点
 → 「動かない」状態を長時間ループしていた。旧ファイル/.pyc/保存漏れ…どれも初心者あるあるだけど、初動が遅かった。
- 環境管理:3点
 → 本番とテストの設定混在。本番用APIキーが無効だったのに気づいたのも遅すぎ。
- 安全設計:6点
 → 結果的にエラー制御や上限設計を組み込んだのは良い。ただ、それは“後からの補填”にすぎない。
- ロギング & 可観測性:7点
 → loguruとSlack通知は良かった。でも識別タグを“後から”足してる時点で、可観測性が受動的。
- テスト・自動化:2点
 → ユニットテストなし、CIもなし。完全に人力確認任せ。これでは本番に出すには怖い。
- 学習姿勢:8点
 → とはいえ、問題を言語化してフィードバックを取り込んでいく姿勢はちゃんとあった。
総合評価:5/10(ギリ及第点)
正直、素質はある。ただ「開発プロセス全体」に改善余地が山ほどある、というのが本音。
💥 反省点を厳しく6つ
- 「実行バイナリ=ソースコード」と思い込んでいた
 → 保存してなかったり、.pycに旧コードが残っていたり。これ、初歩だけど超大事。
 → 対策:CIテストに“バージョンタグ出力”を入れて、本番前に必ず確認。
- 設定ファイルの分離が甘すぎた
 → mainnetとtestnetが同居。config書き間違いは事故のもと。
 → 対策:.envによる環境変数管理+CIで制限する。
- 安全設計が完全に後追いだった
 → MAX建玉制限、max_cycles…全部“やばそうだから後で足した”。
 → 対策:最初にフェイルセーフを設計書に入れる癖をつける。
- テストネットを“信用しすぎ”た
 →/historyがcategoryパラメータを受け付けないなど、完全に罠だった。
 → 対策:ドキュメント+StackOverflowで事前に仕様差を把握。
- デバッグが“勘”と“手動”に依存していた
 →print→修正→再実行の繰り返しは、時間効率が悪い。
 → 対策:pdb、構造化ログ、VSCodeのステップ実行を習慣に。
- バージョン管理の痕跡がなかった
 → Gitでのタグ、PRレビュー、ブランチ運用…どこにも見当たらない。
 → 対策:GitFlowじゃなくてもいいから、main/testで分けるのは最低限。
🧭 これからの30日間の行動プラン
- 1週目:Gitの再構成 & 環境変数管理
 → 設定混在を根絶する。
- 1〜2週目:pytestで10本テストを書く
 → バグにすぐ気づけるようにする。
- 2週目:GitHub ActionsでCIを導入
 → テストが通らなきゃマージできない環境を作る。
- 2〜3週目:Docker + Compose化
 → Redis/Bot/監視をワンコマンド起動できるように。
- 3週目:本番のCanary実行(少ロット×短時間)
 → 小さく動かして、大きな失敗を防ぐ。
- 3〜4週目:自動ロールバック機構の追加
 → 連続エラー→Slack→自動停止→PagerDuty。これで安心。
- 4週目:Prometheusでパフォーマンスを可視化
 → どこで時間かかってるかを明示。
- 毎日:30分だけコード写経
 → OSSのBotやBacktest系を写す。これが一番伸びる。
💡 意識しておくべき5つのポイント
- 「コードを書く」より「仕組みを作る」が重要。
- “動いた”をゴールにしない。「再現性」「安全な失敗」がセット。
- まず疑うのは“コード”ではなく“環境”。
- 小さく賭けて、早く失敗して、早く学ぶ。
- AIは補助輪。写経し直して、手で再実装してこそスキルになる。
💬 最後に一言、今日の学びをまとめるならば…
「動かない」時間の大半は、コードの問題じゃない。
それ以前の“環境”と“手順”に対する無理解が原因だ。
コードより、まずプロセスを疑え。
次回のラジオでは、いよいよCI導入と本番常時運転のテスト結果を報告できるように頑張ります。
以上、よだかのおまけコーナーでした!
現在のコード:自分用ログ
#!/usr/bin/env python3
"""
MMBot v1.7  (2025‑04‑22)  —  Mainnet‑ready + safety guards
────────────────────────────────────────────────────────────────────
■ 機能
1. スプレッド検出で指値 2 本   … place_limit_order()
2. タイムアウトで自動キャンセル … wait_for_fill() → cancel_order()
3. loguru でファイル & コンソールログ (日次ローテーション 7 日保持)
4. 約定結果を SQLite に保存し、サイクル毎に累計 PnL を計算
5. 追加ガード
   • DB を mainnet / testnet で分離
   • 1 回あたりの想定建玉 (qty*price) 上限           MAX_NOTIONAL_USD
   • 連続 3 回注文失敗で自動停止                     consecutive_errors
   • 任意サイクル数で自動終了 (デフォ 0 = 無限)      max_cycles
"""
from __future__ import annotations
import asyncio, json, time, hmac, hashlib, sqlite3, sys
from argparse import ArgumentParser
from pathlib import Path
from urllib.parse import urlencode
import aiohttp, pybotters
from loguru import logger
# ────── 0. 定数 ───────────────────────────────────────────
LOG_FILE            = "mm_bot_{time:YYYYMMDD}.log"
MAX_NOTIONAL_USD    = 50      # ここを超える注文は無視
ERROR_THRESHOLD     = 3       # 連続失敗で停止
# これらは __main__ で上書き
EXCHANGE_MODE: str  # 'mainnet' | 'testnet'
REST_BASE: str
WS_PUBLIC: str
DB_FILE: Path
# ────── 1. 共通ユーティリティ ─────────────────────────
def create_signature(cfg: dict, body: str = "", params: dict | None = None) -> dict:
    ts  = str(int(time.time() * 1000))
    api, sec = cfg["bybit"]["apiKey"], cfg["bybit"]["secret"]
    rw  = cfg.get("recv_window", "5000")
    payload = urlencode(sorted(params.items())) if params else (body or "")
    msg = ts + api + rw + payload
    sig = hmac.new(sec.encode(), msg.encode(), hashlib.sha256).hexdigest()
    return {
        "Content-Type":       "application/json",
        "X-BAPI-API-KEY":     api,
        "X-BAPI-TIMESTAMP":   ts,
        "X-BAPI-SIGN":        sig,
        "X-BAPI-RECV-WINDOW": rw,
    }
async def place_limit_order(sess, cfg, sym, side, qty, price) -> str:
    body = json.dumps({
        "category":"linear","symbol":sym,"side":side,
        "orderType":"Limit","qty":str(qty),"price":str(price),"timeInForce":"GTC"
    })
    async with sess.post(REST_BASE+"/v5/order/create",
                         headers=create_signature(cfg, body), data=body) as r:
        j = await r.json()
    if j.get("retCode") != 0:
        raise RuntimeError(j)
    return j["result"]["orderId"]
async def cancel_order(sess, cfg, sym, oid) -> bool:
    body = json.dumps({"category":"linear","symbol":sym,"orderId":oid})
    async with sess.post(REST_BASE+"/v5/order/cancel",
                         headers=create_signature(cfg, body), data=body) as r:
        j = await r.json()
    ok = j.get("retCode") == 0
    logger.info(f"cancel {oid} → {ok}")
    return ok
async def wait_for_fill(sess, cfg, sym, oid, timeout=30, interval=1.0):
    end = time.time() + timeout
    path = "/v5/order/realtime"
    while time.time() < end:
        params = {"category":"linear","symbol":sym,"orderId":oid}
        hdr    = create_signature(cfg, "", params)
        url    = f"{REST_BASE}{path}?{urlencode(sorted(params.items()))}"
        async with sess.get(url, headers=hdr) as r:
            j = await r.json()
        if j.get("retCode")==0 and (d:=j["result"].get("data")):
            info = d[0]
            if info.get("orderStatus") == "Filled":
                return True, float(info.get("price", 0.0))
        await asyncio.sleep(interval)
    logger.warning(f"wait_for_fill timeout → cancel {oid}")
    await cancel_order(sess, cfg, sym, oid)
    return False, 0.0
async def notify_slack(url, msg):
    async with aiohttp.ClientSession() as s:
        await s.post(url, json={"text":msg})
# ────── 2. DB ─────────────────────────────────────────────
def init_db() -> sqlite3.Connection:
    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, sym, side, qty, price):
    conn.execute("INSERT INTO trades VALUES (?,?,?,?,?)",(ts,sym,side,qty,price))
    conn.commit()
def calc_pnl(conn, sym) -> float:
    cur = conn.cursor()
    cur.execute(
        "SELECT side, qty, price FROM trades "
        "WHERE symbol=? ORDER BY ts DESC LIMIT 2",(sym,))
    rows = cur.fetchall()
    if len(rows)==2 and {r[0] for r in rows}=={"Buy","Sell"}:
        sell = next(p for s,q,p in rows if s=="Sell")
        buy  = next(p for s,q,p in rows if s=="Buy")
        return (sell-buy)*rows[0][1]
    return 0.0
# ────── 3. メインループ ──────────────────────────────
async def run_loop(sym: str, qty: float, s_entry: float,
                   cfg: dict, interval: int, max_cycles: int):
    global DB_FILE
    DB_FILE = Path(f"mmtrades_{EXCHANGE_MODE}.db")
    conn = init_db()
    orderbook: dict[str,float] = {}
    cycles = 0
    consecutive_errors = 0
    logger.info(f"MMBot start  mode={EXCHANGE_MODE}")
    async with pybotters.Client(base_url=WS_PUBLIC) as ws, aiohttp.ClientSession() as sess:
        await ws.ws_connect(
            WS_PUBLIC,
            send_json=[{"op":"subscribe","args":[f"orderbook.1.{sym}"]}],
            hdlr_json=lambda m,w:(
                orderbook.update({"ask":float(m["data"]["a"][0][0])}) if m.get("data") and m["data"].get("a") else None,
                orderbook.update({"bid":float(m["data"]["b"][0][0])}) if m.get("data") and m["data"].get("b") else None
            )
        )
        while "bid" not in orderbook: await asyncio.sleep(0.3)
        while True:
            if max_cycles and cycles >= max_cycles:
                logger.warning("Reached max_cycles → exit")
                return
            bid, ask = orderbook["bid"], orderbook["ask"]
            spread_pct = (ask - bid) / bid
            logger.debug(f"spread={spread_pct:.6f}")
            if spread_pct > s_entry:
                notional = abs(qty) * ask
                if notional > MAX_NOTIONAL_USD:
                    logger.warning(f"notional {notional:.2f} > limit {MAX_NOTIONAL_USD} → skip")
                    await asyncio.sleep(interval); continue
                logger.info("spread OK → send orders")
                buy_p, sell_p = int(bid - 1), int(ask + 1)
                try:
                    buy_id  = await place_limit_order(sess, cfg, sym, "Buy",  qty, buy_p)
                    sell_id = await place_limit_order(sess, cfg, sym, "Sell", qty, sell_p)
                    consecutive_errors = 0
                except Exception as e:
                    consecutive_errors += 1
                    logger.error(e)
                    await notify_slack(cfg["slack_webhook_url"],
                        f":warning: order error ({consecutive_errors}/{ERROR_THRESHOLD}): {e}")
                    if consecutive_errors >= ERROR_THRESHOLD:
                        logger.critical("too many errors → exit")
                        return
                    await asyncio.sleep(interval)
                    continue
                filled_b, pr_b = await wait_for_fill(sess, cfg, sym, buy_id)
                filled_s, pr_s = await wait_for_fill(sess, cfg, sym, sell_id)
                ts = int(time.time()*1000)
                if filled_b: record_trade(conn, ts, sym, "Buy",  qty, pr_b)
                if filled_s: record_trade(conn, ts, sym, "Sell", qty, pr_s)
                pnl = calc_pnl(conn, sym)
                logger.success(f"cycle {cycles}  pnl={pnl:.4f}")
                await notify_slack(cfg["slack_webhook_url"],
                    f"📈 *MMBot* cycle {cycles}\n"
                    f"spread `{spread_pct:.5f}`\n"
                    f"Buy @{buy_p} → {'Fill' if filled_b else 'Cancel'}\n"
                    f"Sell@{sell_p} → {'Fill' if filled_s else 'Cancel'}\n"
                    f"PnL `{pnl:.4f}`")
                cycles += 1
                await asyncio.sleep(interval)
            else:
                await asyncio.sleep(1)
# ────── 4. CLI & Entry ────────────────────────────────
def cli():
    p = ArgumentParser()
    p.add_argument("--mode",    choices=["mainnet","testnet"], default="mainnet")
    p.add_argument("--config",  default=None)
    p.add_argument("--symbol",  default="BTCUSDT")
    p.add_argument("--lot",     type=float, default=0.001)
    p.add_argument("--s_entry", type=float, default=0.0005)
    p.add_argument("--interval",type=int,   default=10)
    p.add_argument("--max_cycles", type=int, default=20,
                   help="0=無限  n>0 でその回数で自動停止")
    return p.parse_args()
if __name__ == "__main__":
    args = cli()
    cfg_file = args.config or f"config_{args.mode}.json"
    if not Path(cfg_file).exists():
        sys.exit(f"Config not found: {cfg_file}")
    with open(cfg_file) as f: cfg = json.load(f)
    EXCHANGE_MODE = args.mode
    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]
    logger.remove()
    logger.add(lambda m: print(m, end=""))
    logger.add(LOG_FILE, rotation="00:00", retention="7 days", compression="zip")
    try:
        asyncio.run(run_loop(
            sym        = args.symbol,
            qty        = args.lot,
            s_entry    = args.s_entry,
            cfg        = cfg,
            interval   = args.interval,
            max_cycles = args.max_cycles,
        ))
    except KeyboardInterrupt:
        logger.warning("Interrupted by user")
起動コマンド
python MMbotbybit.py \ --mode mainnet \ --config config_mainnet.json \ --symbol BTCUSDT \ --lot 0.001 \ --s_entry 0.0005 \ --interval 10 \ --max_cycles 20
