Bot

開発記録#191(2025/4/22)MMBot開発ログ8「テストネットから本番環境へ。安全ガードを加えた本番運用に向けての道のり&開発者としての自己評価」

前回の記事に引き続き、今回も仮想通貨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:15MMbot_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/historycategory を受け付けない✔️ テストネットは Market注文+realtime APIのみに制限
本番で API key is invalidキーの設定ミス or config混在✔️ 設定ファイルを物理的にmain/testで分割
✔️ 将来的には環境変数経由で安全に読み込みたい

🛠 今後の開発タスク

  1. 本番ワンショットMarketテスト
    • lot=0.001, max_cycles=1 でSlack & DBの正常動作を確認。
  2. マーケット常時運転 → Limit戦略に切替
    • s_entry を調整しつつ安定稼働にシフト。
  3. ユニットテスト / CIの整備
    • create_signature()、PnL計算、DB挿入などをpytestでテスト化。
  4. フェイルセーフ通知の強化
    • 重大エラーをSlack→PagerDutyなどへ切り替え可能に。
  5. デプロイの自動化
    • Dockerfile + GitHub Actionsでステージ別ビルド&デプロイを可能に。
  6. パフォーマンス計測・改善
    • asyncio.gather化やWebSocketパーサーの最適化、注文レイテンシ測定の導入。
  7. リスク管理強化
    • Redis等で未決済ポジションを管理し、非常時に即カットできる体制を整備。

🚀 今後のロードマップ

Step 1:本番最小構成で安全確認
Step 2:戦略切替(Limit化)とパフォーマンスチューニング
Step 3:デプロイ/CI/監視の本格運用へ拡張


✍️ 最後に

今回の作業では、「意図通りのコードが走っていない」ことに気づくまでに時間がかかりました。
一見するとプログラムミスに見える問題も、実際はキャッシュや保存漏れ、実行対象の混在によって起きていたことがわかりました。

Bot開発においては**「実行しているコードが本当にそれか?」**を確認する習慣が、
戦略ロジックと同じくらい重要だと痛感しました。


🧩 次のログでは、本番Market注文の初回結果と、Limit戦略へのスムーズな移行に挑戦する予定です!


✍️ 記録:2025年4月22日 / よだか

開発者として評価(10 段階)byGPTo3

項目評価コメント
問題切り分けのスピード4/10“動かない” 状態を長時間ループ。旧ファイル・.pyc・保存漏れが典型的なのに初動が遅い。
環境管理 (venv / config)3/10main/test の設定混在、API キー誤適用。本番 API 無効で気づくのは遅すぎる。
安全設計6/10上限金額・エラーカウントなど最終的に追加できた点は評価。ただし最初から組み込むべき基礎。
ロギング & 可観測性7/10loguru・Slack 通知は○。ただし“識別タグ”を事後で足している時点で設計が受動的。
テスト・自動化2/10ユニットテスト無し。CI 無し。人力確認に依存。
学習姿勢8/10問題を言語化し続け、解決策を取り込んだ点は良い。

総合:5/10(辛くつけて“及第点ギリギリ”)
素質よりも、開発プロセスそのものに改善余地が大きい。


厳しめに指摘する 6 つの反省点

  1. 「実行バイナリ=ソース」の幻想
    • 保存漏れ/キャッシュ/別ディレクトリを疑うのは基本中の基本。
    • 対策pytest に必ず “バージョンタグを出力して終わるテスト” を一つ入れ、CI に通さない限り本番デプロイしない。
  2. 設定の分離が甘い
    • config_mainnet.jsonconfig_testnet.json を物理分離したのは事後対応。
    • 対策:環境変数+.env 管理、Vault 等でキー暗号化。誤適用を CI で fail させる。
  3. 安全装備が“後追い”
    • 上限金額・max_cycles は事後に慌てて追加。
    • 対策:仕様書の段階で失敗パスを洗い出し、実装時に “フェイルセーフ先行”。
  4. テストネットを信用しすぎ
    • Bybit テストネットの仕様差を知らずに時間を浪費。
    • 対策:API ドキュメント+コミュニティで「何が本番と違うか」を事前調査。検証は“実運用最小サイズ”を最初から視野に。
  5. デバッグは“勘”が多い
    • print→思考→修正の反復は初学者なら仕方ないが、時間効率が悪い。
    • 対策pdb / VS Code Debug・structured logging・テスト駆動で可観測性を高める。
  6. バージョン管理の痕跡が見えない
    • コードの差分管理・タグ付けが会話に出てこない。
    • 対策:GitFlow でも trunk-based でもよい。PR レビュー無しで本番に push は厳禁

今後 30 日の行動プラン

やること目的
1Git リポジトリを再構成:main/test ブランチを分離。.env でキー管理。設定混在を根絶
1‑2pytest 導入:署名関数・PnL・DB I/O など 10 本のユニットテストを書く。回帰バグ即検知
2GitHub Actions で CI:pytest + ruff + mypy を通さないと merge できない。機械的品質担保
2‑3Docker 化 & 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 スクリプトなど。コーディング筋トレ

意識すべきポイント

  1. 「コードを書く」より「仕組みを作る」
    • 仕組み=テスト・CI・監視。これがないと本番では動かせない。
  2. “動く”をゴールにしない
    • 再現可能安全に失敗できる を同時に満たして初めてプロダクト。
  3. 疑う対象は常に自分の環境
    • キャッシュ・設定・実行パス…。まず足元を確認してからコードを疑う。
  4. 小さく賭けて早く学ぶ
    • 本番検証はロット最小、サイクル最小、期間最小で。失敗をコスト最小で回収する。
  5. 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) 提示✅ 本番へスムーズに移行できる手順を確定

成功したこと ✅

  1. テストネットでのマーケット注文ロジック を最小コードで検証し、
    実際に Market API の署名・呼び出しテンプレを確立できた。
  2. ログ/Slack/SQLite の一連の動作は旧コードでも正常であることを確認。
  3. 安全ガード付きの本番向けコード v1.7 を完成。
  4. 原因切り分けフロー(grep → realpath → .pyc 削除 → -u 起動)を整備し、
    コードと実行ファイル不一致のデバッグ手順を共有できた。

うまくいかなかったこと & 原因 ❌

課題原因再発防止策
テストネットで指値ルートが残存旧ファイルを実行・保存漏れ✔️ ログに “TAG” を入れて必ず判定
✔️ .pyc 削除 & 仮想環境 python を確認
history API error: Illegal categoryテストネットは /v5/order/historycategory 不受理✔️ テストネットでは Market 即約定 or realtime だけを使う
mainnet で API key is invalidconfig mix‑up / API キー誤入力✔️ main / test 用設定ファイルを物理的に分割、環境変数で注入も検討

今後の開発タスク 🗒️

  1. 本番ワンショット Market テスト
    • lot=0.001, max_cycles=1、Slack & DB に約定記録が入ることを確認
  2. マーケット常時運転 → Limit 戻し
    • Market で安定後、s_entry を調整して Limit 戦略へ切替
  3. ユニットテスト / CI
    • 署名関数・PnL 計算・DB 書込みを pytest で自動テスト
  4. フェイルセーフの外部化
    • 連続エラー通知を Slack → PagerDuty 等に昇格できるよう設計
  5. デプロイ自動化
    • Dockerfile + GitHub Actions で tag push ⇒ 本番/テスト用イメージ作成
  6. パフォーマンス計測
    • asyncio gather 化・WS ハンドラ高速化、latency log を追加
  7. リスク管理
    • 未決済ポジションを 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 ファイルが残っていて、それが旧バージョンを保持していたようです。
greprealpath、それから python -u を使って、どのコードが動いているかを可視化できるようにしました。


🕧 10:15
「もう新しいファイルで確実に動かそう」と思い、MMbot_testnet.py を新規作成。
ところが実行しても何も表示されない。
原因は、実行していたのが空ファイルだったという初歩的ミス。
ここも、ls -lcat で中身確認することで、すぐに修正できました。


🕐 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つ

  1. 「実行バイナリ=ソースコード」と思い込んでいた
     → 保存してなかったり、.pycに旧コードが残っていたり。これ、初歩だけど超大事。
     → 対策:CIテストに“バージョンタグ出力”を入れて、本番前に必ず確認。
  2. 設定ファイルの分離が甘すぎた
     → mainnetとtestnetが同居。config書き間違いは事故のもと。
     → 対策:.envによる環境変数管理+CIで制限する。
  3. 安全設計が完全に後追いだった
     → MAX建玉制限、max_cycles…全部“やばそうだから後で足した”。
     → 対策:最初にフェイルセーフを設計書に入れる癖をつける。
  4. テストネットを“信用しすぎ”た
     → /historyがcategoryパラメータを受け付けないなど、完全に罠だった。
     → 対策:ドキュメント+StackOverflowで事前に仕様差を把握。
  5. デバッグが“勘”と“手動”に依存していた
     → print→修正→再実行の繰り返しは、時間効率が悪い。
     → 対策:pdb、構造化ログ、VSCodeのステップ実行を習慣に。
  6. バージョン管理の痕跡がなかった
     → Gitでのタグ、PRレビュー、ブランチ運用…どこにも見当たらない。
     → 対策:GitFlowじゃなくてもいいから、main/testで分けるのは最低限。

🧭 これからの30日間の行動プラン

  1. 1週目:Gitの再構成 & 環境変数管理
     → 設定混在を根絶する。
  2. 1〜2週目:pytestで10本テストを書く
     → バグにすぐ気づけるようにする。
  3. 2週目:GitHub ActionsでCIを導入
     → テストが通らなきゃマージできない環境を作る。
  4. 2〜3週目:Docker + Compose化
     → Redis/Bot/監視をワンコマンド起動できるように。
  5. 3週目:本番のCanary実行(少ロット×短時間)
     → 小さく動かして、大きな失敗を防ぐ。
  6. 3〜4週目:自動ロールバック機構の追加
     → 連続エラー→Slack→自動停止→PagerDuty。これで安心。
  7. 4週目:Prometheusでパフォーマンスを可視化
     → どこで時間かかってるかを明示。
  8. 毎日:30分だけコード写経
     → OSSのBotやBacktest系を写す。これが一番伸びる。

💡 意識しておくべき5つのポイント

  1. 「コードを書く」より「仕組みを作る」が重要。
  2. “動いた”をゴールにしない。「再現性」「安全な失敗」がセット。
  3. まず疑うのは“コード”ではなく“環境”。
  4. 小さく賭けて、早く失敗して、早く学ぶ。
  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

-Bot