[Python] Regularly send a day's stock analysis report by email

[Python] Regularly send a day's stock analysis report by email

Suppose my current needs are:

Suppose I have realized the time-sharing/daily/weekly/monthly K-line of a certain stock and the drawing of corresponding indicators (RSI, BOLL, OBV, MACD).

insert image description here

If I want the system to send me analysis reports on several self-selected stocks every day after the stock market closes, I can do it in the following way:

  • 1) Let the system calculate and draw the time-sharing/daily/weekly/monthly K-lines and corresponding indicators of several stocks today;

  • 2) The drawn picture is automatically uploaded to Alibaba Cloud OSS, and a link about the uploaded picture is returned url;

  • 3) According to the given htmltemplate, use Jinja2the toolkit to dynamically render today's stock drawing results htmlinto

  • 4) The system sends the generated htmlfile to the designated recipient in the form of email;

It is divided into 4 modules, and the email sending, html template rendering, image bed building, and timing tasks are introduced in turn. The complete code to realize this requirement is not given here.

1. Python sends email

References:

1) EmailSenderEncapsulation

The code of the packaged mail sending class EmailSenderis as follows: including text content, sending attachments with txt, pdf, pictures and .html

# 参考 https://blog.csdn.net/weixin_55154866/article/details/128098092
# 参考 https://www.runoob.com/python/python-email.html
# 参考 https://blog.csdn.net/YP_FlowerSky/article/details/124451913
import smtplib
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
import pdfplumber
from pathlib import Path

class EmailSender:
    def __init__(self,mail_host,mail_user,mail_pass,sender):
        '''
        @param mail_host 设置登录及服务器信息,比如网易邮箱是smtp.163.com; type=str
        @param mail_user 邮箱用户名; type=str
        @param mail_pass 邮箱授权码; type=str
        @param sender 邮件发送方邮箱地址; type=str
        '''
        self.mail_host = mail_host
        self.mail_user = mail_user
        self.mail_pass = mail_pass
        self.sender = sender

    '''初始化一封邮件'''
    def init_email(self,receivers,content,subject):
        '''
        @param receivers 接收方邮箱集合,适合群发; type=list
        @param content 文本内容;type=str
        @param subject 主题; type=str
        '''
        # 参考 https://blog.csdn.net/YP_FlowerSky/article/details/124451913
        self.receivers = receivers
        # 添加一个MIMEmultipart类,处理正文及附件
        self.message = MIMEMultipart()    #MIMEMultipart可以允许带附件
        self.message['From'] = self.sender  #发送方邮箱地址
        self.message['To'] = ','.join(receivers)   #接收方邮箱地址, 将['[email protected]','[email protected]']处理成'[email protected],[email protected]'的str
        self.message['Subject'] = subject
        self.message.attach(MIMEText(content,'plain', 'utf-8')) # 文本内容 (plain文本格式,utf-8编码)

    '''为邮件添加附件'''
    def email_wrapper(self,filePath,fileType="text"):
        '''
        @param filePath 文件路径; type=str
        @param fileType 文件类型,可选 ['text','html','image'];  type=str
        '''
        if(fileType == 'text'):
            suffix = filePath.split(".")[-1]
            # 添加一个pdf文本附件, 用MIMEApplication封装 参考 https://blog.csdn.net/popboy29/article/details/126396549
            if (suffix == 'pdf'):
                with open(filePath, "rb") as f:
                    pdf_attach = MIMEApplication(f.read(), _subtype="pdf")
                    #如果出现邮件发送成功,但邮箱接收到的附件变为bin格式的情况时,检查add_header是否出错 参考https://blog.csdn.net/hxchuadian/article/details/125773738
                    pdf_attach.add_header('Content-Disposition', 'attachment', filename=str(Path(filePath).name))
                    self.message.attach(pdf_attach)
            else:
                #添加一个txt文本附件,用MIMEText封装
                with open(filePath,'r')as h:
                    content2 = h.read()
                #设置txt参数
                text_attach = MIMEText(content2,'plain','utf-8')
                #附件设置内容类型,方便起见,设置为二进制流
                text_attach['Content-Type'] = 'application/octet-stream'
                #设置附件头,添加文件名
                text_attach['Content-Disposition'] = f'attachment;filename="{
      
      filePath}"'
                self.message.attach(text_attach)

        if (fileType == 'html'):
            # 推荐使用html格式的正文内容,这样比较灵活,可以附加图片地址,调整格式等
            with open(filePath,'r') as f:
                # 设置html格式参数
                html_attach = MIMEText(f.read(), 'base64', 'gb2312')  # 将html文件以附件的形式发送
                html_attach['Content-Type'] = 'application/octet-stream'
                html_attach.add_header('Content-Disposition', 'attachment',filename=str(Path(filePath).name))  # filename是指下载的附件的命名
                self.message.attach(html_attach)

        if (fileType == 'image'):
            # 添加照片附件,用MIMEImage封装
            with open(filePath, 'rb') as fp:
                picture_attach = MIMEImage(fp.read())
                # 与txt文件设置相似
                picture_attach['Content-Type'] = 'application/octet-stream'
                picture_attach['Content-Disposition'] = f'attachment;filename="{
      
      filePath}"'
            # 将内容附加到邮件主体中
            self.message.attach(picture_attach)

    #登录并发送
    def sendEmail(self):
        try:
            smtpObj = smtplib.SMTP()
            smtpObj.connect(self.mail_host, 25)
            smtpObj.login(self.mail_user, self.mail_pass)
            smtpObj.sendmail(
                self.sender, self.receivers, self.message.as_string())  #receivers群发, receivers是一个列表['[email protected]','[email protected]']
            print('success')
            smtpObj.quit()
        except smtplib.SMTPException as e:
            print('error', e)

2) Possible problems

reference

2. Jinja2 dynamically renders html pages

Refer to Python's jinja2 template engine to generate HTML

htmlThe template is as follows:

<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<html align='left'>
  <body>
    <h1>{
   
   {today}}股票分析报告</h1>
    <table>
      {% for stock in stocks %}
      <tr align='center'>
          <td>{
   
   { stock.code }}</td>
          <td>{
   
   { stock.codeName }}</td>
          <td><a href="{
   
   {stock.minute_kline_path}}">分时K线图</a> </td>
          <td><a href="{
   
   {stock.daily_kline_path}}">日K线图</a> </td>
          <td><a href="{
   
   {stock.week_kline_path}}">周K线图</a> </td>
          <td><a href="{
   
   {stock.month_kline_path}}">月K线图</a> </td>
      </tr>
      {% endfor%}
    </table>
  </body>
</html>

pythoncode show as below:

import datetime

from jinja2 import Environment, FileSystemLoader
import datetime

import sys
import os
from pathlib import Path
curPath = os.path.abspath(os.path.dirname(__file__))
rootPath = os.path.split(curPath)[0]
sys.path.append(rootPath)

imgDir = os.path.join(rootPath,"html_task/temp/")

def generate_html(today, stocks):
    env = Environment(loader=FileSystemLoader('./'))
    template = env.get_template('template.html')
    with open("result.html", 'w+') as fout:
        html_content = template.render(today=today,
                                       stocks=stocks)
        fout.write(html_content)


if __name__ == "__main__":
    today = datetime.datetime.now().strftime("%Y-%m-%d")
    stocks = []
    stock1_path = os.path.join(imgDir,"sh601728")
    stock2_path = os.path.join(imgDir,"sz000722")

    stock1 = {
    
    'code': 'sh601728', 'codeName': '中国电信',
              'minute_kline_path': stock1_path + "/" + "minute_K_line.png",
              'daily_kline_path': stock1_path + "/" + "daily_K_line.png",
              'week_kline_path': stock1_path + "/" + "week_K_line.png",
              "month_kline_path" : stock1_path + "/" + "month_K_line.png", }
    stock2 = {
    
    'code': 'sz000722', 'codeName': '湖南发展',
              'minute_kline_path': stock2_path + "/" + "minute_K_line.png",
              'daily_kline_path': stock2_path + "/" + "daily_K_line.png",
              'week_kline_path': stock2_path + "/" + "week_K_line.png",
              "month_kline_path" : stock2_path + "/" + "month_K_line.png", }
    stocks.append(stock1)
    stocks.append(stock2)
    generate_html(today, stocks)  #图片无法正常显示,会报错:Not allowed to load local resource
    # 图片无法正常显示 解决方法参考 http://www.kuazhi.com/post/319149.html

The resulting htmlvisualization looks like this:

insert image description here

But there is a problem - clicking the link can not download or access the picture normally , the main reason is: the browser prohibits the webpage from accessing the local file for security reasons, because the picture is stored in the project directory, so it cannot be accessed through the local url access. (Refer to the browser error: Not allowed to load local resource reasons and solutions_ Sanshui's blog that can't unscrew the bottle cap-CSDN blog )

So here we plan to use the picture link returned by the picture bed urlto solve Not allowed to load local resourcethe problem.

3. Alibaba Cloud OSS builds a map bed

reference

1) Python uploads pictures to OSS

Use pythonto upload the picture to Alibaba Cloud OSS (very cheap, bought a 1-year 9 RMB resource package), and then urlaccess the picture through the link. Among them, it is recommended to use the sum of the RAM user , which is the name of the purchased instance and the regional node of this instance. For the specific acquisition method, refer to the usage process of Alibaba Cloud OSSACCESS_KEY_IDACCESS_KEY_SECRETBUCKET_NAMEOSSENDPOINTOSS

# 使用阿里云OSS + picGo搭建图床 参考 https://www.jianshu.com/p/111ce9603ea6l

# -*- coding: utf-8 -*-
import datetime
import oss2
import unittest

# 阿里云OSS使用流程 参考 https://zhuanlan.zhihu.com/p/567771838
ACCESS_KEY_ID = "LTAI5*****Hu6m"  #RAM账号access_key_id,如果没有用主账号登录工作台创建并授权,关于RAM角色参考 https://ram.console.aliyun.com/roles
ACCESS_KEY_SECRET = "mkit8YsLh*****TYmoh1QRzK"  #RAM账号access_key_secret
ENDPOINT = "oss-cn-shenzhen.aliyuncs.com"  #可以在bucket中获取地域节点endpoint 参考 https://zhuanlan.zhihu.com/p/567771838
BUCKET_NAME = "w*****i-20200401"

#参考 https://www.likecs.com/show-308529932.html#sc=900
class Oss:
    """
    oss存储类
    上传bytes流,返回状态码和url
    """
    def __init__(self, access_key_id=ACCESS_KEY_ID, access_key_secret=ACCESS_KEY_SECRET,
                 endpoint=ENDPOINT, bucket_name=BUCKET_NAME):
        # 阿里云主账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建RAM账号。
        auth = oss2.Auth(access_key_id, access_key_secret)
        # Endpoint以杭州为例,其它Region请按实际情况填写。'http://oss-cn-hangzhou.aliyuncs.com'
        self.bucket = oss2.Bucket(auth, endpoint, bucket_name)

    def upload_bytes(self, file_bytes, image_name):
        """上传bytes文件"""
        result = self.bucket.put_object('{}'.format(image_name), file_bytes)

class OSSTest(unittest.TestCase):

    def test_oss_uploadFile(self):
        oss_obj = Oss()
        with open("temp/sh601728/minute_K_line.png","rb") as f:
            oss_obj.upload_bytes(f.read(),"minute_K_line.png")

    def test_oss_downloadFile(self):
        # 上传后,可以访问的 url 的组成
        photo_name = 'minute_K_line.png'
        domain = f'https://{
      
      BUCKET_NAME}.{
      
      ENDPOINT}/'
        url_photo = domain + photo_name
        print(url_photo)

        #访问该图片时可能会报错:You have no right to access this object because of bucket acl.
        # 解决方法:在bucket的权限控制中,将私有修改为公共读 参考 https://blog.csdn.net/zsy3757486/article/details/126938973

2) PicGoUpload images to OSS using

[Releases · Molunerfinn/PicGo · GitHub](https://github.com/Molunerfinn/PicGo/releases)After downloading and installing from the official website PicGo, configure Alibaba Cloud OSS , , and in the image bed configuration , you can upload images. For details, refer to Using Alibaba Cloud OSS to Build Image Beds - Short BookACCESS_KEY_IDACCESS_KEY_SECRETBUCKET_NAME存储区域地址

3) Image link access error resolution

Under normal circumstances, uploading to pictures can be accessed through the following links:

https://{
    
    BUCKET_NAME}.{
    
    ENDPOINT}/photo_name   #photo_name是上传到图片名称

urlHowever, an error may be reported when testing the image connection:You have no right to access this object because of bucket acl.

Solution: In bucketthe permission control of , change private to public read . Refer to [Alibaba Cloud OSS] You have no right to access this object because of bucket acl._Luyao Yezi's Blog-CSDN Blog

4. APScheduler scheduled tasks

reference

APScheduler timing framework: I finally found a way to call me to wake up every day

APScheduler is a Python timing task framework , which is very convenient to use. Provides tasks based on date, fixed time interval and crontab type, and can persist tasks and run applications in daemon mode .

Use APScheduler needs to be installed

pip install apscheduler

Let’s first look at an example of calling me to get up at 6:30 every morning from Monday to Friday ( the index of Monday is 0, and the index of Friday is 4 )

from apscheduler.schedulers.blocking import BlockingScheduler
from datetime import datetime
# 输出时间
def job():
    print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# BlockingScheduler
scheduler = BlockingScheduler()
scheduler.add_job(job, 'cron', day_of_week='0-5', hour=6, minute=30)
scheduler.start()

1) Four components of APScheduler

The four components of APScheduler are: trigger, job store, executor, and scheduler .

a, trigger (trigger)

Containing scheduling logic, each job has its own trigger that determines which job will run next. Aside from their own initial configuration, triggers are completely stateless.
APScheduler has three built-in triggers:

date: trigger at a specific time point
interval: trigger at a fixed time interval
cron: trigger periodically at a specific time

b. Job store

Storing scheduled jobs, the default job storage is to simply save the job in memory, other job storage is to save the job in the database. A job's data is serialized when saved in the persistent job store, and deserialized when loaded. Schedulers cannot share the same job store.
APScheduler uses MemoryJobStore by default, you can modify the DB storage scheme

c. Executor

To handle the running of jobs, they usually do this by submitting the specified callable objects in the job to a thread or into the pool. When the job is complete, the executor will notify the scheduler.
There are two most commonly used executors:

ProcessPoolExecutor
ThreadPoolExecutor

d. Scheduler

Usually there is only one scheduler in an application, and the developer of the application usually does not directly deal with job storage, schedulers and triggers, instead, the scheduler provides a suitable interface to deal with these. Configuring job storage and executors can be done in the scheduler, such as adding, modifying and removing jobs.

2) Configure the scheduler scheduler

APSchedulerThere are many different ways to configure the scheduler, you can use a configuration dictionary or pass it in as a parameter keyword. You can also create the scheduler first, then configure and add jobs, so you can get more flexibility in different environments.

Let's look at a simple BlockingSchedulerexample

from apscheduler.schedulers.blocking import BlockingScheduler
from datetime import datetime

def job():
    print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 定义BlockingScheduler
sched = BlockingScheduler()
sched.add_job(job, 'interval', seconds=5)
sched.start()

The above code creates one BlockingSchedulerand uses the default memory store and default executor. (The default options are MemoryJobStoreand ThreadPoolExecutor, where the maximum number of threads in the thread pool is 10). After the configuration is complete, use start()the method to start.

If you want to pass parameters to the job , you can add_jobuse argsparameters in , if you want to set the specified id for the job, you can use idparameters

rom datetime import datetime

from apscheduler.schedulers.blocking import BlockingScheduler


def func(name):
    now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    print(now + f" Hello world, {
      
      name}")


scheduler = BlockingScheduler()
scheduler.add_job(func, 'interval', seconds=3, args=["desire"], id="func")
scheduler.start()

Remove jobs :

  • remove_job1) Call the method by the ID of the job
  • 2) Call the method through add_job()the job instance obtained inremove()
  • If a job finishes scheduling (i.e. its triggers are no longer fired), it is automatically removed

If job_idit does not exist, remove_joban error will be reported , which can be handled with try-except

# remove
job = scheduler.add_job(func, 'interval', seconds=3, args=["desire"], id="job_remove")
job.remove()

# remove_job
scheduler.add_job(func, 'interval', seconds=3, args=["desire"], id="job_remove")
scheduler.remove_job(job_id="job_remove")

Terminate the executors in the scheduler :

scheduler.shutdown()  #终止调度器中的任务存储器以及执行器
scheduler.shutdown(wait=False)

By default, the task memory and executor will be terminated, and then all currently executing jobs will be completed (automatically terminated). wait=FalseThis parameter will terminate directly without waiting for any running tasks to complete . But if the scheduler is not executed, shutdown()an error will be reported.

Guess you like

Origin blog.csdn.net/qq_33934427/article/details/129115182