├── agastock ├── .gitignore ├── static │ ├── .gitignore │ └── style.css ├── templates │ ├── detail_us.html │ ├── detail_tw.html │ ├── summary_us.html │ └── summary_tw.html ├── agalog.py ├── parse_stock.py ├── stock_us.py ├── common.py ├── config.py ├── web_root.py ├── stock_twn.py └── stock_base.py ├── doc ├── 2603.png ├── screenshot_tw.png ├── screenshot_us.png ├── matplotlib_setting.png ├── screenshot_linebot.jpg ├── screenshot_tw_2603_01.png ├── screenshot_tw_2603_02.png └── screenshot_parse_stock.png ├── LICENSE └── README.md /agastock/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | out/ 3 | -------------------------------------------------------------------------------- /agastock/static/.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | img/ 3 | -------------------------------------------------------------------------------- /doc/2603.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/2603.png -------------------------------------------------------------------------------- /doc/screenshot_tw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/screenshot_tw.png -------------------------------------------------------------------------------- /doc/screenshot_us.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/screenshot_us.png -------------------------------------------------------------------------------- /doc/matplotlib_setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/matplotlib_setting.png -------------------------------------------------------------------------------- /doc/screenshot_linebot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/screenshot_linebot.jpg -------------------------------------------------------------------------------- /doc/screenshot_tw_2603_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/screenshot_tw_2603_01.png -------------------------------------------------------------------------------- /doc/screenshot_tw_2603_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/screenshot_tw_2603_02.png -------------------------------------------------------------------------------- /doc/screenshot_parse_stock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinage0/agastock/HEAD/doc/screenshot_parse_stock.png -------------------------------------------------------------------------------- /agastock/static/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | #============================================================= 3 | # 4 | # style.css 5 | # 6 | # Copyright ©2021 [Gavin Lee], et. al. 7 | #============================================================= 8 | */ 9 | .stock_table { 10 | font-size: 11pt; 11 | font-family: Arial; 12 | border-collapse: collapse; 13 | border: 1px solid silver; 14 | } 15 | .stock_table td, th { 16 | padding: 5px; 17 | } 18 | .stock_table tr:nth-child(even) { 19 | background: #E0E0E0; 20 | } 21 | .stock_table tr:hover { 22 | background: silver; 23 | cursor: pointer; 24 | } 25 | -------------------------------------------------------------------------------- /agastock/templates/detail_us.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | agastock - {{ticker_name}} 11 | 12 |

台股分析   美股分析

13 | 14 |

布林通道繪圖(台灣時區): {{img_bb_time_str}}, 股價更新(美國時區): {{price_update_time_str}}

15 |

16 |

Google Trend 繪圖: {{img_gtrend_time_str}}

17 |

18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /agastock/templates/detail_tw.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | agastock - {{ticker_name}} 11 | 12 |

台股分析   美股分析

13 | 14 |

布林通道繪圖: {{img_bb_time_str}}, 股價更新: {{price_update_time_str}}

15 |

16 |

Google Trend 繪圖: {{img_gtrend_time_str}}

17 |

18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gavin Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /agastock/templates/summary_us.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | agastock 美股 11 | 12 | 13 |

台股分析   美股分析

14 | 製表時間(台灣時區): {{csv_update_time_str}},股價更新時間(美國時區): {{price_update_time_str}} 15 | 16 | {% autoescape false %} 17 |

{{ html_stock_table }}

18 | {% endautoescape %} 19 | 20 |

*成交金額: 單位為美金百萬(10的六次方)
21 | *成交量: 單位為千股(10的三次方)
22 | *股價: 取自 Yahoo Finance,美股無延遲
23 | *G-Trend: Google Trend 每週統計搜尋數量,急降代表股價即將下殺, 適用於熱門股. 數值為此股票相對歷史的高度, 不同股票間不能比較
24 | *G-Trend漲跌: 以週為單位,今天的MA7和七天前MA7比較
25 | *布林位置: 當前股價距20日布林通道下緣的比例,小於0%可以買. 布林寬度大於 10% 才顯示布林位置
26 | *EPS: 過去12個月累積的每股盈餘,應該是以月為單位,不像台股是季為單位. 資料和財報狗近四季EPS相近
27 | *EPS預估: 在 Yahoo Finance,顯示於 Current Estimate 的 next year 區塊
28 | *本益比預估: 由 yFinance 讀出,但 Yahoo Finance 網頁沒找到此資料
29 | *EPS,本益比,預估等資料都來自Yahoo Finance
30 | *不確定性大的公司可看本益比(Trailing P/E) ,不確定性小的公司可看預估本益比(Forward P/E) ,快速成長公司兩者都看
31 |

32 |

33 | *us log file
34 |

35 | {% autoescape false %} 36 |

{{ html_global_warn }}

37 | {% endautoescape %} 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /agastock/templates/summary_tw.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | agastock 台股 11 | 12 | 13 |

台股分析   美股分析

14 | 製表時間: {{csv_update_time_str}},股價更新時間: {{price_update_time_str}} 15 | 16 | {% autoescape false %} 17 |

{{ html_stock_table }}

18 | {% endautoescape %} 19 | 20 |

*股價: Yahoo Finance 台股延遲報價20分鐘
21 | *成交金額: 單位為新台幣百萬(10的六次方)
22 | *成交量: 單位為千張(10的三次方)
23 | *G-Trend: Google Trend 每週統計搜尋數量, 急降代表股價即將下殺, 適用於熱門股. 數值為此股票相對歷史的高度, 不同股票間不能比較
24 | *G-Trend漲跌: 以週為單位,今天的MA7和七天前MA7比較
25 | *布林位置: 當前股價距20日布林通道下緣的比例,小於0%可以買. 布林寬度大於10%才顯示布林位置
26 | *財報季: 只顯示最新一季. EPS 計算為近四季,例如 ~21Q2 代表 20Q3~21Q2
27 | *EPS: FinMind下載的每股盈餘, 為過去四季總和。公開資訊觀測站的季度EPS相同但顯示為年度累積,例如第三季EPS為第一季+第二季+第三季
28 | *EPS: 每年5/15前公布Q1, 8/14前公布Q2, 11/14前公布Q3, 隔年3/31前公布前年Q4.
29 | *本益比: 即時股價 / 過去四季EPS總和(注意:財報狗本益比使用月均價,而不是即時股價)
30 | *處置股: 5分鐘搓合代表單筆十張或累積30張則全額交割,20分鐘以上都全額交割, 資料來源為 31 | 上市處置股及 32 | 上櫃處置股
33 |

34 |

35 | *tw log file
36 |

37 | {% autoescape false %} 38 |

{{ html_global_warn }}

39 | {% endautoescape %} 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /agastock/agalog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # aga_logger.py 5 | # 6 | # 此module是為了啟用DEBUG level卻不印出其他module訊息 7 | # 8 | # Copyright ©2021 [Gavin Lee], et. al. 9 | #============================================================= 10 | #agastock module 11 | from config import * 12 | 13 | #other module 14 | import logging, sys, colorlog, os 15 | 16 | 17 | 18 | #=========== VARIABLE ============ 19 | _logger= logging.getLogger(name="agastock") 20 | 21 | 22 | #=========== FUNCTION ============ 23 | def init(log_level, log_file) -> None: 24 | global _logger 25 | if ENABLE_GLOBAL_LOG: 26 | _logger = logging.getLogger() #若沒有name,連其他module的level都會改變,設為DEBUG會有很多request的http query訊息 27 | log_level= logging.DEBUG 28 | 29 | os.makedirs(os.path.dirname(log_file), exist_ok=True) 30 | _logger.setLevel(log_level) 31 | 32 | fmt= '%(name)s[%(asctime)s][%(levelname)-8s] %(message)s' 33 | datefmt= '%Y-%m-%d %H:%M:%S' 34 | 35 | #印在 console 加顏色 36 | fmt_color = colorlog.ColoredFormatter( fmt='%(log_color)s'+fmt, datefmt=datefmt, 37 | log_colors={'DEBUG':'green', 'INFO':'white', 'WARNING':'yellow', 'ERROR':'red', 'CRITICAL':'bold_red' } 38 | ) 39 | streamHandler= logging.StreamHandler(sys.stdout) 40 | streamHandler.setFormatter(fmt_color) 41 | _logger.addHandler(streamHandler) 42 | 43 | #印在檔案不加色碼,不然開啟檔案有亂碼 44 | fmt= logging.Formatter(fmt=fmt, datefmt=datefmt) 45 | fileHandler= logging.FileHandler(log_file) 46 | fileHandler.setFormatter(fmt) 47 | _logger.addHandler(fileHandler) 48 | 49 | 50 | def debug(msg): 51 | _logger.debug(msg) 52 | 53 | def info(msg): 54 | _logger.info(msg) 55 | 56 | def warning(msg): 57 | _logger.warning(msg) 58 | 59 | def error(msg): 60 | _logger.error(msg) 61 | 62 | def critical(msg): 63 | _logger.critical(msg) 64 | 65 | def exception(msg): 66 | _logger.exception(msg) 67 | 68 | -------------------------------------------------------------------------------- /agastock/parse_stock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # parse_stock.py 5 | # 6 | # Usage: 7 | # parse_stock.py <-us|-tw> 8 | # 9 | # Copyright ©2021 [Gavin Lee], et. al. 10 | #============================================================= 11 | #agastock module 12 | from common import * 13 | from config import * 14 | from stock_us import StockUs 15 | from stock_twn import StockTwn 16 | import agalog 17 | 18 | #other module 19 | import time, sys, os, traceback, math, fcntl, logging 20 | from datetime import date, datetime, timedelta 21 | 22 | 23 | #=========== PARAMETER ============ 24 | _PATH_LOCK_FILE= '/tmp/parse_stock_%s.lock' 25 | 26 | 27 | #============ FUNCTION ============ 28 | #避免重複執行 29 | #需使用全域變數接收回傳值,因為區域變數在function結束,回收變數一併釋放file locking 30 | def exit_if_dup_execute(lock_file) -> None: 31 | try: 32 | h_lock_file= open(lock_file, 'w') #create if not exist 33 | fcntl.flock(h_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) 34 | return h_lock_file 35 | except BlockingIOError as e: 36 | agalog.warning("Terminate due to duplicated running. lock_file=%s"%lock_file) 37 | sys.exit(1) 38 | 39 | 40 | #============ MAIN ============ 41 | try: 42 | prog_start= datetime.now() #記錄程式執行時間 43 | if len(sys.argv)==2 and sys.argv[1]=='-us': 44 | stock= StockUs() 45 | else: 46 | stock= StockTwn() 47 | 48 | agalog.init(PARSE_STOCK_LOG_LEVEL, PATH_STOCK_PARSER_LOG_REGION%stock.REGION) 49 | h_lock_file= exit_if_dup_execute(_PATH_LOCK_FILE%stock.REGION) #避免重複執行 50 | 51 | #初始化,並處理台股美股不同的資料,目前只有台股的處置股,寫入(通知訊息) 52 | #處置股存在 _df_pusish_list[] 53 | stock.init() #必須在其他function之前執行 54 | 55 | #儲存基本資料: 代號,名稱,產業,TMP_GTName 56 | stock.query_info() 57 | 58 | #儲存股價資料: 股價,漲跌,成交金額,成交量 59 | #股價存在 _df_price_list[] (若是盤中,最新一筆為即時資料) 60 | stock.query_price() 61 | 62 | #儲存布林資料: 布林位置,布林寬度,(通知訊息) 63 | stock.query_bband() 64 | 65 | #儲存Google Trend資料: G-Trend,G-Trend漲跌,HD_GT_URL (通知訊息) 66 | stock.query_google_trend() 67 | 68 | #寫入財報資料: 69 | # 台股: 本益比,EPS,財報季,HD_FinQueryTime 70 | # 美股: 本益比,本益比預估,EPS,EPS預估,EPS成長預估,HD_FinQueryTime 71 | stock.query_finance() 72 | 73 | #[程式修改注意] 新的 queue_xxx() 股票分析函式請加在此,init() 和 push_out_line_notify() 之間 74 | 75 | #送出前面queue_XXX()系列放入_df_notify_list[]的LINE通知,並寫入notify csv 76 | stock.push_out_line_notify() 77 | 78 | #儲存: VAR_WebMsg,VAR_PriceUpdateDate 79 | #並將以上所有資料寫入 stock csv 80 | stock.write_stock_csv() 81 | 82 | spend= datetime.now() - prog_start 83 | agalog.info( "Parse %d tickers successfully. Spend %s"%( len(stock._ticker_list), spend) ) 84 | 85 | except Exception as e: 86 | #未處理的exception寫入log 87 | agalog.exception("意外錯誤") 88 | 89 | 90 | -------------------------------------------------------------------------------- /agastock/stock_us.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # stock_us.py 5 | # 分析美股用的 StockUs 6 | # 7 | # Copyright ©2021 [Gavin Lee], et. al. 8 | #============================================================= 9 | #agastock module 10 | from common import * 11 | from config import * 12 | from stock_base import * 13 | import agalog 14 | 15 | #other module 16 | import numpy as np 17 | import pandas as pd 18 | from datetime import date, datetime, timedelta 19 | from pytrends.exceptions import ResponseError 20 | from requests.exceptions import * #ConnectionError, InvalidURL, InvalidSchema, ... 21 | 22 | 23 | class StockUs(StockBase): 24 | #=========== PARAMETER ============ 25 | _ticker_list= US_TICKER_LIST #股票清單 26 | _name_list = US_NAME_LIST #股票名稱,用於網頁顯示 27 | _group_list = US_GROUP_LIST #產業,用於網頁顯示 28 | _gt_name_list = US_GT_NAME_LIST #Google Trend 查詢字串 29 | 30 | 31 | #=========== 一般設定 ============ 32 | _TITLE= "[US STOCK PARSER]" #標題,用於log首行 33 | REGION= "us" 34 | REGION_TEXT= "美股" 35 | 36 | 37 | #============ PRIVITE FUNCTION ============ 38 | def _get_cust_notify_msg(self, ticker) -> str: 39 | return '' 40 | 41 | 42 | #============ PUBLIC FUNCTION ============ 43 | def init(self,): 44 | super().init() 45 | 46 | 47 | #============ PUBLIC FUNCTION - get_XXX 系列取得股票資訊 ============ 48 | #寫入基本資料: 代號,名稱,產業,TMP_GTName 49 | @QueryHandler_ForLoop( init_vars=['代號','名稱', '產業', 'TMP_GTName'] ) #init_vars[] 為網頁顯示順序 50 | def query_info(self, data_name, ticker, tdata, prev_tdata) -> bool: 51 | tdata['代號']= ticker 52 | 53 | #使用_name_list[] 來設定名稱,沒包含的用股票代號當名稱 54 | if ticker in self._name_list: 55 | tdata['名稱']= self._name_list[ticker] 56 | else: 57 | tdata['名稱']= ticker 58 | 59 | #使用_group_list[] 設定產業, 沒包含的產業都是None,代表網頁顯示空白 60 | if ticker in self._group_list: 61 | tdata['產業']= self._group_list[ticker] 62 | 63 | #使用_gt_name_list[] 設定Google Trend查詢字串,沒包含的都是None,代表不搜尋 Google Trend 64 | if ticker in self._gt_name_list: 65 | tdata['TMP_GTName']= self._gt_name_list[ticker] 66 | 67 | return True 68 | 69 | 70 | #寫入股價資料: 股價,漲跌,成交金額,成交量 71 | #股價寫到 self._df_price_list (若是盤中,最新一筆為即時資料) 72 | def query_price(self) -> bool: 73 | tickets_yf_str= ' '.join(self._ticker_list) 74 | return self._query_price_yfinance(tickets_yf_str) 75 | 76 | 77 | #寫入財報資料: 本益比,本益比預估,EPS,EPS預估,EPS成長預估,EPS成長預估 78 | @QueryHandler_ThreadLoop( expire_hours=FINANCE_EXPIRE_HOURS, init_vars=['本益比', '本益比預估', 'EPS', 'EPS預估', 'EPS成長預估', 'EPS成長預估'] ) #init_vars[] 為網頁顯示順序 79 | def query_finance(self, data_name, ticker, tdata, prev_tdata) -> bool: 80 | t = yf.Ticker(ticker) 81 | tdata['EPS']= get_valid_value2(t.info,'trailingEps') 82 | tdata['EPS預估']= get_valid_value2(t.info,'forwardEps') 83 | if is_valid(tdata['EPS預估']) and is_valid(tdata['EPS']) and tdata['EPS預估']>0 and tdata['EPS']>0: 84 | tdata['EPS成長預估']= (float(tdata['EPS預估']) / float(tdata['EPS'])) - 1 85 | tdata['本益比']= get_valid_value2(t.info,'trailingPE') 86 | tdata['本益比預估']= get_valid_value2(t.info,'forwardPE') 87 | agalog.debug(' %-5s: 財報: EPS=%.2f, EPS預估=%.02f, EPS成長預估=%.1f%%, 本益比=%.2f, 本益比預估=%.2f'%(ticker, tdata['EPS'], tdata['EPS預估'], tdata['EPS成長預估']*100, tdata['本益比'], tdata['本益比預估'])) 88 | return True 89 | -------------------------------------------------------------------------------- /agastock/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # common.py 5 | # 6 | # Copyright ©2021 [Gavin Lee], et. al. 7 | #============================================================= 8 | #agastock module 9 | from config import * 10 | import agalog 11 | 12 | #other module 13 | import time, sys, os, traceback, colorlog, math 14 | import numpy as np 15 | import pandas as pd 16 | from datetime import datetime 17 | 18 | 19 | #=========== COMMON PARAMETER ============ 20 | DIR_BASE= os.path.dirname( os.path.abspath(__file__) ) #此檔案所在目錄 21 | DIR_OUT= DIR_BASE + "/out" 22 | 23 | PATH_STOCK_CSV_REGION= DIR_OUT + "/stock_summary_%s.csv" #%s 將代換為 tw or us 24 | PATH_NOTIFY_CSV_REGION= DIR_OUT + "/line_notify_%s.csv" #%s 將代換為 tw or us 25 | 26 | URL_STOCK_PARSER_LOG_REGION= "/static/logs/stock_parser_%s.log" #%s代換為tw or us 27 | PATH_STOCK_PARSER_LOG_REGION= DIR_BASE + URL_STOCK_PARSER_LOG_REGION 28 | 29 | URL_IMG_REGION= "/static/img/%s_stock_%s_%s.png" #第一個%s將代換為tw or us, 第二個%s將代換為股票代號(例如0050, AAPL), 第三個%s將代換為bb or gtrend or ma 30 | PATH_IMG_REGION= DIR_BASE + URL_IMG_REGION 31 | 32 | 33 | # ============ FUNCTION DataFrame處理 ============ 34 | def df_check_columns(df, exp_columns): 35 | for col in exp_columns: 36 | if col not in df.columns: 37 | return False 38 | return True 39 | 40 | 41 | #因為每筆股票讀回資料不同,例如t.info['trailingEps']不存在會導致exception,因此設計get_valid系列,若是不存在就回傳錯誤值 42 | #本來使用None當錯誤值,但是 print('%f'%None) 會發生exception,因此改用float('nan'),print('%f'%float('nan')) 可印出nan。 43 | #但注意不能用整數印出,要避免用'%d'印出get_valid_XXX得到的資料 44 | def get_valid_value3(dic, index1, index2): 45 | if is_invalid3(dic, index1, index2): 46 | return float('nan') 47 | return dic[index1][index2] 48 | 49 | def get_valid_value2(dic, index): 50 | if is_invalid2(dic, index): 51 | return float('nan') 52 | return dic[index] 53 | 54 | def get_valid_value(val): 55 | if is_invalid(val): 56 | return float('nan') 57 | return val 58 | 59 | 60 | def is_invalid3(dic, index1, index2): 61 | try: 62 | return is_invalid( dic[index1][index2] ) 63 | except (TypeError, KeyError) as e: #TypeError代表dic is None, KeyError代表dic[]不存在index 64 | return True 65 | 66 | def is_invalid2(dic, index): 67 | try: 68 | return is_invalid( dic[index] ) 69 | except (TypeError, KeyError) as e: #TypeError代表dic is None, KeyError代表dic[]不存在index 70 | return True 71 | 72 | def is_invalid(val): 73 | if val is None: 74 | return True 75 | elif type(val)==float: 76 | return math.isnan(val) 77 | if type(val)==np.float64: 78 | return np.isnan(val) 79 | elif type(val)==str: 80 | return val.strip()=='' 81 | elif type(val)==int or type(val)==np.int64: 82 | return False 83 | elif type(val)==pd.core.frame.DataFrame or type(val)==pd.core.series.Series: 84 | return (val is None) or (len(val)==0) 85 | else: 86 | raise Exception("is_invalid() 無法處理 %s"%type(val)) 87 | 88 | 89 | def is_valid3(dic, index1, index2): 90 | return not is_invalid3(dic, index1, index2) 91 | 92 | def is_valid2(dic, index): 93 | return not is_invalid2(dic, index) 94 | 95 | def is_valid(val): 96 | return not is_invalid(val) 97 | 98 | 99 | # ============ FUNCTION 陣列處理 ============ 100 | #取得array最後一個, 或是指定-2, -3等倒數index 101 | def last(arr, index=-1): 102 | return arr[arr.size+index] 103 | 104 | 105 | def last_iloc(arr, index=-1): 106 | return arr.iloc[arr.size+index] 107 | 108 | 109 | #init_dic_var 初始化 self._data[ticker][XXX] 的順序,在轉換為 DataFrame 之後即是網頁顯示順序 110 | #本來初始值設為None,但這樣再print時因為None轉成str會報exception,需要額外判斷,用''比較簡單,即使沒有賦值也可以print 111 | def init_dic_var(dic, var_list): 112 | for var in var_list: 113 | dic[var]= float('nan') 114 | 115 | 116 | # ============ FUNCTION 時間處理 ============ 117 | def file_time_str(fpath: str): 118 | if os.path.isfile(fpath): 119 | ftime= os.stat(fpath).st_mtime 120 | return time.strftime("%Y/%m/%d %H:%M:%S",time.localtime(ftime)) 121 | else: 122 | return '' 123 | 124 | 125 | def datetime_str(): 126 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 127 | 128 | 129 | #取得月日字串 "7/23" 130 | #input: str "2021-07-23" or datetime 131 | def date_to_md(date_str:str) -> str: 132 | if isinstance(date_str,datetime): 133 | md_str= datetime.strftime(date_str,'%m/%d') 134 | elif isinstance(date_str,str): 135 | md_str= date_str[5:].replace("-","/") 136 | else: 137 | return '' 138 | 139 | if md_str[0]=='0': 140 | md_str= md_str[1:] 141 | return md_str 142 | 143 | 144 | # ============ FUNCTION 其他 ============ 145 | def read_stock_summary_csv(region): 146 | path_csv= PATH_STOCK_CSV_REGION%region 147 | if not os.path.isfile(path_csv): 148 | return None, None, None 149 | 150 | #(1) 若沒設定dtype,代號0050會轉成int變成50 151 | #(2) read_csv()當中不能指定index_col="代號",一旦指定,台股的index為數字(例如0050),將強制轉型為Int64,0050的00立即遺失,即使dtype設定0:str也沒用 152 | # 解法1: read_csv不指定index_col, 然後df= df.set_index('代號', drop=False),但造成 web_root.py 無法用 '代號' 排序,說和 index 名稱 ambiguous 153 | # 解法2(採納): to_csv(index=True),read_csv指定dtype={0:str}或是{'Unnamed: 0':str},再設定df= df.set_index(0,drop=True)即可 154 | df = pd.read_csv(path_csv, 155 | dtype={0:str, '代號':str, 'G-Trend':float, 'G-Trend漲跌':float, '漲跌':float, '布林位置':float, '布林寬度':float, 'VAR_WebMsg':str}) 156 | df= df.set_index(df.columns[0], drop=True) 157 | csv_update_time_str= file_time_str(path_csv) 158 | 159 | price_update_time_str= '' 160 | if is_valid(df) and ('VAR_PriceUpdateDate' in df.columns): 161 | price_update_time_str= df['VAR_PriceUpdateDate'].iloc[0] 162 | 163 | return df, csv_update_time_str, price_update_time_str 164 | 165 | 166 | def get_exception_msg(e): 167 | error_class = e.__class__.__name__ #寫入錯誤類型 168 | detail = e.args #寫入詳細內容 169 | cl, exc, tb = sys.exc_info() #寫入Call Stack 170 | extract_tb= traceback.extract_tb(tb) 171 | 172 | #顯示agastock的最後一個執行點。若找不到agastock就顯示最後執行點(但不應該發生) 173 | for i in range(-1,-len(extract_tb)-1,-1): 174 | if "agastock" in extract_tb[i][0]: 175 | break 176 | else: 177 | i= -1 178 | 179 | fileName = extract_tb[i][0] #寫入發生的檔案名稱 180 | lineNum = extract_tb[i][1] #寫入發生的行號 181 | funcName = extract_tb[i][2] #寫入發生的函數名稱 182 | errMsg = "File \"{}\", line {}, in {}, [{}] {}".format(os.path.basename(fileName), lineNum, funcName, error_class, detail) 183 | return errMsg 184 | 185 | 186 | def is_float(s): 187 | try: 188 | float(s) # for int, long and float 189 | except ValueError: 190 | return False 191 | return True 192 | 193 | 194 | # ============ FUNCTION 檔案系統 ============ 195 | #remove file if not exist 196 | def silence_remove(fpath: str): 197 | if os.path.isfile(fpath): 198 | os.remove(fpath) 199 | assert(not os.path.isfile(fpath)) 200 | 201 | -------------------------------------------------------------------------------- /agastock/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # config.py 5 | # 參數設定 6 | # 7 | # Copyright ©2021 [Gavin Lee], et. al. 8 | #============================================================= 9 | import logging 10 | 11 | #---------------------------------------------------------------------------------------- 12 | # 帳號設定 13 | #---------------------------------------------------------------------------------------- 14 | #若要啟用Line通知,需在 https://developers.line.biz/zh-hant/ 申請LineBot,並填入ID及TOKEN 15 | #空字串代表不通知 16 | LINEBOT_ID= '' 17 | LINEBOT_TOKEN= '' 18 | 19 | #之前需要付費的FinMind帳號才能下載台股EPS,本益比等,現在好像不用了, https://finmindtrade.com/ 20 | #FindMind,[使用者資訊]頁面,顯示為 "api token 金鑰" 21 | TW_FM_TOKEN= '' 22 | 23 | 24 | 25 | 26 | #---------------------------------------------------------------------------------------- 27 | # stock_us.py 28 | #---------------------------------------------------------------------------------------- 29 | #美股清單 30 | US_TICKER_LIST=[ 'QQQ','VT','VTI', 'ARKW','ARKG', #ETF 31 | 'GOOGL','AMZN', 'AAPL', 'TSLA', 'MSFT', 'AMD', 'FB', 'NVDA', 'TSM', 'UMC','INTC', #科技股 32 | 'PYPL','NOW','CRM', #穩定新創 33 | 'PLTR','NOW','CRM', #新創中的毛票 34 | 'DAC', 'ZIM', #航運 35 | 'ABNB', 'BA', 'RCL', 'CCL', #航旅油 36 | 'X', #原物料: X 美國鋼鐵 37 | 'WMT','SBUX', 'COST', 'DIS', #消費 38 | ] 39 | #US_TICKER_LIST=['QQQ', 'AMZN', 'TSM'] #解除註解可改用較少股票來測試 40 | 41 | #股票名稱,用於網頁顯示 42 | #和 US_TICKER_LIST[] 不需一一對應,此處多的捨棄,少的顯示代號(例如QQQ適合顯示代號) 43 | US_NAME_LIST = {'GOOGL':'Google','AMZN':'亞馬遜', 'AAPL':'Apple', 'TSLA':'Tesla', #科技股 44 | 'MSFT':'微軟', 'FB':'Facebook', 'NVDA':'Nvidia', 'TSM':'台積電', 'UMC':'聯電','INTC':'Intel', #科技股 45 | 'PYPL':'Paypal','NOW':'ServiceNow','CRM':'Salesforce', #穩定新創 46 | 'PLTR':'Palantir', #新創中的毛票 47 | 'DAC':'Danaos', 'ZIM':'ZIM', #航運 48 | 'ABNB':'Airbnb', 'BA':'波音', 'RCL':'皇家郵輪', 'CCL':'嘉年華遊輪', #航旅油 49 | 'X':'美國鋼鐵', #原物料: X 美國鋼鐵 50 | 'WMT':'Walmat','SBUX':'星巴克', 'COST':'Costco', 'DIS':'Disney', #消費 51 | } 52 | 53 | #產業,用於網頁顯示 54 | #和 US_TICKER_LIST[] 不需一一對應,此處多的捨棄,少的顯示空白 55 | US_GROUP_LIST = {'QQQ':'ETF','VT':'ETF','VTI':'ETF', 'ARKW':'ETF','ARKG':'ETF', #ETF 56 | 'GOOGL':'科技','AMZN':'科技', 'AAPL':'科技', 'TSLA':'科技','MSFT':'科技','AMD':'半導體', #科技股 57 | 'FB':'科技', 'NVDA':'半導體', 'TSM':'半導體', 'UMC':'半導體','INTC':'半導體', #科技股 58 | 'PYPL':'Fintech','NOW':'IT服務','CRM':'IT服務', #穩定新創 59 | 'PLTR':'科技(毛票)', #新創中的毛票 60 | 'BABA':'科技', #中概股 61 | 'DAC':'海運', 'ZIM':'海運', #航運 62 | 'ABNB':'旅遊', 'BA':'旅遊', 'RCL':'旅遊', 'CCL':'旅遊', #航旅油 63 | 'X':'鋼鐵', #原物料: X 美國鋼鐵 64 | 'WMT':'零售','SBUX':'零售', 'COST':'零售', 'DIS':'影視', #消費 65 | } 66 | 67 | #Google Trend 查詢字串 68 | #和 US_TICKER_LIST[] 不需一一對應,此處多的捨棄,少的不查 (例如聯電UMC,Google Trend 搜尋量太少,不適合列入) 69 | US_GT_NAME_LIST = {'GOOGL':'Google','AMZN':'Amazon', 'AAPL':'Apple', 'TSLA':'Tesla', #科技股 70 | 'MSFT':'Microsoft', 'FB':'Facebook', 'NVDA':'Nvidia', 'TSM':'TSM', 'INTC':'Intel', 'AMD':'AMD', #科技股 71 | 'PYPL':'PayPal','NOW':'ServiceNow','CRM':'Salesforce', #穩定新創 72 | 'PLTR':'PLTR', #新創中的毛票 73 | 'DAC':'DAC', 'ZIM':'ZIM', #航運 74 | 'ABNB':'Airbnb', 'BA':'Boeing', 'RCL':'RCL', 'CCL':'Carnival', #航旅油 75 | 'X':'United States Steel', #原物料: X 美國鋼鐵 76 | 'WMT':'Walmart','SBUX':'Starbucks', 'COST':'Costco', 'DIS':'Disney', #消費 77 | } 78 | 79 | 80 | 81 | 82 | #---------------------------------------------------------------------------------------- 83 | # stock_twn.py 84 | #---------------------------------------------------------------------------------------- 85 | #台股清單 86 | TW_TICKET_LIST= ['0050', #ETF 87 | '2330', '2454','2303','2401', #半導體: 2330台積電, 2454聯發科, 2303聯電, 2401凌陽, 88 | '2615', '2603', '2609', #航運: 2615萬海, 2603長榮海, 2609陽明 89 | '9943','2731', '2727', #娛樂: 9943好樂迪, 2731雄獅, 2727王品 90 | '2317', '2451', #電子: 2317鴻海, 2451創見 91 | '6741', #雲端: 6741 91-APP 92 | ] 93 | #TW_TICKET_LIST= ['6741', '2317', '0050'] #解除註解可改用較少股票來測試 94 | 95 | #PS: 台股名稱及產業顯示證交所資料 96 | 97 | #台股Google Trend預設使用證交所名稱查詢,但像雄獅會搜尋到雄獅文具,就需要覆寫在下表 98 | #空字串代表不搜尋 Google Trend。 ETF不用特別寫會自動忽略 99 | TW_GT_NAME_OVERWRITE_LIST= {'2731':'雄獅旅遊', 100 | '6741':'91APP'} 101 | 102 | 103 | 104 | 105 | #---------------------------------------------------------------------------------------- 106 | # web_root.py 107 | #---------------------------------------------------------------------------------------- 108 | #綁定的IP, "0.0.0.0" 代表所有網路介面 109 | WEB_SERVER_BIND_IP= "0.0.0.0" 110 | 111 | #綁定的port. 若設定80,在ubnutu需執行下指令, 其中python要符合系統際版本 112 | # sh sudo 113 | # sudo setcap 'cap_net_bind_service=+ep' /usr/bin/python3.8 114 | WEB_SERVER_PORT= 80 115 | 116 | #網頁 column 都有超連接可排序,預設升冪排序(ascend_next=1),寫在下表的 column 則改為降冪排序(ascend_next=0) 117 | #不該包含'訊息通知',因為它大都空白,需要升冪(ascend_next=1)才會文字在前空白在後 118 | WEB_SORT_DESCEND_LIST= ["代號", '名稱', "漲跌", "布林位置","本益比預估"] 119 | 120 | #此站網址,用於 LINE 通知. 只要 domain name 不需要加 port,結尾不要加斜線 121 | WEB_BASE_URL= "http://test.com" 122 | 123 | #啟用flask debug mode,每次修改 .py 都自動重啟flask web server 124 | WEB_DEBUG_FLASK= False 125 | 126 | #顯示所有欄位,包括 HD_XXXX 及 VAR_XXXX 隱藏欄位,可幫助除錯 127 | WEB_DEBUG_SHOW_ALL_COLUMNS= False 128 | 129 | 130 | 131 | 132 | #---------------------------------------------------------------------------------------- 133 | # stock_base.py 134 | #---------------------------------------------------------------------------------------- 135 | #=====< 一般設定 >====== 136 | #URL Query Timeout, 目前只用於台股 137 | URL_TIMEOUT_SEC= 10 138 | 139 | #繪圖顯示天數. 考慮Google Trend使用'today 3-m'下載90天資料,所有圖一律顯示90天 140 | DIAGRAM_DAYS= 90 141 | 142 | #歷史股價下載天數. 為了配合DIAGRAM_DAYS=90: 143 | # 約需170天股價,才能在紅綠燭圖畫出90天MA60() 144 | # 約需110天股價,才能畫出90天布林通道(使用20日均線繪製)。至少30天股價才能算出當天布林通道 145 | STOCK_QUERY_DAYS= 180 146 | 147 | #布林通道 148 | BBAND_DAYS= 20 #布林通道計算天數,一般取20日,即中線為MA20 (20日移動平均) 149 | BBAND_WIDTH_DISPLAY_MIN= 0.08 #0.1代表10%以上寬度才顯示布林通道位置,設門檻是因為寬度太小用布林通道沒意義,一天漲跌就超過了 150 | 151 | #Google Trend 顯示條件 152 | #50代表 "本週或上週MA7" 在50以上才顯示 G-Trend 漲跌. 值太小顯示漲跌沒意義 153 | GTREND_MA7_RATE_DISPLAY_MIN= 50 154 | 155 | 156 | #=====< 有效時限 >====== 157 | #Google Trend 更新頻率 158 | #經測試,台股設定台灣區域,大都台灣時間凌晨12點更新到三天前資料,例如 2021-08-01 00:00:00 更新到 2021-07-29 的資料 159 | #但一整天測試下來,Google Trend的值會變化,原因不明 160 | GTREAD_EXPIRE_HOURS= 4 #4小時, 可以是浮點數例如 0.5 161 | 162 | #財報更新頻率 163 | #台股財報: Q1在5/15前公布, Q2在8/14前公布, Q3在11/14前公布, Q4和年報在隔年3/31前公布 (此套件在台股只查季報,不查年報) 164 | FINANCE_EXPIRE_HOURS= 3*24 #3天, 可以是浮點數例如半小時為 0.5 165 | 166 | 167 | #===< LINE 通知條件 >=== 168 | #布林通道 169 | NOTIFY_BBAND_LOWER= 0.0 #0.0 代表股價小於布林通道下緣(0%)即通知買入. 0.1 代表 10%. 百分比為股價距離布林通道下緣位置 170 | 171 | #Google Trend,和七日前的MA7相比,本週MA7漲70%以上,並且結果大於70就通知,代表可買入 172 | NOTIFY_GT_MA7_RISE_RATE= 0.7 #70% 173 | NOTIFY_GT_MA7_RISE_TO= 70 174 | 175 | #Google Trend,七日前MA7超過70,並且本週MA7跌70%以上就通知,代表可賣出 176 | NOTIFY_GT_MA7_FALL_RATE= -0.7 #-70% 177 | NOTIFY_GT_MA7_FALL_FROM= 70 178 | 179 | #Google Trend,和前三日最低值相比,升高超過200%(也就是三倍), 並大於三個月最大值的1.5倍 180 | NOTIFY_GT_RISE_RATE= 2.0 #三倍 181 | NOTIFY_GT_COMPARE_RESULT_MAX= 1.5 #150% 182 | 183 | #Google Trend 和前三日最高值相比,下降超過-70% 並低於三個月最小值的50% 184 | NOTIFY_GT_FALL_RATE= -0.7 #-70% 185 | NOTIFY_GT_COMPARE_RESULT_MAX= 0.5 #50% 186 | 187 | 188 | #=======< 設定 >======= 189 | #啟用 multi-thread 支援 190 | # 美股: 35支股票,初次執行由 243sec 加速到 42sec,再次執行都是 31sec. 主要加速 query_info(),每支股票耗時 5sec 191 | # 台股: 35支股票,初次執行由 53sec 加速到 39sec,再次執行都是 25sec. 稍微加速 query_finance(),每支股票耗時 0.3sec 192 | MULTI_THREAD_SUPPORT= True 193 | 194 | 195 | #=== Yahoo Finance === 196 | #刪除 9:00-9:20 因為台股延遲報價 20min 造成當天 NaN 股價. 刪除後,Summary 頁面可顯示前一天股價,避免顯示NaN 197 | YFINANCE_FIX_TW_TIME_SHIFT= True 198 | 199 | #修正Yahoo Finance的bug: 有時部分日期的價為NaN,發生日前數天的volume剛好被乘以一百倍. 此功能還原正確volume,但最多連續還原20天,避免影響到volume爆量的狀況 200 | YFINANCE_FIX_HIGH_VOLUME= False 201 | 202 | 203 | #======< 繪圖用的色碼 >====== 204 | #default color order for matplotlib 205 | # https://blog.csdn.net/mighty13/article/details/113764337 206 | COLOR_BLUE='#1f77b4' 207 | COLOR_ORANGE='#ff7f0e' 208 | COLOR_GREEN='#2ca02c' 209 | COLOR_RED='#d62728' 210 | COLOR_PURPLE='#9467bd' 211 | COLOR_BROWN='#8c564b' 212 | COLOR_PINK='#e377c2' 213 | COLOR_GRAY='#7f7f7f' 214 | COLOR_YELLOW='#bcbd22' 215 | COLOR_CYAN_BLUE='#17becf' 216 | 217 | 218 | 219 | 220 | #---------------------------------------------------------------------------------------- 221 | # parse_stock.py 222 | #---------------------------------------------------------------------------------------- 223 | #選項:logging.DEBUG, logging.INFO, logging.WARN, logging.ERROR, logging.CRITICAL, logging.EXCEPTION 224 | #設為 logging.DEBUG 可印出每個 QueryHandler 內容,例如 Google Trend, EPS, 本益比等 225 | PARSE_STOCK_LOG_LEVEL= logging.INFO 226 | 227 | 228 | 229 | 230 | #---------------------------------------------------------------------------------------- 231 | # agalog.py 232 | #---------------------------------------------------------------------------------------- 233 | #設為True可啟用所有module的 debug print,包括 request 的 http 連線,可幫助了解爬蟲 234 | ENABLE_GLOBAL_LOG= False 235 | 236 | 237 | -------------------------------------------------------------------------------- /agastock/web_root.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # web_root.py 5 | # 6 | # URL: 7 | # / => tw summary 8 | # /tw => tw summary 9 | # /tw/0050 => tw 0050 detail 10 | # /staic/img/tw_stock_0050_ma.png => tw 0050 image 11 | # 12 | # /us => us summary 13 | # /us/AAPL => us AAPL detail 14 | # /staic/img/us_stock_AAPL_bb.png => us AAPL image 15 | # 16 | # Copyright ©2021 [Gavin Lee], et. al. 17 | #============================================================= 18 | #agastock module 19 | from common import * 20 | from config import * 21 | import agalog 22 | 23 | #other module 24 | from flask import Flask, render_template, request, Response 25 | import pandas as pd 26 | import time, os, sys, math, re, logging 27 | 28 | 29 | # ======== FUNCTION ======== 30 | def request_stock_summary(region): 31 | html_global_log_file= URL_STOCK_PARSER_LOG_REGION%region 32 | 33 | #讀取csv股票資料 34 | df_data, csv_update_time_str, price_update_time_str= read_stock_summary_csv(region) 35 | if df_data is None: #csv讀取失敗 36 | html_global_warn= "[錯誤] %s 讀取失敗"%(PATH_STOCK_CSV_REGION%region) 37 | html_stock_table= '' 38 | return (render_template('summary_%s.html'%region, **locals() )) 39 | 40 | #讀取 parse_stock.ps1 儲存的 WebMsg 41 | html_global_warn= '' 42 | if ('VAR_WebMsg' in df_data) and len(df_data['VAR_WebMsg'])>0 and is_valid(df_data['VAR_WebMsg'].iloc[0]): 43 | html_global_warn= '' 44 | for line in df_data['VAR_WebMsg'].iloc[0].split('
\n'): 45 | if re.match('^\[錯誤\]', line) is not None: 46 | color= '#d62728' #red 47 | else: 48 | color= '#ff7f0e' #orange 49 | html_global_warn+= "%s
"%(color, line) 50 | 51 | #get得到的ascend參數代表升冪或降冪排序 52 | ascend = request.args.get('ascend') 53 | if ascend=='1' or ascend=='0': 54 | ascend= int(ascend) 55 | else: 56 | ascend= 1 #預設升冪, 小排到大 57 | 58 | #此處先排序,之後會改變內容加超連接。內容改變就沒法排序了 59 | sort = request.args.get('sort') 60 | if not (sort in df_data.columns): #布林位置為預設排序 61 | sort= '布林位置' 62 | sort_list= [sort,'代號'] #除了網頁傳入參數sort,還有次級排序'代號' 63 | for s in reversed(sort_list): 64 | if (s not in df_data.columns): 65 | sort_list.remove(s) 66 | df_data= df_data.sort_values(by=sort_list, ascending=(ascend==1)) #小排到大 67 | 68 | #美股股價需小數點兩位,台股只要一位 69 | if '股價' in df_data.columns: 70 | if region=="us": 71 | df_data['股價']= df_data['股價'].apply(lambda x: format(x, '.2f')) 72 | else: #tw 73 | df_data['股價']= df_data['股價'].apply(lambda x: format(x, '.1f')) 74 | 75 | #顯示為百分比 76 | for col in ['布林寬度', '布林位置']: 77 | if col in df_data.columns: 78 | df_data[col]= df_data[col].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else format(x, '.1%')) 79 | 80 | #顯示為貨幣 81 | for col in ['成交金額']: 82 | if col in df_data.columns: 83 | df_data["成交金額"]= df_data["成交金額"].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else '${0:,.0f}'.format(x/1000000)) 84 | 85 | #顯示$符號 + 小數兩位 86 | for col in ['EPS','EPS預估']: 87 | if col in df_data.columns: 88 | df_data[col]= df_data[col].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else '${0:,.02f}'.format(x)) 89 | 90 | #顯示逗點數字,除以1000 91 | for col in ['成交量']: 92 | if col in df_data.columns: 93 | df_data[col]= df_data[col].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else '{0:,.0f}'.format(x/1000)) 94 | 95 | #顯示小數兩位 96 | for col in ['本益比','本益比預估']: 97 | if col in df_data.columns: 98 | df_data[col]= df_data[col].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else '{0:,.02f}'.format(x)) 99 | 100 | #紅漲綠跌-數字 101 | if '漲跌' in df_data.columns: 102 | df_data['漲跌']= df_data['漲跌'].apply(lambda x: ('' if x>=0 else '') + format(x, '.1%') + "" ) 103 | 104 | #紅漲綠跌-百分比 105 | for col in ['G-Trend漲跌','EPS成長預估']: 106 | if col in df_data.columns: 107 | df_data[col]= df_data[col].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else (('' if float(x)>=0 else '') + format(float(x), '.1%') + "") ) 108 | 109 | #超連接到Google Trend 110 | if ('G-Trend' in df_data.columns): 111 | df_data['G-Trend']= df_data['G-Trend'].apply(lambda x: '' if ((not is_float(x)) or math.isnan(x)) else str(format(x, '.0f')) ) #先轉成str才能做以下操作,但記先排序之後再轉,轉成str之後排許變成100 > 10 > 22 > 23 > ... 112 | if ('HD_GT_URL' in df_data.columns): 113 | df_data['G-Trend']= "
" + df_data['G-Trend'] + "
" 114 | 115 | #超連接到 Yahoo Finance (美股) 及 財報狗(台股) 116 | for col in ['本益比','本益比預估','EPS','EPS預估']: 117 | if (col in df_data.columns) and ('代號' in df_data.columns): 118 | if region=='us': #Yahoo Finance 119 | if col in ['EPS預估']: 120 | df_data[col]= "
" + df_data[col] + "
" 121 | else: 122 | df_data[col]= "
" + df_data[col] + "
" 123 | else: #財報狗的EPS符合FinMind,但本益比計算為[月均價 / 近4季EPS總和],和 agastock 計算[即時股價/ 近4季EPS總和]不同 124 | df_data[col]= "
" + df_data[col] + "
" 125 | 126 | #超連接到detail頁面 127 | for col in ['名稱','代號']: 128 | if (col in df_data.columns) and ('代號' in df_data.columns): 129 | df_data[col] = "
" + df_data[col] + "
" 130 | 131 | #刪除空欄位 132 | if not WEB_DEBUG_SHOW_ALL_COLUMNS: 133 | for col in df_data.columns: 134 | for item in df_data[col]: 135 | if is_valid(item): 136 | break 137 | else: 138 | df_data= df_data.drop(col, axis = 1) 139 | 140 | #刪除不顯示的欄位. 這個藥最後處理,等其他欄位讀完再刪 141 | if not WEB_DEBUG_SHOW_ALL_COLUMNS: 142 | for col in df_data.columns: 143 | if re.match('^(VAR_|HD_)', col) is not None: 144 | df_data= df_data.drop(col, axis = 1) 145 | 146 | #Column 名稱加上超連接, 參數 ascend 代表升冪或降冪排序. 初次點column為升冪,再次點同個column變降冪. WEB_SORT_DESCEND_LIST 中的 column 相反 147 | #之後需存取 column name 需改用 col_new_name_list[] 148 | col_new_name_list= {} 149 | for col in df_data.columns: 150 | col_new_name_list[col]= col 151 | for col in df_data.columns: 152 | if col in df_data.columns: #如果前面已經刪掉就不用處理 153 | if col==sort: 154 | ascend_next= [1,0][ascend] #同個欄位,下個排序要顛倒 155 | symbol= [' ↑',' ↓'][ascend] 156 | else: 157 | #以下 column list 預設排序由小到大(ascend_next=1), 沒包含的 column 由大到小(ascend_next=0) 158 | if col in WEB_SORT_DESCEND_LIST: 159 | ascend_next= 1 160 | else: 161 | ascend_next= 0 162 | symbol= '' 163 | col_new_name_list[col]= '%s%s'%(region, col, ascend_next, col, symbol) 164 | df_data= df_data.rename(columns={col:col_new_name_list[col]}) 165 | 166 | #準備html 167 | def highlight_punish_grid(val): 168 | return 'background-color:#FF7575' if ("分:" in str(val)) else '' 169 | 170 | #df已經排序,印出column會發現順序是新的,index也就是[i]是亂的,此處的i期望還是0,1,2,3等陣列真正索引,因此要使用iloc[i] 171 | def highlight_notify_column(column): #row: 'pandas.core.series.Series' 172 | if ('通知訊息' in col_new_name_list) and (col_new_name_list['通知訊息'] in df_data.columns): 173 | return ['' if is_invalid(df_data[col_new_name_list['通知訊息']].iloc[i]) else 'background-color:#FFFF93' for i in range(len(column))] 174 | else: 175 | return ['' for i in range(len(column))] 176 | 177 | #設定顏色格式 178 | s= df_data.style.hide_index() #第一行1,2,3,...不顯示 179 | s= s.set_na_rep('') #set_na_rep(): 如果沒此行,空白欄位都顯示nan 180 | s= s.apply(highlight_notify_column) #有通知的行標黃色 181 | s= s.applymap(highlight_punish_grid) #處置資訊標紅色 182 | s= s.set_properties(**{'text-align': 'center'}) 183 | html_stock_table= s.render().replace("/") 211 | def tw_request_stock_detail(region, ticker): 212 | return request_stock_detail(ticker, region) 213 | 214 | 215 | 216 | # ======== SUMMARY ======== 217 | @app.route("/tw", methods=['GET']) 218 | @app.route("/", methods=['GET']) 219 | def tw_stock(): 220 | return request_stock_summary("tw") 221 | 222 | @app.route("/us") 223 | def us_stock(): 224 | return request_stock_summary("us") 225 | 226 | 227 | 228 | # ======== MAIN ======== 229 | if __name__ == "__main__": 230 | app.run(debug=WEB_DEBUG_FLASK, host=WEB_SERVER_BIND_IP, port=WEB_SERVER_PORT) 231 | 232 | 233 | 234 | 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # agastock 2 | 3 | ## 開發動機 4 | 就在海運飆漲的2021年7月,差點跪在地上喜迎財富自由的當下,EPS超高好消息不斷的長榮竟然套在202元一去不回,有圖有真相(哭) 5 | 6 | 7 | 忽然體會到追高殺低不是辦法,魯蛇我得靠邏輯分析也能出頭天,經過三個月無數個不出門的周末,產出簡單的爬蟲和分析工具。 8 | 9 | 上過金融研訓院的量化交易課,老師說好策略不用程式也能賺錢,爛策略走程式賠更快。開源不見得能產出好策略,但希望能集眾人力量產出策略平台,希望大家給予寶貴的建議! 10 | 11 | ## 簡介 12 | 針對設好的台股美股清單,定期下載股票資訊,計算布林通道,Google Trend 等指標,取得 EPS 及本益比,並在指標觸發時發 LINE 通知,提醒買賣股票 13 | 14 | 觸發條件: 15 | - 台股列為處置股 16 | - 股價低於布林通道下緣(並且布林寬度不會太窄) 17 | - Google Trend 和過去三日最低值比較大幅上升,或過去三日最高值比較大幅降低 18 | - Google Trend 七日移動平均,和上週比較大幅升高或降低 19 | 20 | 資料來源: 21 | - FinMind: 台股EPS及本益比(之前需要付費帳號才能下載,現在好像不用) 22 | - 證交所、櫃買中心: 台股處置股、股票名、產業別 23 | - Yahoo Finance:使用 yfinance 取得台股美股的即時股價、歷史成交價,美股EPS及本益比。台股延遲報價20分鐘,美股無延遲 24 | - Google Trend: 使用 pytrends 取得 Google 搜尋量統計 25 | 26 | 展示網站: http://stock.tw-maker.net 27 | 28 | ## 執行介面 29 | 網頁顯示結果 [web_root.py](agastock/web_root.py): 30 | 31 | 32 | 33 | 34 | 35 | 分析程式 [parse_stock.py](agastock/parse_stock.py): 36 | 37 | 38 | LINE 通知: 39 | 40 | 41 | ## 如何開始 42 | 以下步驟於 Ubuntu 18.04 及 20.04 測試。Ubuntu 20.04 on Windows Subsystem for Linux (WSL) 也可使用,但需將 crontab 改成 Windows 排程。 43 | 44 | 1. 安裝 Python 及 agastock 需要的相依套件,加上 ta-lib 約占用 740MBytes 硬碟空間 45 | ```sh 46 | sudo apt-get update #require arround 160MBytes on Ubuntu 20.04 47 | sudo apt install python3 python3-pip #require around 220MBytes 48 | pip3 install pandas numpy matplotlib pandas_datareader mplfinance yfinance \ 49 | twstock line-bot-sdk flask pyquery colorlog pytrends #require around 260MBytes 50 | ``` 51 | 52 | 1. 更新 twstock 台股名稱資料庫,才能查詢到新上市股票的名稱,產業,上市上櫃別。資料庫未更新可能查不到新股股價,例如 91app。因為查詢 yfinance 時需要區別上市及上櫃,上市加上.TW,上櫃加上.TWO,加錯即無法查到。 53 | ```sh 54 | python3 -c "import twstock;twstock.__update_codes()" 55 | ``` 56 | 57 | 1. 安裝 ta-lib 以計算布林通道及移動平均。直接 pip3 install 會失敗,改用手動編譯。約需要100MBytes。 58 | ```sh 59 | wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz 60 | tar -zxvf ta-lib-0.4.0-src.tar.gz 61 | cd ta-lib 62 | ./configure --prefix=/usr 63 | make 64 | sudo make install 65 | pip3 install ta-lib 66 | ``` 67 | 68 | 1. 需設定中文字型,matplotlib 繪圖時才能顯示中文 69 | 1. 編輯 matplotlib 設定檔 70 | ```sh 71 | mpath=`python3 -c "import matplotlib;print(matplotlib.matplotlib_fname())"` 72 | echo $mpath #ex. ~/.local/lib/python3.8/site-packages/matplotlib/mpl-data/matplotlibrc 73 | vi $mpath 74 | ``` 75 | 76 | 1. 將 font.family 與 font.**sans**-serif 註解(#)移除,並在 font.sans-serif 後方加入 "Microsoft JhengHei (注意不是 font.**serif**,兩個很像)。範例: 77 | 78 | 79 | 1. 下載微軟雅黑 (Microsoft JhengHei) 字型,複製到 matplotlib 字型目錄 80 | ```sh 81 | dpath=`python3 -c "import matplotlib;print(matplotlib.get_data_path())"` 82 | wget "https://gitlab.aiacademy.tw/junew/june_toolbox/raw/master/matplotlib_ch/msj.ttf" 83 | mv msj.ttf "$dpath/fonts/ttf/" 84 | ls $dpath/fonts/ttf/msj.ttf 85 | ``` 86 | 87 | 1. 刪除字型暫存檔,下次 import matplotlib 才會重讀設定檔 88 | ```sh 89 | fpath=`python3 -c "import matplotlib;print(matplotlib.get_cachedir())"` 90 | echo $fpath #ex. ~/.cache/matplotlib 91 | rm -f $fpath/fontlist*.json 92 | ``` 93 | 94 | 1. 取得 agastock source code 95 | ```sh 96 | cd ~ 97 | git clone https://github.com/aga4gavin/agastock.git 98 | cd agastock/agastock/ 99 | ``` 100 | 101 | 1. 分析台股美股 102 | ```sh 103 | python3 parse_stock.py -tw #should print "Parse XX tickers successfully" 104 | python3 parse_stock.py -us #should print "Parse XX tickers successfully" 105 | ls out #should print "line_notify_tw.csv line_notify_us.csv stock_summary_tw.csv stock_summary_us.csv" 106 | ``` 107 | 確保 /agastock/out/ 目錄有產生以上四個 csv 檔案 108 | 109 | 1. 執行 flask web server 110 | ```sh 111 | python3 web_root.py 112 | ``` 113 | 以瀏覽器開啟網址,若是本機可開啟 http://127.0.0.1:8080/ ,網頁應顯示剛產生的 stock_summary_xxw.csv。若是EC2,Flask印出的log可能是內網IP地址,外網IP地址要到EC2管理介面查看 114 | 115 | 1. 設定台灣時區並編輯排程 116 | ```sh 117 | sudo timedatectl set-timezone "Asia/Taipei" 118 | sudo nano /etc/crontab 119 | ``` 120 | 121 | 貼上以下設定,美股開盤時間每10分鐘更新,盤後每2小時更新。台股開盤時間每10分鐘更新,盤後每2小時更新。 122 | /home/ubuntu/agastock/agastock 須置換成實際路徑 123 | ```python 124 | #agastock bootup 125 | @reboot ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 web_root.py 126 | @reboot ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -tw 127 | @reboot ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -us 128 | 129 | #agastock tw 130 | */10 9-15 * * 1-5 ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -tw 131 | 30 */2 * * * ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -tw 132 | 133 | #agastock us 134 | */10 21-23 * * 1-5 ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -us 135 | */10 0-4 * * 2-6 ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -us 136 | 30 */2 * * * ubuntu cd /home/ubuntu/agastock/agastock && /usr/bin/python3 parse_stock.py -us 137 | ``` 138 | 139 | 1. 重開機觸發排程 140 | ```sh 141 | sudo reboot 142 | ``` 143 | 144 | 1. 重開機後 [web_root.py](agastock/web_root.py) 應該自動執行。再次檢查 http://127.0.0.1:8080/ 是否正常顯示台股美股 145 | 146 | 1. 更改Ubuntu防火牆設定,確保外網可連接,測試外網網址 147 | 148 | ## 其他設定 149 | [config.py](agastock/config.py) 包含所有設定,常變更的有: 150 | 151 | - 若要啟用Line通知,需申請免費的LineBot,https://developers.line.biz/zh-hant/ 152 | 申請完的帳號填入 LINEBOT_ID 及 LINEBOT_TOKEN 153 | 154 | - 美股清單: US_TICKER_LIST 155 | 156 | - 台股清單: TW_TICKET_LIST 157 | 158 | - 用於LINE通知的網址: WEB_BASE_URL 159 | 160 | - 布林通道通知條件: NOTIFY_BBAND_HIGHER、NOTIFY_BBAND_LOWER 161 | 162 | - Google Trend 通知條件: NOTIFY_GT_MA7_RISE_RATE、NOTIFY_GT_MA7_RISE_TO 等等 163 | 164 | - flask web server: 165 | - WEB_DEBUG_FLASK 設定 True,可開啟 flask 除錯功能,每次修改 [web_root.py](agastock/web_root.py) 將觸發 flask web server 自動重啟 166 | - WEB_SERVER_PORT 可更改綁定的 port。若設定標準 http port 80,在 ubuntu 需執行下命令 167 | ```sh sudo 168 | pypath=$(realpath /usr/bin/python3) #ex. /usr/bin/python3.8 169 | sudo setcap 'cap_net_bind_service=+ep' $pypath 170 | ``` 171 | 172 | ## 錯誤排除 173 | - 若重開機後無法開啟網頁,可查看 cron log 看是否出錯 174 | ```sh 175 | grep CRON /var/log/syslog 176 | ``` 177 | 應該要看到 178 | > ubuntu@ip-172-31-19-156:~$ grep CRON /var/log/syslog 179 | > Sep 4 23:20:15 ip-172-31-19-156 cron[443]: (CRON) INFO (pidfile fd = 3) 180 | > Sep 4 23:20:15 ip-172-31-19-156 cron[443]: (CRON) INFO (Running **@reboot jobs**) 181 | > Sep 4 23:20:15 ip-172-31-19-156 CRON[501]: (ubuntu) CMD ( cd /home/ubuntu/agastock/agastock && /usr/bin/python3 **web_root.py**) 182 | > Sep 4 23:20:15 ip-172-31-19-156 CRON[494]: (ubuntu) CMD ( cd /home/ubuntu/agastock/agastock && /usr/bin/python3 **parse_stock.py -us**) 183 | > Sep 4 23:20:15 ip-172-31-19-156 CRON[500]: (ubuntu) CMD ( cd /home/ubuntu/agastock/agastock && /usr/bin/python3 **parse_stock.py -tw**) 184 | > Sep 4 23:20:17 ip-172-31-19-156 CRON[454]: (CRON) info (No MTA installed, discarding output) 185 | > Sep 4 23:20:30 ip-172-31-19-156 CRON[453]: (CRON) info (No MTA installed, discarding output) 186 | > Sep 4 23:20:42 ip-172-31-19-156 CRON[452]: (CRON) info (No MTA installed, discarding output) 187 | 188 | - 若執行 [parse_stock.py](agastock/parse_stock.py) 出現以下錯誤,可能微軟雅黑字型尚未下載,請參照前面說明下載微軟字型 189 | > /home/user/.local/lib/python3.8/site-packages/matplotlib/backends/backend_agg.py:203: RuntimeWarning: Glyph 20729 **missing from current font**. 190 | font.set_text(s, 0, flags=flags) 191 | 192 | - 若網頁顯示不正常,漏資料,可在網頁下方下載 "*tw log file" 或是 "*us log file",也可在以下路徑觀看log: 193 | * ~/agastock/static/logs/stock_parser_tw.log 194 | * ~/agastock/static/logs/stock_parser_us.log 195 | 196 | 或著手動執行以下命令看是否產生錯誤 197 | ```sh 198 | cd ~/agastock 199 | python3 parse_stock.py -us 200 | python3 parse_stock.py -tw 201 | ``` 202 | 203 | - 以下指令可刪除輸出和暫存檔,恢復初始狀態(需改成實際路徑) 204 | ```sh 205 | rm -f /home/ubuntu/agastock/agastock/out/* /home/ubuntu/agastock/agastock/static/logs/* \ 206 | /home/ubuntu/agastock/agastock/static/img/* /home/ubuntu/agastock/agastock/__pycache__/* \ 207 | /tmp/parse_stock_*.lock 208 | ``` 209 | - 可在 [config.py](agastock/config.py) 開啟以下除錯功能: 210 | * PARSE_STOCK_LOG_LEVEL 改成 logging.DEBUG 印出除錯訊息 211 | * WEB_DEBUG_SHOW_ALL_COLUMNS 設定 True,在網頁顯示 stock_summary_xx.csv 隱藏欄位 212 | * ENABLE_GLOBAL_LOG 設定 False,印出所有 module 的 debug print,其中 request module 訊息可了解爬蟲運作 213 | * MULTI_THREAD_SUPPORT 設定 False,把使用 multi-thread 的 queue_xxx() 改用 single-thread,若懷疑是 multi-thread 問題可幫助除錯 214 | 215 | - 若新上市台股無法取得名稱,可執行以下指令更新台股清單 216 | ```sh 217 | python3 -c "import twstock;twstock.__update_codes()" 218 | ``` 219 | 再執行 parse_stock.py -tw 重新爬蟲 220 | 221 | ## 各套件使用限制 222 | - FinMind: [官網說明](https://finmindtrade.com/analysis/#/Sponsor/sponsor) 未輸入TW_FM_TOKEN每小時300次,免費會員每小時600次,付費Backer提高到每小時1600次 223 | - 證交所: [根據twstock說明](https://github.com/mlouielu/twstock),每 5 秒鐘 3 個 request,超過的話會被 ban,據說每次request間隔1秒即可, 224 | - Google Trend: [據此部落格](https://github.com/GeneralMills/pytrends/issues/202)說明,每小時400次 225 | - LINE bot :[官網說明](https://tw.linebiz.com/service/account-solutions/line-official-account/),LINE Message API 免費帳號一個月可傳送 500 則訊息。 226 | - Yahoo Finance: [據此部落格](https://towardsdatascience.com/free-stock-data-for-python-using-yahoo-finance-api-9dafd96cad2e)說明,每小時 2000次 request 227 | 228 | ## 檔案結構 229 | ```python 230 | agastock 231 | │ README.md #本說明檔 232 | │ web_root.py #使用 flask framework 的網頁主程式 233 | │ parse_stock.py #定期執行的 parser 主程式 234 | │ stock_base.py #clas StockBase 235 | │ stock_tw.py #class StockTwn,寫入 stock_tw.csv 及 line_notify_tw.csv 236 | │ stock_us.py #class StockUs, 寫入 stock_us.csv 及 line_notify_us.csv 237 | │ common.py #stock_XX.py 和 web_root.py 共用的設定檔及 library function 238 | │ agalog.py #logging 239 | │ config.py #設定檔 240 | │ 241 | └───out #儲存 parse_stock.py 輸出 242 | │ │ line_notify_tw.csv #儲存台股LINE通知紀錄,相同原因等過期再觸發才會重傳 243 | │ │ line_notify_us.csv #儲存美股LINE通知紀錄,相同原因等過期再觸發才會重傳 244 | │ │ stock_summary_tw.csv #儲存台股parse結果 245 | │ │ stock_summary_us.csv #儲存美股parse結果 246 | │ 247 | └───static #flask framework 指定的靜態檔案目錄 248 | │ │ style.css #網頁共用的 style 249 | │ │ img #儲存 parse_stock.py 輸出的股票圖片 250 | │ │ logs #儲存 parse_stock.py 產生的 log 251 | │ 252 | └───templates #flask framework 指定的 html template 目錄 253 | │ detail_tw.html 254 | │ detail_us.html 255 | │ summary_tw.html 256 | │ summary_us.html 257 | ``` 258 | 259 | ## 軟體設計筆記 260 | - [parse_stock.py](agastock/parse_stock.py) 為股票分析主程式,使用 StockUs 和 StockTwn 取得美股台股資料,將不同股票(ticker)儲存於 _data[ticker]。各個 _data[ticker] 一開始為無元素 dict, 建立步驟為: 261 | - queue_xxx() 根據參數 init_vars[col1, col2...], 將 _data[ticker] 加入新的 column,成為 _data[ticker][col1], _data[ticker][col2],..., 並填入初始值 float('nan') 262 | 263 | - 初始值選用 float('nan') 的優點是,它可被印出 "%f"%float('nan') 也可做數學運算 float('nan') + 1,結果都是 nan 但不會觸發 exception,可簡化錯誤處理. 但不能使用 %d 列印, '%d'%float('nan') 會造成 exception 264 | 265 | - queue_xxx() 從網路下載股票資料,將有下載到的資料填入 _data[ticker][col]. 無資料 column 還是 float('nan'),例如新股91app尚無年度EPS 266 | 267 | - _data[] column 命名規則: 268 | - 一般股票資訊: 只有這類會顯示在 [web_root.py](agastock/web_root.py) 產生的網頁,例如名稱,股價,G-Trend等. 以下三類皆不顯示 269 | 1. TMP_XXX: 分析股票過程暫存,不會寫入 stock_summary_xx.csv,例如TMP_GTName 270 | 1. VAR_XXX: 和 ticker 無關的變數,只存在 _data[first ticker][VAR_XXX],例如 VAR_WebMsg 為網頁下方警告訊息 271 | 1. HD_XXX: 其他隱藏欄位,例如 HD_GT_URL 是讓 [web_root.py](agastock/web_root.py) 產生 Google Trend 超連接使用 272 | 273 | - queue_xxx() 股票分析函式 274 | - 若發生錯誤,應呼叫 _add_err_msg() 及 add_warn_msg(),將錯誤儲存於給 VAR_WebMsg 給在網頁顯示 275 | 276 | - 為了方便增加新的 queue_xxx(),使用Decorator 設計通用的 QueryHandler,包含以下功能: 277 | 1. 執行函式前,根據 init_vars[] 初始化 _data[ticker][col] 278 | 1. 使用 exception handler 保護,確保 parse_stock.csv 執行成功並寫入 stock_summary_xx.csv 279 | 1. 支援 multi-thread,容易在 single-thread 及 multi-thread 之間切換 280 | 1. 支援股票資料緩存,過期才需要重新下載,避免存取過於頻繁被網站 ban,例如測試時常超過 Google Trend 每小時 400 次限制 281 | 282 | - QueryHandler 包含以下三種: 283 | - @QueryHandler_NoLoop: 由函式自行處理 ticker loop,例如台股 _query_punish_stock() 需一次下載所有處置股 284 | - @QueryHandler_ForLoop: 以 single-thread 處理 ticker loop,函式只需處理單支 ticker,例如台股 query_info() 是簡單的迴圈,適合用 single-thread 285 | - @QueryHandler_ThreadLoop: 以 multi-thread 處理 ticker loop,函式只需處理單支ticker,例如美股 query_info() 下載每支股票需時5秒, multi-thread 可大幅加速 286 | 287 | - QueryHandler 使用方式: 288 | - @QueryHandler_ForLoop( expire_hours=TT, init_vars=['XX','YY'] ) 289 | - 參數 expire_hours 啟動用緩存功能,忽略此參數代表每次執行 [parse_stock.py](agastock/parse_stock.py) 都重新查詢,例如 query_price() 290 | - 參數 init_vars 供 _data[ticker][col] 初始化使用 291 | 292 | - 使用 QueryHandler 的 queue_xxx() 需符合以下介面,分為兩類: 293 | 1. query_XX(self) #@QueryHandler_NoLoop 294 | 1. query_OO(self, ticker, tdata, prev_tdata) #@QueryHandler_ForLoop, @QueryHandler_ThreadLoop 295 | - 查詢成功回傳True,若有設定 expire_hours 代表到期前不再查詢。失敗回傳False 296 | 297 | - Thread 298 | - 被 @QueryHandler_ThreadLoop 修飾的 queue_xxx() 支援 multi-thread,只能呼叫 thread-save 函式 299 | - 若需呼叫非 thread-save 函式: 300 | - 自定 function 可使用 @ThreadSaver 保護,例如 _add_warn_msg() 301 | - 系統 function 可使用 _thread_mutex.acquire() 及 _thread_mutex.release() 保護,例如 matplotlib 302 | - 指標設計 303 | - Google Trend 搜尋時間段可設定以下參數: 304 | 1. 'today 12-m' 回傳一年資料,以週為單位,不滿一週的部分得到 partial=False。如果從上個完整周計算的話,資料可能延後1-6天 305 | 1. 'today 1-m' 回傳一個月資料,以天為單位。可得到昨日資料但只有一個月,但時間太短無法判斷漲跌 306 | 1. 此套件使用 'today 3-m' 回傳90天資料,以天為單位,缺點是資料延遲三天。因為週末搜尋量劇減,用單日比較若比到週末不公平,八日九日都會計算到週末,七日或七日倍數最適合。因此使用MA7搭配日資料判斷趨勢變化 307 | 308 | - 網頁欄位顯示順序依照 queue_xxx() 初始化 init_vars[] 的順序,例如 309 | - stock.query_bband() #init_vars=['布林位置','布林寬度'] 310 | - stock.query_google_trend() #init_vars=['G-Trend','G-Trend漲跌', 'HD_GT_URL'] 311 | - 以上排序為: '布林位置','布林寬度', 'G-Trend','G-Trend漲跌', 'HD_GT_URL' 312 | 313 | - 命名規則 314 | - _XXX,一個底線開頭的變數或函式,在父類別 StockBase 和子類別 StockUs/StockTwn 之間共用,外界不該使用 315 | 316 | - [web_root.py](agastock/web_root.py) 負責將 stock_summary_xx.csv 顯示為網頁,以下為網址範例: 317 | - http://127.0.0.1:8080/tw => tw summary 318 | - http://127.0.0.1:8080/tw/0050 => tw 0050 detail 319 | - http://127.0.0.1:8080/static/img/tw_stock_0050_ma.png => tw 0050 image 320 | - http://127.0.0.1:8080/us => us summary 321 | - http://127.0.0.1:8080/us/AAPL => us AAPL detail 322 | - http://127.0.0.1:8080/static/img/us_stock_AAPL_bb.png => us AAPL image 323 | 324 | ## Q&A 325 | 1. 如何增加股票? 326 | - 請修改 [config.py](agastock/config.py) 的 US_TICKER_LIST (美股) 及 TW_TICKET_LIST (台股) 327 | 328 | 1. 如何下載和分析更多股票資訊? 329 | - 請選擇一個 queue_xxx() 函式做為範例修改,並加在 [parse_stock.py](agastock/parse_stock.py) 以下註解處: 330 | ```python 331 | #[程式修改注意] 新的 queue_xxx() 股票分析函式請加在此,init() 和 push_out_line_notify() 之間 332 | ``` 333 | 334 | 1. 如何儲存資料,有用到資料庫嗎? 335 | - 未使用資料庫,只用 csv 檔案儲存。[parse_stock.py](agastock/parse_stock.py) 儲存兩類檔案: 336 | (1) stock_summary_xx.csv 儲存所有股票資料,提供 [web_root.py](agastock/web_root.py) 顯示於網頁 337 | (2) line_notify_xx.csv 儲存 line 通知紀錄,避免同一筆通知重複傳送。每種通知皆有過期天數,例如台股處置股設定 14 天過期,符合證交所慣例 338 | 339 | 1. 如何避免查詢太密集被網站ban? 340 | - 設有緩存機制,支援的 queue_xxx() 將資料儲存於 stock_summary_xx.csv ,在參數 expire_hours 到期前,會沿用 csv 資料而不會重新查詢,避免被 ban 341 | - 支援緩存的函式有: 342 | ```python 343 | query_google_trend() #Google Trend 搜尋趨勢,有效期預設為 GTREAD_EXPIRE_HOURS=4 小時 344 | query_finance() #財報,有效期預設為 FINANCE_EXPIRE_HOURS=3*24,三天 345 | ``` 346 | 347 | - 不支援緩存的函式有: 348 | ```python 349 | query_price(), _query_price_yfinance() #查詢股價需即時,不緩存。收盤後不常執行應該不會被 ban 350 | query_bband() #布林通道需要即時股價計算,配合 query_price() 不緩存 351 | query_info() #股票名稱等資本資訊,本地資料不需網路下載,無需緩存 352 | _query_punish_stock() #台股處置股,台股證交所只要隔一秒讀取就不會被 ban 353 | ``` 354 | 355 | 1. 為何股價圖有段時間沒線圖或是交易量暴增100倍? 356 | - 請見[已知問題](#已知問題),此為 Yahoo Finance 的 bug 357 | 358 | 1. LINE 通知需要收費嗎? 359 | - [官網說明](https://tw.linebiz.com/service/account-solutions/line-official-account/),LINE Message API 免費帳號一個月可傳送 500 則訊息。欲開通 LINE 通知服務,請在 https://developers.line.biz/zh-hant/ 註冊帳號,並將 LINEBOT_ID 及 LINEBOT_TOKEN 填入 [config.py](agastock/config.py) 360 | 361 | ## 已知問題 362 | - Yahoo Finance 讀取台股,有時股價全部 NaN,並且股價日期為週末 363 | - Yahoo Finance 有時候部分日期 NaN,並且回去幾天Volume都爆量一百倍,可在 [config.py](agastock/config.py) 開啟 YFINANCE_FIX_HIGH_VOLUME 啟用修正機制 364 | 365 | ## 參考資料 366 | - Python dataframe talbe format,https://www.cjavapy.com/article/209/ 367 | - 布林區間,https://blog.csdn.net/qq_41437512/article/details/105473845 368 | - Google Trend,https://rpubs.com/JJChiou/google_trends_1 369 | - Matplotlib繪圖, https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html 370 | - 財金手札 Finance Note,https://ycy-blog.herokuapp.com/ 371 | -------------------------------------------------------------------------------- /agastock/stock_twn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # stock_twn.py 5 | # 分析台股用的 StockTwn 6 | # 7 | # Copyright ©2021 [Gavin Lee], et. al. 8 | #============================================================= 9 | #agastock module 10 | from common import * 11 | from config import * 12 | from stock_base import * 13 | import agalog 14 | 15 | #other module 16 | import numpy as np 17 | import pandas as pd 18 | from datetime import date, datetime, timedelta 19 | from pytrends.exceptions import ResponseError 20 | from requests.exceptions import * #ConnectionError, InvalidURL, InvalidSchema, ... 21 | #from IPython.core.display import HTML 22 | from pyquery import PyQuery as pq 23 | from urllib.error import * #HTTPError 24 | import twstock, requests, io 25 | 26 | 27 | #=========== FinMind ============ 28 | _FM_URL_DATA = "https://api.finmindtrade.com/api/v4/data" 29 | _FM_URL_SNAPSHOT= "https://api.finmindtrade.com/api/v4/taiwan_stock_tick_snapshot" 30 | 31 | 32 | class StockTwn(StockBase): 33 | #=========== PARAMETER ============ 34 | #股票清單 35 | _ticker_list= TW_TICKET_LIST 36 | 37 | #台股Google Trend預設使用證交所名稱,但像雄獅會搜尋到雄獅文具,就需要覆寫在下表 38 | #空字串代表不搜尋Google Trend。 ETF不用特別寫會自動忽略 39 | __gt_name_overwrite_list= TW_GT_NAME_OVERWRITE_LIST 40 | 41 | 42 | #=========== 一般設定 ============ 43 | _TITLE= "[TW STOCK PARSER]" #標題,用於log首行 44 | REGION= "tw" 45 | REGION_TEXT= "台股" 46 | 47 | 48 | #============ MEMBER VARIABLE ============ 49 | __punish_list= {} 50 | 51 | 52 | #============ PRIVITE FUNCTION ============ 53 | #取得處置股字串 54 | def _get_cust_notify_msg(self, ticker) -> str: 55 | msg= '' 56 | if is_valid2(self.__punish_list, ticker): 57 | msg= "處置:%s\n\n"%self.__punish_list[ticker].replace("分:","分 ") 58 | return msg 59 | 60 | 61 | def __get_url_csv(self, url, data_name='', iStart=None, iEndBoundary=None, exp_columns=[], min_rows=0, decode='big5'): 62 | try: 63 | csv = requests.get(url, timeout=URL_TIMEOUT_SEC).content.decode(decode) 64 | csv= '\r\n'.join( csv.split("\r\n")[iStart:iEndBoundary] ) #如果資料行數不夠,[:]不會造成exception,頂多回傳空陣列 65 | df= pd.read_csv( io.StringIO(csv) ) 66 | 67 | if len(df) < min_rows: 68 | self._add_warn_msg(" __get_url_csv 讀取'%s'得到的資料太少,預期至少%d筆但只得到%d筆. url:%s"%(data_name, min_rows, len(df), url)) 69 | return None 70 | elif not df_check_columns(df, exp_columns): #欄位不正確的狀況沒實際遇過 71 | self._add_warn_msg(" __get_url_csv 讀取'%s'得到的欄位不正確, 預期%s, 實際為%s. url:%s"%(data_name, exp_columns, df.columns, url)) 72 | return None 73 | 74 | return df 75 | 76 | except (ResponseError, KeyError, ConnectionError, InvalidURL, InvalidSchema, UnicodeError, MissingSchema, ReadTimeout) as e: 77 | #ResponseError是FinMind query超過次數, KeyError是FinMind API名稱輸入錯誤例如TaiwanStockInfo輸入成TaiwanStockInfoXXX 78 | #ConnectionError是DNS解析錯誤, InvalidURL是網址DNS解析失敗可能網址打錯, InvalidSchema是http協定打錯, UnicodeError可能是網址錯誤造成回傳資料不是big5 79 | #MissingSchema是網址空白 80 | self._add_warn_msg("__get_url_csv 讀取'%s'失敗, %s"%(data_name, get_exception_msg(e))) 81 | return None 82 | 83 | 84 | def __query_finmind(self, param={}, data_name='', min_rows=0, exp_columns=[], fm_url=_FM_URL_DATA, index_col=None): 85 | try: 86 | param2= param.copy() #使用param2來request url,但印出錯誤時使用原來的param,才不會把私人資料token也印出 87 | param2['token']= TW_FM_TOKEN 88 | resp_json = requests.get(fm_url, params=param2, timeout=URL_TIMEOUT_SEC).json() #params為GET參數 89 | data= pd.DataFrame(resp_json["data"]) 90 | 91 | if len(data) < min_rows: 92 | self._add_warn_msg(" __query_finmind 讀取'%s'得到的資料太少,預期至少%d筆但只得到%d筆. Param: %s"%(data_name, min_rows, len(data), param)) 93 | return None 94 | elif not df_check_columns(data, exp_columns): #欄位不正確的狀況沒實際遇過 95 | self._add_warn_msg(" __query_finmind 讀取'%s'得到的欄位不正確, 預期%s, 實際為%s. Param: %s"%(data_name, exp_columns, data.columns, param)) 96 | return None 97 | 98 | if index_col is not None: 99 | data.index= data[index_col] 100 | return data 101 | 102 | except (ResponseError, KeyError, ConnectionError, InvalidURL, InvalidSchema, UnicodeError, MissingSchema, ReadTimeout) as e: 103 | #ResponseError是FinMind query超過次數, KeyError是FinMind API名稱輸入錯誤例如TaiwanStockInfo輸入成TaiwanStockInfoXXX 104 | #ConnectionError是DNS解析錯誤, InvalidURL是網址DNS解析失敗可能網址打錯, InvalidSchema是http協定打錯, UnicodeError可能是網址錯誤造成回傳資料不是big5 105 | #MissingSchema是網址空白 106 | self._add_warn_msg("[錯誤] __query_finmind 讀取'%s'失敗. Param: %s, %s"%(data_name, param, get_exception_msg(e))) 107 | return None 108 | 109 | #============ PUBLIC FUNCTION ============ 110 | #初始化,並處理台股美股不同的資料,目前只有台股的處置股,寫入(通知訊息) 111 | #處置股儲存於 _df_pusish_list[] 112 | def init(self): 113 | super().init() 114 | 115 | #台股處置股寫入_df_pusish_list[] 116 | self._query_punish_stock() 117 | 118 | 119 | #============ PUBLIC FUNCTION - get_XXX 系列取得股票資訊 ============ 120 | #寫入處置股清單. 從櫃買中心讀取,不能太頻繁 121 | @QueryHandler_NoLoop() #不需要init_var. 不能使用expire_hours,因為_data_reuse()不會複製__punish_list[],但LINE通知訊息需要它 122 | def _query_punish_stock(self, data_name) -> bool: 123 | pun_name_list= {} 124 | 125 | #上市處置股 126 | df= self.__get_url_csv(url="https://www.twse.com.tw/announcement/punish?response=csv&startDate=&endDate=", 127 | data_name="上市處置股", 128 | iStart=1, iEndBoundary=-1, #第一行說明資料日期沒用,最後兩行沒資料(-1代表最後兩行都捨棄) 129 | exp_columns=["證券代號","處置起迄時間","處置內容",], 130 | decode='big5') 131 | if df is not None: 132 | for i in df.index: 133 | #[上市處置內容] 134 | #1處置原因:該有價證券之交易,最近十個營業日內已有六個營業日達本公司「公布注意交易資訊」標準,且該股票於最近三十個營業日內曾發布處置交易資訊。 135 | #2處置期間:自民國一百十年七月七日起至一百十年七月二十日﹝十個營業日,如遇: 136 | # a有價證券最後交易日在處置期間,僅處置至最後交易日, 137 | # b有價證券停止買賣、全日暫停交易則順延執行, 138 | # c開休市日變動則調整處置迄日〕。 139 | #3處置措施: 140 | # a以人工管制之撮合終端機執行撮合作業(約每二十分鐘撮合一次)。 141 | # b所有投資人每日委託買賣該有價證券時,應就其當日已委託之買賣,向該投資人收取全部之買進價金或賣出證券。 142 | # c信用交易部分,應收足融資自備款或融券保證金。有關信用交易了結部分,則依相關規定辦理。 143 | #agalog.info("%s => %s"%(i,df['處置內容'].iloc[i])) 144 | if "每九十分鐘撮合一次" in df['處置內容'].iloc[i]: 145 | text= "90分:" 146 | elif "每六十分鐘撮合一次" in df['處置內容'].iloc[i]: 147 | text= "60分:" 148 | elif "每四十五分鐘撮合一次" in df['處置內容'].iloc[i]: 149 | text= "45分:" 150 | elif "每二十五分鐘撮合一次" in df['處置內容'].iloc[i]: 151 | text= "25分:" 152 | elif "每二十分鐘撮合一次" in df['處置內容'].iloc[i]: 153 | text= "20分:" 154 | elif "每十五分鐘撮合一次" in df['處置內容'].iloc[i]: 155 | text= "15分: " 156 | elif "每十分鐘撮合一次" in df['處置內容'].iloc[i]: 157 | text= "10分: " 158 | elif "每五分鐘撮合一次" in df['處置內容'].iloc[i]: 159 | text= "5分:" 160 | else: 161 | text= "未知的處置:%s"%df['處置內容'].iloc[i] 162 | 163 | #110/07/07~110/07/20 164 | text+= " %s"%df["處置起迄時間"].iloc[i] #注意上櫃是"起訖",上市是"起迄",兩個欄位名不同,不能複製貼上 165 | 166 | #一支股票可能被處置多次,多行用
分隔 167 | ticker= str(df['證券代號'].iloc[i]) #上市可以直接比對'證券代號',但跟著上櫃一起轉str更安全 168 | pun_name_list[ticker]= df['證券名稱'].iloc[i] 169 | if ticker in self._ticker_list: 170 | if ticker not in self.__punish_list: 171 | self.__punish_list[ticker]= text 172 | else: 173 | self.__punish_list[ticker]+= ", " + text 174 | agalog.debug( " [上市] 找到處置股在清單中 (%s %s) => %s"%(ticker, df['證券名稱'].iloc[i], text)) 175 | else: 176 | agalog.debug( " [上市] 找到不在清單中的處置股 (%s %s) => %s"%(ticker, df['證券名稱'].iloc[i], text)) 177 | 178 | 179 | #上櫃處置股 180 | t= time.localtime() 181 | roc_date= "%d/%02d/%02d"%(t.tm_year-1911, t.tm_mon, t.tm_mday) # 西元 2011/07/14 轉成民國 110/07/14,上櫃處置參數需要民國年 182 | 183 | #若不指定起訖日期,則預設為昨天到今天,也就是昨天處置結束的股票還在. 因此指定起訖都是今天,只顯示今天還在處置期的上櫃股 184 | df= self.__get_url_csv(url="https://www.tpex.org.tw/web/bulletin/disposal_information/disposal_information_download.php?l=zh-tw&sd=%s&ed=%s"%(roc_date,roc_date), 185 | data_name="上櫃處置股", 186 | iStart=2, iEndBoundary=-3, #第兩行說明資料日期沒用,最後四行沒資料(-3代表最後四行都捨棄) 187 | exp_columns=["證券代號","處置起訖時間","處置內容",], #注意上櫃是"起訖",上市是"起迄",兩個欄位名不同,不能複製貼上 188 | decode='big5') 189 | if df is not None: 190 | for i in df.index: 191 | #[上櫃處置內容] 192 | #因連續3個營業日達本中心作業要點第四條第一項第一款經本中心公布注意交易資訊,爰自110年07月02日起10個營業日(110年07月02日至110年07月15日,如遇休市、 193 | #有價證券停止買賣、全日暫停交易則順延執行)改以人工管制之撮合終端機執行撮合作業(約每5分鐘撮合一次),各證券商於投資人每日委託買賣該有價證券數量單筆達10 194 | #交易單位或多筆累積達30交易單位以上時,應就其當日已委託之買賣,向該投資人收取全部之買進價金或賣出證券。信用交易部分,則收足融資自備款或融券保證金。但信用 195 | #交易了結及違約專戶委託買賣該有價證券時,不在此限。 196 | #agalog.info("%s => %s"%(i,df['處置內容'].iloc[i])) 197 | if "每90分鐘撮合一次" in df['處置內容'].iloc[i]: 198 | text= "90分:" 199 | elif "每60分鐘撮合一次" in df['處置內容'].iloc[i]: 200 | text= "60分:" 201 | elif "每45分鐘撮合一次" in df['處置內容'].iloc[i]: 202 | text= "45分:" 203 | elif "每25分鐘撮合一次" in df['處置內容'].iloc[i]: 204 | text= "25分:" 205 | elif "每20分鐘撮合一次" in df['處置內容'].iloc[i]: 206 | text= "20分:" 207 | elif "每15分鐘撮合一次" in df['處置內容'].iloc[i]: 208 | text= "15分:" 209 | elif "每10分鐘撮合一次" in df['處置內容'].iloc[i]: 210 | text= "10分:" 211 | elif "每5分鐘撮合一次" in df['處置內容'].iloc[i]: 212 | #text= "5分/單筆十張或累積30張則全額交割" 213 | text= "5分:" 214 | else: 215 | text= "" 216 | self._add_warn_msg(" %-5s: 處置內容找不到每幾分鐘交割,訊息: %s"%(ticker,df['處置內容'].iloc[i])) 217 | 218 | #110/07/07~110/07/20 219 | text+= " %s"%df["處置起訖時間"].iloc[i] #注意上櫃是"起訖",上市是"起迄",兩個欄位名不同,不能複製貼上 220 | 221 | #一支股票可能被處置多次,多行用
分隔 222 | ticker= str(df['證券代號'].iloc[i]) #上櫃的'證券代號'是Numpy type,要轉成str比對,不然會不符合 223 | pun_name_list[ticker]= df['證券名稱'].iloc[i] 224 | if ticker in self._ticker_list: 225 | if ticker not in self.__punish_list: 226 | self.__punish_list[ticker]= text 227 | else: 228 | self.__punish_list[ticker]+= ", " + text 229 | agalog.debug( " [上櫃] 找到不在清單中的處置股 (%s %s) => %s"%(ticker, df['證券名稱'].iloc[i], text)) 230 | else: 231 | agalog.debug( " [上櫃] 找到處置股在清單中 (%s %s) => %s"%(ticker, df['證券名稱'].iloc[i], text)) 232 | 233 | for ticker in self._ticker_list: 234 | if ticker in self.__punish_list: 235 | reason= "處置股 %s"%(self.__punish_list[ticker]) 236 | msg= "台股: %s %s列處置股, %s"%(ticker, pun_name_list[ticker], self.__punish_list[ticker]) 237 | 238 | #處置14天就結束,二次處置日期不同,應該會因為reason可以重複通知 239 | self._queue_line_notify(ticker, pun_name_list[ticker], reason, msg, data_name, expire_days=14) 240 | 241 | return True 242 | 243 | 244 | #寫入基本資料: 代號,名稱,產業,TMP_GTName 245 | #前提: query_info() 已經執行,並填入 __gt_name_overwrite_list[] 246 | @QueryHandler_ForLoop( init_vars=['代號','名稱', '產業', 'TMP_GTName'] ) #init_vars[] 為網頁顯示順序 247 | def query_info(self, data_name, ticker, tdata, prev_tdata) -> bool: 248 | tdata['代號']= ticker 249 | tdata['名稱']= '' 250 | tdata['產業']= '' 251 | if ticker in twstock.codes: 252 | tdata['名稱']= twstock.codes[ticker].name 253 | if twstock.codes[ticker].type=='ETF': 254 | tdata['產業']= 'ETF' 255 | else: 256 | tdata['產業']= twstock.codes[ticker].group.replace("工業","").replace("事業","").replace("金融保險","金融")\ 257 | .replace("生技醫療","生技").replace("電子商務","電商").replace("其他電子類","電子").replace("其他電子","電子")\ 258 | .replace("文化創意","文化").replace("子零組件","電子").replace("業","") 259 | else: 260 | self._add_err_msg(" %-5s: 在 twstock.codes[] 找不到此代號,可嘗試執行 python3 -c 'import twstock;twstock.__update_codes()' 更新代號資料庫"%ticker) 261 | 262 | #google trend名稱 263 | if ticker in self.__gt_name_overwrite_list: 264 | tdata['TMP_GTName']= self.__gt_name_overwrite_list[ticker] 265 | else: 266 | tdata['TMP_GTName']= tdata['名稱'] 267 | 268 | return True 269 | 270 | 271 | #寫入股價資料: 股價,漲跌,成交金額,成交量 272 | #股價寫到 _df_price_list (若是盤中,最新一筆為即時資料) 273 | def query_price(self) -> bool: 274 | #yfinance 台股代號需加上.TW(上市) or .TWO(上櫃) 275 | tickets_yf_str= '' 276 | for ticker in self._ticker_list: 277 | if (ticker in twstock.codes) and (twstock.codes[ticker].market=='上櫃'): 278 | ticker_yf= "%s.TWO"%ticker 279 | else: 280 | ticker_yf= "%s.TW"%ticker 281 | tickets_yf_str+= " %s"%ticker_yf 282 | 283 | return self._query_price_yfinance(tickets_yf_str) 284 | 285 | 286 | @QueryHandler_ThreadLoop( expire_hours=FINANCE_EXPIRE_HOURS, init_vars=['本益比', '本益比預估', 'EPS', 'EPS預估', 'EPS成長預估', 'EPS成長預估'] ) #init_vars[] 為網頁顯示順序 287 | def query_finance(self, data_name, ticker, tdata, prev_tdata) -> bool: 288 | t = yf.Ticker(ticker) 289 | tdata['EPS']= get_valid_value2(t.info,'trailingEps') 290 | tdata['EPS預估']= get_valid_value2(t.info,'forwardEps') 291 | if is_valid(tdata['EPS預估']) and is_valid(tdata['EPS']) and tdata['EPS預估']>0 and tdata['EPS']>0: 292 | tdata['EPS成長預估']= (float(tdata['EPS預估']) / float(tdata['EPS'])) - 1 293 | tdata['本益比']= get_valid_value2(t.info,'trailingPE') 294 | tdata['本益比預估']= get_valid_value2(t.info,'forwardPE') 295 | agalog.debug(' %-5s: 財報: EPS=%.2f, EPS預估=%.02f, EPS成長預估=%.1f%%, 本益比=%.2f, 本益比預估=%.2f'%(ticker, tdata['EPS'], tdata['EPS預估'], tdata['EPS成長預估']*100, tdata['本益比'], tdata['本益比預估'])) 296 | return True 297 | 298 | 299 | #寫入財報資料: 本益比,EPS,財報季 300 | #前提: 已執行 query_price() 取得股價,存在tdata['股價'] 301 | #FinMind下載的EPS符合公開資訊觀測站 https://mops.twse.com.tw/mops/web/t163sb04,但後者的EPS為年度累積,例如第三季EPS為第一季+第二季+第三季 302 | @QueryHandler_ThreadLoop( expire_hours=FINANCE_EXPIRE_HOURS, init_vars=['本益比', 'EPS', '財報季'] ) #init_vars[] 為網頁顯示順序 303 | def query_finance(self, data_name, ticker, tdata, prev_tdata) -> bool: 304 | if tdata['產業']=='ETF': 305 | agalog.debug(" %-5s: ETF 不查詢財報"%ticker) 306 | return True 307 | 308 | start_date= date.today() - timedelta(days=30*28) #至少取得四季財報 309 | fin_list = self.__query_finmind( param={"dataset": "TaiwanStockFinancialStatements", "data_id": ticker, "start_date": start_date}, 310 | data_name="%s 財報"%ticker, 311 | min_rows=1, 312 | exp_columns=['date', 'stock_id', 'type', 'value']) 313 | if is_invalid(fin_list) : 314 | self._add_err_msg(" %-5s: 從 FinMind 下載財報錯誤,fin_list is None"%ticker) 315 | return False 316 | 317 | if not df_check_columns(fin_list, ['date', 'stock_id', 'type', 'value', 'origin_name']) : 318 | self._add_err_msg(" %-5s: 從 FinMind 下載財報錯誤, columns 不正確,讀到 %s"%(ticker, fin_list.columns.to_list())) 319 | return False 320 | 321 | eps_list= fin_list.loc[fin_list['type']=='EPS'] 322 | if len(eps_list['date']) < 4: 323 | agalog.info(" %-5s: 財報的EPS不足4季,只有%d季,無法計算TTM EPS (近四季EPS)"%(ticker, len(eps_list['date']))) 324 | return True 325 | 326 | #只留下近四季財報,舊的刪除 327 | while len(eps_list)>4: 328 | eps_list= eps_list.drop( eps_list.index[[0]] ) 329 | 330 | try: 331 | d0= datetime.strptime(eps_list['date'].iloc[0],'%Y-%m-%d') 332 | d3= datetime.strptime(eps_list['date'].iloc[3],'%Y-%m-%d') 333 | except ValueError as e: 334 | self._add_err_msg(" %-5s: 從 FinMind 下載財報錯誤的日期格式錯誤,[3]:%s, [0]:%s"%(ticker, eps_list['date'].iloc[3], eps_list['date'].iloc[0])) 335 | return False 336 | 337 | if (d3 - d0).days > 290: #連續四季財報,例如 2020-09-30 ~ 2021-06-30,間隔日期為九個月 273 天左右,超過代表有不連續資料 338 | agalog.info(" %-5s: 近4季財報的不連續,期間為 %s 到 %s,無法計算TTM EPS"%(ticker, d3, d0)) 339 | return True 340 | 341 | tdata['EPS']= eps_list['value'].sum() #過去四季EPS總和 342 | if is_valid(tdata['股價']): 343 | tdata['本益比'] = tdata['股價'] / tdata['EPS'] #本益比定義好像要用月均價,此處用即時價格 344 | tdata['財報季'] = "~" + eps_list['date'].iloc[3][2:4] + eps_list['date'].iloc[3][5:].replace('03-31','Q1').replace('06-30','Q2').replace('09-30','Q3').replace('12-31','Q4') 345 | agalog.debug(" %-5s: 本益比 %.2f = 股價 %.2f / EPS %.1f (%s ~ %s)"%(ticker, tdata['本益比'], tdata['股價'], tdata['EPS'], eps_list['date'].iloc[0], eps_list['date'].iloc[3])) 346 | return True 347 | 348 | -------------------------------------------------------------------------------- /agastock/stock_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #============================================================= 3 | # 4 | # stock_base.py 5 | # StockBase 為股票分析基礎類別,被StockUs和StockTwn繼承 6 | # 7 | # _data[] 儲存所有股票資料,結果將寫入stock csv,讓web_root.py讀取顯示於網頁。 8 | # 9 | # _data[] 的 column 命名規則為: 10 | # TMP_XXX: 運算暫存使用,寫入 stock csv 之前會刪除 (例如TMP_GTName) 11 | # VAR_XXX: 給 web_root.py 的資料,和ticker無關所以只存在 _data 首位 (例如VAR_WebMsg) 12 | # HD_XXX: 其他隱藏欄位,例如 (例如VAR_finance_ExpireTime是給Stock class自己看的, HD_GT_URL是讓web_root.py產生超連接使用) 13 | # 其他: 寫入 stock csv, 由 web_root.py 讀取顯示於 summary 頁面 14 | # 15 | # Copyright ©2021 [Gavin Lee], et. al. 16 | #============================================================= 17 | #agastock module 18 | from common import * 19 | from config import * 20 | import agalog 21 | 22 | #other module 23 | import os, talib, re, threading 24 | import matplotlib.dates as mdates 25 | import matplotlib.pyplot as plt 26 | from matplotlib.dates import * #MO, TU, WE, TH, FR, SA, SU 27 | import pandas as pd 28 | import mplfinance as mpf 29 | from datetime import date, datetime, timedelta 30 | from linebot import LineBotApi 31 | from linebot.models import TextSendMessage 32 | from linebot.exceptions import LineBotApiError 33 | from pytrends.request import TrendReq 34 | from pytrends.exceptions import ResponseError 35 | from functools import wraps 36 | import yfinance as yf 37 | 38 | 39 | #=========== Decorator QueryHandler ============ 40 | #針對處理股票的Query系列函式,設計三個 Decorator,具有以下功能: 41 | # 1, ticker loop 42 | # QueryHandler_NoLoop: 由函式自行處理loop,例如台股 _query_punish_stock(),一次下載所有處置股 43 | # QueryHandler_ForLoop: 以single-thread處理loop,函式只需要處理單支ticker,例如台股 query_info() 是簡單的迴圈,single-thread即可 44 | # QueryHandler_ThreadLoop: 以multi-thread處理loop,函式只需要處理單支ticker,並例如美股 query_info(),每支股票須時5秒,用multi-thread大幅縮短時間 45 | # 46 | # 2, Exception Handler, 避免錯誤造成整個 parse_stock.py 錯誤,確保即使有錯還是能產生 stock csv 47 | # 48 | # 3, init_vars['XX', 'YY', ...], 初始化 _data[ticker]['XX'],_data[ticker]['YY'], ... 49 | # 由於初次建立 'XX', 'YY' 等, 初始化順序即為 DataFrame _data 轉為 csv 的顯示順序,例如代號、名稱、漲跌、... 50 | # 51 | # 4, 傳入參數 expire_hours 啟動暫存功能,逾期才查詢 52 | # 53 | # Query Decorator 介面: 54 | # QueryHandler_ForLoop( expire_hours=TT, init_vars=['XX','YY'] ) 55 | # 參數 expire_hours 代表啟動用暫存功能 56 | # 參數 init_vars 代表網頁顯示順序,並且init var成float('nan'),確保後續function使用不會錯誤,並且print("%s")不會發生exception 57 | # 58 | # Query函式標準interface: 59 | # query_XX(self) #QueryHandler_NoLoop 60 | # query_OO(self, ticker, tdata, prev_tdata) #QueryHandler_ForLoop, QueryHandler_ThreadLoop 61 | # 查詢成功回傳True,若有設定expire_hours代表到期前不再查詢。失敗回傳False 62 | 63 | #過期才執行,沒過期就從 stock csv 讀回資料 64 | def _data_reuse(self, fun_name, init_vars, expire_hours): 65 | data_name= fun_name.replace('query_','') 66 | var_expire_time= "VAR_%s_ExpireTime"%data_name 67 | prev_data= self._df_prev_data 68 | 69 | #前置檢查,決定是否複製資料 70 | if is_invalid(prev_data): #可能是None,若之前stock csv不存在,或是不包含此ticker 71 | agalog.info(" 重新下載 %s,因為 stock csv 不存在"%(data_name)) 72 | return var_expire_time, data_name 73 | 74 | diff_ticker= set(self._ticker_list).difference(prev_data.columns.to_list()) #這個還要確認 75 | if len(diff_ticker)>0: 76 | agalog.info(" 重新下載 %s,因為 stock csv 股票不完整,缺少 %s"%(data_name, diff_ticker)) 77 | return var_expire_time, data_name 78 | 79 | diff_var= set(init_vars+['通知訊息']).difference(prev_data.index) 80 | if len(diff_var)>0: 81 | agalog.info(" 重新下載 %s,因為 stock csv 欄位不完整,缺少 %s"%(data_name, diff_var)) 82 | return var_expire_time, data_name 83 | 84 | if var_expire_time not in prev_data.index: 85 | agalog.info(" 重新下載 %s,因為 stock csv 欄位不完整,缺少 %s,可能因為上次查詢失敗"%(data_name, var_expire_time)) 86 | return var_expire_time, data_name 87 | 88 | expire_time_str= prev_data[prev_data.columns[0]][var_expire_time] 89 | if is_invalid(expire_time_str): 90 | agalog.info(" 重新下載 %s,因為 stock csv 的 var_expire_time 內容為空"%(data_name)) 91 | return var_expire_time, data_name 92 | 93 | expire_time= datetime.strptime(expire_time_str, "%Y-%m-%d %H:%M:%S") 94 | if datetime.now() >= expire_time: 95 | agalog.info(" 重新下載 %s,因為 %.1f 小時效期已經過期"%(data_name, expire_hours)) 96 | return var_expire_time, data_name 97 | 98 | 99 | #複製資料 100 | agalog.info(" 沿用之前下載的 %s,因為 %.1f 小時效期尚未到期"%(data_name, expire_hours)) 101 | for ticker in self._ticker_list: 102 | for var in init_vars: 103 | self._data[ticker][var]= get_valid_value3(prev_data, ticker, var) 104 | 105 | if is_valid(prev_data[ticker]['通知訊息']): 106 | for line in prev_data[ticker]['通知訊息'].split('
'): 107 | if data_name in line: 108 | self._data[ticker]['通知訊息']+= "%s
"%line 109 | 110 | 111 | #複製之前的query time,注意不能以上for loop複製,因為上次的ticker0可能不同 112 | ticker0= self._ticker_list[0] 113 | self._data[ticker0][var_expire_time]= prev_data[prev_data.columns[0]][var_expire_time] 114 | 115 | return None, None #代表資料複製完成 116 | 117 | 118 | #成功query之後,填入 expire time 119 | def _data_reuse_update_expire(self, var_expire_time, expire_hours): 120 | ticker0= self._ticker_list[0] 121 | expire_time= datetime.now() + timedelta(hours=expire_hours) 122 | self._data[ticker0][var_expire_time]= datetime.strftime(expire_time,'%Y-%m-%d %H:%M:%S') 123 | 124 | 125 | #直接執行 func(), 但加上 init var, exception handler, 過期複製 126 | #執行順序: _query_punish_stock() > QueryHandler_NoLoop.__call__() > wraps() > func() == _query_punish_stock() 127 | class QueryHandler_NoLoop(object): 128 | def __init__(s, expire_hours=None, init_vars=[]): 129 | s.expire_hours= expire_hours 130 | s.init_vars= init_vars 131 | 132 | def __call__(s, func): 133 | def wrap(self, *args,**kwargs): 134 | agalog.info(func.__name__ + " (@QueryHandler_NoLoop)") 135 | if not self._init_done: 136 | raise Exception("執行 %s() 發生錯誤,尚未執行 init()"%func.__name__) 137 | 138 | #若有設定expire_hours並且尚未過期,就從 stock csv 複製資料 139 | data_name= '' 140 | if s.expire_hours is not None: 141 | var_expire_time, data_name= _data_reuse(self, func.__name__, s.init_vars, s.expire_hours) 142 | if var_expire_time is None: #代表資料複製成功 143 | return True 144 | 145 | #執行實際function 146 | try: 147 | for ticker in self._ticker_list: 148 | init_dic_var(self._data[ticker], s.init_vars) 149 | ret= func(self, data_name, *args,**kwargs) 150 | except Exception as e: 151 | self._add_err_msg("執行 %s 發生 exception, %s"%(func.__name__, get_exception_msg(e))) 152 | return False 153 | 154 | #若有設定expire_hours就寫入expire time 155 | if s.expire_hours is not None: 156 | if ret: 157 | _data_reuse_update_expire(self, var_expire_time, s.expire_hours) 158 | else: 159 | self._add_warn_msg(" %s 查詢失敗,下次執行時將重新查詢,不等待 %d 小時效期"%(data_name, s.expire_hours)) 160 | 161 | return ret 162 | 163 | return wrap 164 | 165 | 166 | #使用 single-thread 對所有 ticker 執行函式 167 | #執行順序: __main__ call query_info() > QueryHandler_ForLoop.__call__() > wraps() 168 | # > 對所有 ticker 執行 func() == query_info() 169 | class QueryHandler_ForLoop(object): 170 | def __init__(s, expire_hours=None, init_vars=[]): 171 | s.expire_hours= expire_hours 172 | s.init_vars= init_vars 173 | 174 | def __call__(s, func): 175 | def wrap(self): 176 | agalog.info(func.__name__ + " (@QueryHandler_ForLoop)") 177 | if not self._init_done: 178 | raise Exception("執行 %s() 發生錯誤,尚未執行 init()"%func.__name__) 179 | 180 | #若有設定expire_hours並且尚未過期,就從 stock csv 複製資料 181 | data_name= '' 182 | if s.expire_hours is not None: 183 | var_expire_time, data_name= _data_reuse(self, func.__name__, s.init_vars, s.expire_hours) 184 | if var_expire_time is None: #代表資料複製成功 185 | return True 186 | 187 | #執行實際function 188 | ret= True 189 | for ticker in self._ticker_list: 190 | try: 191 | tdata= self._data[ticker] #已經在__init__初始化為{},不會是None 192 | init_dic_var(tdata, s.init_vars) #初始化self._data[ticker][XXX],亦為網頁顯示順序 193 | prev_tdata= get_valid_value2(self._df_prev_data, ticker) #可能是None,若之前stock csv不存在,或是不包含此ticker 194 | ret= ret and func(self, data_name, ticker, tdata, prev_tdata) 195 | except Exception as e: 196 | self._add_err_msg(" %-5s: 執行 %s 發生 exception, %s"%(ticker, func.__name__, get_exception_msg(e))) 197 | ret= False 198 | 199 | #若有設定expire_hours就寫入expire time 200 | if s.expire_hours is not None: 201 | if ret: 202 | _data_reuse_update_expire(self, var_expire_time, s.expire_hours) 203 | else: 204 | self._add_warn_msg(" %s 部分或全部股票查詢失敗,下次執行時將重新查詢,不等待 %d 小時效期"%(data_name, s.expire_hours)) 205 | 206 | return ret 207 | 208 | return wrap 209 | 210 | 211 | #使用 multi-thread 對所有 ticker 執行函式 212 | #執行順序: __main__ call query_bband() > QueryHandler_ThreadLoop.__call__() > wraps() 213 | # > 對所有 ticker 執行 ===(thread)===> multi_thread_exp_cather > func() == query_bband() 214 | class QueryHandler_ThreadLoop(object): 215 | def __init__(s, expire_hours=None, init_vars=[]): 216 | s.expire_hours= expire_hours 217 | s.init_vars= init_vars 218 | 219 | def __call__(s, func): 220 | #在 function 外面加一層 exception handler 221 | def multi_thread_exp_cather(func, ret, self, data_name, ticker, tdata, prev_tdata): 222 | try: 223 | ret[0]= func(self, data_name, ticker, tdata, prev_tdata) 224 | except Exception as e: 225 | self._add_err_msg(" %-5s: 執行 %s 發生 exception, %s"%(ticker, func.__name__, get_exception_msg(e))) 226 | ret[0]= False 227 | 228 | def wrap(self): 229 | agalog.info(func.__name__ + " (@QueryHandler_ThreadLoop)") 230 | if not self._init_done: 231 | raise Exception("執行 %s() 發生錯誤,尚未執行 init()"%func.__name__) 232 | 233 | #若有設定expire_hours並且尚未過期,就從 stock csv 複製資料 234 | data_name= '' 235 | if s.expire_hours is not None: 236 | var_expire_time, data_name= _data_reuse(self, func.__name__, s.init_vars, s.expire_hours) 237 | if var_expire_time is None: #代表資料複製成功 238 | return True 239 | 240 | #執行實際function 241 | threads= {} 242 | rets= {} 243 | for ticker in self._ticker_list: 244 | tdata= self._data[ticker] #_data已經在__init__初始化為{},不會是None 245 | init_dic_var(tdata, s.init_vars) #_data[ticker][XXX],亦為網頁顯示順序 246 | prev_tdata= get_valid_value2(self._df_prev_data, ticker) #可能是None,若之前stock csv不存在,或是不包含此ticker 247 | rets[ticker]= [None] 248 | threads[ticker]= threading.Thread(target=multi_thread_exp_cather, args=(func, rets[ticker], self, data_name, ticker, tdata, prev_tdata)) 249 | threads[ticker].start() 250 | if not MULTI_THREAD_SUPPORT: 251 | threads[ticker].join() 252 | 253 | #並等待Thread結束並計算回傳值 254 | ret= True 255 | for ticker in self._ticker_list: 256 | threads[ticker].join() 257 | ret= ret and rets[ticker][0] 258 | 259 | #若有設定expire_hours就寫入expire time 260 | if s.expire_hours is not None: 261 | if ret: 262 | _data_reuse_update_expire(self, var_expire_time, s.expire_hours) 263 | else: 264 | self._add_warn_msg(" %s 部分或全部股票查詢失敗,下次執行時將重新查詢,不等待 %d 小時效期"%(data_name, s.expire_hours)) 265 | 266 | return ret 267 | 268 | return wrap 269 | 270 | 271 | #=========== Decorator Thread-Save ============ 272 | #在multi-thread function 中執行的 function 必須 thread-save,自訂function須由 @ThreadSaver 保護 273 | #可槽狀進入 274 | def ThreadSaver(func): 275 | @wraps(func) 276 | def wrap(self, *args, **kwds): 277 | self._thread_mutex.acquire() 278 | ret= func(self, *args, **kwds) 279 | self._thread_mutex.release() 280 | return ret 281 | return wrap 282 | 283 | 284 | #============================================================= 285 | class StockBase: 286 | #============ MEMBER VARIABLE ============ 287 | #csv 路徑,將在 init() 賦值 288 | _PATH_STOCK_CSV= None 289 | _PATHNOTIFY_CSV= None 290 | 291 | #RLock允許同一個thread重複lock,用於設計@ThreadSaver 292 | _thread_mutex= threading.RLock() 293 | 294 | #init() 執行後設為 True 295 | _init_done= False 296 | 297 | #儲存所有股票資料,例如 _data[ticker]['代號']. 298 | _data= {} 299 | 300 | #儲存 init() 讀取的 stock csv 301 | _df_prev_data= None 302 | 303 | #儲存 ger_price() 讀取的歷史股價 304 | _df_price_list= None 305 | 306 | #交給網頁顯示的訊息 307 | _web_msg= '' 308 | 309 | #當query_price(), query_bband(), query_google_trend(), query_info() 等 function 需要通知LINE時,暫存 LINE 通知內容 310 | #由於 _df_notify_list[] 包含之前已送出的 notify csv 加上這次的新通知,使用 notified 欄位區別. push_out_line_notify()在寫入notify csv前會設定notified=True 311 | #欄位 notify_date, msg 目前無作用 312 | _df_notify_list= pd.DataFrame( {'ticker':[], 'name':[], 'expire_date':[], 'notify_date':[], 'reason':[], 'notified':[], 'msg':[]} ) 313 | 314 | 315 | #============ MEMBER FUNCTION - UNILITY ============ 316 | @ThreadSaver 317 | def _add_warn_msg(self, msg:str) -> None: 318 | agalog.warning(msg) 319 | self._web_msg+= "[警告] " + msg.strip() + "
\n" 320 | 321 | 322 | @ThreadSaver 323 | def _add_err_msg(self, msg:str) -> None: 324 | agalog.error(msg) 325 | self._web_msg+= "[錯誤] " + msg.strip() + "
\n" 326 | 327 | 328 | #============ MEMBER FUNCTION ============ 329 | #將 notify msg 加入 _df_notify_list 330 | @ThreadSaver 331 | def _queue_line_notify(self, ticker:str, name:str, reason:str, msg: str, data_name:str, expire_days) -> None: 332 | self._data[ticker]['通知訊息']+= '
%s
'%(data_name,reason) 333 | 334 | #忽略已通知過的訊息 335 | for i in range(len(self._df_notify_list['expire_date'])): 336 | if ticker==self._df_notify_list['ticker'].iloc[i] and reason==self._df_notify_list['reason'].iloc[i]: 337 | agalog.info(" 忽略已通知過的訊息: %s"%msg.replace("\n",".")) 338 | return 339 | 340 | #紀錄這次noitify,之前從notify csv讀出的notified=False,這次新加的notified=True 341 | notify_date= datetime.today() #type: datetime.datetime 342 | expire_date= notify_date + timedelta(days=expire_days) #type: datetime.datetime 343 | _df_new= pd.DataFrame( {"notify_date":[notify_date], "expire_date":[expire_date], "reason":[reason], "ticker":[ticker], "name":[name], 'notified':[False], 'msg':msg} ) 344 | self._df_notify_list= self._df_notify_list.append(_df_new) 345 | 346 | 347 | #等所有資訊蒐集完,把 _queue_line_notify() 累積的 notify msg 加上 cust_msg 並送出 348 | def push_out_line_notify(self) -> None: 349 | df= self._df_notify_list.loc[self._df_notify_list['notified']==False] # notified=False 代表這次新加的 350 | for i in range(len(df)): 351 | ticker= df.iloc[i]['ticker'] 352 | tdata= self._data[ticker] 353 | 354 | #即使部分資料還沒賦值,初始值為'',*100等於把字串成以一百倍,''還是''不會改變 355 | msg_tail= '此時股價%.1f位於布林位置%.1f%%, 布林寬度%.0f%%, 今天漲跌%.1f%%\n'%(tdata['股價'], tdata['布林位置']*100, tdata['布林寬度']*100, tdata['漲跌']*100) 356 | 357 | #WEB_BASE_URL 尾端如果有 '\' 已經在 init() 被移除 358 | if WEB_SERVER_PORT==80: 359 | URL_FULL= WEB_BASE_URL 360 | else: 361 | URL_FULL= "%s:%d"%(WEB_BASE_URL,WEB_SERVER_PORT) 362 | 363 | cust_msg= self._get_cust_notify_msg(ticker) #台股處置股 364 | msg2= "[%s]\n\n%s\n\n%s\n%s%s/%s/%s"%(datetime_str(), df.iloc[i]['msg'], msg_tail, cust_msg, URL_FULL, self.REGION, ticker) 365 | 366 | #通知LINE 367 | if LINEBOT_ID=='' or LINEBOT_TOKEN=='': 368 | agalog.info(" LINE通知(未設定ID不發出): %s"%df.iloc[i]['msg'].replace("\n",".")) 369 | else: 370 | try: 371 | agalog.info(" LINE通知: %s"%df.iloc[i]['msg'].replace("\n",".")) 372 | line_bot_api = LineBotApi(LINEBOT_TOKEN) #事前準備 373 | line_bot_api.push_message(LINEBOT_ID, TextSendMessage(text=msg2)) 374 | except LineBotApiError as e: 375 | self._add_err_msg(" Line發送錯誤, 訊息:%s, %s"%(msg2, get_exception_msg(e))) 376 | 377 | #設為已通知 378 | self._df_notify_list['notified']= self._df_notify_list['notified'].apply(lambda x: True) 379 | 380 | #儲存notify檔案,_df_notify_list的index沒設定,也不需儲存。此時的columns及index為: 381 | # columns => Index(['ticker', 'name', 'expire_date', 'notify_date', 'reason', 'notified', 'msg'] 382 | # index => RangeIndex(start=0, stop=3, step=1) 383 | self._df_notify_list.to_csv(self._PATHNOTIFY_CSV, index=False) 384 | 385 | 386 | #輸出 stock csv 387 | def write_stock_csv(self) -> None: 388 | #給web_root.py看的資訊,放在table首位[0],後面[1:]都不填 389 | ticker0= self._ticker_list[0] 390 | self._data[ticker0]['VAR_WebMsg']= self._web_msg 391 | 392 | #yfinance 回報的股價更新時間 393 | if is_valid(self._df_price_list): #type: pd.core.frame.DataFrame 394 | self._data[ticker0]['VAR_PriceUpdateDate']= self._df_price_list.index[-1] 395 | 396 | #本來使用_data[ticker][XXX] 儲存,這是為了multi-thread當中,各個 thread 只存取自己的_data[ticker] dict,確保 thread-save 397 | #輸出前倒置為 _data[XXX][ticker] 的存取方式,給 web_root.py 顯示出來才是橫資料,縱軸股票 398 | df_data= pd.DataFrame(self._data).T 399 | 400 | #刪除TMP開頭的欄位,不寫數 csv 401 | for col in df_data.columns: 402 | if "TMP_" in col: 403 | df_data= df_data.drop(col, axis = 1) 404 | 405 | #'通知訊息' 移到最後,給 web_root.py 顯示用 406 | if '通知訊息' in df_data: 407 | df_notify= df_data['通知訊息'] 408 | df_data= df_data.drop('通知訊息', axis = 1) 409 | df_data['通知訊息']= df_notify 410 | 411 | #需要存入index,因為讀出來後,要用 df['代號'][ticker] 方式存取 412 | df_data.to_csv(self._PATH_STOCK_CSV, index=True) 413 | 414 | 415 | def init(self) -> None: 416 | #檔案路徑 417 | self._PATH_STOCK_CSV= PATH_STOCK_CSV_REGION%self.REGION #stock csv 418 | self._PATHNOTIFY_CSV= PATH_NOTIFY_CSV_REGION%self.REGION #Line通知紀錄 419 | 420 | #印出設定檔 421 | agalog.info( "-----------------------------------------------------------") 422 | agalog.info(self._TITLE) 423 | agalog.info("DIR_BASE: %s"%DIR_BASE) 424 | agalog.info("PATH_STOCK_CSV: %s"%self._PATH_STOCK_CSV) 425 | agalog.info("PATHNOTIFY_CSV: %s"%self._PATHNOTIFY_CSV) 426 | 427 | #建立目錄 428 | os.makedirs( os.path.dirname(PATH_IMG_REGION), exist_ok=True) 429 | os.makedirs( os.path.dirname(self._PATH_STOCK_CSV), exist_ok=True) 430 | os.makedirs( os.path.dirname(self._PATHNOTIFY_CSV), exist_ok=True) 431 | 432 | #讀取notify csv 433 | if os.path.isfile(self._PATHNOTIFY_CSV): 434 | # 1 parse_dates 會把 notify_date, expire_date 讀取成 435 | # 2 必須設定dtype,不然台股ticker 0050會讀成50 436 | # 3 push_out_line_notify() 存入時沒設定index,讀出來也不需要。讀出的columns及index為: 437 | # columns => Index(['ticker', 'name', 'expire_date', 'notify_date', 'reason', 'notified', 'msg'] 438 | # index => RangeIndex(start=0, stop=3, step=1) 439 | df_notify_list= pd.read_csv( self._PATHNOTIFY_CSV, parse_dates= ['expire_date', 'notify_date'], 440 | dtype={'reason':str, 'ticker':str, 'name':str} ) 441 | if df_notify_list.columns.tolist()==self._df_notify_list.columns.tolist(): 442 | #理應都是 notified=True,但若真有False會造成 push_out_line_notify() 誤認新訊息,因此再寫一次True 443 | df_notify_list['notified']= df_notify_list['notified'].apply(lambda x: True) 444 | self._df_notify_list= df_notify_list 445 | else: 446 | self._add_err_msg("%s 欄位不正確,放棄讀取,Column:%s"%(os.path.basename(self._PATHNOTIFY_CSV),df_notify_list.columns.tolist())) 447 | 448 | #移除過期的notify 449 | for i in reversed(range(len(self._df_notify_list['expire_date']))): #必須要倒著算,如果從0往上算,刪除列會影響後面index導致錯誤 450 | if self._df_notify_list['expire_date'].iloc[i] <= datetime.today(): 451 | agalog.info(" 刪除過期LINE通知: %s %s => %s expire on %s"%(self._df_notify_list.iloc[i]['ticker'],self._df_notify_list.iloc[i]['name'],self._df_notify_list.iloc[i]['reason'],self._df_notify_list.iloc[i]['expire_date']) ) 452 | self._df_notify_list= self._df_notify_list.drop( self._df_notify_list.index[[i]] ) 453 | 454 | #讀取上次寫入的 stock csv 455 | df_prev_data, dummy, dummy= read_stock_summary_csv(self.REGION) 456 | 457 | if is_valid2(df_prev_data, '代號'): 458 | self._df_prev_data= df_prev_data.T 459 | agalog.info("stock csv 讀取成功") 460 | else: 461 | agalog.info("stock csv 讀取失敗") 462 | 463 | #確保ticker_list股票都是唯一的,刪除重複股票 464 | self._ticker_list= list(set(self._ticker_list)) 465 | 466 | #刪除空字串股票,空字串會造成yf.download()報錯 467 | if '' in self._ticker_list: 468 | self._ticker_list.remove('') 469 | 470 | #刪除空字串和重複股票後,若股票是空的就報錯 471 | if len(self._ticker_list)==0: 472 | raise Exception("_ticker_list 內容為空") 473 | 474 | for ticker in self._ticker_list: 475 | self._data[ticker]= {} #確保_data[ticker]都有資料,後續操作即不用再判斷 476 | self._data[ticker]['通知訊息']= '' #確保_data[ticker]['通知訊息']都有資料,才能直接用 += 加新訊息 477 | 478 | #解決中文顯示問題 479 | plt.rcParams['font.sans-serif'] = ['Microsoft JhengHei'] 480 | plt.rcParams['axes.unicode_minus'] = False 481 | 482 | self._init_done= True 483 | 484 | 485 | #============ MEMBER FUNCTION - QUERY HANDLER ============ 486 | 487 | #寫入股價資料: 股價,漲跌,成交金額,成交量 488 | #歷史股價寫到 self._df_price_list[]. 若是盤中,最新一筆為即時資料 489 | @QueryHandler_NoLoop( init_vars=['股價', '漲跌', '成交金額', '成交量'] ) #init_vars[] 為網頁顯示順序. 不能設定expire_hours, 因為此函式要寫入df_price_list[]給BBand使用 490 | def _query_price_yfinance(self, data_name, tickets_yf_str:str) -> bool: 491 | #======== 下載 yfinance 歷史股價 ======== 492 | #注意: yfinance 常下載到無資料的日期,若一支支股票下載日期會直接消失,因此需要整批股票一起下載,部分股票的無資料日才能顯示斷層 493 | start_date= datetime.now() - timedelta(days=STOCK_QUERY_DAYS) 494 | df_price_list= yf.download(tickers= tickets_yf_str, start=start_date, group_by='ticker', auto_adjust=False, progress=False) 495 | if is_invalid(df_price_list): #若股票都不存在, 會回傳 len(df_price_list)==0 的 dataframe, 但column都還是存在 496 | self._add_err_msg(" 從 Yahoo Finance 讀不到任何股價") 497 | return False 498 | 499 | #yfinance 台股使用 2412.TW/6741.TWO 這種 ticker,但 _ticker_list 沒有 .TW/.TWO,為了後續處理方便,移除df_price_list的.TW/.TWO 500 | #此迴圈會讀到('AAA','Open'),('AAA','Close'),..., ('MSFT','Open') 等, col[0]重複相同ticker好幾次,但重複rename看來沒問題 501 | for col in df_price_list.columns: 502 | if '.TWO' in col[0]: 503 | #agalog.debug(" %s remove .TWO"%(col[0])) 504 | df_price_list.rename(columns={col[0]:col[0].replace('.TWO','')}, inplace=True) 505 | elif '.TW' in col[0]: 506 | #agalog.debug(" %s remove .TW"%(col[0])) 507 | df_price_list.rename(columns={col[0]:col[0].replace('.TW','')}, inplace=True) 508 | 509 | #當天資料都是NaN可能是台股9:00-9:20尚無當天資料,刪除最新一天 510 | if YFINANCE_FIX_TW_TIME_SHIFT: 511 | for col in df_price_list.columns: 512 | if is_valid(df_price_list[col][-1]): 513 | break 514 | else: 515 | self._add_warn_msg(" Yahoo Finance 讀回的股價最新一天 %s 都是 Nan,可能是台股延遲20分鐘,早上9:00-9:20尚無當天資料"%(df_price_list.index[-1])) 516 | df_price_list= df_price_list.drop( df_price_list.index[-1] ) 517 | 518 | 519 | #======== 處理每支股票 ======== 520 | for ticker in self._ticker_list: 521 | #確保網頁顯示都是新圖,如果錯誤中斷舊圖也已不存在 522 | img_ma= PATH_IMG_REGION%(self.REGION, ticker, "ma") 523 | silence_remove(img_ma) 524 | 525 | #若代號錯誤,yfinance 還是會包含ticker,但是內容全為NaN. dropna()會清空資料回傳len()==0的Dataframe 526 | if (ticker in df_price_list) and len(df_price_list[ticker].dropna())==0: 527 | #每次 drop 都是 Nan 的股票時,會印出 PerformanceWarning: dropping on a non-lexsorted multi-index without a level parameter may impact performance. 528 | #執行 remove_unused_levels() 可造成只印一次,不確定如何完全消除 529 | df_price_list.columns = df_price_list.columns.remove_unused_levels() 530 | df_price_list.drop(ticker, axis=1, inplace=True) 531 | 532 | if (ticker not in df_price_list): 533 | self._add_err_msg(" %-5s: 從 Yahooo Finance 下載股價失敗"%ticker) 534 | continue 535 | 536 | try: 537 | tdata= self._data[ticker] 538 | df_price= df_price_list[ticker] 539 | 540 | if not df_check_columns(df_price, ['Open','High','Low','Close','Adj Close','Volume']): 541 | self._add_err_msg(" %-5s: Yahoo Finance 讀回的股票欄位不正確,讀到 %s"%df_price.columns.to_list()) 542 | continue 543 | 544 | #當yfinance某些日期股價變成NaN,這日期之前數天的volume剛好乘以一百倍,此處還原正確volume,但最多連續還原20天,避免還原到真的爆量的天數 545 | if YFINANCE_FIX_HIGH_VOLUME: 546 | volume_check= df_price['Volume'].median() * 30 #比中位數大30倍應該是觸發此問題 547 | rollback_days= 0 548 | #agalog.debug(" %-5s: volume-median=%d, volume_check=%d"%(ticker, df_price['Volume'].median(), volume_check)) 549 | for i in reversed(range(len(df_price['Volume']))): 550 | if np.isnan(df_price['Volume'].iloc[i]): 551 | #agalog.debug(" %-5s: (rollback_days=%02d) [%d] %s is NaA ====> set rollback_days to 30"%(ticker, rollback_days, i, df_price_list.index[i])) 552 | rollback_days= 20 #觀察發現Volume問題大概7-14天,設定20天為修正上限 553 | elif rollback_days>0: 554 | if df_price['Volume'].iloc[i] > volume_check: 555 | #agalog.debug(" %-5s: (rollback_days=%d) [%d] %s = %.2f > volume_check %.2f ====> devide by 100"%(ticker, rollback_days, i, df_price_list.index[i], df_price['Volume'].iloc[i], volume_check)) 556 | df_price_list.loc[:, (ticker,'Volume')].iloc[i]/= 100 557 | self._add_warn_msg(" %-5s: Yahoo Finance 股價 NaN 並且 Volume 爆量,可能觸發 bug,啟動修復機制"%(ticker)) 558 | rollback_days-= 1 559 | if rollback_days==0: 560 | self._add_err_msg(" %-5s: Yahoo Finance 的 Volumne 從 Nan 回修 20 天還是有爆量 Volume, 可能尚未修完或有其他錯誤"%(ticker)) 561 | else: 562 | #agalog.debug(" %-5s: (rollback_days=%d) [%d] %s = %.2f <= volume_check %.2f ====> set rollback_days to 0"%(ticker, rollback_days, i, df_price_list.index[i], df_price['Volume'].iloc[i], volume_check)) 563 | rollback_days= 0 564 | 565 | tdata= self._data[ticker] 566 | tdata['股價']= df_price['Close'][-1] 567 | tdata['漲跌']= (df_price['Close'][-1] / df_price['Close'][-2] ) - 1 568 | tdata['成交量']= df_price['Volume'][-1] 569 | tdata['成交金額']= tdata['成交量'] * tdata['股價'] #今日成交金額, 使用成交價 x 成交量估算 570 | agalog.debug(" %-5s: 股價%.2f, 漲跌%.0f%%, 成交量%.0f, 成交金額%.0f"%(ticker, tdata['股價'], tdata['漲跌']*100, tdata['成交量'], tdata['成交金額'])) 571 | 572 | #======== 繪製紅綠燭型圖 ======== 573 | now = datetime.now() 574 | weekday= now.weekday() #0-6 代表週一到週日 575 | if weekday in range(0,2): 576 | dend = now + timedelta(days=7-weekday) #若今天是周一到周三,就畫圖到下周一,右邊才有空間繪製股價數值 577 | else: #周日為7 578 | dend = now + timedelta(days=7-weekday+7) #若今天是週四到周日,就畫圖到第二個周一,右邊才有空間繪製股價數值 579 | dstart = dend + timedelta(days=-DIAGRAM_DAYS) 580 | 581 | df_ma= df_price.copy() #先複製再修改,才不會影響後續繪圖 582 | df_ma.index = pd.DatetimeIndex(df_ma.index) 583 | mc = mpf.make_marketcolors(up='r', down='g', edge='', wick='inherit', volume='inherit') #上漲紅色,下跌綠色 584 | s = mpf.make_mpf_style(base_mpf_style='yahoo', marketcolors=mc) 585 | title= "%s %s MA5/MA10/MA20/MA60 均線圖"%(ticker, tdata['名稱']) 586 | fig,ax= mpf.plot(df_ma, type='candle', style=s, mav=(5, 10, 20, 60), title=title, ylabel_lower='成交量', ylabel='股價', 587 | volume=True, figsize=(14,7.5), returnfig=True, columns=['Open', 'High', 'Low', 'Close', 'Volume'], 588 | xlim=(dstart, dend), #不能用獨立的\plt.xlim(left=dstart, right=dend),無法顯示線,原因不明 589 | #savefig=dict(fname=img_ma,bbox_inches='tight') 590 | ) 591 | fig.savefig(fname=img_ma,bbox_inches='tight') 592 | plt.close(fig) 593 | 594 | except (TypeError, KeyError) as e: #_df_prev_data==None觸發TypeError, _df_prev_data["本益比"]不存在觸發KeyError 595 | self._add_err_msg(" %s 處理歷史股價發生 exception, %s"%(ticker, get_exception_msg(e))) 596 | 597 | self._df_price_list= df_price_list 598 | return True 599 | 600 | 601 | 602 | #寫入財報資料: 布林位置,布林寬度,(通知訊息) 603 | #前提條件: query_price() 已執行過,並將歷史股價寫入 _df_price_list[ticker] 604 | #注意: yfinance 寫入 _df_price_list[ticker] 的股價有些是NaN,導致upper, middle, lower 等回傳值都是 NaN 605 | @QueryHandler_ThreadLoop( init_vars=['布林位置','布林寬度'] ) #init_vars[] 為網頁顯示順序 606 | def query_bband(self, data_name, ticker, tdata, prev_tdata) -> bool: 607 | #確保網頁顯示都是新圖,如果錯誤中斷舊圖也已不存在 608 | img_bb= PATH_IMG_REGION%(self.REGION, ticker, "bb") 609 | silence_remove(img_bb) 610 | 611 | if is_invalid(self._df_price_list) or (ticker not in self._df_price_list): 612 | self._add_err_msg(" %-5s: 忽略 BBand 計算,因為沒有股價資料"%ticker) 613 | return False 614 | 615 | df_price= self._df_price_list[ticker] 616 | upper, middle, lower= talib.BBANDS( df_price['Close'], timeperiod=BBAND_DAYS, nbdevup=2, nbdevdn=2, matype=0) 617 | close_today= df_price['Close'][-1] 618 | lower_today= last(lower) 619 | upper_today= last(upper) 620 | middle_today= last(middle) 621 | bb_width= (upper_today/middle_today - 1)*2 #因為lower有可能是負數,不能直接用upper/lower 622 | 623 | if bb_width >= BBAND_WIDTH_DISPLAY_MIN: 624 | tdata['布林位置']= (close_today - lower_today) / (upper_today - lower_today) 625 | agalog.debug(" %-5s: bb_width=%.2f, upper_today=%.2f, middle_today=%.2f, lower_today=%.2f"%(ticker, bb_width, upper_today, middle_today, lower_today)) 626 | 627 | #LINE通知 628 | if tdata['布林位置'] <= NOTIFY_BBAND_LOWER: 629 | notify_lower_price= NOTIFY_BBAND_LOWER * (upper_today - lower_today) + lower_today 630 | reason= "股價低於布林通道下緣" #注意: NOTIFY_BBAND_LOWER可能不是剛好下緣,例如設為0.1 (10%) 即為下緣上方,但文字統一寫下緣 631 | msg= "%s: %s %s股價%.1f低於布林通道下緣%.1f"%(self.REGION_TEXT, ticker, tdata['名稱'], close_today, notify_lower_price) 632 | self._queue_line_notify(ticker, tdata['名稱'], reason, msg, data_name, expire_days=6) #若在布林通道下緣來回震盪,6 天內不會重複通知 633 | 634 | tdata['布林寬度']= bb_width 635 | now = datetime.now() 636 | weekday= now.weekday() #0-6 代表週一到週日 637 | if weekday in range(0,2): 638 | dend = now + timedelta(days=7-weekday) #若今天是周一到周三,就畫圖到下周一,這樣才有空間繪製股價數值 639 | else: #周日為7 640 | dend = now + timedelta(days=7-weekday+7) #若今天是週四到周日,就畫圖到第二個周一,這樣才有空間繪製股價數值 641 | dstart = dend + timedelta(days=-DIAGRAM_DAYS) 642 | 643 | #======== 布林通道圖 ======== 644 | self._thread_mutex.acquire() #matplotlib不是thread-save,需要被保護 645 | df_bb= pd.DataFrame(); 646 | df_bb['收盤價']= df_price['Close'] 647 | df_bb['上緣']= upper 648 | df_bb['下緣']= lower 649 | df_bb['中線MA20']= middle 650 | df_bb.index = pd.DatetimeIndex(df_price.index) #要有日期作為index, 橫軸裁示時間 651 | ax = df_bb.plot.line( figsize=(12,4), title="%s %s 布林通道圖"%(ticker,tdata['名稱']), color={'收盤價':COLOR_ORANGE, '上緣':COLOR_RED, '下緣':COLOR_GREEN, '中線MA20':COLOR_GRAY}) 652 | 653 | #樣式 654 | ax.yaxis.tick_right() 655 | ax.yaxis.set_label_position("right") 656 | ax.xaxis.set_major_locator( mdates.WeekdayLocator(byweekday=(MO)) ) #mdates.DayLocator(7)會造成刻度顯示不在最新一天,但WeekdayLocator()會在最新天 657 | ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d')) 658 | plt.xlim(left=dstart, right=dend) #X軸時間區間 659 | ax.xaxis.set_minor_locator( plt.NullLocator() ) #Google Trend圖需要此行才能隱藏次刻度,但布林通道不用,原因不明 660 | 661 | ax.xaxis.grid(True, which='major') 662 | plt.xticks(rotation=40) #X軸刻度斜著顯示 663 | for tick in ax.get_xticklabels(): #X軸刻度靠右對齊 664 | tick.set_horizontalalignment('right') #Google Trend若沒此行,會變成置中對齊 665 | plt.xlabel('') 666 | plt.ylabel('即時股價') 667 | 668 | df= df_price.loc[ df_price['Close'].apply(lambda x: not np.isnan(x)) ] #yfinance有bug會些部分日期的股價為NaN,需去除NaN留下正確數字 669 | price= df['Close'][-1] 670 | date_str= date_to_md(df.index[-1]) 671 | x_text= df_price.index[-1] + timedelta(days=1) 672 | plt.text(x_text, price, '%.1f\n( %s )'%(price, date_str), ha='left', va= 'center',fontsize=12, color=COLOR_ORANGE ) 673 | plt.legend(loc="best", bbox_to_anchor=(1.2, 1.03)) #Legend放在圖右側,避免覆蓋數值文字 674 | fig = ax.get_figure() 675 | fig.savefig(fname=img_bb, bbox_inches='tight') 676 | plt.close(fig) 677 | self._thread_mutex.release() 678 | return True 679 | 680 | 681 | 682 | #前提: get_stock_data() 寫好_data: TMP_GTName 683 | #寫入資料: G-Trend,G-Trend漲跌,HD_GT_URL 684 | @QueryHandler_ThreadLoop( expire_hours=GTREAD_EXPIRE_HOURS, init_vars=['G-Trend','G-Trend漲跌', 'HD_GT_URL'] ) #init_vars[] 為網頁顯示順序 685 | def query_google_trend(self, data_name, ticker, tdata, prev_tdata) -> bool: 686 | #ETF不查詢 Google Trend,未設定 TMP_GTName 也不查詢 687 | if is_invalid(tdata['TMP_GTName']): 688 | agalog.debug(" %-5s: 未設定 Google Trend 字串,不查詢 Google Trend"%(ticker)) 689 | return True 690 | elif tdata['產業']=='ETF': 691 | agalog.debug(" %-5s: ETF 不查詢 Google Trend"%(ticker)) 692 | return True 693 | 694 | 695 | #=============== 變數準備 =============== 696 | gt_name= tdata["TMP_GTName"] 697 | name= tdata["名稱"] 698 | tdata['HD_GT_URL']= "https://trends.google.com.tw/trends/explore?date=today%203-m&geo=" + self.REGION.upper() +"&q=" + gt_name.replace(" ","%20") 699 | msg_gt= "\n\n" + tdata['HD_GT_URL'] 700 | img_gtrend= PATH_IMG_REGION%(self.REGION, ticker, "gtrend") 701 | 702 | #確保網頁顯示都是新圖,如果錯誤中斷舊圖也已不存在 703 | silence_remove(img_gtrend) 704 | 705 | #========== 查詢 Google Trend ========== 706 | # 若使用'today 12-m',不滿一週的部分partial=False,如果從上個完整周計算的話,資料可能延後1-6天,所以改成'today 1-m' or 'today 3-m'手動計算MA7移動平均線 707 | # 若使用'now 7-d', 雖然可以去除近三天資料,但只有七天不足以判斷 708 | # 最後使用'toady 3-m', 有每日資料,缺點是近三天的資料沒有, 例如今天是2021/7/14, 最新資料只到2021/7/11 709 | try: 710 | pytrend= TrendReq(hl='zh-TW', tz=360) #hl為關鍵字語系, 但設定'zh-TW' or 'en-US'得到相同結果. tz為時區,但設為360 or 180也得到相同結果 711 | pytrend.build_payload(kw_list=[gt_name], cat=0, timeframe='today 3-m', geo=self.REGION.upper(), gprop='') #一次最多查詢五個字串,此處只查一個 712 | df_gtrend= pytrend.interest_over_time() 713 | 714 | #刪除不完整資料, 應該都是最後一筆 715 | for i in reversed(range(df_gtrend[gt_name].size)): 716 | if df_gtrend["isPartial"][i]: 717 | df_gtrend= df_gtrend.drop( df_gtrend.index[[i]] ) 718 | 719 | except ResponseError as e: 720 | self._add_err_msg(" %-5s: Google Trend 搜尋失敗,%s"%(ticker, get_exception_msg(e))) 721 | return False 722 | 723 | if len(df_gtrend) < 10: 724 | self._add_warn_msg(" %-5s: Google Trend 下載資料過少,只有 %d 筆"%(ticker, len(df_gtrend))) 725 | return False 726 | 727 | #計算七日移動平均 728 | #注意:Google Trend 週末搜尋量劇減,用單日比較若比到週末不公平,八日九日都會計算到週末,七日或七日倍數最適合 729 | df_gtrend['MA7'] =talib.MA(df_gtrend[gt_name], timeperiod=7, matype=0) 730 | tdata["G-Trend"]= df_gtrend['MA7'][-1] 731 | 732 | #============== 漲跌計算及LINE通知 ============== 733 | #Google Trend MA7 太小就不計算漲跌 734 | if (df_gtrend['MA7'][-1]=NOTIFY_GT_MA7_FALL_FROM: 746 | reason= "Google Trend MA7 降低" 747 | msg= "%s: %s %s 的 Google Trend MA7從上週 %.0f 降低至本週 %.0f (%.1f%%)"%(self.REGION_TEXT, ticker, name, df_gtrend['MA7'][-8], df_gtrend['MA7'][-1], gt_ma7_rate*100 ) 748 | self._queue_line_notify(ticker, tdata['名稱'], reason, (msg+msg_gt), data_name, expire_days=1) 749 | 750 | if gt_ma7_rate >= NOTIFY_GT_MA7_RISE_RATE and df_gtrend['MA7'][-1]>=NOTIFY_GT_MA7_RISE_TO: 751 | reason= "Google Trend MA7 拉高" 752 | msg= "%s: %s %s 的 Google Trend MA7從上週 %.0f 拉高至本週 %.0f (%.1f%%)"%(self.REGION_TEXT, ticker, name, df_gtrend['MA7'][-8], df_gtrend['MA7'][-1], gt_ma7_rate*100 ) 753 | self._queue_line_notify(ticker, tdata['名稱'], reason, (msg+msg_gt), data_name, expire_days=1) 754 | 755 | #trend MA1 up 756 | gt_min_prev= min(df_gtrend[gt_name][-2], df_gtrend[gt_name][-3], df_gtrend[gt_name][-4]) 757 | if gt_min_prev==0: 758 | gt_min_prev= 1 759 | gt_raise_rate= float( df_gtrend[gt_name][-1] / gt_min_prev ) - 1 760 | 761 | #trend MA1 up - LINE通知 762 | if gt_raise_rate >= NOTIFY_GT_RISE_RATE and df_gtrend[gt_name][-1]>df_gtrend[gt_name][:len(df_gtrend[gt_name])-4].max()*2: 763 | reason= "Google Trend 三日內急升" 764 | msg= "%s: %s %s 的 Google Trend 三日內從 %.0f 急升至%.0f (%.1f%%),並超過三個月最大值%.0f一倍以上"%(self.REGION_TEXT, ticker, name, gt_min_prev, df_gtrend[gt_name][-1], gt_raise_rate*100, df_gtrend[gt_name][:len(df_gtrend[gt_name])-4].max() ) 765 | self._queue_line_notify(ticker, tdata['名稱'], reason, (msg+msg_gt), data_name, expire_days=1) 766 | 767 | #trend MA1 down 768 | gt_max_prev= max(df_gtrend[gt_name][-2], df_gtrend[gt_name][-3], df_gtrend[gt_name][-4]) 769 | if gt_max_prev==0: 770 | gt_max_prev= 1 771 | gt_down_rate= float( df_gtrend[gt_name][-1] / gt_max_prev ) - 1 772 | agalog.debug(" %-5s: Google Trend ma7: %.0f -> %.0f (%.1f%%), up: %.0f -> %.0f (%.1f%%), down: %.0f -> %.0f (%.1f%%)"%(ticker, 773 | df_gtrend['MA7'][-8],df_gtrend['MA7'][-1], tdata["G-Trend漲跌"]*100, 774 | gt_min_prev,df_gtrend[gt_name][-1], gt_raise_rate*100, 775 | gt_max_prev,df_gtrend[gt_name][-1], gt_down_rate*100)) 776 | 777 | #trend MA1 down - LINE通知 778 | if gt_down_rate <= NOTIFY_GT_FALL_RATE and df_gtrend[gt_name][-1] y_text_ma7: 815 | y_text_trend+= ( 7- diff ) / 2 816 | y_text_ma7-= ( 7 - diff ) / 2 817 | else: 818 | y_text_trend-= ( 7 - diff ) / 2 819 | y_text_ma7+= ( 7 - diff ) / 2 820 | 821 | plt.legend(loc="best", bbox_to_anchor=(1.17, 1.01)) #Legend放在圖右側,避免覆蓋數值文字 822 | x_text = last(df_gtrend.index) + timedelta(days=1) 823 | plt.text( x_text, y_text_trend, '%.0f'%df_gtrend[gt_name][-1], ha='left', va= 'center',fontsize=12, color=COLOR_GRAY ) 824 | plt.text( x_text, y_text_ma7, '%.0f ( %s )'%(df_gtrend['MA7'][-1],date_to_md(last(df_gtrend.index))), ha='left', va= 'center',fontsize=12, color=COLOR_ORANGE ) 825 | fig = ax.get_figure() 826 | fig.savefig(fname=img_gtrend, bbox_inches='tight') 827 | plt.close(fig) 828 | self._thread_mutex.release() 829 | return True 830 | 831 | --------------------------------------------------------------------------------