まだ証券アプリで手作業？Pythonでテクニカル分析を自動化し始めた話

# 2次スクリーニングに向けた選抜基準の検討

# 🌱はじめに
D進するために、株式投資で進学資金と生活費を養おうとする無謀な計画を遂行するために、前回は、財務指標（ROE・PER・営業利益率）をもとに東証プライム市場の2000銘柄を約200に選抜する一次スクリーニングの方法を紹介しました。

:::note
[【銘柄選抜】財務指標（ROE・PER・営業利益率）による1次スクリーニング]
(https://qiita.com/rS_alonewolf/items/ba9dbbf7d6163f4435a5)
:::


今回はその続きで、200銘柄の中から、さらに有望な銘柄を選抜する2次スクリーニングについて取り上げます。

株を購入する際に、証券アプリは直感的で、使いやすいからよく使っています。しかし、数百の銘柄から、1つずつRSIや移動平均線などのテクニカル指標を確認していくのは、現実的ではありません。毎回アプリを開いて指標をチェックし、チャートを切り替える…この手作業は非常に時間がかかり、これを人間がポチポチやるのは無理です。特に私のようなズボラ人間には難易度が高いです。

そこで、Pythonを用いて**自動でテクニカルスクリーニングを行うモデル**を作りたいと考えています。

---

# 🤔現状と課題
しかし、実際にコードを書き進める中で、ある問題に気づきました。

一般的に使われるテクニカル指標（RSIや移動平均線など）は有名ですが、
「それらが本当にどれだけ有効なのか」「どの条件設定なら安定して成果を出せるのか」については、
明確な答えが見つからなかったのです。

さらに、個別銘柄のテクニカルパターンだけで安定して成果を上げるのは難しく、
「市場全体（地合い）の影響」を無視できない可能性も考えられました。

そこで今回は、
複数のテクニカル条件と市場地合いを掛け合わせ、
最適な銘柄選定条件を探索・検証するシステムの構築に挑戦しました。

概念図
選抜条件を複数試してみて、平均リターンが高い条件を見つけたい。
![Screen Shot 2025-04-27 at 11.43.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066570/92d88daf-ae5c-421b-9198-7daf1460ed80.png)


---

# ✍️行ったこと
以下の処理ができるコードを書きました。
## ① スクリーニング
- 数種類のテクニカル指標をもとに、パラメータ設定を切り替えながら複数の条件で銘柄を自動選抜できるシステムを構築。

## ② バックテスト
- 過去データを起点にスクリーニングを実施し、選抜銘柄の未来30日・60日・90日後リターンを取得。
→これにより、どのパターンで選別すると良いかが判断できる。

- 地合いスコア（TOPIX連動ETFデータ由来）も併せて記録し、条件の有効性を検証できる仕組みを構築。
→これにより、地合いと有効な選抜条件に違いがああるのかどうかがわかる。


## ③ 結果出力
- スクリーニング・検証結果を集計し、各条件ごとのsummaryファイル（CSV）を作成。
- 個別銘柄の株価推移とRSI推移をチャート化してPNGファイルとして保存。
---

## 🛠️実装
それでは作成したコードを紹介します。

## ① 環境準備・Driveマウント・銘柄リスト読み込み
Google colabで行なっています。yfinanceはcolabにデフォルトでインストールされてないので、インストールが必要です。
```python
# -----------------------------
# 環境準備
# -----------------------------
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
from google.colab import drive
import time

# Google Drive をマウント
drive.mount('/content/drive')

```


## ② スクリーニング対象日の指定・選抜条件定義
バックテストで再現性を知りたいので、複数日を指定して、ループ処理ができるようにします。同時に複数の選抜パターンを試せるように定義を行います。

▶ 使っているテクニカル指標の意味

- RSI (Relative Strength Index)
→ 株価の「買われすぎ・売られすぎ」を示す指標（70以上で過熱、30以下で売られすぎとされる）

- 移動平均線（MA5、MA25、MA75）
→ 株価のトレンドを滑らかにする指標。短期・中期・長期トレンドを示す。
短期の平均線が中長期の上にくる時がゴールデンクロス、株価が上がる兆候。
逆はデッドクロス。
＊ma_epsは、各線の収束（交差しかけてるかどうか）を数値化した指標。

- 短期トレンド（MA_5_slope）
→ 短期移動平均（5日線）の傾き。プラスなら上昇傾向、マイナスなら下降傾向を示す。


```python
# -----------------------------
# 日付リスト
# -----------------------------
date_list = [
    "2022-01-13", "2022-01-24", "2022-01-28",
    # （以下省略）
]

# -----------------------------
# スクリーニング条件定義（複数パターン）
# -----------------------------
screening_conditions = [
    {"name": "pattern1", "rsi_range": (25, 50), "ma_eps": 0.02, "slope_period": 5, "slope_thresh": 0.02},
    {"name": "pattern2", "rsi_range": (25, 50), "ma_eps": 0.02, "slope_period": 5, "slope_thresh": 0.04},
    # （以下 必要に応じて続く）
]
```

## ③日付ごとに処理開始

複数日付のリストのから各日を取り出してループさせています。ここで、各日に対応する1次スクリーニングを通過した銘柄リスト*を読み込ませる処理をしています。出力されたデータの保存先もここで指定しておきます。

*日々の財務(1次)スクリーニングは、yfinanceでできますが、過去の財務指標は別の取得元が必要です。
2025年5月25日　追記
```python
# -----------------------------
# 日付ごとの処理
# -----------------------------
for today in date_list:
    print(f"▶ 処理開始: {today}")

    # その日付に対応する1stスクリーニング結果を読み込み
    input_csv_path = f'/content/drive/MyDrive/stock_prediction/ver.1/results/Qiita/1st/1st_filtered_prime_{today}.csv'
    df_list = pd.read_csv(input_csv_path)

    # 出力先ディレクトリ作成
    output_dir = f'/content/drive/MyDrive/stock_prediction/ver.1/results/Qiita/2nd/2nd_32pts_slope5_{today}'
    os.makedirs(output_dir, exist_ok=True)
```

## ④地合いスコア（TOPIX連動）を算出

TOPIXの5日リターン・MA25乖離・RSIを組み合わせ、地合いをシンプルに数値化したスコアを設計しました。詳細は後日投稿します（リンク貼る予定）。

```python
    # -----------------------------
    # TOPIX連動ETF（1306.T）から地合いスコアを算出
    # -----------------------------
    df_score = yf.download("1306.T", start="2020-01-01", end='2025-04-06') #*end = todayだと正しくスコアが反映されないため、修正しました（2025-05-02）
    if isinstance(df_score.columns, pd.MultiIndex):
        df_score.columns = df_score.columns.get_level_values(0)
    df_score = df_score.rename(columns={"Open": "Open", "High": "High", "Low": "Low", "Close": "Close", "Volume": "Volume"})
    
    df_score["MA25"] = df_score["Close"].rolling(25).mean()
    df_score["MA75"] = df_score["Close"].rolling(75).mean()
    df_score["MA25_diff"] = (df_score["Close"] - df_score["MA25"]) / df_score["MA25"]
    df_score["5d_return"] = df_score["Close"].pct_change(5)
    
    delta = df_score['Close'].diff()
    gain = np.where(delta > 0, delta, 0)
    loss = np.where(delta < 0, -delta, 0)
    avg_gain = pd.Series(gain, index=df_score.index).rolling(14).mean()
    avg_loss = pd.Series(loss, index=df_score.index).rolling(14).mean()
    rs = avg_gain / (avg_loss + 1e-10)
    df_score['RSI'] = 100 - (100 / (1 + rs))
    
    df_score['score'] = 0
    df_score['score'] += (df_score['5d_return'] > 0.01).astype(int)
    df_score['score'] += (df_score['MA25_diff'] > 0).astype(int)
    df_score['score'] += (df_score['RSI'] > 55).astype(int)
    df_score['score'] -= (df_score['RSI'] < 45).astype(int)
    df_score['score'] -= (df_score['5d_return'] < -0.01).astype(int)
    df_score['score'] -= (df_score['MA25_diff'] < 0).astype(int)
```

## ⑤条件別選抜ループ（バックテスト含む）
### やっていること

1. **複数パターン（screening_conditions）でループ開始**  
    → それぞれ専用の保存フォルダを作成。

2. **df_listから1銘柄ずつ取り出して計算**
    - 移動平均線（MA5, MA25, MA75）とその収束度合い（交差しそうかどうか）
    - MA5傾き（slope）
    - RSI　　
    を算出。

3. **スクリーニング判定**
    - 【移動平均線の収束状態＋MA5の傾き】が指定条件を満たしているか。
    - 【RSIが指定範囲内】にあるか。
    全てを満たす銘柄のみ合格→リストに追加。

4. **バックテスト用リターン計算**
    - スクリーニング日から30日後、60日後、90日後の株価を取得。
    - スクリーニング日時点の株価からのリターン（%）を計算。

### ポイント・工夫

- **移動平均線（MA）の収束性**（収束→ゴールデンクロスの準備段階）を捉える設計。
- **MA5の傾き（slope）** を加味し、トレンド転換直後を狙う。
- **RSI範囲フィルター**で買われすぎ・売られすぎを避ける。
- **地合いスコア**も同時取得し、後から地合い別成績分析が可能。
- 通過銘柄の**実測リターンを計測**しており、バックテスト可能。


```python
    # -----------------------------
    # スクリーニング条件別ループ
    # -----------------------------
    summary = []
    
    for cond in screening_conditions:
        result = []
        chart_dir = f"{output_dir}/charts_{cond['name']}"
        os.makedirs(chart_dir, exist_ok=True)
    
        for _, row in df_list.iterrows():
            ticker = row["Ticker"]
            df = yf.download(ticker, start="2020-12-01", end="2025-04-06")
            time.sleep(0.7)
            print(ticker)
    
            if isinstance(df.columns, pd.MultiIndex):
                df.columns = df.columns.get_level_values(0)
            if df.empty or len(df) < 75:
                continue
    
            df["MA_5"] = df["Close"].rolling(5).mean()
            df["MA_25"] = df["Close"].rolling(25).mean()
            df["MA_75"] = df["Close"].rolling(75).mean()
    
            delta = df["Close"].diff().values.flatten()
            gain = np.where(delta > 0, delta, 0)
            loss = np.where(delta < 0, -delta, 0)
            avg_gain = pd.Series(gain, index=df.index).rolling(14).mean()
            avg_loss = pd.Series(loss, index=df.index).rolling(14).mean()
            rs = avg_gain / (avg_loss + 1e-10)
            df["RSI"] = 100 - (100 / (1 + rs))
            df["MA_5_slope"] = df["MA_5"].diff(cond["slope_period"]) / cond["slope_period"]
    
            # 判定
            try:
                screening_date = pd.Timestamp(today)
                if screening_date not in df.index:
                    screening_date = df.index[df.index.get_indexer([screening_date], method="nearest")[0]]
    
                last_row = df.loc[screening_date]
                close_last = float(last_row["Close"])
                ma5_last = float(last_row["MA_5"])
                ma25_last = float(last_row["MA_25"])
                ma75_last = float(last_row["MA_75"])
                slope_last = float(df["MA_5_slope"].loc[screening_date])
                rsi_last = float(last_row["RSI"])
            except:
                continue
    
            epsilon = close_last * cond["ma_eps"]
            trend_flag = (abs(ma5_last - ma25_last) < epsilon and abs(ma25_last - ma75_last) < epsilon and slope_last > cond["slope_thresh"])
            rsi_flag = (cond["rsi_range"][0] < rsi_last < cond["rsi_range"][1])
    
            if trend_flag and rsi_flag:
                screening_idx = df.index.get_loc(screening_date)
    
                returns = {}
                for days in [30, 60, 90]:
                    if screening_idx + days < len(df):
                        future_price = df["Close"].iloc[screening_idx + days]
                        past_price = df["Close"].iloc[screening_idx]
                        returns[f"Return({days}d)%"] = (future_price - past_price) / past_price * 100
                    else:
                        returns[f"Return({days}d)%"] = np.nan
    
                result.append({
                    "Ticker": ticker,
                    "Return(30d)%": returns["Return(30d)%"],
                    "Return(60d)%": returns["Return(60d)%"],
                    "Return(90d)%": returns["Return(90d)%"]
                })
```

## ⑥結果出力

- **チャート画像の保存**
    - Close価格＋MA線推移
    - RSI推移
    をプロットして保存。
    
- **パターンごとにまとめ**
    - 抽出された銘柄とバックテストリターン結果をCSVに保存。
    - さらに、平均リターン・勝率を集計してsummaryに追加。
    
```python
                # グラフの作成
                fig, axs = plt.subplots(2, 1, figsize=(10, 6))
                axs[0].plot(df.index, df["Close"], label="Close")
                axs[0].plot(df.index, df["MA_5"], label="MA_5")
                axs[0].plot(df.index, df["MA_25"], label="MA_25")
                axs[0].plot(df.index, df["MA_75"], label="MA_75")
                axs[0].legend()
                axs[1].plot(df.index, df["RSI"], label="RSI")
                axs[1].axhline(70, color='r', linestyle='--')
                axs[1].axhline(30, color='g', linestyle='--')
                axs[1].legend()
                plt.tight_layout()
                plt.savefig(f"{chart_dir}/{ticker}.png")
                plt.close()
                
        # 条件ごとのリスト保存
        df_result = pd.DataFrame(result)
        df_result.to_csv(f"{output_dir}/result_{cond['name']}_{today}.csv", index=False)
        
        mean_return30 = df_result["Return(30d)%"].mean() if not df_result.empty else np.nan
        win_rate30 = (df_result["Return(30d)%"] > 0).mean() * 100 if not df_result.empty else np.nan
        
        summary.append({
        "Pattern": cond["name"],
        "銘柄数": len(df_result),
        "平均リターン(30日)%": round(mean_return30, 2),
        "勝率(30日後にプラス)%": round(win_rate30, 2),
        })
        
        # summaryを保存
        pd.DataFrame(summary).to_csv(f"{output_dir}/summary_32pts_slope5_{today}.csv", index=False)
        
        print(f"✅ {today} 完了")
        
        print("全日付 完了")
```
## 出力例
- 各選抜パターンごとに画像が格納されたフォルダ
- 各選抜パターンで通過した銘柄が記録されたcsv
- 全体の総括csv
これらが日付ごとにフォルダに保存されます。

コード実行時の様子　かなりゆっくりなので今後対策考えないと...
![converted_light.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066570/b398072f-40f0-43ee-914d-3008c0e4b288.gif)

各日付のフォルダに出力結果が入っている様子
![Screen Shot 2025-04-27 at 10.27.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066570/ce7cde28-49b4-4c0b-93d7-1b883970c530.png)

画像フォルダに格納される　現在までの値動きとテクニカル指標
![Screen Shot 2025-04-27 at 10.30.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066570/f9a5e49a-2944-473e-b2ac-c9444b56078c.png)

各パターンごとのcsvに記録される
通過した銘柄とそれらの実際のリターン
![Screen Shot 2025-04-27 at 10.32.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066570/6fb7cbca-5775-43be-869e-fa371c54d153.png)

総括csvに保存される　
- 各パターンを通過した銘柄の数
- 実測リターンの平均値
- 勝率
- 地合いスコア
![Screen Shot 2025-04-27 at 10.33.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066570/50eb60ec-60eb-4473-9120-191dfde0f03f.png)




# 📝 まとめ

今回の取り組みでは、  
**「複数のテクニカル条件と市場地合いを掛け合わせ、最適な銘柄選定条件を探索・検証するシステム」**  
の構築に向けた第一段階として、  

- 1stスクリーニング通過銘柄に対して、
- 複数のテクニカル条件を適用した2ndスクリーニングとバックテスト、
- 地合いスコアの付与と結果保存、

を自動化するコードを作成しました。

これにより、  
**条件別・地合い別にリターン傾向を分析できる基盤**が整いました。

---

# 🚀 次回予告

次回は、  
今回生成したデータを用いて、

地合いスコア別に、各スクリーニング条件のパフォーマンスを比較し、
安定してリターンを出せる選抜パターンが存在するかを解析します。

精度検証の記事のリンクです。

https://qiita.com/rS_alonewolf/items/a8856f0cb28ae6a7da57

もっと強靭なモデルを作りたいので、改善策も載せてます。

https://qiita.com/rS_alonewolf/items/4e02442524a666703660