前回(開発史7)の末尾で予告したとおり、今回はこのBotの作りを書きます。これまでの記事は成績の話ばかりで、何がどう動いているのかを一度も書いていませんでした。
先に1枚の図にまとめます。

以下、図の左から右へ説明します。
入口: gRPCのストリーム1本に、フィルター2枚
Botが見ているのは、Solanaのチェーン上で起きるPump.fun関連のトランザクションです。これをRPCで何度も問い合わせるのではなく、gRPCのストリームで押し出してもらう形にしています。ストリームは1本で、フィルターが2枚かかっています。
- 全Pump.fun — 新しい銘柄の発行、買い、売り。成功したトランザクションだけ。
- 自分のウォレット — 自分が送ったトランザクションの着地。成功も失敗も両方。
2枚目が、後で出てくる「第2エンジン」の目になります。
ストリームは切れます。切れたら3秒待って張り直すループは最初からありましたが、8月に「切れたことに気づけない」障害が起きました。接続は生きているように見えて、何も流れてこない。これは接続の状態を見ても分かりません。対策は2つで、通信路の生存確認(keepalive)を入れたことと、「45秒イベントが来なかったら切れたとみなして張り直す」見張り番をエンジン側に置いたことです。10月には深夜に短い切断が57回続いた日がありましたが、見張り番が毎回張り直して、エンジン自体は止まりませんでした。
本体: Rustの1プロセスに、7つの仕事
実行エンジンはRustで書いた1つのプログラムです。中では、役目の違う7つの非同期タスクが同時に走っています。
| 記号 | 仕事 | 中身 |
|---|---|---|
| A | 受信 | ストリームを読んで、エンジン内部のイベントに変換する |
| B | 指揮 | 銘柄ごとの板(仮想のSOLとトークンの残高)とホルダー数を更新し、入口の条件を判定し、保有中は毎イベントで出口を判定する |
| C | 発注 | 買い・売りのトランザクションを組んで送る。DryRunのときは送らずに記録する |
| D | ブロックハッシュ | 1秒ごとに最新のブロックハッシュを取り直す(送信に必要) |
| E | 残玉そうじ | 売り損ねて残ったトークンを後から売る。実弾のときだけ動く |
| F | 確認 | 送ったトランザクションをRPCで取り直し、実際に受け取った数量と支払ったSOLを確定する |
| G | 第2エンジン | 自分のトランザクションがチェーンに載った瞬間を、ストリームのほうから検知して知らせる |
この7つの間は、Rustのチャネル(片方向のメッセージの通り道)でつながっています。
設計で一番大事にしているのは、状態を書き換えられるのはBだけ、という決まりです。「いま買っている最中か」「保有中か」「売っている最中か」という状態と、保有しているポジションの中身は、Bの中にしかありません。他のタスクはBにメッセージを送るだけで、Bの持ち物を直接いじることはできません。これを守ると、同時に走る7つのタスクが互いの足を踏まなくなります。ロックの取り合いもありません。
第2エンジン: 着地を「先に」知る
図のGが、このBotを「ツインエンジン」と呼んでいる理由です。
買いのトランザクションを送ったあと、Botは「本当に載ったのか」「いくらで約定したのか」を知る必要があります。普通にやると、RPCに問い合わせて返事を待つ(Fの仕事)ことになり、これが遅い。4月の計測では、買いの確認に平均2,069ms、売りの確認に2,075msかかっていました。
そこで、ストリームの2枚目のフィルターで自分のウォレットのトランザクションを拾い、チェーンに載った瞬間にBへ「着地した」と知らせる専用のタスクを足しました。これがGです。Gは状態を一切持ちません。拾って、分類して、知らせるだけです。
Gを足したあとの計測では、買いの確認が267ms、売りの確認が159msになりました。Fは廃止せず、Gの後ろで実際の約定値を取り直す役に回っています。先に知って動き、後から正確な数字で帳尻を合わせる、という二段構えです。計測した9回すべてで、Gのほうが先に着いていました。
理論値と実弾は、同じプログラム
これまでの記事で「理論値(DryRun)」と書いてきたものの正体は、このエンジンそのものです。
起動時の設定が1つあり、DryRunのときは、Cが「送る」代わりに「送ったことにして記録する」だけになります。入口の判定も出口の判定も、板の更新も、実弾のときと同じコードが同じ順番で走ります。違うのは最後の1歩だけです。
DryRunの損益は、板の式(定数積の自動マーケットメーカー)で「このとき0.2 SOL買っていたら、売ったときいくら戻るか」を計算した値です。決済のたびに1件ずつJSONで保存され、それを並べたのが前回までの累積カーブです。
実弾のときは、Cが本当に署名して送ります。送り先はRPCで、売りには逃げ道が3段あります。まずPump.funの板に直接、だめならDFlow、それもだめならJupiterです。ボンディングカーブを卒業した銘柄は板が変わるので、この順番が要ります。
同じプログラムで動くことの効用は、「DryRunで良かったのに実弾で違う動きをした」という疑いを、コードの差ではなく、開発史(4)で書いた三つの溝(固定費・執行・地合い)に絞れることです。
通知は、UDPで1行投げるだけ
エンジンはDiscordを直接叩きません。決済や入場のたびに、ローカルのUDPポートへJSONを1行投げるだけです。
受け取るのはPythonの監視役で、こちらが文面を整え、損益グラフなどを描いて、Discordに送ります。エンジンの側に「通知が送れなかったら」という分岐を持たせたくなかったので、こうなっています。Discordが落ちても、グラフの描画で何かが壊れても、エンジンの売買には影響しません。実際、10月末に監視役のグラフ表示を直したときも、監視役だけを14秒止めて差し替え、エンジンは触っていません。
置き場所: 1 vCPU・1GBのVPSが3台
これだけの仕事をしているエンジンですが、置き場所は小さなVPSです。3台とも1 vCPU・1GB RAMで、重い計算(過去の記録の再生、検定)はMacBook側でやります。
役割は分けています。
- 記録機: 同じエンジンをDryRunで回し続ける係。鍵を持ちません。前回の1,251本は、この1台が36日間止まらずに書いた帳簿です。
- 実弾機: 同じバイナリをLiveで武装する係。鍵はこの1台にしかありません。今は止めています。
- 予備・分析: 過去のストリーム記録の再生と検定。
「記録機と実弾機で同じバイナリが動いていること」は、ファイルのハッシュを突き合わせて確かめています。片方だけに手が入ると、理論値と実弾の比較が意味を失うからです。
壊れたところの記録
この構成は、一度にできたものではありません。壊れた順に書きます。
- 売りが2.8秒止まる(7月): 売りの手前に別の処理が割り込んでいた。順番を入れ替えて根絶し、売りの送信までが0.04〜0.07秒になった。
- 「切れたことに気づけない」ストリーム(8月): 上で書いたとおり。keepaliveと見張り番で対処。
- メモリ不足でビルドが止まる(1GBのVPSの宿命): 動かすのは平気だが、コンパイルの山で足りなくなる。ビルドのときだけ一時的にスワップを足し、終わったら外す運用に。
- 監視役が別のプロセスとポートを取り合って止まる(7月): 通知が途絶えたが、エンジンは無事だった。分離しておいてよかった例。
まだ直していないものもあります。入口の手前の数値の上限が広すぎて、本来通すべき銘柄の一部を門前で弾いている疑いが10月から残っています。影響の大きさは測定中で、直すときは理論値の記録に段差が入るので、日付を書き添えて直します。
なぜこの作りなのか
速さのためだけではありません。試す場所と、動き続ける場所を分けるためです。
入口や出口の条件を変える実験は、過去の記録の再生と理論値の記録でやります。実弾機のバイナリは、検証を通ったものしか置きません。状態を書けるのはBだけ、通知はエンジンの外、鍵は1台だけ、同じバイナリをハッシュで確認——どれも「思いつきの変更が本番の売買を壊さない」ための柵です。
開発史(1)で「アーキテクチャは速度のためでなく、規律のためにある」と書きました。この図は、その1行を具体にしたものです。
まとめ
- 入口はgRPCのストリーム1本・フィルター2枚。切れたことに気づく見張り番つき。
- 本体はRustの1プロセスに7つの非同期タスク。状態を書けるのは指揮役の1つだけ。
- 第2エンジンが自分の取引の着地を先に知らせ、確認が2秒から0.2〜0.3秒になった。
- 理論値と実弾は同じプログラム。違うのは「送るか、記録するか」の最後の1歩だけ。
- 通知はUDPで1行投げて、Pythonの監視役がDiscordへ。エンジンは外に依存しない。
- 置き場所は1 vCPU・1GBのVPS3台。記録機・実弾機・予備で役割を分け、同じバイナリをハッシュで確認している。
次は、実弾機を動かすときに書きます。
本記事の内容は2026年4月〜11月の当方の記録に基づきます。数字は当方の環境での計測値です。鍵の置き方・接続先・入口の条件の数値は非公開です。本記事は投資助言ではありません。暗号資産の自動売買には元本を失うリスクがあります。

コメント