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
)