RSIアラートをGitHub Actionsで常時稼働|PC不要の監視

自動化・運用

「ドル円のチャート、もう一週間まともに見てない。。。」

ドル円は毎日それなりに動くので、以前は子供が寝たあとにチャートを確認する習慣がありました。でも最近は寝かしつけで僕も一緒に寝落ちすることが増え、気づいたら一週間チャートを開いてない、なんてことも。せめて「ここはアラートだけ出してほしい」というポイントを通知してくれる仕組みがあれば、と思って自動化しました。

自宅PCで動かす案は早々に諦めました。寝落ち対策が目的なのに、そのPCの電源を切られたら意味がありません(子供に2回切られました)。GitHub Actionsで動かせば、自宅PCが落ちていても通知が飛びます。しかも無料枠で足ります。

ただ、実際に3か月ほど運用してみて、素直にはいかない点が3つありました。スケジュールが時間どおりに動かないこと、状態を保存できないこと、そして60日放置すると勝手に止まること。この記事では、その回避策込みで手順をまとめます。

なぜRSIアラートにしたのか

本当はAIで売買判断まで全部任せたい気持ちもあるんですが、ドル円は中央銀行や指標発表の影響が大きく、機械任せにすると痛い目を見るのも何度か経験済みです。なので「判断は自分、見逃し防止だけ機械に任せる」というラインに留めました。1時間足のRSI(14)が30以下/70以上に入ったタイミングで通知する、それだけです。

通知先はSlackのIncoming Webhookにしています。LINE Notifyは2025年3月末で終了してしまったので選択肢から外れましたが、そもそも仕事でSlackを開きっぱなしなので、こちらの方が確実に見ます。

GitHub Actionsの落とし穴

思っていたこと実際対処
5分おきに正確に動く混雑時は遅延、まれにスキップ時刻ではなく足で判定する
前回の状態を覚えている毎回まっさらな環境状態をリポジトリにコミット
放っておいても動き続ける60日コミットがないと停止状態コミットが兼ねる
無料で使い放題publicは無料、privateは従量実行頻度を落とす

1つ目が特に効きました。cron: "*/5 * * * *"と書いても、そのとおりには来ません。GitHubの負荷が高い時間帯は10分以上ずれますし、実行されない回もあります。「5分おきに来る前提」で書いたコードは壊れます。

ただ、1時間足を見るなら遅延はほぼ問題になりません。足が確定してから通知が来るまで数分ずれるだけです。逆に言えば、分単位の精度が要るならこの構成は向いていません。

2つ目の状態管理は必須です。実行のたびに新しいVMが立つので、前回何を通知したか覚えていません。何もしないと、RSIが30を割っている間ずっと5分おきに鳴り続けます。

本体のコード

import json
import os
from datetime import datetime, timezone
from pathlib import Path

import numpy as np
import pandas as pd
import requests
import yfinance as yf

SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK"]
STATE = Path("state.json")

def rsi_wilder(close: pd.Series, period: int = 14) -> pd.Series:
    """MT4やTradingViewと同じWilder方式。単純移動平均版とは数値が数ポイント違う。"""
    delta = close.diff()
    gain, loss = delta.clip(lower=0), (-delta).clip(lower=0)
    ag = gain.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
    al = loss.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
    return (100 - 100 / (1 + ag / al.replace(0, np.nan))).fillna(100)

def notify(text: str) -> None:
    r = requests.post(SLACK_WEBHOOK, json={"text": text}, timeout=15)
    r.raise_for_status()

def load_state() -> dict:
    return json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}

def save_state(d: dict) -> None:
    STATE.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")

RSIをWilder方式にしているのは、Slackに届いた数字とスマホのチャートを見比べたときに一致させるためです。単純移動平均で計算すると数ポイントずれて、「30を割ってないぞ」と混乱します。

LOW, LOW_EXIT = 30, 35
HIGH, HIGH_EXIT = 70, 65

def decide_zone(rsi: float, prev: str) -> str:
    if prev == "oversold":
        return "neutral" if rsi >= LOW_EXIT else "oversold"
    if prev == "overbought":
        return "neutral" if rsi <= HIGH_EXIT else "overbought"
    if rsi <= LOW:
        return "oversold"
    if rsi >= HIGH:
        return "overbought"
    return "neutral"

def main() -> None:
    df = yf.download("JPY=X", period="30d", interval="1h",
                     auto_adjust=False, progress=False)
    if df.empty:
        print("データ取得失敗")
        return
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)

    close = df["Close"].dropna()
    close.index = pd.to_datetime(close.index, utc=True)
    bar = close.index[-1]

    # 休場判定:足が古すぎるなら市場が動いていない
    age_min = (pd.Timestamp.now(tz=timezone.utc) - bar).total_seconds() / 60
    if age_min > 150:
        print(f"休場または停止中 (last={bar}, {age_min:.0f}分前)")
        return

    r = float(rsi_wilder(close).dropna().iloc[-1])
    price = float(close.iloc[-1])

    state = load_state()
    if state.get("last_bar") == bar.isoformat():
        print(f"同じ足は処理済み ({bar}) RSI={r:.1f}")
        return

    prev_zone = state.get("zone", "neutral")
    zone = decide_zone(r, prev_zone)

    if zone != prev_zone and zone in ("oversold", "overbought"):
        icon = ":arrow_down:" if zone == "oversold" else ":arrow_up:"
        label = "売られすぎ" if zone == "oversold" else "買われすぎ"
        jst = bar.tz_convert("Asia/Tokyo")
        notify(f"{icon} USDJPY {price:.3f} RSI={r:.1f} {label}ゾーン "
               f"(1h足 {jst:%m/%d %H:%M})")
        print("通知しました")
    else:
        print(f"通知なし RSI={r:.1f} zone={zone}")

    save_state({
        "last_bar": bar.isoformat(),
        "zone": zone,
        "rsi": round(r, 2),
        "updated": datetime.now(timezone.utc).isoformat(),
    })

if __name__ == "__main__":
    main()

last_barで同じ足を弾き、ゾーンの遷移でのみ通知します。解除の閾値をずらしている(30で入って35で抜ける)のは、境界付近でバタつくのを防ぐためです。これがないと、RSIが29.8と30.2を往復するたびに鳴ります。

ワークフローの設定

.github/workflows/usdjpy_alert.ymlを置きます。要点は最後にstate.jsonをコミットして戻すところです。

name: usdjpy-rsi-alert

on:
  schedule:
    - cron: "*/15 * * * *"
  workflow_dispatch:        # 手動実行できるようにしておく

permissions:
  contents: write           # state.json をコミットするために必要

concurrency:
  group: usdjpy-alert
  cancel-in-progress: false # 前の実行が終わるまで待つ

jobs:
  alert:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install yfinance pandas requests
      - run: python usdjpy_alert.py
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
      - name: persist state
        run: |
          git config user.name "github-actions"
          git config user.email "actions@github.com"
          git add state.json
          git diff --staged --quiet || git commit -m "update state [skip ci]"
          git push

git diff --staged --quiet ||で、変更がないときにコミットをスキップしています。これを入れないと「コミットするものがない」でジョブが失敗し、失敗通知が毎回届きます。

concurrencyも入れておきます。遅延した実行と次の実行が重なると、両方が同じ足を処理して二重通知になることがあります。

そして地味に重要なのが、このstate.jsonのコミットが「60日ルール」の回避も兼ねることです。GitHubは60日間コミットがないリポジトリのスケジュール実行を自動停止します。状態を毎回コミットしていれば、その心配はありません。

Secretsの設定

リポジトリのSettings→Secrets and variables→ActionsでSLACK_WEBHOOKを登録します。Webhook URLをコードに直書きしてpublicリポジトリに置くと、数分でクローラーに拾われます。SlackのWebhook URLは、知っていれば誰でもそのチャンネルに投稿できてしまいます。

コスト面では、publicリポジトリなら実行時間は無料です。privateだと無料枠(月2000分程度)を消費します。1回あたり40秒として15分間隔なら月に約2000分。ぎりぎり足りないので、僕はpublicリポジトリにして、秘密の情報はSecretsに寄せています。

3か月運用してみて

最初は5分間隔・閾値30/70で始めましたが、いまは15分間隔・閾値25/75に落ち着きました。理由は単純で、通知が多すぎて見なくなったからです。

1時間足のRSIが30を割るのは、感覚より頻繁に起きます。最初の2週間で20回近く鳴り、そのうち実際にポジションを取ったのは1回でした。17回の空振りは、通知そのものへの信頼を削ります。閾値を25/75にしたら月に数回まで減り、鳴ったときに必ず見るようになりました。

どのくらい鳴るかは、事前に測れます。

def alert_frequency(close: pd.Series, thresholds=(20, 25, 30, 35)) -> pd.DataFrame:
    r = rsi_wilder(close).dropna()
    days = (r.index[-1] - r.index[0]).days or 1
    rows = []
    for t in thresholds:
        below = r <= t
        # ゾーンに入った回数(連続はまとめて1回)
        entries = int((below & ~below.shift(1).fillna(False)).sum())
        rows.append({
            "threshold": t,
            "entries": entries,
            "per_month": round(entries / days * 30, 1),
        })
    return pd.DataFrame(rows).set_index("threshold")

print(alert_frequency(close))

これを先に回して、月に何回鳴るかを見てから閾値を決めるべきでした。「月3回くらいなら毎回ちゃんと見る」という自分の許容量から逆算する方が、指標の理論値より役に立ちます。

まとめ:「自動判断」より「自動見張り」

子育てで時間が削られている個人投資家にとっては、「自動売買」よりも「自動見張り」のほうがリターンへの寄与が大きい気がしています。エントリーは自分で判断したいけど、見逃したくはない。そのバランスです。

GitHub Actionsで組む場合のポイントは4つでした。スケジュールは遅延する前提で、時刻ではなく足で判定する。状態はstate.jsonにしてコミットで永続化する。permissions: contents: writeconcurrencyを忘れない。そして閾値は、鳴る回数を先に測ってから決める。

自宅PCで動かす構成にしたい場合はタスクスケジューラでの運用記事、RSIの計算方式やLINE移行の詳細はRSIアラートBotの記事にまとめています。次は同じ仕組みを日経平均先物の押し目検知にも応用してみる予定です。

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