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/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 とは
項目 | 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 )