対話型LINE BOTの作り方003 (株価チャートを描画する)

#概要
[前回](https://qiita.com/mahiroaug/items/50efc5ab7ff084aa06cc)までに対話型LINE BOTの原型を作成し、テキストメッセージおよび画像メッセージの送信ができることを確認した。
今回は実用編として株価情報の取得ならびにチャート描画を行いユーザに提供する機能を実装する。

##システム構成

LINEアプリからユーザが起動コードとティッカーシンボルを含む所定の書式のメッセージを投下(①)すると、LINE BOTが株価情報サービスサイトのAPIにアクセスしスクレイピングを行う(②)。取得したデータを元に株価チャートを作図し画像データとして保存する(③)とともに、LINE BOTは画像メッセージにそのURLを記述してLINEアプリに返信する。LINEアプリが所定の画像URLへアクセスしダウンロード(④)するという流れ。

![システム構成003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/f3b64ea8-ba6a-6807-4a8c-2753e88589c2.png)

上の図例ではアマゾンの株価を取得している。
起動コードは下記の４パターンを定義した。（〇〇〇にティッカーシンボルを入力する）

- `株価`〇〇〇
- `チャート`〇〇〇
- `stock price` 〇〇〇
- `chart` 〇〇〇

いずれを指定しても動作に違いはない。なお指定された銘柄が存在しない場合はエラーコードをテキストメッセージで返すようにする。


##採用したもの一覧

| 種類 | 名前 | 備考 |
|:--|:--|:--|
| DB取得ライブラリ | pandas-datareader | 0.8.1 |
| 作図ライブラリ | matplotlib | 3.1.1 |
| 株価情報サービス | IEX Cloud | アメリカの新興取引所であるInvestors Exchange Groupが提供するAPI。(利用にはAPI_KEYの取得が必要) |
| 〃 | Yahoo Finance | 2019年11月現在、生存確認。(ただしスクレイピングは原則禁止されているらしいのであくまで動作確認用として用いるのみ)|
| 〃 | Google Finance | 未確認 |

DBサーバいろいろあるみたいだが、株価情報が安定して取得できるは多くない模様。
一番確実なのでいうとIEX Cloudなのかな？

#1.(IEX Cloudの場合)API_KEYの取得

[IEX Cloud](https://iexcloud.io/)にてユーザ登録しログインすると、
ポータルから[API Tokens]⇒[My Tokens]⇒[PUBLISHABLE]にて
API_KEYである`pk_xxxxxxxxxxx`の文字列を確認することができ
![IEX_cloud.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/b925758f-e73e-d5db-48a3-a562e0d8a3cf.png)

#2.Python3.6環境への移行

いきなり話が脱線したところから始まるが
前回までに構築してきた現LINE BOTが動作する仮想環境(botenv)は`python3.4`をベースにしていたが、
今回のメインとなる`pandas-datareader`はどうやら`python3.4`に対応しておらず、
インストールしようとしたらpythonのversion変更を迫られてしまった。
そこで本稿では備忘録として`python3.6`の仮想環境構築と、現環境の移設手順を記録しておく。
誰かの役に立つことがあれば幸いである。

まず事前準備として現行環境で利用しているパッケージリストを作成しておく。

```bash:現行環境のpipパッケージリストを作成する
(botenv) [botenv]$ pip freeze > requrements.txt
(botenv) [borenv]$ deactivate
[borenv]$ cd ..
$
```

OS本体にpython3.6が入っていなければインストール

```bash:python3.6インストール
$ sudo yum install -y https://centos7.iuscommunity.org/ius-release.rpm
$ sudo yum install  -y python36u-devel python36u-pip
$ python3.6 -V
python 3.6.7

#（参考）デフォルトバージョン
$ python -V
python 2.6.6
```

python3.6がデフォルトバージョンとして動作する仮想環境を新規作成する

```bash:pythonバージョン指定で仮想環境を新規作成(botenv2)
$ virtualenv -p python3.6 botenv2
$ cd botenv2
[borenv2]$ source bin/activate
#結果確認
(botenv2) [borenv2]$ python -V
Python 3.6.7
```

先ほど抜き出したパッケージリストを元に一括インストール

```bash:パッケージの一括インストール
(botenv2) [borenv2]$ pip install -r ../botenv/requrements.txt
#結果確認
(botenv2) [borenv2]$ pip list
```

以上で環境構築は完了。
従来と同じ要領でDjangoプロジェクトとアプリの作成を行い、
ソースコードを移設してくれば移設完了。

```bash:
#Djangoプロジェクト(プロジェクト名：line_bot)作成
(botenv2) [botenv2]$ django-admin startproject line_bot
(botenv2) [botenv2]$ cd line_bot

#botアプリケーション（アプリ名：bot）作成
(botenv2) [line_bot]$ python manage.py startapp bot

#各種ソースコードを移設
(省略)
```

もっと要領良い手順があればご教示くださいorz

>　(参考) [Python, pipでrequirements.txtを使ってパッケージ一括インストール](https://note.nkmk.me/python-pip-install-requirements/)
>　(参考) [virtualenvの仮想環境構築でPythonのバージョンを指定する](https://mtitg.com/virtualenv-python3/)

#3.株価情報データの取得

必要パッケージの準備を行う。

```bash:パッケージ導入
#pipアップデート
(botenv2) [line_bot]$ pip install --upgrade pip
#pandasインストール
(botenv2) [line_bot]$ pip install pandas
(botenv2) [line_bot]$ pip install pandas-datareader
```

動作確認としてAT&Tの株価を取得してみる。

```python:IEXの場合
(botenv2) [line_bot]$ python
Python 3.6.7 (default, Dec  5 2018, 15:02:16) 
[GCC 4.4.7 20120313 (Red Hat 4.4.7-23)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> import pandas_datareader.data as web
>>> from datetime import datetime
>>> 
>>> os.environ['IEX_API_KEY'] = 'pk_xxxxxxxxxxxx' #API_KEYを設定
>>> 
>>> start = datetime(2019,10,1)
>>> end = datetime(2019,10,10)
>>> t = web.DataReader('T', 'iex', start, end)
>>> print(t.head())
             open   high    low  close    volume
date                                            
2019-10-01  37.95  37.96  37.37  37.41  24334716
2019-10-02  37.35  37.35  36.92  37.08  26612141
2019-10-03  37.00  37.20  36.66  37.19  21001457
2019-10-04  37.22  37.52  37.13  37.51  22598045
2019-10-07  37.58  37.87  37.52  37.66  21245336
>>> 
```

ちなみにyahooだともう少し細かい情報が得られる。

```python:yahoo_financeの場合
>>> t2 = web.DataReader('T', 'yahoo', start, end)
>>> print(t2.head())
                 High        Low       Open      Close      Volume  Adj Close
Date                                                                         
2019-09-30  37.919998  37.529999  37.590000  37.840000  28341900.0  37.325100
2019-10-01  37.959999  37.369999  37.950001  37.410000  23038500.0  36.900951
2019-10-02  37.349998  36.919998  37.349998  37.080002  26257000.0  36.575443
2019-10-03  37.200001  36.660000  37.000000  37.189999  20372600.0  36.683945
2019-10-04  37.520000  37.130001  37.220001  37.509998  21914100.0  36.999588
>>> 
```

#4.株価チャートの描画

必要なパッケージのインストールを行う。

```bash:パッケージ導入
(botenv2) [line_bot]$ pip install matplotlib
(botenv2) [line_bot]$ pip install https://github.com/matplotlib/mpl_finance/archive/master.zip
(botenv2) [line_bot]$ pip install seaborn
(botenv2) [line_bot]$ pip install numpy
```

試しにAlphabetの2年チャートを描画してみる。

```python:株価チャート描画
import os
import datetime
from dateutil.relativedelta import relativedelta

import pandas_datareader.data as web
import matplotlib.pyplot as plt

#グラフを滑らかに
import seaborn as sns
sns.set_style('whitegrid')

source = 'iex'
os.environ['IEX_API_KEY'] = 'pk_xxxxxxxxxxxxxx' 

#銘柄と期間指定
symbol = 'GOOG'
end = datetime.datetime.now()
start = end - relativedelta(years=2)

#データ取得
df = web.DataReader(symbol, source, start, end)

#初期化
plt.figure()
#チャート描画
df['close'].plot(figsize=(10, 5), color='blue', label=symbol)
#凡例の表示
plt.legend()
```

上記コードを手元のPC上のJupyter Notebookで実行すると下記のグラフが出力される。

![output_2_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/54b27374-db4a-fde4-a922-f337089d2f34.png)

今回はこれをデフォルトフォーマットとしてBOTに実装していくこととしたい。
さてこの`pyplot`であるが、BOTアプリはサーバサイドで動作するため標準出力先がない。よってこのままではグラフを表示することはできない。そこで描画プロセスの出力先にバックエンドを指定して画像ファイルとして書き出すという処理が用いられる。
バックエンドを設定するためには`pyplot`をimportする直前に下記のように記述する必要がある。

```python:plotをbackendに出力処理（記述する順番に注意）
import matplotlib as mpl
mpl.use('Agg')
import matplotlib.pyplot as plt
```

また画像ファイルへの出力には`savefig`メソッドを使用することで実現する。

```python:バックエンドを画像ファイルとして書き出し
 plt.savefig(xxx.png)
```

#5.BOTアプリに実装

それでは本格的にBOTのコードを書いていく。
今回は新たに外部ファイル`getStockdata.py`を用意して一連の処理を外部関数`get_chart`としてまとめることとした。
一度取得した生データはCSVファイルとして保存しておき、同日に同じ銘柄への要求が繰り返し発生した場合は、APIアクセスの代わりに既存CSVファイルを読み込むようにすることにより、APIサーバへの負荷を軽減させている。
株価の最新値と前日比を計算する簡単な数式も組み込む。

```python:line_bot/bot/getStockdata.py
import os
import shutil
import datetime
import logging
logger = logging.getLogger('getStockdata')

#date
import datetime
from dateutil.relativedelta import relativedelta

#dandas
import pandas as pd
import pandas_datareader.data as web

#plotをbackendに出力処理
import matplotlib as mpl
mpl.use('Agg')
import matplotlib.pyplot as plt

#図形処理
import seaborn as sns
sns.set_style('whitegrid')

#計算処理
import numpy as np
import mpl_finance as mpf

#png保存ディレクトリ
STOCK_DIR = '/usr/share/nginx/html/static/stock'

#データソース選択
##source = 'iex'
##os.environ['IEX_API_KEY'] = 'pk_xxxxxxxx' #API_KEYを入力する
source = 'yahoo'


#チャート取得関数 (引数が空の場合はT(AT&T)のデータを参照する)
def get_chart(symbol='T'):

  #初期化
  plt.figure()

  symbol = symbol.upper()
  name = 'chart-' + symbol + '-' + str(datetime.date.today())
  namePNG = name + '.png'
  nameCSV = name + '.csv'
  pathPNG = STOCK_DIR + '/' + namePNG
  pathCSV = STOCK_DIR + '/' + nameCSV

  #既に当日分のデータが取得済の場合はCSVから読み込み
  if os.path.exists(pathCSV):
    logger.debug('read CSV') #logging
    df = pd.read_csv(pathCSV)
    df['Date'] = pd.to_datetime(df['Date']) #ObjectからDatetimeへ型変換
    logger.debug(df['Date'].dtype) #logging
    df.set_index('Date',inplace=True) #DatetimeIndexとしてIndex設定
    logger.debug(type(df.index)) #logging
    logger.debug('read CSV ---> Done') #logging

  #データが存在しない場合は新規取得
  else:
    try:
      #期間指定
      end = datetime.datetime.now()
      start = end - relativedelta(years=2)
      #データ取得
      logger.debug('read web data') #logging
      df = web.DataReader(symbol, source, start, end)
      logger.debug('read web data ---> Done') #logging
    except:
      return None,'Exception error:\nsymbol [' + symbol + '] is not found.'

    #グラフ描画
    df['Adj Close'].plot(figsize=(10, 5), color='blue', label=symbol)
    plt.title('symbol:' + symbol +  ' 2-year chart ')
    plt.legend()
    plt.savefig(namePNG)
    logger.debug('read web data ---> make png -> Done') #logging    

    #CSV出力
    df.to_csv(nameCSV)
    logger.debug('read web data ---> make csv -> Done') #logging    
    
    #WEBサーバへ配置
    shutil.move(namePNG,pathPNG)
    shutil.move(nameCSV,pathCSV)

  #応答文
  logger.debug('calc') #logging   
  idx = df.tail(1).index
  s_date = str(idx[0].date())
  prc = np.array(df.tail(2))
  s_price = str(round(prc[1,5],2))
  s_diff = '{:+.2%}'.format((prc[1,5] - prc[0,5]) / prc[0,5])
  text = '[' + symbol + '] ' + s_date + '\n終値 $' + s_price + ' (前日比' + s_diff + ')'
  logger.debug('calc ---> Done') #logging   

  return namePNG,text


if __name__ == '__main__':
  print(get_chart())
```

BOT本体コードの方には起動コードによる条件分岐とティッカーシンボルの抽出処理のみを追加する。

```python:line_bot/bot/chatbot.py(★追記)
# -*- Coding: utf-8 -*-

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
from django.shortcuts import render
from datetime import datetime
from time import sleep
import requests
import json
import base64
import logging
import os
import random
import log.logconfig
from utils import tools
import re #★追記
from .getStockdata import get_chart #★追記

logger = logging.getLogger('commonLogging')

LINE_ENDPOINT = 'https://api.line.me/v2/bot/message/reply'
LINE_ACCESS_TOKEN = '' #Line developer管理画面で確認
LINE_ALLOW_USER='' #Line developer管理画面で確認

ULOCAL_ENDPOINT = 'https://chatbot-api.userlocal.jp/api/chat'
ULOCAL_API_KEY = '' #Userlocal登録時に確認

IMAGE_DIR = '/usr/share/nginx/html/media'
IMAGE_URL = 'https://mydomain.com/media'
CATVIEW_KEY = ['カメラ','室内','部屋','猫']
CATVIEW_RES = ['カメラの画像を送るよ',
               '猫ちゃんの様子です',
               '自宅警備中です',
               '写真で確認よろしく']

STOCK_URL = 'https://mydomain.com/static/stock' #★追記
STOCKVIEW_KEY = ['株価','stock price','チャート','chart'] #★追記

@csrf_exempt
def line_handler(request):

    #exception
    if not request.method == 'POST':
      return HttpResponse(status=200)

    logger.debug('line_handler message incoming') #logging
    out_log = tools.outputLog_line_request(request) #logging
    request_json = json.loads(request.body.decode('utf-8'))

    for event in request_json['events']:
      reply_token = event['replyToken']
      message_type = event['message']['type']
      user_id = event['source']['userId']

      #whitelist
      if not user_id == LINE_ALLOW_USER:
        logger.warning('invalid userID:' + user_id) #logging
        return HttpResponse(status=200)

      #action
      if message_type == 'text':
        if any(s in event['message']['text'] for s in OUTVIEW_KEY):
          action_res(reply_token,'outview')

        elif any(s in event['message']['text'] for s in STOCKVIEW_KEY): #★追記
          action_data(reply_token,'stockview',event['message']['text'])  #★追記

        else:
          #ulocal chat
          response_text(reply_token,ulocal_chatting(event))

    return HttpResponse(status=200)

def action_res(reply_token,command,):
    if command == 'outview':
    #監視カメラ画像
      files = os.listdir(IMAGE_DIR)
      #logger.info(files) #logging
      orgFiles = [ s for s in files if '_view' in s ]
      orgPATH = IMAGE_DIR + '/' + orgFiles[0]
      orgUrl = IMAGE_URL + '/' + orgFiles[0]
      subtext = random.choice(OUTVIEW_RES)

      sleep(1)
      response_image(reply_token,orgUrl,orgUrl,subtext)

##########################################★追記 ここから
def action_data(reply_token,command,value):
    if command == 'stockview':
    #株価チャート
      logger.debug('get_chart on') #logging

      symbols = re.search('[a-zA-Z]+$', value)
      logger.debug('get_chart symbol = ' + symbols.group()) #logging

      result = get_chart(symbols.group())
      logger.debug('get_chart finish') #logging

      if result[0] is not None:   
        orgUrl = STOCK_URL + '/' + result[0]
        response_image(reply_token,orgUrl,orgUrl,result[1])
      else:
        response_text(reply_token,result[1])
##########################################★追記 ここまで

def response_image(reply_token,orgUrl,preUrl,text):
    payload = {
      "replyToken": reply_token,
      "messages":[
        {
          "type": 'text',
          "text": text
        },
        {
          "type": 'image',
          "originalContentUrl": orgUrl,
          "previewImageUrl": preUrl
        }
      ]
    }
    line_post(payload)


def response_text(reply_token,text):
    payload = {
      "replyToken": reply_token,
      "messages":[
        {
          "type": 'text',
          "text": text
        }
      ]
    }
    line_post(payload)


def line_post(payload):
    url = LINE_ENDPOINT
    header = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + LINE_ACCESS_TOKEN
    }
    requests.post(url, headers=header, data=json.dumps(payload))
    out_log = tools.outputLog_line_response(payload) #logging
    logger.debug('line_handler message -->reply') #logging


def ulocal_chatting(event):
    url = ULOCAL_ENDPOINT   
    payload={
      'key'    : ULOCAL_API_KEY,
      'message': event['message']['text']
    }  

    out_log = tools.outputLog_ulocal_request(payload) #logging
    logger.debug('ulocal_chatting send request') #logging
    ulocal_res = requests.get(url,payload)
    logger.debug('ulocal_chatting -->recv response') #logging
    out_log = tools.outputLog_ulocal_response(ulocal_res) #logging

    data = ulocal_res.json()
    response = data['result']
    return response

```

それでは新環境でLINE BOTを起動してみる。

```bash:line_botを起動
(botenv2) [line_bot]$ gunicorn --bind 127.0.0.1:8000 line_bot.wsgi:application
```

LINEアプリから書式に沿ったメッセージを投下すると結果が得られるだろう。

![Screenshot130b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/98bf5d31-a8a9-4fb7-00b0-0f4ae11b8264.png)

応答が機械的になってしまうのでもう少し人間味を加えたほうがいいかな。
今回はここまで。