日経平均の終値を、毎朝AIで自動予想(LSTM/BeautifulSoup/flask/render)

この記事の目的

AI、投資、プログラミングに、最近興味を持った初心者です。
これらの理解を深めるため、とにかく何か作ってみようと思い、
「日経平均の終値を、毎朝6:45に自動で予測」するWebアプリの構築に取り組んでおります。
だいぶ完成に近づいてきたと感じましたので、一度この段階で試行錯誤で得た学びの整理と共有を、そしてもし現在抱える問題にヒントをいただける方に巡り合えればと思い、本記事を書かせていただいています。

Webアプリ概要

動作の流れは以下の通りです。

  1. 日経平均に影響を与える指標を学習材料に、あらかじめLSTMを用いた学習モデルを作成

  2. 毎朝6:45時点での、1の指標の最新データをBeautifulSoupで自動収集。毎朝6:45の数分前に、renderの無料instanceを叩き起こす必要があるため、自宅のNASからアプリページにアクセス

  3. 収集したデータをもとに、1で作成した学習モデルで当日の日経平均の終値を予測

  4. 予測値や過去実績などの各種情報をWebページ上に描画。描画にはflaskとrender無料instanceを使用

アプリページへのリンクは下記ですが、はじめにいくつか言い訳をさせてください 汗

  • 上記の通り、まだ手動で時折デプロイして更新する程度ですので、デプロイが滞り、日付けがずれてエラーが発生していることも、しばしばございますことご容赦ください。

  • render無料枠では、instanceが基本的には眠った状態です。アクセスがあってはじめて目を覚まして表示の準備をするため、表示までに30秒程度かかるようです。

  • 見た目へのこだわりはゼロです。。エラー等で画面をご覧いただけない方のため、下記に画面のスクリーンショットも貼らせていただきます。

画面のスクリーンショット

今のところ、gitでも公開させていただいております。

進捗とrender無料instanceの挙動に関する問題

現在の進捗と、抱えている問題は次の通りです。
「毎朝6:45に自動で予測」まではでき、予測直後にはその日の終値予測値を表示するのですが、15分経つと眠りにつく「renderの無料instance」が再度目を覚ました時には、デプロイした時点の予測値に表示が戻ってしまうという問題を抱えています。
ですので、まだ「毎朝ローカルで予測して、たまにデプロイ」という手動操作を続けています。
もしどなたか対処方法をご存知でしたら、ぜひご教示をいただけましたら嬉しいです。

これまでの開発内容

1. LSTMに学習させるべき指標の選定

まずは、日経平均に影響を与えていそうな指標を集めました。集める基準は、「日々の日経平均の上下に関する記事や各種の金融本などでよく目にするもの」ということで、わりと主観的です。
可能なものは2001年1月1日以降のデータを、見つけられなかったものについてはできるだけ古いデータをということで範囲を定めました。数年程度では学習材料としての荒波をはじめ市場環境の多様性が少ないと思いますし、長すぎると時代が異なるのではと思ったので、インターネットがかなり普及してきた2001年以降にしました。

その上で、各種指標間の相関をとった一覧が下の表1です。
絶対値が0.7以上(強い相関)のものに濃色を、
0.4以上(かなり相関)のものに淡色をつけています。

表1: 日経平均に関係しそうな指標同士の相関

金融初心者の自分には、この表だけでも味わい深いものがありました。定性的に言われていることが定量面でも出ていてしっくり来たことや、逆に新たに学んだこともありました。

  • 前日のダウ平均の動きにつられがち、という日々の感覚に一致

  • 社会に出回るお金の量(マネタリーベース)が増えれば、株価も上がる。だから異次元緩和期間は株価の調子がよかった。

  • 上の2つとの相関の方が、企業が稼ぐ利益を表すEPSとの相関よりも高い

  • 日本のGDPとの相関よりも、米国のGDPとの相関の方が高い

  • ダウ平均は、米国のGDP成長としっかり相関

  • 中央銀行が変化させた直後は大きく日経平均やダウ平均を動かす長短金利も、長期的な相関では必ずしも上位に入ってこない。特に短期金利との相関は極めて低い

少し脱線しましたが、ここから、以下の組み合わせで、LSTMに学習させ、日経平均の予測値と実績値の二乗平均平方根誤差(RMSE)が最も小さくなる組み合わせを探しました。

  • 指標の組み合わせ:10通り(下の表2参照)

  • 全データのうち、学習のための訓練データの割合(Train size):3通り(0.8, 0.9, 0.95。表2の左から2列目参照)

  • 予測の際、何日前までの指標データを予測材料とするか(lookback):10通り(10, 15, 18, 22, 23, 24, 26, 30, 60日。表3参照)


表2. 指標/学習用データの割合の組み合わせ

単純に掛け算すると、10通り×3通り×10通り = 300通りとなりますが、実験ごとに成績が良くなる組み合わせ傾向を見ながら当たりをつけて取り組んだので、実際に行った実験は下の表3のように42通りです。


表3. 組み合わせ別RMSE

これら実験から、以下の組み合わせを学習モデルの材料データとして採用することにしました。

  • 用いる指標:日経平均、日経225銘柄のEPS(指数ベース)、10年国債金利、マネタリーベース、失業率、ダウ平均。※ダウ平均以外は日本国内の指標。10年国債利回りが2006年2月7日以降のデータしかとれなかったため、全指標同日以降のデータに揃えた。

  • Train size:0.95。つまり2006年2月7日以降の約17年間のうち、約16年分を訓練データとして活用。

  • lookback:24。予測を行う日の24日前までの選定指標データを予測材料として用いる。

2. 学習モデルの開発

学習モデルには、KerasのLSTMを用いました。上述した3つの組み合わせについて絞り込みを行った後(「並行」で行うだけの時間がとれなかったため、絞り込んだ「後」にしました)、学習モデルに関して以下のパラメーターの試行錯誤を行った結果、下記コードの設定に落ち着きました。

  • 中間層の数:1, 2

  • 各層のunit数:16, 32

  • 重みの初期化方法:glot_normal, he_normal

  • Dropout率:0, 0.2

  • バッチ正規化の有無:True, False

# coding: UTF-8

import numpy as np
import pandas as pd
import math
from tensorflow import keras
from tensorflow.keras import layers
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error
from pickle import dump

# 選定した指標データは事前に収集し、エクセルファイルに保存しているので読み込んでデータフレームに落す
dataframe = pd.read_excel('./Nikkei_10.xlsx', sheet_name='no_nan', usecols=[0, 1, 2, 3, 4, 5, 6, 7])

dataset = dataframe[['Nikkei', 'EPS_ind', 'JBond10', 'JMB', 'JUER', 'JCPI', 'Dow']].to_numpy()
dataset = dataset.astype('float32')

# 訓練データの割合設定。全3通り試した
train_size = int(len(dataset) * 0.95)
train, test = dataset[0:train_size, :], dataset[train_size:len(dataset), :]

# 各指標データを標準化
scaler = StandardScaler()
scaler_train = scaler.fit(train)

# 標準化されたオブジェクトを保存。日々の予測用のスクリプトで用いる
dump(scaler_train, open('../NikkeiPrediction/nikkei_10_standard.pkl', 'wb'))

train = scaler_train.transform(train)
test = scaler_train.transform(test)

# 標準化データを戻す際に用いる
mean, scale = scaler_train.mean_[0], scaler_train.scale_[0]


# データセット作成のための関数
def create_dataset(dataset, look_back):
    data_X, data_Y = [], []
    for i in range(look_back, len(dataset)):
        data_X.append(dataset[i - look_back:i, :])
        data_Y.append(dataset[i, 0])
    return np.array(data_X), np.array(data_Y)


# 訓練・検証の両データセットを作成
look_back = 24  # 何日前までさかのぼって学習材料とするか。10通り試した。
train_X, train_Y = create_dataset(train, look_back)
test_X, test_Y = create_dataset(test, look_back)

# 学習モデル要に成型
train_X = train_X.reshape(train_X.shape[0], train_X.shape[1], train_X.shape[2])
test_X = test_X.reshape(test_X.shape[0], test_X.shape[1], test_X.shape[2])


# 学習モデル作成のための関数
def pred_activity_lstm(input_dim,
                       units_1=64,
                       units_2=32,
                       loss_method='mean_squared_error',
                       optimizer_method='adam',
                       kernel_init_method='glorot_normal',
                       batch_normalization=False,
                       dropout_rate=None
                       ):
    model = keras.Sequential()

    # 入力層
    model.add(
        layers.LSTM(
            input_shape=(input_dim[0], input_dim[1]),
            units=units_1,
            kernel_initializer=kernel_init_method,
            return_sequences=True
        ))

    # バッチ正規化の設定を反映
    if batch_normalization:
        model.add(layers.BatchNormalization())

    # ドロップアウト率の設定を反映
    if dropout_rate:
        model.add(layers.Dropout(dropout_rate))

    # 中間層
    model.add(
        layers.LSTM(
            units=units_2,
            kernel_initializer=kernel_init_method,
            return_sequences=False
        ))

    # 2つ目の層について、バッチ正規化の設定を反映
    if batch_normalization:
        model.add(layers.BatchNormalization())

    # 2つ目の層について、ドロップアプト率の設定を反映
    if dropout_rate:
        model.add(layers.Dropout(dropout_rate))

    # 出力層
    model.add(layers.Dense(units=1))
    model.compile(loss=loss_method, optimizer=optimizer_method,
                  metrics=['accuracy'])

    return model


# 学習モデル作成
input_size = [look_back, train_X.shape[2]]
model = pred_activity_lstm(
    input_dim=input_size,
    units_1=64,
    units_2=32,
    loss_method='mean_squared_error',
    optimizer_method='adam',
    kernel_init_method='glorot_normal',
    batch_normalization=False,
    dropout_rate=None
)

# モデル改善が止まった時点で学習を止める
early_stopping = keras.callbacks.EarlyStopping(monitor='val_loss', patience=10, verbose=2)

# 学習実行
model.fit(train_X, train_Y, epochs=15, batch_size=1, verbose=2)

# 予測実行
train_predict = model.predict(train_X)
test_predict = model.predict(test_X)

# 予測データ・実績データを標準化データから戻す
train_predict = train_predict * scale + mean
train_Y = train_Y * scale + mean
test_predict = test_predict * scale + mean
test_Y = test_Y * scale + mean

# 予測と実績との間の二乗平均平方根誤差(RMSE)を計算
train_score = math.sqrt(mean_squared_error(train_Y, train_predict[:, 0]))
print('Train Score: %2.f RMSE' % (train_score))
test_score = math.sqrt(mean_squared_error(test_Y, test_predict[:, 0]))
print('Test Score: %2.f RMSE' % (test_score))

# モデルの保存。日々の予測用のスクリプトで用いる
model.save('nikkei_10_rmse_274.h5')

3. Webアプリ作成

ファイル構成は、以下の通りです。flaskの文法に沿った構造で、git経由でrenderの無料instanceにデプロイしました。

ファイル構成

flaskの文法や、html/cssなどを除く、本アプリ独自の主だったファイルは以下の通りで、1, 6, 7については、後ほどコードも貼らせていただきます。他にもご興味があるファイルがございましたら、gitをご覧ください。

  1. Nikkei_10_utilized.py:毎朝6:45に実行され、各種指標のWebスクレイピング・学習モデルによる日経平均終値の予測を行います。

  2. Nikkei_10.xlsx:Nikkei_10_utilized.pyが予測する際に活用する過去の実績データです。Nikkei_10_utilized.pyによって、毎朝最新データが蓄積されるファイルでもあります。

  3. mblong.xlsx:Nikkei_10_utilized.pyにおいて、マネタリーベースの値を取得する際に、取得元からダウンロードされるファイルです。

  4. nikkei_10_standard.pkl:Nikkei_10_utilized.pyにおいて、データ標準化のために用いられます。学習モデル開発の際に作成したものです。

  5. nikkei_10_rmse_274.h5:Nikkei_10_utilized.pyにおいて、予測の際に用いられます。学習モデル開発の際に作成したものです。

  6. main.py:Nikkei_10_utilized.pyを毎朝6:45に動作させるためのscheduleや、Webページの描画のためのflaskを用いた記述がなされています。

  7. db.py:sqlite3を用いたデータベース・テーブル作成を行います。

最も時間がかかったのは、「renderの無料instanceで、flask_schedulerが動作しない問題」との格闘で、今なお「動作はするけど、動作結果が記録されない」という状況で、克服しきれていません。そのあたりを中心に試行錯誤の内容をご説明していきます。

まずは比較的シンプルなmain.py, db.pyについてご説明し、最後に肝となるNikkei_10_utilized.pyついてご説明します。

main.pyです。flaskの文法に沿った記述です。
データの取り扱いについて、使い慣れたエクセル保存だけでなく、データベースの取り扱い方法も知りたいと思い、sqlite3を入れてみました。

from flask import Flask, render_template
from flask_bootstrap import Bootstrap
from datetime import date, datetime, timedelta
import pandas as pd
import os
import sqlite3
import sys

import Nikkei_10_utilized
import db

# きちんと動作しない問題が発生し、いろいろ調べる中でpathを通したら動いたという情報があったので、参考にしました。
sys.path.append("..\\NikkeiPredictionPackage")

app = Flask(__name__)
bootstrap = Bootstrap(app)

DATABASE = 'database.db'
db.create_actual_table()
db.create_prediction_table()

## ここからflask_apscheduler。毎朝6:45に、Nikkei_10_utilized.pyを動かして、当日の終値を計算。
from flask_apscheduler import APScheduler
import pytz

jst = pytz.timezone('Asia/Tokyo')
today_time = datetime.now().astimezone(jst)
today = today_time.date()
yesterday = today - timedelta(days=1)

# set configuration values
class Config:
    SCHEDULER_API_ENABLED = True

app.config.from_object(Config())

# initialize scheduler
scheduler = APScheduler()
scheduler.init_app(app)

@scheduler.task('cron', id='do_job_1', hour='6', minute='45', timezone=jst)
def nikkei_prediction():
    Nikkei_10_utilized.predict()

scheduler.start()
## ここまでflask_apscheduler

# ここから、Webページへの描画です。
@app.route('/')
def index():
    # Nikkei_10_utilized.pyで予測してデータベースに格納した、予測データを読み込みます。
    with sqlite3.connect(DATABASE) as con:
        df_from_sql = pd.read_sql('SELECT * FROM prediction', con)
    update_time = df_from_sql.iloc[-1,0]
    df_from_sql = df_from_sql.set_index('Date')
    if str(today) in df_from_sql.index:
        predict = df_from_sql.loc[str(today),'predict']
    # yesterdayも入れているのは、午前0:00~6:45の間は、「前日に予測したデータ」を参照する必要があるからです。
    else:
        predict = df_from_sql.loc[str(yesterday), 'predict']
    predict = "{:,.0f}".format(predict)

    # 過去30日分の予測値や実績値を、ページ下部に表形式で表示するために読み込みます。
    con = sqlite3.connect(DATABASE)
    actual_data = con.execute('SELECT * FROM actual LIMIT 30') .fetchall()
    con.close()

    # 日付けデータのゼロ埋め削除。Windowsでローカル実行する際は、-mなどではなく、#mなどに表記を修正する必要
    d_html = datetime.strptime(update_time, '%Y-%m-%d').strftime('%-m/%-d') 

    # 空のdataリストにactual_dataを格納
    data = []
    for row in reversed(actual_data):
        data.append({'Date':row[0], 'Nikkei':"{:,.0f}".format(row[1]), 'predict':"{:,.0f}".format(row[2]), 'gap':"{:,.0f}".format(row[3]), 'Nikkei_dod':"{:,.0f}".format(row[4]), 'predict_dod':"{:,.0f}".format(row[5]), 'direction_check':row[6]})

    return render_template(
        'index.html',
        d_html=d_html,
        predict=predict,
        data=data
    )

if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host ='0.0.0.0',port = port)


次に、db.pyです。以下2つのテーブルを生成します。

  • actual:過去の日経平均の予測値や、現実の値などを格納

  • prediction:日々の予測値のみを格納

import sqlite3

DATABASE = 'database.db'

def create_actual_table(): #actualのテーブルをつくる関数を定義する。
    con = sqlite3.connect(DATABASE)
    con.execute("CREATE TABLE IF NOT EXISTS actual (Date DATE PRIMARY KEY,Nikkei FLOAT64,last_predict FLOAT64,gap FLOAT64,Nikkei_dod FLOAT64,predict_dod FLOAT64,direction_check STRING)")
    con.close()

def create_prediction_table(): #predictのテーブルをつくる関数を定義する。
    con = sqlite3.connect(DATABASE)
    con.execute("CREATE TABLE IF NOT EXISTS prediction (Date DATE PRIMARY KEY, predict FLOAT64)")
    con.close()


最後にNikkei_10_utilized.pyです。

コードは大きく3ブロック構成です。

  1. Webスクレイピングで各種指標の最新データを収集し、整理・保存

  2. 最新データと学習モデルを用いた当日の日経平均の終値を予測

  3. Webページ下部の予測実績表に表示させるためのデータを作成

Webスクレイピングの方法として、最初は多様なウェブサイトに対応できるSeleniumを用いていたのですが、renderの無料instance上で動作させることができませんでした。

  • そこで、Seleniumを諦め、こちらの記事を参考にさせていただいて(素晴らしい記事をありがとうございました!)requests-HTMLとpyppeteerを用いたアプローチに変更したのですが、この方法でも動作できずでした。

  • ここまでの2つの方法で発生するWebdriverやChroniumといったファイルのダウンロードに対応していないのではと考え、requestsとBeautifulSoupに切り替えたところ、動作しました。この方法の実行過程で発生する、「requestsによってデータ取得元からエクセルファイルをダウンロード」という動作は問題なかったので、実行ファイルのダウンロードがNGなのかもしれないと想像しています。

# coding: UTF-8
import numpy as np
import pandas as pd
from tensorflow import keras
from pickle import load

from datetime import datetime
from datetime import date, timedelta
import re
import os
import calendar
import sqlite3

from bs4 import BeautifulSoup
import requests
from urllib.request import urlopen
from shutil import copyfileobj


def predict():
    os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
    DATABASE = 'database.db'

    # 既存データ読み込み・新規データと連結・整理
    dataframe = pd.read_excel('./Nikkei_10.xlsx', sheet_name='no_nan', usecols=[0, 1, 2, 3, 4, 5, 6, 7])
    dataset = dataframe[['Nikkei', 'EPS_ind', 'JBond10', 'JMB', 'JUER', 'JCPI', 'Dow']]
    dataset = dataset.astype('float32')

    # 今日・昨日・一昨日の日付を、日本時間で取得
    import pytz

    jst = pytz.timezone('Asia/Tokyo')
    today_time = datetime.now().astimezone(jst)
    today = today_time.date()
    yesterday = today - timedelta(days=1)
    the_day_before_yesterday = yesterday - timedelta(days=1)
    print('yesterday = ', yesterday)

    ## ここから、Webスクレイピング・収集データの整理・保存
    # 前日の日経平均の終値を取得
    r = requests.get('https://www.nikkei.com/markets/worldidx/chart/nk225/')
    soup = BeautifulSoup(r.text, 'html.parser')
    Nikkei_place = soup.find('span', class_='economic_value_now a-fs26')
    Nikkei = Nikkei_place.text
    Nikkei = float(Nikkei.split(' ')[0].replace(',', ''))
    print('Nikkei = ', Nikkei)

    # 前日のPER(指数ベース)終値を取得し、EPSを計算
    r = requests.get('https://indexes.nikkei.co.jp/nkave/archives/data?list=per')
    soup = BeautifulSoup(r.text, 'html.parser')

    elems_td = soup.find_all('td')

    keys = []
    for elem_td in elems_td:
        key = elem_td.text
        keys.append(key)

    keys = keys[3:]

    per_index_list = []
    for i, key in enumerate(keys):
        if i % 3 == 2:
            if key == '-':
                per_index_list.append('nan')
            else:
                per_index_list.append(float(key))

    per_index = per_index_list[-1]

    if per_index == 'nan':
        EPS_ind = float(dataset.EPS_ind[-1:])
    else:
        EPS_ind = float(Nikkei / per_index)

    print('per_index = ', per_index)
    print('EPS_ind = ', EPS_ind)

    # 前日の10年国債利回りの終値を取得
    r = requests.get('https://fund.smtb.jp/smtbhp/qsearch.exe?F=market3')
    soup = BeautifulSoup(r.text, 'html.parser')
    JBond10_place = soup.select_one(
        '#market3 > div > div > div.mod-layout > div > div:nth-child(1) > div > div > ul > li:nth-child(1) > span:nth-child(2) > b')
    JBond10 = JBond10_place.text
    JBond10 = float(JBond10.split(' ')[0].replace(',', ''))
    print('JBond10 = ', JBond10)

    # 前日の日本のマネタリーベースが記載されたファイルを、ダウンロードして取得
    url = "https://www.boj.or.jp/statistics/boj/other/mb/mblong.xlsx"
    save_name = url.split('/')[-1]

    with urlopen(url) as input_file, open(save_name, 'wb') as output_file:
        copyfileobj(input_file, output_file)

    JMB_df = pd.read_excel(save_name, sheet_name='平残(Average amounts outstanding)', usecols=[2], skiprows=7, )
    JMB_df = JMB_df.dropna(how='all')
    JMB = int(JMB_df.iloc[-1:, 0])
    print('JMB = ', JMB)

    # 前日の失業率を取得
    r = requests.get('https://www.stat.go.jp/data/roudou/sokuhou/tsuki/index.html')
    soup = BeautifulSoup(r.text, 'html.parser')
    JUER_place = soup.select_one(
        '#section > article:nth-child(2) > table > tbody > tr:nth-child(3) > td:nth-child(4) > strong')
    JUER = JUER_place.text
    JUER = float(JUER.split(' ')[0].replace('%', ''))
    print('JUER = ', JUER)

    # 前日のCPIを取得
    r = requests.get('https://www.stat.go.jp/data/cpi/sokuhou/tsuki/index-z.html')
    soup = BeautifulSoup(r.text, 'html.parser')
    JCPI_place = soup.select_one('#section > article:nth-child(2) > div > p')
    JCPI_place_0 = JCPI_place.text.splitlines()[0]
    JCPI_place_0_0 = JCPI_place_0.split(' ')[1]
    JCPI = float(re.findall(r'\d+(?:\.\d+)?', JCPI_place_0_0)[-1])
    print('JCPI = ', JCPI)

    # 前日のダウ平均の終値を取得
    r = requests.get('https://us.kabutan.jp/indexes/%5EDJI/chart')
    soup = BeautifulSoup(r.text, 'html.parser')
    Dow_place = soup.select_one(
        'body > div > div > div.float-left.w-main > main > div.border-azure.border-2 > div.pr-2px.pb-2px.pl-2px > div.flex.justify-between.items-end > div.w-full.pr-2px > div.pl-1.pb-1.pr-2.flex > div.flex.w-full.items-end.justify-between > div.flex-1.text-right.text-3xl.mr-1')
    Dow = Dow_place.text
    Dow = Dow.split(' ')[0].replace('$', '')
    Dow = float(Dow.split(' ')[0].replace(',', ''))
    print('Dow = ', Dow)

    # 収集したデータをデータフレームに整理
    latest = pd.DataFrame(
        data=np.array([[yesterday, Nikkei, EPS_ind, JBond10, JMB, JUER, JCPI, Dow]]),
        columns=['Date', 'Nikkei', 'EPS_ind', 'JBond10', 'JMB', 'JUER', 'JCPI', 'Dow']
    )
    print(latest)

    # 既存データと新規収集データを連結
    dataframe = pd.concat([dataframe, latest], axis=0)

    # 月に一度、前月データが公表される指標については、取得次第前月末日までさかのぼって上書き
    # 毎月15日に「前月の結果」としてデータ公表される指標の場合、例えば2/14までは1/15に公表された「12月の結果」データが毎日Nikkei_10.xlsxに蓄積されます。
    # 2/15に「1月の結果」データが公表された時点で、1/31移行の蓄積データを「1月の結果」に上書きします。
    dt = dataframe['Date'].iloc[-1]
    if dt.month == 1:
        l_year = dt.year - 1
        l_month = 12
    else:
        l_year = dt.year
        l_month = dt.month - 1

    l_day = calendar.monthrange(dt.year, dt.month)[1]
    target_date = datetime(l_year, l_month, l_day)

    dataframe = dataframe.set_index('Date')

    dataframe.loc[target_date:, 'JMB'] = JMB
    dataframe.loc[target_date:, 'JUER'] = JUER
    dataframe.loc[target_date:, 'JCPI'] = JCPI

    dataframe.reset_index(inplace=True)

    dataframe.to_excel('./Nikkei_10.xlsx', sheet_name='no_nan', index=False)
    ## ここまで、Webスクレイピング・収集データの整理・保存


    ## ここから最新データと学習モデルを用いた予測
    new_dataset = dataframe[['Nikkei', 'EPS_ind', 'JBond10', 'JMB', 'JUER', 'JCPI', 'Dow']].to_numpy()
    new_dataset = new_dataset.astype('float32')

    # 予測材料は24日前までの実績データ
    lookback = 24
    actual = new_dataset[len(new_dataset) - lookback:, :]

    # データを標準化
    scaler_train = load(open('nikkei_10_standard.pkl', 'rb'))
    actual = scaler_train.transform(actual)
    mean, scale = scaler_train.mean_[0], scaler_train.scale_[0]

    # 学習モデル用に成形
    actual = actual.reshape(1, actual.shape[0], actual.shape[1])

    # 保存していた学習モデルを読み込み、予測実行
    model = keras.models.load_model('nikkei_10_rmse_274.h5', compile=False)

    predict = model.predict(actual)
    predict = predict * scale + mean
    predict = float(predict[0][0])

    print('今日の終値は、', predict, '円')

    # 予測値をpredictionテーブルに保存
    con = sqlite3.connect(DATABASE)
    cur = con.cursor()
    cur.execute('INSERT INTO prediction(Date, predict) VALUES(?, ?)', [today, predict])
    con.commit()
    con.close()
    ## ここまで、当日の日経平均終値の予測

    ## ここから、Webページ下部の実績表に表示させるためのデータを作成
    # 前日の日経平均の予測値を読み込み
    with sqlite3.connect(DATABASE) as con:
        df_from_sql = pd.read_sql('SELECT * FROM prediction', con)
    df_from_sql = df_from_sql.set_index('Date')
    if str(yesterday) in df_from_sql.index:
        last_predict = df_from_sql.loc[str(yesterday), 'predict']
    else:
        last_predict = 0
    print('last_predict = ', last_predict)

    # 前々日の日経平均の予測値を読み込み
    with sqlite3.connect(DATABASE) as con:
        df_from_sql = pd.read_sql('SELECT * FROM prediction', con)
    df_from_sql = df_from_sql.set_index('Date')
    if str(the_day_before_yesterday) in df_from_sql.index:
        sec_last_predict = df_from_sql.loc[str(the_day_before_yesterday), 'predict']
    else:
        sec_last_predict = 0
    print('sec_last_predict = ', sec_last_predict)

    # 前々日の日経平均の実績値を読み込み
    with sqlite3.connect(DATABASE) as con:
        df_from_sql = pd.read_sql('SELECT * FROM actual', con)
    df_from_sql = df_from_sql.set_index('Date')
    if str(the_day_before_yesterday) in df_from_sql.index:
        sec_last_Nikkei = df_from_sql.loc[str(the_day_before_yesterday), 'Nikkei']
    else:
        sec_last_Nikkei = 0
    print('sec_last_Nikkei = ', sec_last_Nikkei)

    # 前日の予測値と実績値の誤差を計算
    if last_predict == 0:
        gap = 0
    else:
        gap = Nikkei - last_predict
    print('gap = ', gap)

    # 前々日から前日にかけての日経平均の実績値の差分を計算
    if sec_last_Nikkei == 0:
        Nikkei_dod = 0
    else:
        Nikkei_dod = Nikkei - sec_last_Nikkei
    print('Nikkei_dod = ', Nikkei_dod)

    # 前々日から前日にかけての日経平均の予測値の差分を計算
    if last_predict == 0 or sec_last_predict == 0:
        predict_dod = 0
    else:
        predict_dod = last_predict - sec_last_predict
    print('predict_dod = ', predict_dod)

    # 【Webページには表示させていません】前々日から前日にかけての予測値の上下方向と、実績値の上下方向が一致していれば「アタリ」
    if Nikkei_dod == 0 and predict_dod == 0: direction_check = 'アタリ'
    if Nikkei_dod < 0 and predict_dod < 0: direction_check = 'アタリ'
    if Nikkei_dod > 0 and predict_dod > 0: direction_check = 'アタリ'
    if Nikkei_dod < 0 and predict_dod > 0: direction_check = 'はずれ'
    if Nikkei_dod > 0 and predict_dod < 0: direction_check = 'はずれ'
    if Nikkei_dod == 0 and predict_dod > 0: direction_check = 'はずれ'
    if Nikkei_dod == 0 and predict_dod < 0: direction_check = 'はずれ'
    if Nikkei_dod < 0 and predict_dod == 0: direction_check = 'はずれ'
    if Nikkei_dod > 0 and predict_dod == 0: direction_check = 'はずれ'

    print(direction_check)

    # ここまでの計算結果をactualテーブルに格納
    con = sqlite3.connect(DATABASE)
    cur = con.cursor()
    cur.execute(
        'INSERT INTO actual(Date, Nikkei, last_predict, gap, Nikkei_dod, predict_dod, direction_check) VALUES(?, ?, ?, ?, ?, ?, ? )',
        [yesterday, Nikkei, last_predict, gap, Nikkei_dod, predict_dod, direction_check])
    con.commit()
    con.close()

4. flask_apschedulerを、renderの無料instanceで実行させるための試行錯誤と残された問題

ここでかなりはまってしまいました。後々、一連の試行錯誤を経た後にrenderの説明を読むとすんなり理解できたのですが、最初はなぜスケジュール通りに実行されないのかわかりませんでした。
最初に私なりの結論ですが、「renderの無料instanceをスケジュール通り実行させるには、実行させたい時間にinstanceを目覚めさせておく必要」があります。

まず、公式によるrenderの無料instanceについての説明です。

Web Services on the free instance type are automatically spun down after 15 minutes of inactivity. When a new request for a free service comes in, Render spins it up again so it can process the request.

This can cause a response delay of up to 30 seconds for the first request that comes in after a period of inactivity.

https://render.com/docs/free
  • 15分何も無いと寝ます

  • (アクセスされたり)何らかrequestがあると起きますが、目が覚めるのに30秒くらいかかります

この記述を上の結論として腹落ちするまでに、初心者の自分には以下の試行錯誤が必要でした。

  1. requestにflask_apschedulerも含まれるのでは?→含まれないようでした

  2. ログを見ると、アクセスしなくてもたまに目覚めてる。目覚めの周期を見極められれば、デプロイするタイミング次第で6:45に目覚めさせておくことができるのでは?→目覚めの周期はよくわかりませんでした。

  3. アクセスすれば起きることは確かで、起きている間であればflask_apschedulerが動くことは実験から確認がとれたので、6:45より少し前にアクセスして起こせば良いのでは?→今、ここにいます

3を実現するために、以下の3つの方法を考えました。

  1. 同一のrender/gitアカウント内で、2つのWebアプリを作成。アプリ間で15分ごとにお互いにアクセスして起こし続け合う→render無料instanceの750時間/月までという稼働制限にひっかかるためアウト

  2. 二つのrender/gitアカウントを作成し、それぞれ1つづつアプリを作成し、アプリ間で15分ごとにお互いにアクセスして起こし続け合う→gitの規約に一人一アカウントまでとありアウト

  3. ほぼ稼働しっぱななしの自宅のNASから、毎朝6:45より少し前に、Webアプリに自動でアクセスするプログラムを実装する→これが一瞬うまくいったと思ったのに、ぬか喜びだったと発覚してしまったところにいます

3の詳細です。
NASへの実装は、こちらの記事を参考に進めさせていただきました(素晴らしい記事をありがとうございました!)。

以下のコードを6:45より前に5回、後に1回実行させています。6:45前に1回だけだとなぜか目覚めていないことがあったので、これでもかと5回アクセスしてもらっています。

# coding: UTF-8

import requests
from bs4 import BeautifulSoup
import time

# https://nikkei-prediction.onrender.com/ にアクセス
r = requests.get('https://nikkei-prediction.onrender.com/')
time.sleep(90)
soup = BeautifulSoup(r.text, 'html.parser')
Nikkei_place = soup.select_one('body > div > h1')
Nikkei = Nikkei_place.text
print(Nikkei)

Webページにアクセスし、h1タグで表示している日経平均の予測値を取得してくるという処理です。
実行後、NASからは取得した日経平均の予測値を記載した実行結果メールが送られてくるように設定します。

下記は、2/17 6:42の実行結果メールです。Password欄に、その時点でWebページに表示されている日経平均の予測値が記載されています。まだ、「2/16」の予測値ですが、少なくともスクレイピングできているのでinstanceは目覚めてくれています。

6:45直前の実行結果メール

そして、いよいよ6:55の実行結果メールです。

6:45より後の実行結果メール

やりました!見事2/17の予測値が表示されています。無事、スケジュール通りにNikkei_10_utilized.pyを実行してくれたようです。
これでようやく長い闘いが終わったと思ったのですが、このブログを書こうと思って、数時間後Webページにアクセスしたところ…

数時間後のWebページの状態

ショックなことに2/16に戻っていました…予測して、寝て、目覚めたら、何事もなかったかのように予測前の状態に戻ってしまっていたのです。

ログにも特に異常が出ていないですし、「どうやって原因追及したらいいのか」、「勉強目的ならそろそろ手を引いてもいいのではないのか」などなど様々な思いが去来している状況でございます。
原因追及の方法や、対策方法など、なんらか次につながる知識やアイデアをお持ちの方がいらっしゃいましたら、ご教示をいただけましたら大変有難く存じます。

この記事が気に入ったらサポートをしてみませんか?