AI自動売買の結果をDiscordに自動通知する仕組みを実装した
- この記事で分かること
- 無人で動く売買パイプラインの実行結果・エラーをDiscordのincoming webhookに自動通知する仕組みと、通知の失敗が本体処理を止めないようにする設計
- 実際に使った環境
- Python / httpx / Discordのincoming webhook。AI日本株自動売買実験(`trading/`サブプロジェクト)の本番実装
- 筆者の結論
- 通知の中身(銘柄名・価格・損益)を作り込むより、「通知が失敗してもジョブ本体は絶対に止めない」設計と、テストが本物に届いてしまった事故の再発防止の方が実装の肝でした
AI日本株自動売買の実験は、平日の取引時間中に1時間おきの実発注、引け後に記事・動画生成と、すべて無人のスケジュール運用で回しています。無人であるがゆえに「今日は本当にちゃんと動いたのか」「エラーで止まっていないか」に気づく手段が別途必要になり、Discordのincoming webhookへの自動通知を実装しました。
🔵 実装してみて思うこと
通知機能自体はincoming webhookにJSONをPOSTするだけで、コードとしては数十行で終わりました。実装していて一番緊張したのは機能そのものより、後述する「テストが誤って本物のDiscordに通知を送ってしまった」事故の後始末です。無人運用は「動いていることに気づける」だけでなく「テストが本番環境を巻き込まない」ことも同じくらい重要だと痛感しました。
通知の送り先はDiscordのincoming webhookだけ
Discord Bot APIのような本格的な仕組みは使わず、サーバー設定から発行できるincoming webhook URLに対して、JSONをPOSTするだけのシンプルな実装にしています。
DISCORD_CONTENT_LIMIT = 2000
"""Discord webhookのcontentフィールドの文字数上限。"""
def send_discord_message(content: str, *, webhook_url: str | None = None) -> None:
"""Discordのincoming webhookへメッセージを送信する。
webhook_url省略時はsettings.alert_webhook_urlを使う。空文字なら
何もしない(通知は任意機能)。送信失敗時はログに残すだけで例外は
送出しない。
"""
url = webhook_url if webhook_url is not None else settings.alert_webhook_url
if not url:
return
if len(content) > DISCORD_CONTENT_LIMIT:
content = content[: DISCORD_CONTENT_LIMIT - 1] + "…"
try:
response = httpx.post(url, json={"content": content}, timeout=10.0)
response.raise_for_status()
except Exception:
logger.exception("Discordへの通知送信に失敗しました")ポイントは2つあります。1つは、webhook URLが未設定(空文字)の場合は何もせず即座に返すこと。通知は本体運用に必須の機能ではないため、設定していない環境でもエラーにならないようにしています。もう1つは、送信自体が失敗しても例外を外に投げないこと。通知の失敗でスケジューラ本体が止まってしまっては本末転倒だからです。
どこから呼んでいるか:実発注ジョブの結果を毎回通知する
呼び出し元は、1時間おきに実行される実発注ジョブ(run_live_trading_job)です。発注の有無やエラーの種類によって、送るメッセージを出し分けています。
if result.aborted:
send_discord_message(f"⚠️ [{now_jst}] 実発注ジョブ: kabuステーションへの接続に失敗し、今回はスキップしました。")
elif result.orders_submitted:
send_discord_message(
f"🤖 [{now_jst}] 実発注ジョブ実行\n"
f"総資産: {result.total_asset:,.0f}円\n"
f"判断{result.decisions_made}件 / 発注{result.orders_submitted}件\n" + "\n".join(order_lines)
)
else:
send_discord_message(
f"🤖 [{now_jst}] 実発注ジョブ実行: 発注なし(判断{result.decisions_made}件、"
f"総資産{result.total_asset:,.0f}円)"
)例外発生時も同様に、except節で捕まえてから通知を送っています。
except Exception as exc:
logger.exception("live trading job failed")
with session_scope() as session:
session.add(SystemEvent(level="ERROR", category="live_trading_job_failed", message=str(exc), detail={}))
send_discord_message(f"❌ [{now_jst}] 実発注ジョブでエラーが発生しました: {exc}")「発注あり」「発注なし」「接続失敗」「予期せぬエラー」の4パターンを分けて通知することで、Discordを開いた瞬間にどの状態だったかが一目で分かるようにしています。
通知の中身:銘柄名・価格・損益まで表示する
当初は「発注N件」のような件数だけの通知でしたが、それだけでは何が起きたのか分からないため、1件ずつ「銘柄名(コード) 株数 売買 @価格」の形式に整形し、SELLの場合は損益も付け加えました。
def _format_order_line(session, broker, positions: dict, order: Order) -> str:
name = symbol_name(order.symbol)
display_name = f"{name}({order.symbol})" if name else order.symbol
if order.side == "SELL":
price = ... # 判断時点の市場データを優先、無ければ現在値
held_avg_cost = _held_avg_cost_for_decision(session, order.symbol, "SELL", order.submitted_at)
if held_avg_cost is not None:
pnl = (price - held_avg_cost) * order.qty
pnl_text = f" (損益: {'+' if pnl >= 0 else ''}{pnl:,.0f}円)"
else:
pnl_text = ""
return f"・{display_name} {order.qty}株 売却 @{price:,.1f}円{pnl_text}"
...
return f"・{display_name} {order.qty}株 購入 @{price:,.1f}円"「購入」「売却」という日本語表記にしたのも、通知を読む一瞬で意味が伝わるようにという理由からの意図的な選択です(BUY/SELLの英語表記より読みやすいと判断しました)。
注意
この価格・損益の推定は、正式な約定価格を記録する`Execution`行の実装が未完成なための暫定処理です。当初は売却時に現在値を使う設計にしていましたが、同じ銘柄を当日中に買って売って手仕舞うと、通知作成時点では保有ポジションが既に無いため購入価格の表示も現在値に引っ張られ、「買値と売値が同じに見える」実バグが起きました。判断のきっかけになったAIの市場データ(発注時点に近いスナップショット)を優先する方式に直して解消しています。
テストが本物のDiscordに通知を送ってしまった事故
実装していて一番肝を冷やしたのは、機能そのものではなくテストでした。pytestでスケジューラジョブのテストを実行した際、実際に設定していたDiscordのwebhookにテスト用のダミーメッセージ(「GROQ_API_KEYが未設定です」「VIDEO123」のような明らかにテスト用の文字列)が本物のDiscordチャンネルに届いてしまったのです。原因は単純で、テストコードがsettings.alert_webhook_urlを明示的に無効化していなかったため、.envに設定した本物のwebhook URLがそのままテスト実行時にも読み込まれていたためでした。
対策として、tests/conftest.pyにすべてのテストへ自動適用される(autouse)フィクスチャを追加し、テスト実行中は強制的にwebhook URLを空文字にするようにしました。
@pytest.fixture(autouse=True)
def _disable_discord_webhook(monkeypatch):
monkeypatch.setattr(settings, "alert_webhook_url", "")実際のtests/conftest.pyには、この事故の経緯そのものをコメントとして残しています。
"""pytest共通フィクスチャ。
2026-09-11、`test_scheduler_jobs.py`のテストが`settings.alert_webhook_url`
(ユーザーが実際に設定した本物のDiscord webhook URL)を無効化しないまま
`send_discord_message`を呼び出し、テスト用のダミーメッセージが実際のDiscord
チャンネルへ送信されてしまう事故が起きた。テスト全体でalert_webhook_urlを
強制的に空文字にし、個別のテストが明示的に上書きしない限り、外部へは
一切送信されないようにする。
"""
@pytest.fixture(autouse=True)
def _disable_discord_webhook_by_default(monkeypatch):
monkeypatch.setattr(settings, "alert_webhook_url", "")このフィクスチャを追加した後、実際にテストスイートを実行した結果です。15件すべて合格しています。
$ pytest tests/test_scheduler_jobs.py -v
...
tests/test_scheduler_jobs.py::test_format_order_line_uses_decision_price_not_current_price_after_same_day_round_trip PASSED [100%]
============================= 15 passed in 3.51s ==============================
このバグから得た教訓は明確です: 外部への通知・投稿を伴う機能をテストするときは、実際の送信先(webhook、API、メール等)が本物の設定のままテストに紛れ込んでいないかを、機能実装の直後に必ず確認する必要があります。今回は個人のDiscordチャンネルへの誤送信で実害は軽微でしたが、これがSNS投稿やメール送信であればもっと深刻な事故になっていたはずです。
よくある質問
Q. Discordの通知が来ない場合、まず何を確認すればいいですか?
A. .envのALERT_WEBHOOK_URLが空のままだと、送信処理自体が何もせず終了する設計にしています(未設定でもジョブが落ちないようにするため)。まずこの値が設定されているか、Discord側でwebhook URLが無効化されていないかを確認してください。
Q. 通知の送信に失敗したら、売買処理自体も止まってしまいますか?
A. 止まりません。send_discord_messageは送信中の例外を内部でキャッチしてログに残すだけで、呼び出し元には一切伝播させない設計にしています。通知はあくまで「気づくための手段」であり、本体の売買処理を巻き込んで止めてはいけないという方針です。
まとめ
Discord通知そのものは「webhookにJSONをPOSTするだけ」という小さな実装ですが、無人運用の安心感には直結する機能でした。実際、運用初日に総資産の計算が誤っていることにDiscordの通知経由ですぐ気づけたこともあり、売買ロジックの解説記事で触れているリスク管理ルールと同じくらい、無人運用を支える実用的な仕組みだと感じています。
AI日本株トレード実験