Pythonによる株式情報表示GUIアプリ(トミィさん作成コードのWebアプリ化)
見出し画像

Pythonによる株式情報表示GUIアプリ(トミィさん作成コードのWebアプリ化)

みなさん、こんにちは。ときかねえさんです♪

最近、米国株サークルを主催されているトミィさんにお誘いいただき同サークルに加入することにしました。トミィさんは以下のように、株式銘柄一覧のヒートマップ表示と指定した一銘柄の詳細表示を行うPythonコードを書かれていて、いずれもnoteで公開されています。

このPythonコードがとっても秀逸です♪

そこで今回はこの二つを合わせたWebアプリを作ってみました。Webアプリ化には、最近私のお気に入りであるstreamlitを用いました。もし興味があればご覧ください♪

アプリの概要とスクリーンショット

本アプリは、以下のようにブラウザで動作し一覧ヒートマップを表示します。また、左側のサイドバーにあるラジオボタンで選択された銘柄の詳細情報をヒートマップの横に並べて描画します。デフォルトでは以下のように"マイクロソフト"が選ばれています。

画像3

ラジオボタンを"アップル"に変更すると以下のように描画が変更されます。(描画変更にはやや時間がかかります。)

画像3

ブラウザのリサイズに応じて表示領域の画像やテキストも自動的に変更されます。

画像3

また、「x」印をクリックするとサイドバーを非表示にすることができます。 

画像4

サイドバーを再表示するには「>」印をクリックします。

アプリのPythonコード

以下にコードを記します。ヒートマップ表示と詳細表示それぞれで関数化しており、関数の中身はトミィさんオリジナルのものとほぼそのままとなっています。
(関数化にあわせて整形すべきかも知れませんが今回は行っていません。)
*Jupyterでは起動しないので注意してください。

# test.py

import sys
import pandas as pd
from math import floor, ceil
import yahoo_fin.stock_info as si
import japanize_matplotlib 
import seaborn as sns
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image, ImageDraw, ImageFont

import datetime as datetime
import pytz
import os

import requests
import mplfinance as mpf
import yfinance as yf

import streamlit as st
st.set_page_config(layout="wide")

# 出力画像の保存先です。環境に応じて適宜変更してください。
output_dir = './mydirectory'

tickers1 = {'^VIX':'VIX指数', '^TNX':'金利', 'HYG':'HY債'
,'^DJI':'ダウ', '^RUT':'ラッセル', '^GSPC':'S&P500'
, '^IXIC':'ナスダック'}

tickers2 = {'VIS':'資本財', 'VAW':'素材', 'VCR':'一般消費財'
   , 'VDE':'エネルギー', 'VPU':'公益', 'VDC':'生活必需品'
   , 'VHT':'ヘルスケア', 'VOX':'電気通信', 'VFH':'金融'
   , 'VGT':'情報技術', 'XLRE':'不動産'}

tickers3 = {'DIA':'ダウ', 'IWM':'ラッセル2000', 'VOO':'S&P500'
   , 'QQQ':'ナスダック', 'IWB':'ラッセル1000', 'VTI':'トータル'
   , 'CLOU':'クラウド', 'VUG':'グロース指数', 'IWF':'ラッセルグロース'
   , 'VONV':'ラッセルバリュー', 'VTV':'大型バリュー', 'VT':'全世界'
   , 'VXUS':'全世界', 'VWO':'新興国', 'IEMG':'新興国'
   , 'SPYD':'高配当', 'HDV':'高配当', 'VYM':'高配当'
   , 'VIG':'増配'}

tickers4 = {'MSFT':'マイクロソフト', 'AAPL':'アップル', 'FB':'フェイスブック'
   , 'GOOGL':'グーグル', 'AMZN':'アマゾン', 'TSLA':'テスラ'
   , 'NFLX':'ネットフリックス', 'NVDA':'エヌビディア', 'TWTR':'ツイッター'
   , 'BABA':'アリババ', 'BIDU':'バイドゥ'}

categ = {'指数':tickers1, 'セクター':tickers2, 'ETF':tickers3, 'FANG+M':tickers4}

def get_rgb(limit, value):
   if(value<=-limit): return (244,53,56)
   if(value>=limit): return (48,204,90)
   if(value==0): return (65,69,84)
   if(np.isnan(value)): return (65,69,84)
   color_list = sns.diverging_palette(12, 150, sep=1, n=100, center="dark")
   rgb=color_list[50+int(value/limit*50)]
   return (int(rgb[0]*255*1.5),int(rgb[1]*255*1.5),int(rgb[2]*255*1.5))

##### 初期設定 ここから ##################
signiture = 'トミィ @toushi_tommy'

# フォント
jap_font = '/content/drive/MyDrive/fonts/meiryo.ttc'
if not os.path.exists(jap_font): jap_font = module_dir+'japanize_matplotlib/fonts/ipaexg.ttf'

font_name=ImageFont.truetype(jap_font,12)
font_cate=ImageFont.truetype(jap_font,16)
font_ticker=ImageFont.truetype(jap_font,16)
font_pct=ImageFont.truetype(jap_font,12)

# 1day #####
diff_day = 1
max_per = 3 # maxのパーセンテージ
## 1week ##### #diff_day  = 5 #max_per  = 3 # maxのパーセンテージ
## 1month ##### #diff_day  = 22 #max_per  = 10 # maxのパーセンテージ

x_size = 7 # 横に表示する最大数

x_width = 90
y_width = 75
##### 初期設定 ここまで ##################


# ##########################
# ### ヒートマップ表示の関数 ###
# ##########################
def get_heatmap():
   global output_dir
   global tickers1, tickers2, tickers3, tickers4, categ
   global diff_day, max_per, x_size, x_width, y_width
   global font_name, font_cate, font_ticker, font_pct
   y_size=0
   for name in categ.keys():
       y_size=y_size+ceil(len(categ[name])/x_size)

   im = Image.new('RGB', (x_width*x_size, y_width*y_size+30*len(categ)), (38,41,49))
   draw = ImageDraw.Draw(im)

   y_pos=0
   for name in categ.keys():
       x_pos=0
       draw.rectangle([(0, y_pos), (x_width*x_size, y_pos+30)], fill=(247, 203, 77), outline=(38,41,49), width=1)
       draw.text((x_pos+10, y_pos+10),name,'black',font=font_cate)
       y_pos=y_pos+30
       for ticker in categ[name].keys():
           data = si.get_data(ticker)["adjclose"]
           data_pct=data.pct_change(diff_day) 

           stock='$%.2f'%(data.iat[-1])
           pct=data_pct.iat[-1]
           chgpct='{:.2%}'.format(pct)
           if chgpct[:1]!='-': chgpct='+'+chgpct

           draw.rectangle([(x_pos, y_pos), (x_pos+x_width, y_pos+y_width)], fill=get_rgb(max_per/100,pct), outline=(38,41,49), width=1)
           draw.text((x_pos+20, y_pos+5), ticker,'white',font=font_ticker)
           draw.text((x_pos+10, y_pos+25), categ[name][ticker][:6],'white',font=font_name)
           draw.text((x_pos+20, y_pos+40), stock, 'white',font=font_pct)
           draw.text((x_pos+20, y_pos+55), chgpct, 'white',font=font_pct)

           x_pos=x_pos+x_width
           if((x_pos+x_width)>x_width*x_size)|(ticker==list(categ[name].keys())[-1]):
               x_pos=0
               y_pos=y_pos+y_width

   draw.text((x_width*x_size-160, 10),datetime.datetime.now(pytz.timezone('Asia/Tokyo')).strftime("%Y/%m/%d %H:%M"),'black',font=font_cate)

   if not os.path.isdir(output_dir): os.makedirs(output_dir)
   outfile=output_dir+'test.png'
   im.save(outfile)
   #im.save(output_dir+'test.png')
   return outfile


# ######################################
# ### 指定した銘柄の詳細情報を取得する関数 ###
# ######################################
@st.cache # リロードのたびに呼ばれてしまわないように
def get_stock_info(ticker):

   font_jap18=ImageFont.truetype(jap_font, 18)
   font_jap40=ImageFont.truetype(jap_font, 40)
   font_jap20=ImageFont.truetype(jap_font, 20)
   font_jap14=ImageFont.truetype(jap_font, 14)

   today = datetime.datetime.now(pytz.timezone('Asia/Tokyo'))
   if not os.path.isdir(output_dir): os.makedirs(output_dir)
   tmp_file = output_dir+'tmp.png'
   tmp2_file = output_dir+'tmp2.png'

   data = yf.Ticker(ticker)
   im = Image.new('RGB', (960, 1280), (255,255,255))
   draw = ImageDraw.Draw(im)

   # アイコン ##########################################################
   open(tmp2_file, 'wb').write(requests.get(data.info['logo_url']).content) #if  os.path.getsize(tmp_file)>1: im.paste(Image.open(tmp_file).copy(), (30, 30))
   if os.path.getsize(tmp2_file)>1:
       image = Image.open(tmp2_file)
       image.load()
       background = Image.new("RGB", image.size, (255, 255, 255))
       if len(image.split())>3: background.paste(image, mask=image.split()[3])
       else: background.paste(image)
       background.save(tmp2_file,quality=95)
       im.paste(Image.open(tmp2_file).copy(), (30, 95-int(image.size[1]/2)))

   #####################################################################
   # 銘柄説明文取得
   japDetail=''
   try:
       site = requests.get("https://us.kabutan.jp/stocks/"+ticker)
       sdat = BeautifulSoup(site.text,'html.parser')
       da = sdat.find_all("dd", class_="text-left pt-1 pl-4")
       jname = [re.sub('.*>(.*)<.*', r'\1', s) for s in [s.replace('&amp;', '&') for s in [str(i) for i in da]]]
       da = sdat.find_all("dd", class_="text-left pt-1 pl-4 text-md")
       detail = [re.sub('.*>(.*)<.*', r'\1', s) for s in [s.replace('\n', '').replace('&amp;', '&') for s in [str(i) for i in da]]]
       japDetail=jname[0]+':'+detail[0]
   except:
       pass

   #####################################################################
   str_dat = ticker+'の銘柄情報'
   draw.line((310,100, 330+draw.textsize(str_dat, font_jap40)[0],100), fill='yellow', width=30)
   draw.text((320, 70), str_dat, 'black', font=font_jap40)
   draw.text((680, 20), signiture, 'blue', font=font_jap20)
   draw.text((680, 150), "更新日時:"+today.strftime("%Y/%m/%d"),'blue',font=font_jap20)
   draw.rectangle([(10, 10), (950, 1270)], outline='black', width=5)
   draw.line((20,180, 940,180), fill='black', width=1)

   #####################################################################
   draw.text((30, 200), '社名:'+data.info['longName']+', セクター:'+data.info['sector']+', 業界:'+data.info['industry'],
           'black', font=font_jap20)
   draw.text((50, 235), japDetail[:48], 'black', font=font_jap18)
   draw.text((50, 255), japDetail[48:96], 'black', font=font_jap18)
   draw.text((50, 275), japDetail[96:144], 'black', font=font_jap18)
   draw.text((50, 295), japDetail[144:192], 'black', font=font_jap18)
   draw.text((30, 320), 'Webサイト:'+data.info['website'], 'black', font=font_jap20)

   # 株価 #####################################################################
   draw.line((20,360, 940,360), fill='black', width=1)
   draw.text((30, 370), 'アナリスト株価ターゲット:'+str(data.info['numberOfAnalystOpinions'])+'人', 'black', font=font_jap20)
   xpos = 100
   ypos = 430
   length = 650
   if(data.info['numberOfAnalystOpinions'] is not None):
       lo_price = data.info['currentPrice'] if data.info['currentPrice']<data.info['targetLowPrice'] else data.info['targetLowPrice']
       hi_price = data.info['currentPrice'] if data.info['currentPrice']>data.info['targetHighPrice'] else data.info['targetHighPrice']
       avg_per = (data.info['targetMeanPrice'] - lo_price)/(hi_price - lo_price)
       lo_per = (data.info['targetLowPrice'] - lo_price)/(hi_price - lo_price)
       hi_per = (data.info['targetHighPrice'] - lo_price)/(hi_price - lo_price)
       cur_per = (data.info['currentPrice'] - lo_price)/(hi_price - lo_price)
       draw.line((xpos, ypos, xpos+length, ypos), fill='black', width=2)
       draw.ellipse((xpos+length*lo_per-5, ypos-5, xpos+length*lo_per+5, ypos+5), fill=(0, 0, 0))
       draw.ellipse((xpos+length*hi_per-5, ypos-5, xpos+length*hi_per+5, ypos+5), fill=(0, 0, 0))
       draw.ellipse((xpos+length*avg_per-5, ypos-5, xpos+length*avg_per+5, ypos+5), fill='red')
       draw.ellipse((xpos+length*cur_per-5, ypos-5, xpos+length*cur_per+5, ypos+5), fill='blue')
       draw.text((xpos+length*lo_per, ypos+10), '下限:'+str(data.info['targetLowPrice']), 'black', font=font_jap20)
       draw.text((xpos+length*hi_per, ypos+10), '上限:'+str(data.info['targetHighPrice']), 'black', font=font_jap20)
       draw.text((xpos+length*avg_per, ypos-35), '平均:'+str(data.info['targetMeanPrice']), 'red', font=font_jap20)
       draw.text((xpos+length*cur_per, ypos+35), '現在値:'+str(data.info['currentPrice']), 'blue', font=font_jap20)

   # 決算情報 #####################################################################
   draw.line((20,500, 940,500), fill='black', width=1)
   conv_str = lambda x: '$%.2f'%(x/float('1E'+str(3*'{:,}'.format(x).count(','))))+['','K','M','B','T']['{:,}'.format(x).count(',')]
   total_revenue = conv_str(data.info['totalRevenue']) if (data.info['totalRevenue'] is not None) else str(data.info['totalRevenue'])
   quart_revenue = conv_str(data.quarterly_earnings['Revenue'][-1])
   market_cap = conv_str(data.info['marketCap'])
   per = '%.2f'%(data.info['trailingPE']) if 'trailingPE' in data.info else 'N/A'
   psr = '%.2f'%(data.info['marketCap']/4/data.quarterly_earnings['Revenue'][-1]) if(data.quarterly_earnings['Revenue'][-1]>0) else 'N/A'
   yoy = '{:.2%}'.format(data.info['revenueGrowth']) if (data.info['revenueGrowth'] is not None) else str(data.info['revenueGrowth'])

   draw.text((30, 510), '売上(直近1年):'+total_revenue+', 売上('+data.quarterly_earnings.index[-1]+
   '):'+quart_revenue+', YoY:'+yoy, 'black', font=font_jap20)
   draw.text((30, 550), '時価総額:'+market_cap+', PER:'+per+', PSR:'+psr, 'black', font=font_jap20)
   draw.text((30, 590), '発行済株式数:'+"{:,}".format(data.info['sharesOutstanding']), 'black', font=font_jap20)
   draw.text((30, 630), '52週高値:'+'$%.2f'%(data.info['fiftyTwoWeekHigh'])+', 52週安値:'+'$%.2f'%(data.info['fiftyTwoWeekLow']),
           'black', font=font_jap20)

   # チャート ##########################################################
   chart = data.history(period="3mo")
   chart['Percent'] = chart['Close'].pct_change()
   chart['MACD'] = chart['Close'].ewm(span=12, adjust=False).mean() - chart['Close'].ewm(span=26, adjust=False).mean()
   chart['Signal'] = chart['MACD'].rolling(window=9).mean()
   chart['Hist'] = chart['MACD'] - chart['Signal']
   df_diff = chart['Close'].diff()
   df_up, df_down = df_diff.copy(), df_diff.copy()
   df_up[df_up < 0] = 0
   df_down[df_down > 0] = 0
   df_down = df_down * -1
   sim14_up = df_up.rolling(window=14).mean()
   sim14_down = df_down.rolling(window=14).mean()
   chart['RSI'] = sim14_up / (sim14_up + sim14_down) * 100
   chart['RSI_hl'] = 70
   chart['RSI_ll'] = 30
   add_plot=[
   mpf.make_addplot(chart['MACD'], color='m', panel=1, secondary_y=False),
   mpf.make_addplot(chart['Signal'], color='c', panel=1, secondary_y=False),
   mpf.make_addplot(chart['Hist'], type='bar', color='g', panel=1, secondary_y=True),
   mpf.make_addplot(chart['RSI'], panel=2),
   mpf.make_addplot(chart['RSI_hl'], color='b', panel=2, secondary_y=False,linestyle='-.', width=1),
   mpf.make_addplot(chart['RSI_ll'], color='r', panel=2, secondary_y=False,linestyle='-.', width=1)
   ]
   mpf.plot(chart,volume=True, figscale = 0.7, figratio=(4,2),volume_panel=3,type='candle',mav=(5,25),addplot=add_plot
   ,datetime_format='%Y/%m/%d',savefig=tmp_file)

   im.paste(Image.open(tmp_file).copy(), (140, 850))
   #####################################################################
   # 一週間の値動き
   df = yf.download(ticker, interval = "1m", period = "5d")
   str_tmp=""
   day_split=[]
   for i in range(len(df)):
       str_data = df.index[i].strftime("%Y-%m-%d")
       if str_data != str_tmp: day_split.append(df.index[i])
       str_tmp= str_data

   day_split.append(df.index[-1])
   mpf.plot(df, figscale = 0.31, figratio=(4.5,1),
       vlines=dict(vlines=day_split,colors='r',linestyle='-.',linewidths=1),
       type='line',datetime_format='', xrotation=0,savefig=tmp_file)

   im.paste(Image.open(tmp_file).copy(), (140, 680))
   wk = ['月','火','水','木','金','土','日']
   draw.text((500, 670), '過去1週間の値動き', 'black', font=font_jap18)
   for i in range(0,5):
       draw.text((325+i*105, 830), day_split[i].strftime('%m/%d')+'('+wk[day_split[i].weekday()]+')', 'black', font=font_jap14)
       draw.text((325+i*105, 845),'['+'{:.2%}'.format(chart['Percent'][-5+i])+']' , ('blue' if chart['Percent'][-5+i] > 0 else 'red'), font=font_jap14)

   #####################################################################
   draw.text((500, 860), '過去3か月チャート', 'black', font=font_jap18)
   draw.line((20,660, 940,660), fill='black', width=1)
   draw.text((30, 670), 'チャート:'+chart.index[-1].strftime('%Y/%m/%d'), 'black', font=font_jap20)
   draw.text((40, 720), '株価:'+'$%.2f'%(chart['Close'][-1]), 'black', font=font_jap20)
   draw.text((40, 750), '始値:'+'$%.2f'%(chart['Open'][-1]), 'black', font=font_jap20)
   draw.text((40, 780), '高値:'+'$%.2f'%(chart['High'][-1]), 'black', font=font_jap20)
   draw.text((40, 810), '安値:'+'$%.2f'%(chart['Low'][-1]), 'black', font=font_jap20)
   draw.text((40, 870), '前日比:'+'{:.2%}'.format(chart['Percent'][-1]), 'black', font=font_jap20)
   draw.text((40, 900), 'RSI:'+'{:.2%}'.format(chart['RSI'][-1]/100), 'black', font=font_jap20)
   draw.text((40, 930), '52週高値下落:', 'black', font=font_jap20)
   draw.text((50, 960), '{:.2%}'.format((chart['Close'][-1]-data.info['fiftyTwoWeekHigh'])/data.info['fiftyTwoWeekHigh']), 'black', font=font_jap20)
   draw.text((40, 1070), '出来高:', 'black', font=font_jap20)
   draw.text((50, 1100), "{:,}".format(chart['Volume'][-1]), 'black', font=font_jap20)
   draw.text((40, 1140), '出来高上昇率:', 'black', font=font_jap20)
   draw.text((50, 1170), '%.2f'%(chart['Volume'][-1]/data.info['averageVolume10days'])+'倍', 'black', font=font_jap20)
   #####################################################################

   outfile = output_dir+ticker+'_'+today.strftime("%Y%m%d_%H%M%S")+'.png'
   im.save(outfile)
   return outfile

# ##########################################
# ### 以下でstreamlitを用いたWebアプリを構成 ###
# ##########################################

# 全ての銘柄の詳細情報を取得し銘柄とファイル対応を辞書化
def get_all_stocks(tickers):
   mydict=dict([])
   for k in tickers.keys():
       stock_info=get_stock_info(k)
       mydict[k]=stock_info
   return mydict
 
mydict=get_all_stocks(tickers4) # 起動時に前詳細情報を取得しておく

# アプリタイトル
st.title('トミィさんのヒートマップ&銘柄情報を同時に表示するアプリ by ときかねえさん')

# 表示領域のレイアウト
col1,col2=st.columns(2)

# ヒートマップの取得と描画
out1=get_heatmap() 
col1.header('ヒートマップ')
col1.image(out1,use_column_width=True)

# 全ての銘柄の詳細情報を再取得するボタン
button=st.sidebar.button('全ての銘柄の詳細情報再取得')
if button:
   mydict=get_all_stocks(tickers4)
   
# 詳細情報を描画する銘柄を選択するラジオボタン
selected=st.sidebar.radio('詳細表示する銘柄選択',tickers4)

# ラジオボタンで取得された銘柄の詳細情報を描画
col2.header('銘柄詳細情報')
col2.image(mydict[selected],use_column_width=True)

実行には、
streamlit run test.py
とタイプしてください。

アプリ構成箇所は最後の少しだけです。描画の切り替えがやや遅いので改善の余地があります。また、表示日数など他の描画パラメータをUI化しても良いかもしれません。

本当はヒートマップを直接クリックして描画切り替えを行いたかったのですが、画像にマウスイベントを割り当てるのは今のところ対応していないようでして、Streamlitといえども万能ではないようです。(Pythonのみではという意味でして、MarkdownでJavascriptを埋め込むなどすればできなくはないです。)

以上となります。

機会があれば、トレードアルゴリズムの部分も記事にしてみたいと思います。

もしよろしければフォローやスキ♡をお願いしますね♪

本記事がみなさまのPython学習や資産形成の一助になれば幸いです🌷

♪♪♪Have a nice coding day♪♪♪

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
スキありがとうございます❤️
サイエンス・プログラミング・ファイナンス好き筋トレ女子♪ 元ポスドク研究者→専業トレーダー挫折→現在AI関連開発&半自動株式投資。 AI, Fintech, Pytnon, 物理学, 心理学, 美容, 筋トレ ♪♪♪Have a nice coding day♪♪♪