ドル円FXアラートをPythonでDiscordに送る方法【LINE Notify終了後の移行ガイド】

自動化・運用

LINE Notifyのサービス終了が告知されたとき、僕が真っ先に思ったのは「アラートBotを作り直さないといけない」ということでした。子育て中でチャートを見る時間がないぶん、通知だけは絶対に止めたくなかったのです。

いくつか候補を比べた結果、Discordに乗り換えました。決め手は「登録不要で即使える」「無料で制限がゆるい」「グラフ画像もそのまま送れる」の3点です。

この記事では、通知の受け皿をどう選ぶかという比較から入り、Discord Webhookの実装まで解説します。単に送るだけでなく、実運用で困った「通知が多すぎる」「止まっても気づかない」への対処も含めてまとめました。

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

通知先の選択肢を比べる

まず候補を並べて比較しました。個人が株価アラートに使う前提での評価です。

通知先準備の手間費用画像送信備考
Discord数分無料Webhook URLを作るだけ
LINE Messaging APIやや面倒無料枠あり公式アカウントの作成が必要
Slack数分無料Discordとほぼ同等
メール簡単無料埋もれやすい
Telegram数分無料Bot作成が必要

LINE Messaging APIは公式が推奨する後継ですが、公式アカウントを作る必要があり、個人の通知用途には少し重いと感じました。無料枠のメッセージ数にも上限があります。

メールは手軽ですが、他のメールに埋もれて気づかないという致命的な弱点があります。相場の通知は、届いてすぐ気づけないと意味がありません。

結果としてDiscordを選びました。Webhook URLを1つ発行するだけで送信できる手軽さが圧倒的で、スマホアプリの通知も確実に届きます。

実際に使ってみて、想定していなかった利点もいくつかありました。

  • チャンネルを用途別に分けられる: 買いシグナル・売りシグナル・稼働報告を別々に
  • 過去の通知を検索できる: 「あのとき何時にシグナルが出たか」を後から追える
  • PCとスマホで同期する: どちらで見ても既読状態が揃う
  • チャンネルごとに通知設定を変えられる: 重要度に応じて音を鳴らすか選べる

特に2番目が地味に効いています。通知が記録として残るので、「このシグナルに従っていたらどうなっていたか」を後から検証できます。LINEでは流れていってしまっていた情報が、資産として蓄積されるようになりました。

📘 外部参考Discord Webhook(公式ドキュメント)LINE Messaging API(公式ドキュメント)

Discord Webhookを用意する

準備は5分もかかりません。手順は次のとおりです。

  • サーバーを作る: Discordアプリの左側「+」から。自分専用でよい
  • チャンネルを作る: #株価アラート のように用途別に分けると後が楽
  • Webhookを作成する: チャンネル設定 → 連携サービス → ウェブフックを作成
  • URLをコピーする: 「ウェブフックURLをコピー」を押す

このURLは実質的にパスワードと同じです。知っている人は誰でもそのチャンネルに投稿できてしまうので、コードに直書きせず環境変数に置いてください。

# .env に保存する(Gitには絶対に上げない)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1234567890/abcdefg...

環境変数の扱いはPython自動売買の開発環境セットアップ完全版で詳しく扱っています。

チャンネル設計についても、最初に決めておくことをおすすめします。あとから分けるのは面倒なので、僕は次のように用意しました。

チャンネル送るもの通知設定
#signals売買シグナルすべて通知
#daily毎日の稼働報告メンションのみ
#errorsエラーと警告すべて通知
#logs詳細な実行ログ通知しない

Webhookはチャンネルごとに作れるので、用途別に4つのURLを発行して使い分けています。重要なものだけスマホを鳴らす設定にすると、通知疲れを防げます。

最小構成で送ってみる

pip install requests python-dotenv yfinance pandas matplotlib

送信自体は驚くほど簡単で、POSTリクエストを1回投げるだけです。

import os
import requests
from dotenv import load_dotenv

load_dotenv()
WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")


def send(text):
    """Discordにテキストを送る"""
    if not WEBHOOK_URL:
        print("Webhook URLが未設定です")
        return False

    try:
        res = requests.post(WEBHOOK_URL, json={"content": text}, timeout=10)
        res.raise_for_status()
        return True
    except requests.RequestException as e:
        print(f"送信失敗: {e}")
        return False


send("テスト送信です")

スマホに通知が届けば成功です。ライブラリのインストールもトークンの発行も不要で、これだけで動きます。

ここでtimeout を必ず指定するのがポイントです。指定しないと、Discord側が応答しないときにスクリプトが延々と待ち続けます。自動実行では、これが原因で処理全体が止まることがあります。

見やすい通知にする

テキストをそのまま送るだけだと、スマホで見たときに何の通知か分かりにくいです。Discordには埋め込み(Embed)という装飾機能があるので、これを使います。

import datetime as dt

# 色は10進数で指定する(緑・赤・青)
COLOR = {"up": 0x2ECC71, "down": 0xE74C3C, "info": 0x3498DB}


def send_embed(title, description, fields=None, color="info"):
    """整形された通知を送る

    fields : [(見出し, 値, 横並びにするか), ...]
    """
    if not WEBHOOK_URL:
        return False

    embed = {
        "title": title,
        "description": description,
        "color": COLOR.get(color, COLOR["info"]),
        "timestamp": dt.datetime.now().astimezone().isoformat(),
        "footer": {"text": "株価アラート"},
    }
    if fields:
        embed["fields"] = [
            {"name": n, "value": v, "inline": inline}
            for n, v, inline in fields
        ]

    try:
        res = requests.post(WEBHOOK_URL, json={"embeds": }, timeout=10)
        res.raise_for_status()
        return True
    except requests.RequestException as e:
        print(f"送信失敗: {e}")
        return False


send_embed(
    title="📈 買いシグナル",
    description="トヨタ自動車 (7203.T)",
    fields=[
        ("終値", "3,150円", True),
        ("前日比", "+2.3%", True),
        ("RSI", "28.4", True),
        ("根拠", "RSIが30を下回り、MACDが好転しました", False),
    ],
    color="up",
)

inline=True にすると、項目が横並びになって省スペースになります。スマホの画面では、この差がかなり効きます。

色分けも実用的です。緑なら買い、赤なら売りと決めておけば、通知一覧をスクロールするだけで状況を把握できます。

チャート画像を一緒に送る

数字だけでは判断できないことも多いので、チャート画像も添付できるようにしておくと便利です。ここがLINE Notifyから乗り換えて一番良かった点でした。

import io

import matplotlib
matplotlib.use("Agg")          # 画面のない環境(VPS)で描画するため
import matplotlib.pyplot as plt
import yfinance as yf


def send_chart(ticker, name, days=90):
    """チャート画像を生成してDiscordに送る"""
    df = yf.download(ticker, period=f"{days}d", auto_adjust=True,
                     progress=False)
    if df.empty:
        return False

    close = df["Close"]
    ma25 = close.rolling(25).mean()

    fig, ax = plt.subplots(figsize=(10, 5))
    ax.plot(close.index, close, linewidth=1.5, label="終値")
    ax.plot(ma25.index, ma25, linewidth=1.2, linestyle="--", label="25日線")
    ax.set_title(f"{name} ({ticker})")
    ax.legend()
    ax.grid(alpha=0.3)
    fig.autofmt_xdate()

    # ファイルに保存せず、メモリ上で完結させる
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
    plt.close(fig)
    buf.seek(0)

    latest = float(close.iloc[-1])
    change = (latest / float(close.iloc[-2]) - 1) * 100

    try:
        res = requests.post(
            WEBHOOK_URL,
            data={"content": f"**{name}** {latest:,.1f}円 ({change:+.2f}%)"},
            files={"file": ("chart.png", buf, "image/png")},
            timeout=30,
        )
        res.raise_for_status()
        return True
    except requests.RequestException as e:
        print(f"送信失敗: {e}")
        return False


send_chart("7203.T", "トヨタ自動車")

実装で押さえるべき点が2つあります。

  • matplotlib.use("Agg"): VPSなど画面のない環境で描画するために必須
  • io.BytesIO で完結: 一時ファイルを作らないので、後始末が不要

1番目を忘れると、サーバー上で実行したときに「ディスプレイがない」というエラーで落ちます。ローカルでは動くのにVPSで動かない、という典型的なパターンです。

通知が多すぎる問題に対処する

運用を始めてすぐ直面したのがこれでした。条件に合致するたびに通知すると、1日に何十件も飛んできます。そうなると人間は見なくなります。

対策として、同じ銘柄の同じシグナルは一定時間送らないという仕組みを入れました。

import json
from pathlib import Path

STATE_FILE = Path(__file__).resolve().parent / "notify_state.json"
COOLDOWN_HOURS = 24


def _load_state():
    if STATE_FILE.exists():
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    return {}


def _save_state(state):
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=1),
                          encoding="utf-8")


def should_notify(key, cooldown_hours=COOLDOWN_HOURS):
    """同じシグナルを短時間に繰り返さないよう判定する

    key : "7203.T:BUY" のように銘柄とシグナル種別を組み合わせる
    """
    state = _load_state()
    now = dt.datetime.now()

    last = state.get(key)
    if last:
        elapsed = (now - dt.datetime.fromisoformat(last)).total_seconds() / 3600
        if elapsed < cooldown_hours:
            print(f"  スキップ: {key} は {elapsed:.1f}時間前に通知済み")
            return False

    state[key] = now.isoformat()
    _save_state(state)
    return True


# 使い方
if should_notify("7203.T:BUY"):
    send_embed("📈 買いシグナル", "トヨタ自動車", color="up")

これを入れてから、通知の数が1日20件から3件程度に減りました。数が減ったぶん、届いた通知はきちんと見るようになります。

そもそもシグナルの精度を上げて数を絞る、というアプローチも有効です。指標の組み合わせ方はRSI×MACDの逆張り戦略で扱っています。

止まっていることに気づく仕組み

もう1つ、実際に痛い目に遭ったのがこれです。スクリプトが止まっていたのに、2週間気づきませんでした。

原因は単純で、異常時にだけ通知する設計にしていたからです。完全に停止すると、異常検知そのものが動かないので何も届きません。「通知が来ない=相場が動いていない」と勝手に解釈していました。

def daily_heartbeat(checked_count, signal_count, errors=0):
    """1日1回、稼働状況をまとめて送る"""
    color = "down" if errors else "info"
    mark = "⚠️" if errors else "✅"

    send_embed(
        title=f"{mark} 本日の稼働レポート",
        description=f"{dt.datetime.now():%Y年%m月%d日} の実行結果",
        fields=[
            ("監視銘柄", f"{checked_count} 件", True),
            ("シグナル", f"{signal_count} 件", True),
            ("エラー", f"{errors} 件", True),
        ],
        color=color,
    )

シグナルがゼロでも必ず送るのがポイントです。「今日は0件でした」という通知が届けば、少なくともシステムは生きていると分かります。

通知チャンネルはアラート用と稼働報告用で分けておくとさらに使いやすくなります。毎日必ず来る稼働報告と、たまにしか来ない売買シグナルが混ざらずに済みます。

実運用で気をつけること

送信頻度の制限に引っかからないようにする

Discord Webhookには送信頻度の上限があります。短時間に大量送信すると一時的に拒否されるので、複数銘柄をループで処理する場合は間隔を空けます。

import time


def send_with_retry(payload, max_retries=3):
    """レート制限に対応した送信"""
    for attempt in range(max_retries):
        try:
            res = requests.post(WEBHOOK_URL, json=payload, timeout=10)

            if res.status_code == 429:      # 送信しすぎ
                wait = float(res.json().get("retry_after", 5))
                print(f"  レート制限。{wait:.1f}秒待機します")
                time.sleep(wait + 0.5)
                continue

            res.raise_for_status()
            return True
        except requests.RequestException as e:
            print(f"  試行{attempt + 1}: {e}")
            time.sleep(2 ** attempt)        # 待ち時間を倍にしていく
    return False


# 複数銘柄を送るときは、間に少し待ちを入れる
for code, name in [("7203.T", "トヨタ"), ("6501.T", "日立")]:
    send_with_retry({"content": f"{name} をチェックしました"})
    time.sleep(1)

通知の失敗で処理全体を止めない

Discord側の障害で送信に失敗することもあります。通知が送れなかったからといって、データ取得や記録まで止めるべきではありません。

本記事の関数がすべて try/except で囲んで False を返しているのは、このためです。通知は「あったら嬉しい機能」であって、処理の本体ではないと割り切っておくと設計が楽になります。

Webhook URLの管理

繰り返しになりますが、Webhook URLは秘密情報です。以下は必ず守ってください。

  • コードに直書きしない: .env に分離する
  • Gitに上げない: .gitignore.env を追加する
  • 質問サイトに貼らない: エラー相談でコードごと貼るのが最も多い流出経路
  • 漏れたら削除して再発行: Discordの設定画面からすぐできる

幸い、Webhook URLは削除して作り直すのが簡単です。少しでも不安があれば、迷わず再発行してください。

まとめ

LINE Notify終了を機にDiscordへ移行した経緯と、実装方法をまとめました。

  • Discordを選んだ理由はWebhook URL 1つで送れる手軽さと画像送信への対応
  • 送信は requests.post だけ。timeout の指定を忘れない
  • Embedで色分けすると、スマホの通知一覧で状況が一目で分かる
  • VPSでチャートを送るなら matplotlib.use("Agg") が必須
  • 同じシグナルの再送を抑えるクールダウンで、通知は20件から3件に減った
  • シグナル0件でも稼働報告を送る。異常検知だけでは停止に気づけない
  • Webhook URLは実質パスワード。漏れたら迷わず再発行する

移行そのものは半日で終わりました。むしろ良かったのは、作り直す過程で「本当に必要な通知は何か」を考え直せたことです。以前は条件に合うたび全部飛ばしていましたが、いまは絞り込んでいます。

通知は多いほど親切なわけではありません。見なくなった時点で、その仕組みは死んでいます。数を絞って、届いたら必ず見る状態を保つことのほうがずっと重要だと、今回の作り直しで実感しました。

もう1つ、サービス終了という出来事から学んだことがあります。外部サービスに依存する部分は、いつか必ず使えなくなるということです。今回はLINE Notifyでしたが、Discordがずっと使える保証もありません。

だから僕は、通知の送信部分を notifier.py という1つのファイルに閉じ込める設計にしました。次に何かが終了しても、そのファイルだけ書き換えれば移行できます。依存する部分を1か所に集めておく——これが、こうした変化への一番現実的な備えだと思います。

タイトルとURLをコピーしました