├── MANIFEST.in ├── easytrader ├── config │ ├── __init__.py │ ├── xq.json │ ├── global.json │ └── client.py ├── __init__.py ├── exceptions.py ├── log.py ├── pop_dialog_handler.py ├── ht_clienttrader.py ├── api.py ├── gj_clienttrader.py ├── grid_strategies.py ├── remoteclient.py ├── server.py ├── yh_clienttrader.py ├── ricequant_follower.py ├── joinquant_follower.py ├── helpers.py ├── webtrader.py ├── xq_follower.py ├── clienttrader.py ├── follower.py └── xqtrader.py ├── tests ├── __init__.py ├── test_xqtrader.py ├── test_easytrader.py └── test_xq_follower.py ├── mypy.ini ├── Makefile ├── gj_client.json ├── yh_client.json ├── test-requirements.txt ├── .coveragerc ├── .bumpversion.cfg ├── .travis.yml ├── mkdocs.yml ├── xq.json ├── .github └── ISSUE_TEMPLATE.md ├── docs ├── other │ └── xueqiu.md ├── help.md ├── install.md ├── index.md └── usage.md ├── requirements.txt ├── Pipfile ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── README.md ├── setup.py ├── .pylintrc └── Pipfile.lock /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /easytrader/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding:utf8 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | pytest -vx --cov=easytrader tests 3 | -------------------------------------------------------------------------------- /gj_client.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "国金用户名", 3 | "password": "国金明文密码" 4 | } -------------------------------------------------------------------------------- /yh_client.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "银河用户名", 3 | "password": "银河明文密码" 4 | } -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pytest 4 | pytest-cov 5 | 6 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = easytrader/* 4 | omit = tests/* 5 | 6 | [report] 7 | fail_under = -1 8 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.18.2 3 | commit = True 4 | files = easytrader/__init__.py setup.py 5 | tag = True 6 | tag_name = {new_version} 7 | 8 | -------------------------------------------------------------------------------- /easytrader/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .api import use, follower 3 | from . import exceptions 4 | 5 | __version__ = "0.18.2" 6 | __author__ = "shidenggui" 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.6" 5 | 6 | install: 7 | - pip install pipenv 8 | - pipenv install --dev --system 9 | 10 | script: 11 | - pipenv run test 12 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: easytrader 2 | pages: 3 | - [index.md, Home] 4 | - [install.md, 安装] 5 | - [usage.md, 使用] 6 | - [help.md, 常见问题] 7 | - [other/xueqiu.md, 其他, '雪球模拟组合说明'] 8 | theme: readthedocs 9 | -------------------------------------------------------------------------------- /xq.json: -------------------------------------------------------------------------------- 1 | { 2 | "cookies": "雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/", 3 | "portfolio_code": "组合代码(例:ZH818559)", 4 | "portfolio_market": "交易市场(例:us 或者 cn 或者 hk)" 5 | } 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## env 2 | 3 | OS: win7/ win10 / mac / linux 4 | PYTHON_VERSION: 3.x 5 | EASYTRADER_VERSION: 0.xx.xx 6 | BROKER_TYPE: gj / ht / xq / xxx 7 | 8 | ## problem 9 | 10 | ## how to repeat 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /easytrader/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Exceptions 4 | """ 5 | 6 | 7 | class TradeError(IOError): 8 | pass 9 | 10 | 11 | class NotLoginError(Exception): 12 | def __init__(self, result=None): 13 | super(NotLoginError, self).__init__() 14 | self.result = result 15 | -------------------------------------------------------------------------------- /easytrader/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | log = logging.getLogger("easytrader") 5 | log.setLevel(logging.DEBUG) 6 | log.propagate = False 7 | 8 | fmt = logging.Formatter( 9 | "%(asctime)s [%(levelname)s] %(filename)s %(lineno)s: %(message)s" 10 | ) 11 | ch = logging.StreamHandler() 12 | 13 | ch.setFormatter(fmt) 14 | log.handlers.append(ch) 15 | -------------------------------------------------------------------------------- /easytrader/config/xq.json: -------------------------------------------------------------------------------- 1 | { 2 | "login_api": "https://xueqiu.com/user/login", 3 | "prefix": "https://xueqiu.com/user/login", 4 | "portfolio_url": "https://xueqiu.com/p/", 5 | "search_stock_url": "https://xueqiu.com/stock/p/search.json", 6 | "rebalance_url": "https://xueqiu.com/cubes/rebalancing/create.json", 7 | "history_url": "https://xueqiu.com/cubes/rebalancing/history.json", 8 | "referer": "https://xueqiu.com/p/update?action=holdings&symbol=%s" 9 | } 10 | -------------------------------------------------------------------------------- /docs/other/xueqiu.md: -------------------------------------------------------------------------------- 1 | # 雪球组合模拟交易 2 | 3 | 因为雪球组合是按比例调仓的,所以模拟成券商实盘接口会有一些要注意的问题 4 | 5 | * 接口基本与其他券商接口调用参数返回一致 6 | * 委托单不支持挂高挂低(开盘时间都是直接市价成交的) 7 | * 初始资金是按组合净值 1:1000000 换算来的, 可以通过 `easytrader.use('xq', initial_assets=初始资金值)` 来调整 8 | * 委托单的委托价格和委托数量目前换算回来都是按1手拆的(雪球是按比例调仓的) 9 | * 持仓价格和持仓数量问题同上, 但持股市值是对的. 10 | * 一些不合理的操作会直接抛TradeError,注意看错误信息 11 | 12 | ---------------- 13 | 20160909 新增函数adjust_weight,用于雪球组合比例调仓 14 | 15 | adjust_weight函数包含两个参数,stock_code 指定调仓股票代码,weight 指定调仓比例 16 | 17 | -------------------------------------------------------------------------------- /docs/help.md: -------------------------------------------------------------------------------- 1 | 2 | # 如何关闭 debug 日志的输出 3 | 4 | ```python 5 | user = easytrader.use('yh', debug=False) 6 | 7 | ``` 8 | 9 | 10 | # 编辑配置文件,运行后出现 `json` 解码报错 11 | 12 | 13 | 出现如下错误 14 | 15 | ```python 16 | raise JSONDecodeError("Expecting value", s, err.value) from None 17 | 18 | JSONDecodeError: Expecting value 19 | ``` 20 | 21 | 请勿使用 `记事本` 编辑账户的 `json` 配置文件,推荐使用 [notepad++](https://notepad-plus-plus.org/zh/) 或者 [sublime text](http://www.sublimetext.com/) 22 | 23 | ### 其他 24 | 25 | [软件实现原理](http://www.jisilu.cn/question/42707) 26 | -------------------------------------------------------------------------------- /tests/test_xqtrader.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import unittest 3 | 4 | from easytrader.xqtrader import XueQiuTrader 5 | 6 | 7 | class TestXueQiuTrader(unittest.TestCase): 8 | def test_prepare_account(self): 9 | user = XueQiuTrader() 10 | params_without_cookies = dict( 11 | portfolio_code="ZH123456", portfolio_market="cn" 12 | ) 13 | with self.assertRaises(TypeError): 14 | user._prepare_account(**params_without_cookies) 15 | 16 | params_without_cookies.update(cookies="123") 17 | user._prepare_account(**params_without_cookies) 18 | self.assertEqual(params_without_cookies, user.account_config) 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i http://mirrors.aliyun.com/pypi/simple/ 2 | --trusted-host mirrors.aliyun.com 3 | beautifulsoup4==4.6.0 4 | bs4==0.0.1 5 | certifi==2018.4.16 6 | chardet==3.0.4 7 | click==6.7 8 | cssselect==1.0.3; python_version != '3.3.*' 9 | dill==0.2.8.2 10 | easyutils==0.1.7 11 | flask==1.0.2 12 | idna==2.7 13 | itsdangerous==0.24 14 | jinja2==2.10 15 | lxml==4.2.3 16 | markupsafe==1.0 17 | numpy==1.15.0; python_version >= '2.7' 18 | pandas==0.23.3 19 | pillow==5.2.0 20 | pyperclip==1.6.4 21 | pyquery==1.4.0; python_version != '3.0.*' 22 | pytesseract==0.2.4 23 | python-dateutil==2.7.3 24 | python-xlib==0.23 25 | pytz==2018.5 26 | pywinauto==0.6.4 27 | requests==2.19.1 28 | rqopen-client==0.0.5 29 | six==1.11.0 30 | urllib3==1.23; python_version != '3.1.*' 31 | werkzeug==0.14.1 32 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ### requirements 2 | 3 | ### 客户端设置 4 | 5 | 需要对客户端按以下设置,不然会导致下单时价格出错以及客户端超时锁定 6 | 7 | * 系统设置 > 界面设置: 界面不操作超时时间设为 0 8 | * 系统设置 > 交易设置: 默认买入价格/买入数量/卖出价格/卖出数量 都设置为 空 9 | 10 | 同时客户端不能最小化也不能处于精简模式 11 | 12 | ### 云端部署建议 13 | 14 | 在云服务上部署时,使用自带的远程桌面会有问题,推荐使用 TightVNC 15 | 16 | ### 登陆时的验证码识别 17 | 18 | 银河可以直接自动登录, 其他券商如果登陆需要识别验证码的话需要安装 tesseract: 19 | 20 | * `tesseract` : 非 `pytesseract`, 需要单独安装, [地址](https://github.com/tesseract-ocr/tesseract/wiki),保证在命令行下 `tesseract` 可用 21 | 22 | 或者你也可以手动登陆后在通过 `easytrader` 调用,此时 `easytrader` 在登陆过程中会直接识别到已登陆的窗口。 23 | 24 | ### 安装 25 | 26 | ```shell 27 | pip install easytrader 28 | ``` 29 | 30 | 注: `Windows` 用户 `pip` 安装时会提示 `No module named xxx`, 请使用 `pip install xxx` 安装对应缺失的 `module`, 然后再重新 `pip install easytrader`, 可以参考此文档 [INSTALL4Windows.md](other/INSTALL4Windows.md) 31 | 32 | ### 升级 33 | 34 | ```shell 35 | pip install easytrader -U 36 | ``` 37 | 38 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "http://mirrors.aliyun.com/pypi/simple/" 3 | verify_ssl = false 4 | name = "pypi" 5 | 6 | [packages] 7 | pywinauto = "*" 8 | "bs4" = "*" 9 | requests = "*" 10 | dill = "*" 11 | click = "*" 12 | six = "*" 13 | flask = "*" 14 | pillow = "*" 15 | pytesseract = "*" 16 | pandas = "*" 17 | pyperclip = "*" 18 | rqopen-client = ">=0.0.5" 19 | easyutils = "*" 20 | 21 | [dev-packages] 22 | pytest-cov = "*" 23 | pre-commit = "*" 24 | pytest = "*" 25 | pylint = "*" 26 | mypy = "*" 27 | isort = "*" 28 | black = "==18.6b4" 29 | ipython = "*" 30 | better-exceptions = "*" 31 | 32 | [requires] 33 | python_version = "3.6" 34 | 35 | [scripts] 36 | sort_imports = "bash -c 'isort \"$@\"; git add -u' --" 37 | format = "bash -c 'black -l 79 \"$@\"; git add -u' --" 38 | lint = "pylint" 39 | type_check = "mypy" 40 | test = "bash -c 'pytest -vx --cov=easytrader tests'" 41 | lock = "bash -c 'pipenv lock -r > requirements.txt'" 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: python_sort_imports 6 | name: python_sort_imports 7 | entry: pipenv run sort_imports 8 | language: system 9 | types: [python] 10 | - id: python_format 11 | name: python_format 12 | entry: pipenv run format 13 | language: system 14 | types: [python] 15 | - id: python_lint 16 | name: python_lint 17 | entry: pipenv run lint 18 | language: system 19 | types: [python] 20 | - id: python_type_check 21 | name: python_type_check 22 | entry: pipenv run type_check 23 | language: system 24 | types: [python] 25 | - id: python_test 26 | name: python_test 27 | entry: pipenv run test 28 | language: system 29 | types: [python] 30 | verbose: true 31 | - id: django_test 32 | name: django_test 33 | entry: pipenv run test 34 | language: system 35 | types: [python] 36 | verbose: true 37 | -------------------------------------------------------------------------------- /easytrader/config/global.json: -------------------------------------------------------------------------------- 1 | { 2 | "response_format": { 3 | "int": [ 4 | "current_amount", 5 | "enable_amount", 6 | "entrust_amount", 7 | "business_amount", 8 | "成交数量", 9 | "撤单数量", 10 | "委托数量", 11 | "股份可用", 12 | "买入冻结", 13 | "卖出冻结", 14 | "当前持仓", 15 | "股份余额" 16 | ], 17 | "float": [ 18 | "current_balance", 19 | "enable_balance", 20 | "fetch_balance", 21 | "market_value", 22 | "asset_balance", 23 | "av_buy_price", 24 | "cost_price", 25 | "income_balance", 26 | "market_value", 27 | "entrust_price", 28 | "business_price", 29 | "business_balance", 30 | "fare1", 31 | "occur_balance", 32 | "farex", 33 | "fare0", 34 | "occur_amount", 35 | "post_balance", 36 | "fare2", 37 | "fare3", 38 | "资金余额", 39 | "可用资金", 40 | "参考市值", 41 | "总资产", 42 | "股份参考盈亏", 43 | "委托价格", 44 | "成交价格", 45 | "成交金额", 46 | "参考盈亏", 47 | "参考成本价", 48 | "参考市价", 49 | "参考市值" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 shidenggui 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bak 2 | .mypy_cache 3 | .pyre 4 | .pytest_cache 5 | yjb_account.json 6 | htt.json 7 | gft.json 8 | test.py 9 | ht_account.json 10 | .idea 11 | .vscode 12 | .ipynb_checkpoints 13 | Untitled.ipynb 14 | untitled.txt 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | account.json 19 | account.session 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | env/ 26 | build/ 27 | develop-eggs/ 28 | dist/ 29 | downloads/ 30 | eggs/ 31 | .eggs/ 32 | lib/ 33 | lib64/ 34 | parts/ 35 | sdist/ 36 | var/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *,cover 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # cache 75 | tmp/ 76 | 77 | secrets/ 78 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 简介 2 | 3 | * 进行自动的程序化股票交易 4 | * 支持跟踪 `joinquant`, `ricequant` 的模拟交易 5 | * 支持跟踪 雪球组合 调仓, 实盘雪球组合 6 | * 支持通用的同花顺客户端模拟操作 7 | * 支持命令行调用,方便其他语言适配 8 | * 支持远程操作客户端 9 | * 支持 Python3 , Linux / Win / Mac 10 | * 有兴趣的可以加群 `556050652` 一起讨论 11 | * 捐助: 12 | 13 | ![微信](http://7xqo8v.com1.z0.glb.clouddn.com/wx.png?imageView2/1/w/300/h/300) ![支付宝](http://7xqo8v.com1.z0.glb.clouddn.com/zhifubao2.png?imageView2/1/w/300/h/300) 14 | 15 | 16 | ## 公众号 17 | 18 | 扫码关注“易量化”的微信公众号,不定时更新一些个人文章及与大家交流 19 | 20 | ![](http://7xqo8v.com1.z0.glb.clouddn.com/easy_quant_qrcode.jpg?imageView2/1/w/300/h/300) 21 | 22 | 23 | **开发环境** : `OSX 10.12.3` / `Python 3.5` 24 | 25 | ### 支持券商 26 | 27 | * 银河客户端(支持自动登陆), 须在 `windows` 平台下载 `银河双子星` 客户端 28 | * 华泰客户端(网上交易系统(专业版Ⅱ)) 29 | * 国金客户端(全能行证券交易终端PC版) 30 | * 其他券商通用同花顺客户端(需要手动登陆) 31 | 32 | 注: 现在有些新的同花顺客户端对拷贝剪贴板数据做了限制,下面在 [issue](https://github.com/shidenggui/easytrader/issues/272) 里提供几个老版本同花顺的下载地址。如有大家有补充的也可以再下面回复 33 | 34 | 35 | ### 实盘易 36 | 37 | 如果有对其他券商或者通达信版本的需求,可以查看 [实盘易](http://6du.in/0s15Iru) 38 | 39 | ### 模拟交易 40 | 41 | * 雪球组合 by @[haogefeifei](https://github.com/haogefeifei)([说明](other/xueqiu.md)) 42 | 43 | 44 | 45 | ### 相关 46 | 47 | [获取新浪免费实时行情的类库: easyquotation](https://github.com/shidenggui/easyquotation) 48 | 49 | [简单的股票量化交易框架 使用 easytrader 和 easyquotation](https://github.com/shidenggui/easyquant) 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # easytrader 2 | 3 | [![Package](https://img.shields.io/pypi/v/easytrader.svg)](https://pypi.python.org/pypi/easytrader) 4 | [![Travis](https://img.shields.io/travis/shidenggui/easytrader.svg)](https://travis-ci.org/shidenggui/easytrader) 5 | [![License](https://img.shields.io/github/license/shidenggui/easytrader.svg)](https://github.com/shidenggui/easytrader/blob/master/LICENSE) 6 | 7 | * 进行自动的程序化股票交易 8 | * 支持跟踪 `joinquant`, `ricequant` 的模拟交易 9 | * 支持跟踪 雪球组合 调仓 10 | * 支持通用的同花顺客户端模拟操作 11 | * 实现自动登录 12 | * 支持通过 webserver 远程操作客户端 13 | * 支持命令行调用,方便其他语言适配 14 | * 基于 Python3, Win。注: Linux 仅支持雪球 15 | * 有兴趣的可以加群 `556050652` 一起讨论 16 | * 捐助: 17 | 18 | ![微信](http://7xqo8v.com1.z0.glb.clouddn.com/wx.png?imageView2/1/w/300/h/300) ![支付宝](http://7xqo8v.com1.z0.glb.clouddn.com/zhifubao2.png?imageView2/1/w/300/h/300) 19 | 20 | 21 | ## 公众号 22 | 23 | 扫码关注“易量化”的微信公众号,不定时更新一些个人文章及与大家交流 24 | 25 | ![](http://7xqo8v.com1.z0.glb.clouddn.com/easy_quant_qrcode.jpg?imageView2/1/w/300/h/300) 26 | 27 | 28 | **开发环境** : `Ubuntu 16.04` / `Python 3.5` 29 | 30 | ### 相关 31 | 32 | [获取新浪免费实时行情的类库: easyquotation](https://github.com/shidenggui/easyquotation) 33 | 34 | [简单的股票量化交易框架 使用 easytrader 和 easyquotation](https://github.com/shidenggui/easyquant) 35 | 36 | ### 支持券商 37 | 38 | * 银河客户端, 须在 `windows` 平台下载 `银河双子星` 客户端 39 | * 华泰客户端(网上交易系统(专业版Ⅱ)) 40 | * 国金客户端(全能行证券交易终端PC版) 41 | * 其他券商通用同花顺客户端(需要手动登陆) 42 | 43 | 注: 现在有些新的同花顺客户端对拷贝剪贴板数据做了限制,我在 [issue](https://github.com/shidenggui/easytrader/issues/272) 里提供了几个券商老版本的下载地址。 44 | 45 | 46 | ### 模拟交易 47 | 48 | * 雪球组合 by @[haogefeifei](https://github.com/haogefeifei)([说明](doc/xueqiu.md)) 49 | 50 | ### 使用文档 51 | 52 | [中文文档](http://easytrader.readthedocs.io/zh/master/) 53 | 54 | ### 其他 55 | 56 | [软件实现原理](http://www.jisilu.cn/question/42707) 57 | -------------------------------------------------------------------------------- /easytrader/pop_dialog_handler.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import re 3 | import time 4 | from typing import Optional 5 | 6 | from . import exceptions 7 | 8 | 9 | class PopDialogHandler: 10 | def __init__(self, app): 11 | self._app = app 12 | 13 | def handle(self, title): 14 | if any(s in title for s in {"提示信息", "委托确认", "网上交易用户协议"}): 15 | self._submit_by_shortcut() 16 | return None 17 | 18 | if "提示" in title: 19 | content = self._extract_content() 20 | self._submit_by_click() 21 | return {"message": content} 22 | 23 | content = self._extract_content() 24 | self._close() 25 | return {"message": "unknown message: {}".format(content)} 26 | 27 | def _extract_content(self): 28 | return self._app.top_window().Static.window_text() 29 | 30 | def _extract_entrust_id(self, content): 31 | return re.search(r"\d+", content).group() 32 | 33 | def _submit_by_click(self): 34 | self._app.top_window()["确定"].click() 35 | 36 | def _submit_by_shortcut(self): 37 | self._app.top_window().type_keys("%Y") 38 | 39 | def _close(self): 40 | self._app.top_window().close() 41 | 42 | 43 | class TradePopDialogHandler(PopDialogHandler): 44 | def handle(self, title) -> Optional[dict]: 45 | if title == "委托确认": 46 | self._submit_by_shortcut() 47 | return None 48 | 49 | if title == "提示信息": 50 | content = self._extract_content() 51 | if "超出涨跌停" in content: 52 | self._submit_by_shortcut() 53 | return None 54 | 55 | if "委托价格的小数价格应为" in content: 56 | self._submit_by_shortcut() 57 | return None 58 | 59 | return None 60 | 61 | if title == "提示": 62 | content = self._extract_content() 63 | if "成功" in content: 64 | entrust_no = self._extract_entrust_id(content) 65 | self._submit_by_click() 66 | return {"entrust_no": entrust_no} 67 | 68 | self._submit_by_click() 69 | time.sleep(0.05) 70 | raise exceptions.TradeError(content) 71 | self._close() 72 | return None 73 | -------------------------------------------------------------------------------- /easytrader/ht_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pywinauto 3 | import pywinauto.clipboard 4 | 5 | from . import clienttrader 6 | 7 | 8 | class HTClientTrader(clienttrader.BaseLoginClientTrader): 9 | @property 10 | def broker_type(self): 11 | return "ht" 12 | 13 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 14 | """ 15 | :param user: 用户名 16 | :param password: 密码 17 | :param exe_path: 客户端路径, 类似 18 | :param comm_password: 19 | :param kwargs: 20 | :return: 21 | """ 22 | if comm_password is None: 23 | raise ValueError("华泰必须设置通讯密码") 24 | 25 | try: 26 | self._app = pywinauto.Application().connect( 27 | path=self._run_exe_path(exe_path), timeout=1 28 | ) 29 | # pylint: disable=broad-except 30 | except Exception: 31 | self._app = pywinauto.Application().start(exe_path) 32 | 33 | # wait login window ready 34 | while True: 35 | try: 36 | self._app.top_window().Edit1.wait("ready") 37 | break 38 | except RuntimeError: 39 | pass 40 | 41 | self._app.top_window().Edit1.type_keys(user) 42 | self._app.top_window().Edit2.type_keys(password) 43 | 44 | self._app.top_window().Edit3.type_keys(comm_password) 45 | 46 | self._app.top_window().button0.click() 47 | 48 | # detect login is success or not 49 | self._app.top_window().wait_not("exists", 10) 50 | 51 | self._app = pywinauto.Application().connect( 52 | path=self._run_exe_path(exe_path), timeout=10 53 | ) 54 | self._close_prompt_windows() 55 | self._main = self._app.window(title="网上股票交易系统5.0") 56 | 57 | @property 58 | def balance(self): 59 | self._switch_left_menus(self._config.BALANCE_MENU_PATH) 60 | 61 | return self._get_balance_from_statics() 62 | 63 | def _get_balance_from_statics(self): 64 | result = {} 65 | for key, control_id in self._config.BALANCE_CONTROL_ID_GROUP.items(): 66 | result[key] = float( 67 | self._main.window( 68 | control_id=control_id, class_name="Static" 69 | ).window_text() 70 | ) 71 | return result 72 | -------------------------------------------------------------------------------- /easytrader/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | import six 5 | 6 | from .joinquant_follower import JoinQuantFollower 7 | from .log import log 8 | from .ricequant_follower import RiceQuantFollower 9 | from .xq_follower import XueQiuFollower 10 | from .xqtrader import XueQiuTrader 11 | 12 | if six.PY2: 13 | raise TypeError("不支持 Python2,请升级 Python3") 14 | 15 | 16 | def use(broker, debug=True, **kwargs): 17 | """用于生成特定的券商对象 18 | :param broker:券商名支持 ['yh_client', '银河客户端'] ['ht_client', '华泰客户端'] 19 | :param debug: 控制 debug 日志的显示, 默认为 True 20 | :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一百万 21 | :return the class of trader 22 | 23 | Usage:: 24 | 25 | >>> import easytrader 26 | >>> user = easytrader.use('xq') 27 | >>> user.prepare('xq.json') 28 | """ 29 | if not debug: 30 | log.setLevel(logging.INFO) 31 | if broker.lower() in ["xq", "雪球"]: 32 | return XueQiuTrader(**kwargs) 33 | if broker.lower() in ["yh_client", "银河客户端"]: 34 | from .yh_clienttrader import YHClientTrader 35 | 36 | return YHClientTrader() 37 | if broker.lower() in ["ht_client", "华泰客户端"]: 38 | from .ht_clienttrader import HTClientTrader 39 | 40 | return HTClientTrader() 41 | if broker.lower() in ["gj_client", "国金客户端"]: 42 | from .gj_clienttrader import GJClientTrader 43 | 44 | return GJClientTrader() 45 | if broker.lower() in ["ths", "同花顺客户端"]: 46 | from .clienttrader import ClientTrader 47 | 48 | return ClientTrader() 49 | 50 | raise NotImplementedError 51 | 52 | 53 | def follower(platform, **kwargs): 54 | """用于生成特定的券商对象 55 | :param platform:平台支持 ['jq', 'joinquant', '聚宽’] 56 | :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一万, 57 | 总资金由 initial_assets * 组合当前净值 得出 58 | :param total_assets: [雪球参数] 控制雪球总资金,无默认值, 59 | 若设置则覆盖 initial_assets 60 | :return the class of follower 61 | 62 | Usage:: 63 | 64 | >>> import easytrader 65 | >>> user = easytrader.use('xq') 66 | >>> user.prepare('xq.json') 67 | >>> jq = easytrader.follower('jq') 68 | >>> jq.login(user='username', password='password') 69 | >>> jq.follow(users=user, strategies=['strategies_link']) 70 | """ 71 | if platform.lower() in ["rq", "ricequant", "米筐"]: 72 | return RiceQuantFollower() 73 | if platform.lower() in ["jq", "joinquant", "聚宽"]: 74 | return JoinQuantFollower() 75 | if platform.lower() in ["xq", "xueqiu", "雪球"]: 76 | return XueQiuFollower(**kwargs) 77 | raise NotImplementedError 78 | -------------------------------------------------------------------------------- /easytrader/gj_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import tempfile 4 | import time 5 | 6 | import pywinauto 7 | import pywinauto.clipboard 8 | 9 | from . import clienttrader, helpers 10 | 11 | 12 | class GJClientTrader(clienttrader.BaseLoginClientTrader): 13 | @property 14 | def broker_type(self): 15 | return "gj" 16 | 17 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 18 | """ 19 | 登陆客户端 20 | 21 | :param user: 账号 22 | :param password: 明文密码 23 | :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', 24 | 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' 25 | :param comm_password: 通讯密码, 华泰需要,可不设 26 | :return: 27 | """ 28 | try: 29 | self._app = pywinauto.Application().connect( 30 | path=self._run_exe_path(exe_path), timeout=1 31 | ) 32 | # pylint: disable=broad-except 33 | except Exception: 34 | self._app = pywinauto.Application().start(exe_path) 35 | 36 | # wait login window ready 37 | while True: 38 | try: 39 | self._app.top_window().Edit1.wait("ready") 40 | break 41 | except RuntimeError: 42 | pass 43 | 44 | self._app.top_window().Edit1.type_keys(user) 45 | self._app.top_window().Edit2.type_keys(password) 46 | edit3 = self._app.top_window().window(control_id=0x3eb) 47 | while True: 48 | try: 49 | code = self._handle_verify_code() 50 | edit3.type_keys(code) 51 | time.sleep(1) 52 | self._app.top_window()["确定(Y)"].click() 53 | # detect login is success or not 54 | try: 55 | self._app.top_window().wait_not("exists", 5) 56 | break 57 | 58 | # pylint: disable=broad-except 59 | except Exception: 60 | self._app.top_window()["确定"].click() 61 | 62 | # pylint: disable=broad-except 63 | except Exception: 64 | pass 65 | 66 | self._app = pywinauto.Application().connect( 67 | path=self._run_exe_path(exe_path), timeout=10 68 | ) 69 | self._main = self._app.window(title="网上股票交易系统5.0") 70 | 71 | def _handle_verify_code(self): 72 | control = self._app.top_window().window(control_id=0x5db) 73 | control.click() 74 | time.sleep(0.2) 75 | file_path = tempfile.mktemp() + ".jpg" 76 | control.capture_as_image().save(file_path) 77 | time.sleep(0.2) 78 | vcode = helpers.recognize_verify_code(file_path, "gj_client") 79 | return "".join(re.findall("[a-zA-Z0-9]+", vcode)) 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding:utf8 2 | from setuptools import setup 3 | 4 | long_desc = """ 5 | easytrader 6 | =============== 7 | 8 | * easy to use to trade in China Stock 9 | 10 | Installation 11 | -------------- 12 | 13 | pip install easytrader 14 | 15 | Upgrade 16 | --------------- 17 | 18 | pip install easytrader --upgrade 19 | 20 | Quick Start 21 | -------------- 22 | 23 | :: 24 | 25 | import easytrader 26 | 27 | user = easytrader.use('ht') 28 | 29 | user.prepare('account.json') 30 | 31 | user.balance 32 | 33 | return:: 34 | 35 | [{ 'asset_balance': '资产总值', 36 | 'current_balance': '当前余额', 37 | 'enable_balance': '可用金额', 38 | 'market_value': '证券市值', 39 | 'money_type': '币种', 40 | 'pre_interest': '预计利息' ]} 41 | 42 | user.position 43 | 44 | return:: 45 | 46 | [{'cost_price': '摊薄成本价', 47 | 'current_amount': '当前数量', 48 | 'enable_amount': '可卖数量', 49 | 'income_balance': '摊薄浮动盈亏', 50 | 'keep_cost_price': '保本价', 51 | 'last_price': '最新价', 52 | 'market_value': '证券市值', 53 | 'position_str': '定位串', 54 | 'stock_code': '证券代码', 55 | 'stock_name': '证券名称'}] 56 | 57 | user.entrust 58 | 59 | return:: 60 | 61 | [{'business_amount': '成交数量', 62 | 'business_price': '成交价格', 63 | 'entrust_amount': '委托数量', 64 | 'entrust_bs': '买卖方向', 65 | 'entrust_no': '委托编号', 66 | 'entrust_price': '委托价格', 67 | 'entrust_status': '委托状态', # 废单 / 已报 68 | 'report_time': '申报时间', 69 | 'stock_code': '证券代码', 70 | 'stock_name': '证券名称'}] 71 | 72 | user.buy('162411', price=5.55) 73 | 74 | user.sell('16411', price=5.65) 75 | 76 | """ 77 | 78 | setup( 79 | name="easytrader", 80 | version="0.18.2", 81 | description="A utility for China Stock Trade", 82 | long_description=long_desc, 83 | author="shidenggui", 84 | author_email="longlyshidenggui@gmail.com", 85 | license="BSD", 86 | url="https://github.com/shidenggui/easytrader", 87 | keywords="China stock trade", 88 | install_requires=[ 89 | "requests", 90 | "six", 91 | "rqopen-client", 92 | "easyutils", 93 | "flask", 94 | "pywinauto", 95 | "pillow", 96 | "pandas", 97 | ], 98 | classifiers=[ 99 | "Development Status :: 4 - Beta", 100 | "Programming Language :: Python :: 2.6", 101 | "Programming Language :: Python :: 2.7", 102 | "Programming Language :: Python :: 3.2", 103 | "Programming Language :: Python :: 3.3", 104 | "Programming Language :: Python :: 3.4", 105 | "Programming Language :: Python :: 3.5", 106 | "License :: OSI Approved :: BSD License", 107 | ], 108 | packages=["easytrader", "easytrader.config"], 109 | package_data={ 110 | "": ["*.jar", "*.json"], 111 | "config": ["config/*.json"], 112 | "thirdlibrary": ["thirdlibrary/*.jar"], 113 | }, 114 | ) 115 | -------------------------------------------------------------------------------- /easytrader/config/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def create(broker): 3 | if broker == "yh": 4 | return YH 5 | if broker == "ht": 6 | return HT 7 | if broker == "gj": 8 | return GJ 9 | if broker == "ths": 10 | return CommonConfig 11 | raise NotImplementedError 12 | 13 | 14 | class CommonConfig: 15 | DEFAULT_EXE_PATH: str = "" 16 | TITLE = "网上股票交易系统5.0" 17 | 18 | TRADE_SECURITY_CONTROL_ID = 1032 19 | TRADE_PRICE_CONTROL_ID = 1033 20 | TRADE_AMOUNT_CONTROL_ID = 1034 21 | 22 | TRADE_SUBMIT_CONTROL_ID = 1006 23 | 24 | TRADE_MARKET_TYPE_CONTROL_ID = 1541 25 | 26 | COMMON_GRID_CONTROL_ID = 1047 27 | 28 | COMMON_GRID_LEFT_MARGIN = 10 29 | COMMON_GRID_FIRST_ROW_HEIGHT = 30 30 | COMMON_GRID_ROW_HEIGHT = 16 31 | 32 | BALANCE_MENU_PATH = ["查询[F4]", "资金股票"] 33 | POSITION_MENU_PATH = ["查询[F4]", "资金股票"] 34 | TODAY_ENTRUSTS_MENU_PATH = ["查询[F4]", "当日委托"] 35 | TODAY_TRADES_MENU_PATH = ["查询[F4]", "当日成交"] 36 | 37 | BALANCE_CONTROL_ID_GROUP = { 38 | "资金余额": 1012, 39 | "可用金额": 1016, 40 | "可取金额": 1017, 41 | "股票市值": 1014, 42 | "总资产": 1015, 43 | } 44 | 45 | POP_DIALOD_TITLE_CONTROL_ID = 1365 46 | 47 | GRID_DTYPE = { 48 | "操作日期": str, 49 | "委托编号": str, 50 | "申请编号": str, 51 | "合同编号": str, 52 | "证券代码": str, 53 | "股东代码": str, 54 | "资金帐号": str, 55 | "资金帐户": str, 56 | "发生日期": str, 57 | } 58 | 59 | CANCEL_ENTRUST_ENTRUST_FIELD = "合同编号" 60 | CANCEL_ENTRUST_GRID_LEFT_MARGIN = 50 61 | CANCEL_ENTRUST_GRID_FIRST_ROW_HEIGHT = 30 62 | CANCEL_ENTRUST_GRID_ROW_HEIGHT = 16 63 | 64 | AUTO_IPO_SELECT_ALL_BUTTON_CONTROL_ID = 1098 65 | AUTO_IPO_BUTTON_CONTROL_ID = 1006 66 | AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] 67 | 68 | 69 | class YH(CommonConfig): 70 | DEFAULT_EXE_PATH = r"C:\双子星-中国银河证券\Binarystar.exe" 71 | 72 | BALANCE_GRID_CONTROL_ID = 1308 73 | 74 | GRID_DTYPE = { 75 | "操作日期": str, 76 | "委托编号": str, 77 | "申请编号": str, 78 | "合同编号": str, 79 | "证券代码": str, 80 | "股东代码": str, 81 | "资金帐号": str, 82 | "资金帐户": str, 83 | "发生日期": str, 84 | } 85 | 86 | AUTO_IPO_MENU_PATH = ["新股申购", "一键打新"] 87 | 88 | 89 | class HT(CommonConfig): 90 | DEFAULT_EXE_PATH = r"C:\htzqzyb2\xiadan.exe" 91 | 92 | BALANCE_CONTROL_ID_GROUP = { 93 | "资金余额": 1012, 94 | "冻结资金": 1013, 95 | "可用金额": 1016, 96 | "可取金额": 1017, 97 | "股票市值": 1014, 98 | "总资产": 1015, 99 | } 100 | 101 | GRID_DTYPE = { 102 | "操作日期": str, 103 | "委托编号": str, 104 | "申请编号": str, 105 | "合同编号": str, 106 | "证券代码": str, 107 | "股东代码": str, 108 | "资金帐号": str, 109 | "资金帐户": str, 110 | "发生日期": str, 111 | } 112 | 113 | AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] 114 | 115 | 116 | class GJ(CommonConfig): 117 | DEFAULT_EXE_PATH = "C:\\全能行证券交易终端\\xiadan.exe" 118 | 119 | GRID_DTYPE = { 120 | "操作日期": str, 121 | "委托编号": str, 122 | "申请编号": str, 123 | "合同编号": str, 124 | "证券代码": str, 125 | "股东代码": str, 126 | "资金帐号": str, 127 | "资金帐户": str, 128 | "发生日期": str, 129 | } 130 | 131 | AUTO_IPO_MENU_PATH = ["新股申购", "新股批量申购"] 132 | -------------------------------------------------------------------------------- /easytrader/grid_strategies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import io 4 | import tempfile 5 | from typing import TYPE_CHECKING, Dict, List 6 | 7 | import pandas as pd 8 | import pywinauto.clipboard 9 | 10 | from .log import log 11 | 12 | if TYPE_CHECKING: 13 | # pylint: disable=unused-import 14 | from . import clienttrader 15 | 16 | 17 | class IGridStrategy(abc.ABC): 18 | @abc.abstractmethod 19 | def get(self, control_id: int) -> List[Dict]: 20 | """ 21 | 获取 gird 数据并格式化返回 22 | 23 | :param control_id: grid 的 control id 24 | :return: grid 数据 25 | """ 26 | pass 27 | 28 | 29 | class BaseStrategy(IGridStrategy): 30 | def __init__(self, trader: "clienttrader.IClientTrader") -> None: 31 | self._trader = trader 32 | 33 | @abc.abstractmethod 34 | def get(self, control_id: int) -> List[Dict]: 35 | """ 36 | :param control_id: grid 的 control id 37 | :return: grid 数据 38 | """ 39 | pass 40 | 41 | def _get_grid(self, control_id: int): 42 | grid = self._trader.main.window( 43 | control_id=control_id, class_name="CVirtualGridCtrl" 44 | ) 45 | return grid 46 | 47 | 48 | class Copy(BaseStrategy): 49 | """ 50 | 通过复制 grid 内容到剪切板z再读取来获取 grid 内容 51 | """ 52 | 53 | def get(self, control_id: int) -> List[Dict]: 54 | grid = self._get_grid(control_id) 55 | grid.type_keys("^A^C") 56 | content = self._get_clipboard_data() 57 | return self._format_grid_data(content) 58 | 59 | def _format_grid_data(self, data: str) -> List[Dict]: 60 | df = pd.read_csv( 61 | io.StringIO(data), 62 | delimiter="\t", 63 | dtype=self._trader.config.GRID_DTYPE, 64 | na_filter=False, 65 | ) 66 | return df.to_dict("records") 67 | 68 | def _get_clipboard_data(self) -> str: 69 | while True: 70 | try: 71 | return pywinauto.clipboard.GetData() 72 | # pylint: disable=broad-except 73 | except Exception as e: 74 | log.warning("%s, retry ......", e) 75 | 76 | 77 | class Xls(BaseStrategy): 78 | """ 79 | 通过将 Grid 另存为 xls 文件再读取的方式获取 grid 内容, 80 | 用于绕过一些客户端不允许复制的限制 81 | """ 82 | 83 | def get(self, control_id: int) -> List[Dict]: 84 | grid = self._get_grid(control_id) 85 | 86 | # ctrl+s 保存 grid 内容为 xls 文件 87 | grid.type_keys("^s") 88 | self._trader.wait(1) 89 | 90 | temp_path = tempfile.mktemp(suffix=".csv") 91 | self._trader.app.top_window().type_keys(self.normalize_path(temp_path)) 92 | 93 | # Wait until file save complete 94 | self._trader.wait(0.3) 95 | 96 | # alt+s保存,alt+y替换已存在的文件 97 | self._trader.app.top_window().type_keys("%{s}%{y}") 98 | # Wait until file save complete otherwise pandas can not find file 99 | self._trader.wait(0.2) 100 | return self._format_grid_data(temp_path) 101 | 102 | def normalize_path(self, temp_path: str) -> str: 103 | return temp_path.replace('~', '{~}') 104 | 105 | def _format_grid_data(self, data: str) -> List[Dict]: 106 | df = pd.read_csv( 107 | data, 108 | encoding="gbk", 109 | delimiter="\t", 110 | dtype=self._trader.config.GRID_DTYPE, 111 | na_filter=False, 112 | ) 113 | return df.to_dict("records") 114 | -------------------------------------------------------------------------------- /easytrader/remoteclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | 4 | from . import helpers 5 | 6 | 7 | def use(broker, host, port=1430, **kwargs): 8 | return RemoteClient(broker, host, port) 9 | 10 | 11 | class RemoteClient: 12 | def __init__(self, broker, host, port=1430, **kwargs): 13 | self._s = requests.session() 14 | self._api = "http://{}:{}".format(host, port) 15 | self._broker = broker 16 | 17 | def prepare( 18 | self, 19 | config_path=None, 20 | user=None, 21 | password=None, 22 | exe_path=None, 23 | comm_password=None, 24 | **kwargs 25 | ): 26 | """ 27 | 登陆客户端 28 | :param config_path: 登陆配置文件,跟参数登陆方式二选一 29 | :param user: 账号 30 | :param password: 明文密码 31 | :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' 32 | :param comm_password: 通讯密码 33 | :return: 34 | """ 35 | params = locals().copy() 36 | params.pop("self") 37 | 38 | if config_path is not None: 39 | account = helpers.file2dict(config_path) 40 | params["user"] = account["user"] 41 | params["password"] = account["password"] 42 | 43 | params["broker"] = self._broker 44 | 45 | response = self._s.post(self._api + "/prepare", json=params) 46 | if response.status_code >= 300: 47 | raise Exception(response.json()["error"]) 48 | return response.json() 49 | 50 | @property 51 | def balance(self): 52 | return self.common_get("balance") 53 | 54 | @property 55 | def position(self): 56 | return self.common_get("position") 57 | 58 | @property 59 | def today_entrusts(self): 60 | return self.common_get("today_entrusts") 61 | 62 | @property 63 | def today_trades(self): 64 | return self.common_get("today_trades") 65 | 66 | @property 67 | def cancel_entrusts(self): 68 | return self.common_get("cancel_entrusts") 69 | 70 | def auto_ipo(self): 71 | return self.common_get("auto_ipo") 72 | 73 | def exit(self): 74 | return self.common_get("exit") 75 | 76 | def common_get(self, endpoint): 77 | response = self._s.get(self._api + "/" + endpoint) 78 | if response.status_code >= 300: 79 | raise Exception(response.json()["error"]) 80 | return response.json() 81 | 82 | def buy(self, security, price, amount, **kwargs): 83 | params = locals().copy() 84 | params.pop("self") 85 | 86 | response = self._s.post(self._api + "/buy", json=params) 87 | if response.status_code >= 300: 88 | raise Exception(response.json()["error"]) 89 | return response.json() 90 | 91 | def sell(self, security, price, amount, **kwargs): 92 | params = locals().copy() 93 | params.pop("self") 94 | 95 | response = self._s.post(self._api + "/sell", json=params) 96 | if response.status_code >= 300: 97 | raise Exception(response.json()["error"]) 98 | return response.json() 99 | 100 | def cancel_entrust(self, entrust_no): 101 | params = locals().copy() 102 | params.pop("self") 103 | 104 | response = self._s.post(self._api + "/cancel_entrust", json=params) 105 | if response.status_code >= 300: 106 | raise Exception(response.json()["error"]) 107 | return response.json() 108 | -------------------------------------------------------------------------------- /easytrader/server.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from flask import Flask, jsonify, request 4 | 5 | from . import api 6 | from .log import log 7 | 8 | app = Flask(__name__) 9 | 10 | global_store = {} 11 | 12 | 13 | def error_handle(func): 14 | @functools.wraps(func) 15 | def wrapper(*args, **kwargs): 16 | try: 17 | return func(*args, **kwargs) 18 | # pylint: disable=broad-except 19 | except Exception as e: 20 | log.exception("server error") 21 | message = "{}: {}".format(e.__class__, e) 22 | return jsonify({"error": message}), 400 23 | 24 | return wrapper 25 | 26 | 27 | @app.route("/prepare", methods=["POST"]) 28 | @error_handle 29 | def post_prepare(): 30 | json_data = request.get_json(force=True) 31 | 32 | user = api.use(json_data.pop("broker")) 33 | user.prepare(**json_data) 34 | 35 | global_store["user"] = user 36 | return jsonify({"msg": "login success"}), 201 37 | 38 | 39 | @app.route("/balance", methods=["GET"]) 40 | @error_handle 41 | def get_balance(): 42 | user = global_store["user"] 43 | balance = user.balance 44 | 45 | return jsonify(balance), 200 46 | 47 | 48 | @app.route("/position", methods=["GET"]) 49 | @error_handle 50 | def get_position(): 51 | user = global_store["user"] 52 | position = user.position 53 | 54 | return jsonify(position), 200 55 | 56 | 57 | @app.route("/auto_ipo", methods=["GET"]) 58 | @error_handle 59 | def get_auto_ipo(): 60 | user = global_store["user"] 61 | res = user.auto_ipo() 62 | 63 | return jsonify(res), 200 64 | 65 | 66 | @app.route("/today_entrusts", methods=["GET"]) 67 | @error_handle 68 | def get_today_entrusts(): 69 | user = global_store["user"] 70 | today_entrusts = user.today_entrusts 71 | 72 | return jsonify(today_entrusts), 200 73 | 74 | 75 | @app.route("/today_trades", methods=["GET"]) 76 | @error_handle 77 | def get_today_trades(): 78 | user = global_store["user"] 79 | today_trades = user.today_trades 80 | 81 | return jsonify(today_trades), 200 82 | 83 | 84 | @app.route("/cancel_entrusts", methods=["GET"]) 85 | @error_handle 86 | def get_cancel_entrusts(): 87 | user = global_store["user"] 88 | cancel_entrusts = user.cancel_entrusts 89 | 90 | return jsonify(cancel_entrusts), 200 91 | 92 | 93 | @app.route("/buy", methods=["POST"]) 94 | @error_handle 95 | def post_buy(): 96 | json_data = request.get_json(force=True) 97 | user = global_store["user"] 98 | res = user.buy(**json_data) 99 | 100 | return jsonify(res), 201 101 | 102 | 103 | @app.route("/sell", methods=["POST"]) 104 | @error_handle 105 | def post_sell(): 106 | json_data = request.get_json(force=True) 107 | 108 | user = global_store["user"] 109 | res = user.sell(**json_data) 110 | 111 | return jsonify(res), 201 112 | 113 | 114 | @app.route("/cancel_entrust", methods=["POST"]) 115 | @error_handle 116 | def post_cancel_entrust(): 117 | json_data = request.get_json(force=True) 118 | 119 | user = global_store["user"] 120 | res = user.cancel_entrust(**json_data) 121 | 122 | return jsonify(res), 201 123 | 124 | 125 | @app.route("/exit", methods=["GET"]) 126 | @error_handle 127 | def get_exit(): 128 | user = global_store["user"] 129 | user.exit() 130 | 131 | return jsonify({"msg": "exit success"}), 200 132 | 133 | 134 | def run(port=1430): 135 | app.run(host="0.0.0.0", port=port) 136 | -------------------------------------------------------------------------------- /tests/test_easytrader.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import sys 4 | import time 5 | import unittest 6 | 7 | sys.path.append(".") 8 | 9 | TEST_CLIENTS = os.environ.get("EZ_TEST_CLIENTS", "") 10 | 11 | IS_WIN_PLATFORM = sys.platform != "darwin" 12 | 13 | 14 | @unittest.skipUnless("yh" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip yh test") 15 | class TestYhClientTrader(unittest.TestCase): 16 | @classmethod 17 | def setUpClass(cls): 18 | import easytrader 19 | 20 | if "yh" not in TEST_CLIENTS: 21 | return 22 | 23 | # input your test account and password 24 | cls._ACCOUNT = os.environ.get("EZ_TEST_YH_ACCOUNT") or "your account" 25 | cls._PASSWORD = ( 26 | os.environ.get("EZ_TEST_YH_PASSWORD") or "your password" 27 | ) 28 | 29 | cls._user = easytrader.use("yh_client") 30 | cls._user.prepare(user=cls._ACCOUNT, password=cls._PASSWORD) 31 | 32 | def test_balance(self): 33 | time.sleep(3) 34 | result = self._user.balance 35 | 36 | def test_today_entrusts(self): 37 | result = self._user.today_entrusts 38 | 39 | def test_today_trades(self): 40 | result = self._user.today_trades 41 | 42 | def test_cancel_entrusts(self): 43 | result = self._user.cancel_entrusts 44 | 45 | def test_cancel_entrust(self): 46 | result = self._user.cancel_entrust("123456789") 47 | 48 | def test_invalid_buy(self): 49 | import easytrader 50 | 51 | with self.assertRaises(easytrader.exceptions.TradeError): 52 | result = self._user.buy("511990", 1, 1e10) 53 | 54 | def test_invalid_sell(self): 55 | import easytrader 56 | 57 | with self.assertRaises(easytrader.exceptions.TradeError): 58 | result = self._user.sell("162411", 200, 1e10) 59 | 60 | def test_auto_ipo(self): 61 | self._user.auto_ipo() 62 | 63 | 64 | @unittest.skipUnless("ht" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip ht test") 65 | class TestHTClientTrader(unittest.TestCase): 66 | @classmethod 67 | def setUpClass(cls): 68 | import easytrader 69 | 70 | if "ht" not in TEST_CLIENTS: 71 | return 72 | 73 | # input your test account and password 74 | cls._ACCOUNT = os.environ.get("EZ_TEST_HT_ACCOUNT") or "your account" 75 | cls._PASSWORD = ( 76 | os.environ.get("EZ_TEST_HT_PASSWORD") or "your password" 77 | ) 78 | cls._COMM_PASSWORD = ( 79 | os.environ.get("EZ_TEST_HT_COMM_PASSWORD") or "your comm password" 80 | ) 81 | 82 | cls._user = easytrader.use("ht_client") 83 | cls._user.prepare( 84 | user=cls._ACCOUNT, 85 | password=cls._PASSWORD, 86 | comm_password=cls._COMM_PASSWORD, 87 | ) 88 | 89 | def test_balance(self): 90 | time.sleep(3) 91 | result = self._user.balance 92 | 93 | def test_today_entrusts(self): 94 | result = self._user.today_entrusts 95 | 96 | def test_today_trades(self): 97 | result = self._user.today_trades 98 | 99 | def test_cancel_entrusts(self): 100 | result = self._user.cancel_entrusts 101 | 102 | def test_cancel_entrust(self): 103 | result = self._user.cancel_entrust("123456789") 104 | 105 | def test_invalid_buy(self): 106 | import easytrader 107 | 108 | with self.assertRaises(easytrader.exceptions.TradeError): 109 | result = self._user.buy("511990", 1, 1e10) 110 | 111 | def test_invalid_sell(self): 112 | import easytrader 113 | 114 | with self.assertRaises(easytrader.exceptions.TradeError): 115 | result = self._user.sell("162411", 200, 1e10) 116 | 117 | def test_auto_ipo(self): 118 | self._user.auto_ipo() 119 | 120 | 121 | if __name__ == "__main__": 122 | unittest.main() 123 | -------------------------------------------------------------------------------- /easytrader/yh_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import tempfile 4 | 5 | import pywinauto 6 | 7 | from . import clienttrader, grid_strategies, helpers 8 | 9 | 10 | class YHClientTrader(clienttrader.BaseLoginClientTrader): 11 | """ 12 | Changelog: 13 | 14 | 2018.07.01: 15 | 银河客户端 2018.5.11 更新后不再支持通过剪切板复制获取 Grid 内容, 16 | 改为使用保存为 Xls 再读取的方式获取 17 | """ 18 | 19 | grid_strategy = grid_strategies.Xls 20 | 21 | @property 22 | def broker_type(self): 23 | return "yh" 24 | 25 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 26 | """ 27 | 登陆客户端 28 | :param user: 账号 29 | :param password: 明文密码 30 | :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', 31 | 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' 32 | :param comm_password: 通讯密码, 华泰需要,可不设 33 | :return: 34 | """ 35 | try: 36 | self._app = pywinauto.Application().connect( 37 | path=self._run_exe_path(exe_path), timeout=1 38 | ) 39 | # pylint: disable=broad-except 40 | except Exception: 41 | self._app = pywinauto.Application().start(exe_path) 42 | is_xiadan = True if "xiadan.exe" in exe_path else False 43 | # wait login window ready 44 | while True: 45 | try: 46 | self._app.top_window().Edit1.wait("ready") 47 | break 48 | except RuntimeError: 49 | pass 50 | 51 | self._app.top_window().Edit1.type_keys(user) 52 | self._app.top_window().Edit2.type_keys(password) 53 | while True: 54 | self._app.top_window().Edit3.type_keys( 55 | self._handle_verify_code(is_xiadan) 56 | ) 57 | self._app.top_window()["确定" if is_xiadan else "登录"].click() 58 | 59 | # detect login is success or not 60 | try: 61 | self._app.top_window().wait_not("exists visible", 10) 62 | break 63 | # pylint: disable=broad-except 64 | except Exception: 65 | if is_xiadan: 66 | self._app.top_window()["确定"].click() 67 | 68 | self._app = pywinauto.Application().connect( 69 | path=self._run_exe_path(exe_path), timeout=10 70 | ) 71 | self._close_prompt_windows() 72 | self._main = self._app.window(title="网上股票交易系统5.0") 73 | try: 74 | self._main.window(control_id=129, class_name="SysTreeView32").wait( 75 | "ready", 2 76 | ) 77 | # pylint: disable=broad-except 78 | except Exception: 79 | self.wait(2) 80 | self._switch_window_to_normal_mode() 81 | 82 | def _switch_window_to_normal_mode(self): 83 | self._app.top_window().window( 84 | control_id=32812, class_name="Button" 85 | ).click() 86 | 87 | def _handle_verify_code(self, is_xiadan): 88 | control = self._app.top_window().window( 89 | control_id=1499 if is_xiadan else 22202 90 | ) 91 | control.click() 92 | control.draw_outline() 93 | 94 | file_path = tempfile.mktemp() 95 | if is_xiadan: 96 | rect = control.element_info.rectangle 97 | rect.right = round( 98 | rect.right + (rect.right - rect.left) * 0.3 99 | ) # 扩展验证码控件截图范围为4个字符 100 | control.capture_as_image(rect).save(file_path, "jpeg") 101 | else: 102 | control.capture_as_image().save(file_path, "jpeg") 103 | verify_code = helpers.recognize_verify_code(file_path, "yh_client") 104 | return "".join(re.findall(r"\d+", verify_code)) 105 | 106 | @property 107 | def balance(self): 108 | self._switch_left_menus(self._config.BALANCE_MENU_PATH) 109 | 110 | return self._get_grid_data(self._config.BALANCE_GRID_CONTROL_ID) 111 | -------------------------------------------------------------------------------- /easytrader/ricequant_follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | from threading import Thread 5 | 6 | from .follower import BaseFollower 7 | from .log import log 8 | 9 | 10 | class RiceQuantFollower(BaseFollower): 11 | def __init__(self): 12 | super().__init__() 13 | self.client = None 14 | 15 | def login(self, user=None, password=None, **kwargs): 16 | from rqopen_client import RQOpenClient 17 | 18 | self.client = RQOpenClient(user, password, logger=log) 19 | 20 | def follow( 21 | self, 22 | users, 23 | run_id, 24 | track_interval=1, 25 | trade_cmd_expire_seconds=120, 26 | cmd_cache=True, 27 | entrust_prop="limit", 28 | send_interval=0, 29 | ): 30 | """跟踪ricequant对应的模拟交易,支持多用户多策略 31 | :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 32 | :param run_id: ricequant 的模拟交易ID,支持使用 [] 指定多个模拟交易 33 | :param track_interval: 轮训模拟交易时间,单位为秒 34 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 35 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 36 | :param entrust_prop: 委托方式, 'limit' 为限价,'market' 为市价, 仅在银河实现 37 | :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 38 | """ 39 | users = self.warp_list(users) 40 | run_ids = self.warp_list(run_id) 41 | 42 | if cmd_cache: 43 | self.load_expired_cmd_cache() 44 | 45 | self.start_trader_thread( 46 | users, trade_cmd_expire_seconds, entrust_prop, send_interval 47 | ) 48 | 49 | workers = [] 50 | for id_ in run_ids: 51 | strategy_name = self.extract_strategy_name(id_) 52 | strategy_worker = Thread( 53 | target=self.track_strategy_worker, 54 | args=[id_, strategy_name], 55 | kwargs={"interval": track_interval}, 56 | ) 57 | strategy_worker.start() 58 | workers.append(strategy_worker) 59 | log.info("开始跟踪策略: %s", strategy_name) 60 | for worker in workers: 61 | worker.join() 62 | 63 | def extract_strategy_name(self, run_id): 64 | ret_json = self.client.get_positions(run_id) 65 | if ret_json["code"] != 200: 66 | log.error( 67 | "fetch data from run_id %s fail, msg %s", 68 | run_id, 69 | ret_json["msg"], 70 | ) 71 | raise RuntimeError(ret_json["msg"]) 72 | return ret_json["resp"]["name"] 73 | 74 | def extract_day_trades(self, run_id): 75 | ret_json = self.client.get_day_trades(run_id) 76 | if ret_json["code"] != 200: 77 | log.error( 78 | "fetch day trades from run_id %s fail, msg %s", 79 | run_id, 80 | ret_json["msg"], 81 | ) 82 | raise RuntimeError(ret_json["msg"]) 83 | return ret_json["resp"]["trades"] 84 | 85 | def query_strategy_transaction(self, strategy, **kwargs): 86 | transactions = self.extract_day_trades(strategy) 87 | transactions = self.project_transactions(transactions, **kwargs) 88 | return self.order_transactions_sell_first(transactions) 89 | 90 | @staticmethod 91 | def stock_shuffle_to_prefix(stock): 92 | assert ( 93 | len(stock) == 11 94 | ), "stock {} must like 123456.XSHG or 123456.XSHE".format(stock) 95 | code = stock[:6] 96 | if stock.find("XSHG") != -1: 97 | return "sh" + code 98 | if stock.find("XSHE") != -1: 99 | return "sz" + code 100 | raise TypeError("not valid stock code: {}".format(code)) 101 | 102 | def project_transactions(self, transactions, **kwargs): 103 | new_transactions = [] 104 | for transaction in transactions: 105 | new_transaction = {} 106 | new_transaction["price"] = transaction["price"] 107 | new_transaction["amount"] = int(abs(transaction["quantity"])) 108 | new_transaction["datetime"] = datetime.strptime( 109 | transaction["time"], "%Y-%m-%d %H:%M:%S" 110 | ) 111 | new_transaction["stock_code"] = self.stock_shuffle_to_prefix( 112 | transaction["order_book_id"] 113 | ) 114 | new_transaction["action"] = ( 115 | "buy" if transaction["quantity"] > 0 else "sell" 116 | ) 117 | new_transactions.append(new_transaction) 118 | 119 | return new_transactions 120 | -------------------------------------------------------------------------------- /easytrader/joinquant_follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | from datetime import datetime 4 | from threading import Thread 5 | 6 | from . import exceptions 7 | from .follower import BaseFollower 8 | from .log import log 9 | 10 | 11 | class JoinQuantFollower(BaseFollower): 12 | LOGIN_PAGE = "https://www.joinquant.com" 13 | LOGIN_API = "https://www.joinquant.com/user/login/doLogin?ajax=1" 14 | TRANSACTION_API = ( 15 | "https://www.joinquant.com/algorithm/live/transactionDetail" 16 | ) 17 | WEB_REFERER = "https://www.joinquant.com/user/login/index" 18 | WEB_ORIGIN = "https://www.joinquant.com" 19 | 20 | def create_login_params(self, user, password, **kwargs): 21 | params = { 22 | "CyLoginForm[username]": user, 23 | "CyLoginForm[pwd]": password, 24 | "ajax": 1, 25 | } 26 | return params 27 | 28 | def check_login_success(self, rep): 29 | set_cookie = rep.headers["set-cookie"] 30 | if len(set_cookie) < 100: 31 | raise exceptions.NotLoginError("登录失败,请检查用户名和密码") 32 | self.s.headers.update({"cookie": set_cookie}) 33 | 34 | def follow( 35 | self, 36 | users, 37 | strategies, 38 | track_interval=1, 39 | trade_cmd_expire_seconds=120, 40 | cmd_cache=True, 41 | entrust_prop="limit", 42 | send_interval=0, 43 | ): 44 | """跟踪joinquant对应的模拟交易,支持多用户多策略 45 | :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 46 | :param strategies: joinquant 的模拟交易地址,支持使用 [] 指定多个模拟交易, 47 | 地址类似 https://www.joinquant.com/algorithm/live/index?backtestId=xxx 48 | :param track_interval: 轮训模拟交易时间,单位为秒 49 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 50 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 51 | :param entrust_prop: 委托方式, 'limit' 为限价,'market' 为市价, 仅在银河实现 52 | :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 53 | """ 54 | users = self.warp_list(users) 55 | strategies = self.warp_list(strategies) 56 | 57 | if cmd_cache: 58 | self.load_expired_cmd_cache() 59 | 60 | self.start_trader_thread( 61 | users, trade_cmd_expire_seconds, entrust_prop, send_interval 62 | ) 63 | 64 | workers = [] 65 | for strategy_url in strategies: 66 | try: 67 | strategy_id = self.extract_strategy_id(strategy_url) 68 | strategy_name = self.extract_strategy_name(strategy_url) 69 | except: 70 | log.error("抽取交易id和策略名失败, 无效的模拟交易url: %s", strategy_url) 71 | raise 72 | strategy_worker = Thread( 73 | target=self.track_strategy_worker, 74 | args=[strategy_id, strategy_name], 75 | kwargs={"interval": track_interval}, 76 | ) 77 | strategy_worker.start() 78 | workers.append(strategy_worker) 79 | log.info("开始跟踪策略: %s", strategy_name) 80 | for worker in workers: 81 | worker.join() 82 | 83 | @staticmethod 84 | def extract_strategy_id(strategy_url): 85 | return re.search(r"(?<=backtestId=)\w+", strategy_url).group() 86 | 87 | def extract_strategy_name(self, strategy_url): 88 | rep = self.s.get(strategy_url) 89 | return self.re_find( 90 | r'(?<=title="点击修改策略名称"\>).*(?=\ dict: 202 | """生成基本的参数""" 203 | return {} 204 | 205 | def request(self, params) -> dict: 206 | """请求并获取 JSON 数据 207 | :param params: Get 参数""" 208 | return {} 209 | 210 | def format_response_data(self, data): 211 | """格式化返回的 json 数据 212 | :param data: 请求返回的数据 """ 213 | return data 214 | 215 | def fix_error_data(self, data): 216 | """若是返回错误移除外层的列表 217 | :param data: 需要判断是否包含错误信息的数据""" 218 | return data 219 | 220 | def format_response_data_type(self, response_data): 221 | """格式化返回的值为正确的类型 222 | :param response_data: 返回的数据 223 | """ 224 | if isinstance(response_data, list) and not isinstance( 225 | response_data, str 226 | ): 227 | return response_data 228 | 229 | int_match_str = "|".join(self.config["response_format"]["int"]) 230 | float_match_str = "|".join(self.config["response_format"]["float"]) 231 | for item in response_data: 232 | for key in item: 233 | try: 234 | if re.search(int_match_str, key) is not None: 235 | item[key] = helpers.str2num(item[key], "int") 236 | elif re.search(float_match_str, key) is not None: 237 | item[key] = helpers.str2num(item[key], "float") 238 | except ValueError: 239 | continue 240 | return response_data 241 | 242 | def check_login_status(self, return_data): 243 | pass 244 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # 引入 2 | 3 | ```python 4 | import easytrader 5 | ``` 6 | 7 | # 设置券商类型 8 | 9 | **银河客户端** 10 | 11 | ```python 12 | user = easytrader.use('yh_client') 13 | ``` 14 | **华泰客户端** 15 | 16 | ```python 17 | user = easytrader.use('ht_client') 18 | ``` 19 | 20 | **国金客户端** 21 | 22 | ```python 23 | user = easytrader.use('gj_client') 24 | ``` 25 | 26 | **通用同花顺客户端** 27 | 28 | 29 | ```python 30 | user = easytrader.use('ths') 31 | ``` 32 | 33 | 注: 通用同花顺客户端是指对应券商官网提供的基于同花顺修改的软件版本,类似银河的双子星(同花顺版本), 海王星(通达信版本) 34 | 35 | 36 | # 设置账户信息 37 | 38 | 登陆账号有两种方式,`使用参数` 和 `使用配置文件` 39 | 40 | 使用通用同花顺客户端不支持自动登陆,所以无需设置,参看下文`直接连接通用同花顺客户端` 41 | 42 | **参数登录(推荐)** 43 | 44 | ``` 45 | user.prepare(user='用户名', password='雪球、银河客户端为明文密码', comm_password='华泰通讯密码,其他券商不用') 46 | ``` 47 | 48 | 注: 雪球比较特殊,见下列配置文件格式 49 | 50 | **使用配置文件** 51 | 52 | ```python 53 | user.prepare('/path/to/your/yh_client.json') // 配置文件路径 54 | ``` 55 | 56 | **注**: 使用配置文件模式, 配置文件需要自己用编辑器编辑生成, 请勿使用记事本, 推荐使用 [notepad++](https://notepad-plus-plus.org/zh/) 或者 [sublime text](http://www.sublimetext.com/) 57 | 58 | **格式如下** 59 | 60 | 银河/国金客户端 61 | 62 | ``` 63 | { 64 | "user": "用户名", 65 | "password": "明文密码" 66 | } 67 | 68 | ``` 69 | 70 | 华泰客户端 71 | 72 | ``` 73 | { 74 |  "user": "华泰用户名", 75 |  "password": "华泰明文密码", 76 |  "comm_password": "华泰通讯密码" 77 | } 78 | 79 | ``` 80 | 81 | 雪球 82 | 83 | ``` 84 | { 85 | "cookies": "雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/", 86 | "portfolio_code": "组合代码(例:ZH818559)", 87 | "portfolio_market": "交易市场(例:us 或者 cn 或者 hk)" 88 | } 89 | 90 | ``` 91 | 92 | # 直接连接通用同花顺客户端 93 | 94 | 需要先手动登陆客户端到交易窗口,然后运用下面的代码连接交易窗口 95 | 96 | ```python 97 | user.connect(r'客户端xiadan.exe路径') # 类似 r'C:\htzqzyb2\xiadan.exe' 98 | ``` 99 | 100 | ## 某些同花顺客户端不允许拷贝 `Grid` 数据导致无法获取持仓等问题的解决办法 101 | 102 | 现在默认获取 `Grid` 数据的策略是通过剪切板拷贝,有些券商不允许这种方式,所以额外实现了一种通过将 `Grid` 数据存为文件再读取的策略, 103 | 使用方式如下: 104 | 105 | ```python 106 | from easytrader import grid_strategies 107 | 108 | user.grid_strategy = grid_strategies.Xls 109 | ``` 110 | 111 | ### 交易相关 112 | 113 | 以下用法以银河为例 114 | 115 | #### 获取资金状况: 116 | 117 | ```python 118 | user.balance 119 | ``` 120 | 121 | **return** 122 | ```python 123 | [{'参考市值': 21642.0, 124 | '可用资金': 28494.21, 125 | '币种': '0', 126 | '总资产': 50136.21, 127 | '股份参考盈亏': -90.21, 128 | '资金余额': 28494.21, 129 | '资金帐号': 'xxx'}] 130 | ``` 131 | 132 | #### 获取持仓: 133 | 134 | ```python 135 | user.position 136 | ``` 137 | 138 | **return** 139 | ```python 140 | [{'买入冻结': 0, 141 | '交易市场': '沪A', 142 | '卖出冻结': '0', 143 | '参考市价': 4.71, 144 | '参考市值': 10362.0, 145 | '参考成本价': 4.672, 146 | '参考盈亏': 82.79, 147 | '当前持仓': 2200, 148 | '盈亏比例(%)': '0.81%', 149 | '股东代码': 'xxx', 150 | '股份余额': 2200, 151 | '股份可用': 2200, 152 | '证券代码': '601398', 153 | '证券名称': '工商银行'}] 154 | ``` 155 | 156 | #### 买入: 157 | 158 | ```python 159 | user.buy('162411', price=0.55, amount=100) 160 | ``` 161 | 162 | **return** 163 | 164 | ```python 165 | {'entrust_no': 'xxxxxxxx'} 166 | ``` 167 | 168 | 注: 系统可以配置是否返回成交回报。如果没配的话默认返回 `{"message": "success"}` 169 | 170 | #### 卖出: 171 | 172 | ```python 173 | user.sell('162411', price=0.55, amount=100) 174 | ``` 175 | 176 | **return** 177 | 178 | ```python 179 | {'entrust_no': 'xxxxxxxx'} 180 | ``` 181 | 182 | #### 一键打新 183 | 184 | ```python 185 | user.auto_ipo() 186 | ``` 187 | 188 | #### 撤单 189 | 190 | ```python 191 | user.cancel_entrust('buy/sell 获取的 entrust_no') 192 | ``` 193 | 194 | **return** 195 | 196 | ``` 197 | {'message': '撤单申报成功'} 198 | ``` 199 | 200 | 201 | #### 当日成交 202 | 203 | ```python 204 | user.today_trades 205 | ``` 206 | 207 | **return** 208 | 209 | ``` 210 | [{'买卖标志': '买入', 211 | '交易市场': '深A', 212 | '委托序号': '12345', 213 | '成交价格': 0.626, 214 | '成交数量': 100, 215 | '成交日期': '20170313', 216 | '成交时间': '09:50:30', 217 | '成交金额': 62.60, 218 | '股东代码': 'xxx', 219 | '证券代码': '162411', 220 | '证券名称': '华宝油气'}] 221 | ``` 222 | 223 | #### 当日委托 224 | 225 | ```python 226 | user.today_entrusts 227 | ``` 228 | 229 | **return** 230 | 231 | ``` 232 | [{'买卖标志': '买入', 233 | '交易市场': '深A', 234 | '委托价格': 0.627, 235 | '委托序号': '111111', 236 | '委托数量': 100, 237 | '委托日期': '20170313', 238 | '委托时间': '09:50:30', 239 | '成交数量': 100, 240 | '撤单数量': 0, 241 | '状态说明': '已成', 242 | '股东代码': 'xxxxx', 243 | '证券代码': '162411', 244 | '证券名称': '华宝油气'}, 245 | {'买卖标志': '买入', 246 | '交易市场': '深A', 247 | '委托价格': 0.6, 248 | '委托序号': '1111', 249 | '委托数量': 100, 250 | '委托日期': '20170313', 251 | '委托时间': '09:40:30', 252 | '成交数量': 0, 253 | '撤单数量': 100, 254 | '状态说明': '已撤', 255 | '股东代码': 'xxx', 256 | '证券代码': '162411', 257 | '证券名称': '华宝油气'}] 258 | ``` 259 | 260 | 261 | #### 查询今天可以申购的新股信息 262 | 263 | ```python 264 | from easytrader import helpers 265 | ipo_data = helpers.get_today_ipo_data() 266 | print(ipo_data) 267 | ``` 268 | 269 | **return** 270 | 271 | ```python 272 | [{'stock_code': '股票代码', 273 | 'stock_name': '股票名称', 274 | 'price': 发行价, 275 | 'apply_code': '申购代码'}] 276 | ``` 277 | 278 | #### 退出客户端软件 279 | 280 | ``` 281 | user.exit() 282 | ``` 283 | 284 | #### 刷新数据 285 | 286 | ``` 287 | user.refresh() 288 | ``` 289 | 290 | ### 远端服务器模式 291 | 292 | #### 在服务器上启动服务 293 | 294 | ```python 295 | from easytrader import server 296 | 297 | server.run(port=1430) # 默认端口为 1430 298 | ``` 299 | 300 | #### 远程客户端调用 301 | 302 | ```python 303 | from easytrader import remoteclient 304 | 305 | user = remoteclient.use('使用客户端类型,可选 yh_client, ht_client 等', host='服务器ip', port='服务器端口,默认为1430') 306 | 307 | 其他用法同上 308 | ``` 309 | 310 | 311 | #### 雪球组合调仓 312 | 313 | ```python 314 | user.adjust_weight('000001', 10) 315 | ``` 316 | 317 | 318 | ### 跟踪 joinquant / ricequant 的模拟交易 319 | 320 | #### 初始化跟踪的 trader 321 | 322 | 这里以雪球为例, 也可以使用银河之类 easytrader 支持的券商 323 | 324 | ``` 325 | xq_user = easytrader.use('xq') 326 | xq_user.prepare('xq.json') 327 | ``` 328 | 329 | #### 初始化跟踪 joinquant / ricequant 的 follower 330 | 331 | ``` 332 | target = 'jq' # joinquant 333 | target = 'rq' # ricequant 334 | follower = easytrader.follower(target) 335 | follower.login(user='rq/jq用户名', password='rq/jq密码') 336 | ``` 337 | 338 | #### 连接 follower 和 trader 339 | 340 | ##### joinquant 341 | ``` 342 | follower.follow(xq_user, 'jq的模拟交易url') 343 | ``` 344 | 345 | 注: jq的模拟交易url指的是对应模拟交易对应的可以查看持仓, 交易记录的页面, 类似 `https://www.joinquant.com/algorithm/live/index?backtestId=xxx` 346 | 347 | ##### ricequant 348 | 349 | ``` 350 | follower.follow(xq_user, run_id) 351 | ``` 352 | 注:ricequant的run_id即PT列表中的ID。 353 | 354 | 正常会输出 355 | 356 | ![](https://raw.githubusercontent.com/shidenggui/assets/master/easytrader/joinquant.jpg) 357 | 358 | enjoy it 359 | 360 | ### 跟踪 雪球的组合 361 | 362 | #### 初始化跟踪的 trader 363 | 364 | 同上 365 | 366 | #### 初始化跟踪 雪球组合 的 follower 367 | 368 | ``` 369 | xq_follower = easytrader.follower('xq') 370 | xq_follower.login(cookies='雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/') 371 | ``` 372 | 373 | #### 连接 follower 和 trader 374 | 375 | ``` 376 | xq_follower.follow(xq_user, 'xq组合ID,类似ZH123456', total_assets=100000) 377 | ``` 378 | 379 | 380 | 注: 雪球组合是以百分比调仓的, 所以需要额外设置组合对应的资金额度 381 | 382 | * 这里可以设置 total_assets, 为当前组合的净值对应的总资金额度, 具体可以参考参数说明 383 | * 或者设置 initial_assets, 这时候总资金额度为 initial_assets * 组合净值 384 | 385 | * 雪球额外支持 adjust_sell 参数,决定是否根据用户的实际持仓数调整卖出股票数量,解决雪球根据百分比调仓时计算出的股数有偏差的问题。当卖出股票数大于实际持仓数时,调整为实际持仓数。目前仅在银河客户端测试通过。 当 users 为多个时,根据第一个 user 的持仓数决定 386 | 387 | 388 | #### 多用户跟踪多策略 389 | 390 | ``` 391 | follower.follow(users=[xq_user, yh_user], strategies=['组合1', '组合2'], total_assets=[10000, 10000]) 392 | ``` 393 | 394 | #### 目录下产生的 cmd_cache.pk 395 | 396 | 这是用来存储历史执行过的交易指令,防止在重启程序时重复执行交易过的指令,可以通过 `follower.follow(xxx, cmd_cache=False)` 来关闭 397 | 398 | #### 使用市价单跟踪模式,目前仅支持银河 399 | 400 | ``` 401 | follower.follow(***, entrust_prop='market') 402 | ``` 403 | 404 | #### 调整下单间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 405 | 406 | ``` 407 | follower.follow(***, send_interval=30) # 设置下单间隔为 30 s 408 | ``` 409 | #### 设置买卖时的滑点 410 | 411 | ``` 412 | follower.follow(***, slippage=0.05) # 设置滑点为 5% 413 | ``` 414 | 415 | ### 命令行模式 416 | 417 | #### 登录 418 | 419 | ``` 420 | python cli.py --use yh --prepare gf.json 421 | ``` 422 | 423 | 注: 此时会生成 `account.session` 文件保存生成的 `user` 对象 424 | 425 | #### 获取余额 / 持仓 / 以及其他变量 426 | 427 | ``` 428 | python cli.py --get balance 429 | ``` 430 | 431 | #### 买卖 / 撤单 432 | 433 | ``` 434 | python cli.py --do buy 162411 0.450 100 435 | ``` 436 | #### 查看帮助 437 | 438 | ``` 439 | python cli.py --help 440 | ``` 441 | 442 | 443 | -------------------------------------------------------------------------------- /easytrader/xq_follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, print_function, unicode_literals 3 | 4 | import json 5 | import re 6 | from datetime import datetime 7 | from numbers import Number 8 | from threading import Thread 9 | 10 | from . import helpers 11 | from .follower import BaseFollower 12 | from .log import log 13 | 14 | 15 | class XueQiuFollower(BaseFollower): 16 | LOGIN_PAGE = 'https://www.xueqiu.com' 17 | LOGIN_API = 'https://xueqiu.com/snowman/login' 18 | TRANSACTION_API = 'https://xueqiu.com/cubes/rebalancing/history.json' 19 | PORTFOLIO_URL = 'https://xueqiu.com/p/' 20 | WEB_REFERER = 'https://www.xueqiu.com' 21 | 22 | def __init__(self): 23 | super().__init__() 24 | self._adjust_sell = None 25 | self._users = None 26 | 27 | def login(self, user=None, password=None, **kwargs): 28 | """ 29 | 雪球登陆, 需要设置 cookies 30 | :param cookies: 雪球登陆需要设置 cookies, 具体见 31 | https://smalltool.github.io/2016/08/02/cookie/ 32 | :return: 33 | """ 34 | cookies = kwargs.get('cookies') 35 | if cookies is None: 36 | raise TypeError('雪球登陆需要设置 cookies, 具体见' 37 | 'https://smalltool.github.io/2016/08/02/cookie/') 38 | headers = self._generate_headers() 39 | self.s.headers.update(headers) 40 | 41 | self.s.get(self.LOGIN_PAGE) 42 | 43 | cookie_dict = helpers.parse_cookies_str(cookies) 44 | self.s.cookies.update(cookie_dict) 45 | 46 | log.info('登录成功') 47 | 48 | def follow( # type: ignore 49 | self, 50 | users, 51 | strategies, 52 | total_assets=10000, 53 | initial_assets=None, 54 | adjust_sell=False, 55 | track_interval=10, 56 | trade_cmd_expire_seconds=120, 57 | cmd_cache=True, 58 | slippage: float = 0.0): 59 | """跟踪 joinquant 对应的模拟交易,支持多用户多策略 60 | :param users: 支持 easytrader 的用户对象,支持使用 [] 指定多个用户 61 | :param strategies: 雪球组合名, 类似 ZH123450 62 | :param total_assets: 雪球组合对应的总资产, 格式 [组合1对应资金, 组合2对应资金] 63 | 若 strategies=['ZH000001', 'ZH000002'], 64 | 设置 total_assets=[10000, 10000], 则表明每个组合对应的资产为 1w 元 65 | 假设组合 ZH000001 加仓 价格为 p 股票 A 10%, 66 | 则对应的交易指令为 买入 股票 A 价格 P 股数 1w * 10% / p 并按 100 取整 67 | :param adjust_sell: 是否根据用户的实际持仓数调整卖出股票数量, 68 | 当卖出股票数大于实际持仓数时,调整为实际持仓数。目前仅在银河客户端测试通过。 69 | 当 users 为多个时,根据第一个 user 的持仓数决定 70 | :type adjust_sell: bool 71 | :param initial_assets: 雪球组合对应的初始资产, 72 | 格式 [ 组合1对应资金, 组合2对应资金 ] 73 | 总资产由 初始资产 × 组合净值 算得, total_assets 会覆盖此参数 74 | :param track_interval: 轮训模拟交易时间,单位为秒 75 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 76 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 77 | :param slippage: 滑点,0.0 表示无滑点, 0.05 表示滑点为 5% 78 | """ 79 | super().follow(users=users, 80 | strategies=strategies, 81 | track_interval=track_interval, 82 | trade_cmd_expire_seconds=trade_cmd_expire_seconds, 83 | cmd_cache=cmd_cache, 84 | slippage=slippage) 85 | 86 | self._adjust_sell = adjust_sell 87 | 88 | self._users = self.warp_list(users) 89 | 90 | strategies = self.warp_list(strategies) 91 | total_assets = self.warp_list(total_assets) 92 | initial_assets = self.warp_list(initial_assets) 93 | 94 | if cmd_cache: 95 | self.load_expired_cmd_cache() 96 | 97 | self.start_trader_thread(self._users, trade_cmd_expire_seconds) 98 | 99 | for strategy_url, strategy_total_assets, strategy_initial_assets in zip( 100 | strategies, total_assets, initial_assets): 101 | assets = self.calculate_assets(strategy_url, strategy_total_assets, 102 | strategy_initial_assets) 103 | try: 104 | strategy_id = self.extract_strategy_id(strategy_url) 105 | strategy_name = self.extract_strategy_name(strategy_url) 106 | except: 107 | log.error('抽取交易id和策略名失败, 无效模拟交易url: %s', strategy_url) 108 | raise 109 | strategy_worker = Thread( 110 | target=self.track_strategy_worker, 111 | args=[strategy_id, strategy_name], 112 | kwargs={ 113 | 'interval': track_interval, 114 | 'assets': assets 115 | }) 116 | strategy_worker.start() 117 | log.info('开始跟踪策略: %s', strategy_name) 118 | 119 | def calculate_assets(self, 120 | strategy_url, 121 | total_assets=None, 122 | initial_assets=None): 123 | # 都设置时优先选择 total_assets 124 | if total_assets is None and initial_assets is not None: 125 | net_value = self._get_portfolio_net_value(strategy_url) 126 | total_assets = initial_assets * net_value 127 | if not isinstance(total_assets, Number): 128 | raise TypeError('input assets type must be number(int, float)') 129 | if total_assets < 1e3: 130 | raise ValueError('雪球总资产不能小于1000元,当前预设值 {}'.format(total_assets)) 131 | return total_assets 132 | 133 | @staticmethod 134 | def extract_strategy_id(strategy_url): 135 | return strategy_url 136 | 137 | def extract_strategy_name(self, strategy_url): 138 | base_url = 'https://xueqiu.com/cubes/nav_daily/all.json?cube_symbol={}' 139 | url = base_url.format(strategy_url) 140 | rep = self.s.get(url) 141 | info_index = 0 142 | return rep.json()[info_index]['name'] 143 | 144 | def extract_transactions(self, history): 145 | print(history) 146 | if history['count'] <= 0: 147 | return [] 148 | rebalancing_index = 0 149 | transactions = history['list'][rebalancing_index][ 150 | 'rebalancing_histories'] 151 | return transactions 152 | 153 | def create_query_transaction_params(self, strategy): 154 | params = {'cube_symbol': strategy, 'page': 1, 'count': 1} 155 | return params 156 | 157 | # noinspection PyMethodOverriding 158 | def none_to_zero(self, data): 159 | if data is None: 160 | return 0 161 | return data 162 | 163 | # noinspection PyMethodOverriding 164 | def project_transactions(self, transactions, assets): 165 | for transaction in transactions: 166 | weight_diff = self.none_to_zero( 167 | transaction['weight']) - self.none_to_zero( 168 | transaction['prev_weight']) 169 | 170 | initial_amount = abs(weight_diff) / 100 * assets / transaction[ 171 | 'price'] 172 | 173 | transaction['datetime'] = datetime.fromtimestamp( 174 | transaction['created_at'] // 1000) 175 | 176 | transaction['stock_code'] = transaction['stock_symbol'].lower() 177 | 178 | transaction['action'] = 'buy' if weight_diff > 0 else 'sell' 179 | 180 | transaction['amount'] = int(round(initial_amount, -2)) 181 | if transaction['action'] == 'sell' and self._adjust_sell: 182 | transaction['amount'] = self._adjust_sell_amount( 183 | transaction['stock_code'], 184 | transaction['amount']) 185 | 186 | def _adjust_sell_amount(self, stock_code, amount): 187 | """ 188 | 根据实际持仓值计算雪球卖出股数 189 | 因为雪球的交易指令是基于持仓百分比,在取近似值的情况下可能出现不精确的问题。 190 | 导致如下情况的产生,计算出的指令为买入 1049 股,取近似值买入 1000 股。 191 | 而卖出的指令计算出为卖出 1051 股,取近似值卖出 1100 股,超过 1000 股的买入量, 192 | 导致卖出失败 193 | :param stock_code: 证券代码 194 | :type stock_code: str 195 | :param amount: 卖出股份数 196 | :type amount: int 197 | :return: 考虑实际持仓之后的卖出股份数 198 | :rtype: int 199 | """ 200 | stock_code = stock_code[-6:] 201 | user = self._users[0] 202 | position = user.position 203 | try: 204 | stock = next(s for s in position if s['证券代码'] == stock_code) 205 | except StopIteration: 206 | log.info('根据持仓调整 %s 卖出额,发现未持有股票 %s, 不做任何调整', 207 | stock_code, stock_code) 208 | return amount 209 | 210 | available_amount = stock['可用余额'] 211 | if available_amount >= amount: 212 | return amount 213 | 214 | adjust_amount = available_amount // 100 * 100 215 | log.info('股票 %s 实际可用余额 %s, 指令卖出股数为 %s, 调整为 %s', 216 | stock_code, available_amount, amount, adjust_amount) 217 | return adjust_amount 218 | 219 | def _get_portfolio_info(self, portfolio_code): 220 | """ 221 | 获取组合信息 222 | """ 223 | url = self.PORTFOLIO_URL + portfolio_code 224 | portfolio_page = self.s.get(url) 225 | match_info = re.search(r'(?<=SNB.cubeInfo = ).*(?=;\n)', 226 | portfolio_page.text) 227 | if match_info is None: 228 | raise Exception( 229 | 'cant get portfolio info, portfolio url : {}'.format(url)) 230 | try: 231 | portfolio_info = json.loads(match_info.group()) 232 | except Exception as e: 233 | raise Exception('get portfolio info error: {}'.format(e)) 234 | return portfolio_info 235 | 236 | def _get_portfolio_net_value(self, portfolio_code): 237 | """ 238 | 获取组合信息 239 | """ 240 | portfolio_info = self._get_portfolio_info(portfolio_code) 241 | return portfolio_info['net_value'] 242 | -------------------------------------------------------------------------------- /easytrader/clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import functools 4 | import os 5 | import sys 6 | import time 7 | from typing import Type 8 | 9 | import easyutils 10 | 11 | from . import grid_strategies, helpers, pop_dialog_handler 12 | from .config import client 13 | 14 | if not sys.platform.startswith("darwin"): 15 | import pywinauto 16 | import pywinauto.clipboard 17 | 18 | 19 | class IClientTrader(abc.ABC): 20 | @property 21 | @abc.abstractmethod 22 | def app(self): 23 | """Return current app instance""" 24 | pass 25 | 26 | @property 27 | @abc.abstractmethod 28 | def main(self): 29 | """Return current main window instance""" 30 | pass 31 | 32 | @property 33 | @abc.abstractmethod 34 | def config(self): 35 | """Return current config instance""" 36 | pass 37 | 38 | @abc.abstractmethod 39 | def wait(self, seconds: float): 40 | """Wait for operation return""" 41 | pass 42 | 43 | @abc.abstractmethod 44 | def refresh(self): 45 | """Refresh data""" 46 | pass 47 | 48 | 49 | class ClientTrader(IClientTrader): 50 | # The strategy to use for getting grid data 51 | grid_strategy: Type[grid_strategies.IGridStrategy] = grid_strategies.Copy 52 | 53 | def __init__(self): 54 | self._config = client.create(self.broker_type) 55 | self._app = None 56 | self._main = None 57 | 58 | @property 59 | def app(self): 60 | return self._app 61 | 62 | @property 63 | def main(self): 64 | return self._main 65 | 66 | @property 67 | def config(self): 68 | return self._config 69 | 70 | def connect(self, exe_path=None, **kwargs): 71 | """ 72 | 直接连接登陆后的客户端 73 | :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' 74 | :return: 75 | """ 76 | connect_path = exe_path or self._config.DEFAULT_EXE_PATH 77 | if connect_path is None: 78 | raise ValueError( 79 | "参数 exe_path 未设置,请设置客户端对应的 exe 地址,类似 C:\\客户端安装目录\\xiadan.exe" 80 | ) 81 | 82 | self._app = pywinauto.Application().connect( 83 | path=connect_path, timeout=10 84 | ) 85 | self._close_prompt_windows() 86 | self._main = self._app.top_window() 87 | 88 | @property 89 | def broker_type(self): 90 | return "ths" 91 | 92 | @property 93 | def balance(self): 94 | self._switch_left_menus(["查询[F4]", "资金股票"]) 95 | 96 | return self._get_balance_from_statics() 97 | 98 | def _get_balance_from_statics(self): 99 | result = {} 100 | for key, control_id in self._config.BALANCE_CONTROL_ID_GROUP.items(): 101 | result[key] = float( 102 | self._main.window( 103 | control_id=control_id, class_name="Static" 104 | ).window_text() 105 | ) 106 | return result 107 | 108 | @property 109 | def position(self): 110 | self._switch_left_menus(["查询[F4]", "资金股票"]) 111 | 112 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 113 | 114 | @property 115 | def today_entrusts(self): 116 | self._switch_left_menus(["查询[F4]", "当日委托"]) 117 | 118 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 119 | 120 | @property 121 | def today_trades(self): 122 | self._switch_left_menus(["查询[F4]", "当日成交"]) 123 | 124 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 125 | 126 | @property 127 | def cancel_entrusts(self): 128 | self.refresh() 129 | self._switch_left_menus(["撤单[F3]"]) 130 | 131 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 132 | 133 | def cancel_entrust(self, entrust_no): 134 | self.refresh() 135 | for i, entrust in enumerate(self.cancel_entrusts): 136 | if ( 137 | entrust[self._config.CANCEL_ENTRUST_ENTRUST_FIELD] 138 | == entrust_no 139 | ): 140 | self._cancel_entrust_by_double_click(i) 141 | return self._handle_pop_dialogs() 142 | return {"message": "委托单状态错误不能撤单, 该委托单可能已经成交或者已撤"} 143 | 144 | def buy(self, security, price, amount, **kwargs): 145 | self._switch_left_menus(["买入[F1]"]) 146 | 147 | return self.trade(security, price, amount) 148 | 149 | def sell(self, security, price, amount, **kwargs): 150 | self._switch_left_menus(["卖出[F2]"]) 151 | 152 | return self.trade(security, price, amount) 153 | 154 | def market_buy(self, security, amount, ttype=None, **kwargs): 155 | """ 156 | 市价买入 157 | :param security: 六位证券代码 158 | :param amount: 交易数量 159 | :param ttype: 市价委托类型,默认客户端默认选择, 160 | 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] 161 | 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] 162 | 163 | :return: {'entrust_no': '委托单号'} 164 | """ 165 | self._switch_left_menus(["市价委托", "买入"]) 166 | 167 | return self.market_trade(security, amount, ttype) 168 | 169 | def market_sell(self, security, amount, ttype=None, **kwargs): 170 | """ 171 | 市价卖出 172 | :param security: 六位证券代码 173 | :param amount: 交易数量 174 | :param ttype: 市价委托类型,默认客户端默认选择, 175 | 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] 176 | 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] 177 | 178 | :return: {'entrust_no': '委托单号'} 179 | """ 180 | self._switch_left_menus(["市价委托", "卖出"]) 181 | 182 | return self.market_trade(security, amount, ttype) 183 | 184 | def market_trade(self, security, amount, ttype=None, **kwargs): 185 | """ 186 | 市价交易 187 | :param security: 六位证券代码 188 | :param amount: 交易数量 189 | :param ttype: 市价委托类型,默认客户端默认选择, 190 | 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] 191 | 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] 192 | 193 | :return: {'entrust_no': '委托单号'} 194 | """ 195 | self._set_market_trade_params(security, amount) 196 | if ttype is not None: 197 | self._set_market_trade_type(ttype) 198 | self._submit_trade() 199 | 200 | return self._handle_pop_dialogs( 201 | handler_class=pop_dialog_handler.TradePopDialogHandler 202 | ) 203 | 204 | def _set_market_trade_type(self, ttype): 205 | """根据选择的市价交易类型选择对应的下拉选项""" 206 | selects = self._main.window( 207 | control_id=self._config.TRADE_MARKET_TYPE_CONTROL_ID, 208 | class_name="ComboBox", 209 | ) 210 | for i, text in selects.texts(): 211 | # skip 0 index, because 0 index is current select index 212 | if i == 0: 213 | continue 214 | if ttype in text: 215 | selects.select(i - 1) 216 | break 217 | else: 218 | raise TypeError("不支持对应的市价类型: {}".format(ttype)) 219 | 220 | def auto_ipo(self): 221 | self._switch_left_menus(self._config.AUTO_IPO_MENU_PATH) 222 | 223 | stock_list = self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 224 | 225 | if len(stock_list) == 0: 226 | return {"message": "今日无新股"} 227 | invalid_list_idx = [ 228 | i for i, v in enumerate(stock_list) if v["申购数量"] <= 0 229 | ] 230 | 231 | if len(stock_list) == len(invalid_list_idx): 232 | return {"message": "没有发现可以申购的新股"} 233 | 234 | self._click(self._config.AUTO_IPO_SELECT_ALL_BUTTON_CONTROL_ID) 235 | self.wait(0.1) 236 | 237 | for row in invalid_list_idx: 238 | self._click_grid_by_row(row) 239 | self.wait(0.1) 240 | 241 | self._click(self._config.AUTO_IPO_BUTTON_CONTROL_ID) 242 | self.wait(0.1) 243 | 244 | return self._handle_pop_dialogs() 245 | 246 | def _click_grid_by_row(self, row): 247 | x = self._config.COMMON_GRID_LEFT_MARGIN 248 | y = ( 249 | self._config.COMMON_GRID_FIRST_ROW_HEIGHT 250 | + self._config.COMMON_GRID_ROW_HEIGHT * row 251 | ) 252 | self._app.top_window().window( 253 | control_id=self._config.COMMON_GRID_CONTROL_ID, 254 | class_name="CVirtualGridCtrl", 255 | ).click(coords=(x, y)) 256 | 257 | def _is_exist_pop_dialog(self): 258 | self.wait(0.2) # wait dialog display 259 | return ( 260 | self._main.wrapper_object() 261 | != self._app.top_window().wrapper_object() 262 | ) 263 | 264 | def _run_exe_path(self, exe_path): 265 | return os.path.join(os.path.dirname(exe_path), "xiadan.exe") 266 | 267 | def wait(self, seconds): 268 | time.sleep(seconds) 269 | 270 | def exit(self): 271 | self._app.kill() 272 | 273 | def _close_prompt_windows(self): 274 | self.wait(1) 275 | for window in self._app.windows(class_name="#32770"): 276 | if window.window_text() != self._config.TITLE: 277 | window.close() 278 | self.wait(1) 279 | 280 | def trade(self, security, price, amount): 281 | self._set_trade_params(security, price, amount) 282 | 283 | self._submit_trade() 284 | 285 | return self._handle_pop_dialogs( 286 | handler_class=pop_dialog_handler.TradePopDialogHandler 287 | ) 288 | 289 | def _click(self, control_id): 290 | self._app.top_window().window( 291 | control_id=control_id, class_name="Button" 292 | ).click() 293 | 294 | def _submit_trade(self): 295 | time.sleep(0.05) 296 | self._main.window( 297 | control_id=self._config.TRADE_SUBMIT_CONTROL_ID, 298 | class_name="Button", 299 | ).click() 300 | 301 | def _get_pop_dialog_title(self): 302 | return ( 303 | self._app.top_window() 304 | .window(control_id=self._config.POP_DIALOD_TITLE_CONTROL_ID) 305 | .window_text() 306 | ) 307 | 308 | def _set_trade_params(self, security, price, amount): 309 | code = security[-6:] 310 | 311 | self._type_keys(self._config.TRADE_SECURITY_CONTROL_ID, code) 312 | 313 | # wait security input finish 314 | self.wait(0.1) 315 | 316 | self._type_keys( 317 | self._config.TRADE_PRICE_CONTROL_ID, 318 | easyutils.round_price_by_code(price, code), 319 | ) 320 | self._type_keys(self._config.TRADE_AMOUNT_CONTROL_ID, str(int(amount))) 321 | 322 | def _set_market_trade_params(self, security, amount): 323 | code = security[-6:] 324 | 325 | self._type_keys(self._config.TRADE_SECURITY_CONTROL_ID, code) 326 | 327 | # wait security input finish 328 | self.wait(0.1) 329 | 330 | self._type_keys(self._config.TRADE_AMOUNT_CONTROL_ID, str(int(amount))) 331 | 332 | def _get_grid_data(self, control_id): 333 | return self.grid_strategy(self).get(control_id) 334 | 335 | def _type_keys(self, control_id, text): 336 | self._main.window( 337 | control_id=control_id, class_name="Edit" 338 | ).set_edit_text(text) 339 | 340 | def _switch_left_menus(self, path, sleep=0.2): 341 | self._get_left_menus_handle().get_item(path).click() 342 | self.wait(sleep) 343 | 344 | def _switch_left_menus_by_shortcut(self, shortcut, sleep=0.5): 345 | self._app.top_window().type_keys(shortcut) 346 | self.wait(sleep) 347 | 348 | @functools.lru_cache() 349 | def _get_left_menus_handle(self): 350 | while True: 351 | try: 352 | handle = self._main.window( 353 | control_id=129, class_name="SysTreeView32" 354 | ) 355 | # sometime can't find handle ready, must retry 356 | handle.wait("ready", 2) 357 | return handle 358 | # pylint: disable=broad-except 359 | except Exception: 360 | pass 361 | 362 | def _cancel_entrust_by_double_click(self, row): 363 | x = self._config.CANCEL_ENTRUST_GRID_LEFT_MARGIN 364 | y = ( 365 | self._config.CANCEL_ENTRUST_GRID_FIRST_ROW_HEIGHT 366 | + self._config.CANCEL_ENTRUST_GRID_ROW_HEIGHT * row 367 | ) 368 | self._app.top_window().window( 369 | control_id=self._config.COMMON_GRID_CONTROL_ID, 370 | class_name="CVirtualGridCtrl", 371 | ).double_click(coords=(x, y)) 372 | 373 | def refresh(self): 374 | self._switch_left_menus(["买入[F1]"], sleep=0.05) 375 | 376 | def _handle_pop_dialogs( 377 | self, handler_class=pop_dialog_handler.PopDialogHandler 378 | ): 379 | handler = handler_class(self._app) 380 | 381 | while self._is_exist_pop_dialog(): 382 | title = self._get_pop_dialog_title() 383 | 384 | result = handler.handle(title) 385 | if result: 386 | return result 387 | return {"message": "success"} 388 | 389 | 390 | class BaseLoginClientTrader(ClientTrader): 391 | @abc.abstractmethod 392 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 393 | """Login Client Trader""" 394 | pass 395 | 396 | def prepare( 397 | self, 398 | config_path=None, 399 | user=None, 400 | password=None, 401 | exe_path=None, 402 | comm_password=None, 403 | **kwargs 404 | ): 405 | """ 406 | 登陆客户端 407 | :param config_path: 登陆配置文件,跟参数登陆方式二选一 408 | :param user: 账号 409 | :param password: 明文密码 410 | :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' 411 | :param comm_password: 通讯密码 412 | :return: 413 | """ 414 | if config_path is not None: 415 | account = helpers.file2dict(config_path) 416 | user = account["user"] 417 | password = account["password"] 418 | comm_password = account.get("comm_password") 419 | exe_path = account.get("exe_path") 420 | self.login( 421 | user, 422 | password, 423 | exe_path or self._config.DEFAULT_EXE_PATH, 424 | comm_password, 425 | **kwargs 426 | ) 427 | -------------------------------------------------------------------------------- /easytrader/follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import datetime 4 | import os 5 | import pickle 6 | import queue 7 | import re 8 | import threading 9 | import time 10 | from typing import List 11 | 12 | import requests 13 | 14 | from . import exceptions 15 | from .log import log 16 | 17 | 18 | class BaseFollower(metaclass=abc.ABCMeta): 19 | """ 20 | slippage: 滑点,取值范围为 [0, 1] 21 | """ 22 | 23 | LOGIN_PAGE = "" 24 | LOGIN_API = "" 25 | TRANSACTION_API = "" 26 | CMD_CACHE_FILE = "cmd_cache.pk" 27 | WEB_REFERER = "" 28 | WEB_ORIGIN = "" 29 | 30 | def __init__(self): 31 | self.trade_queue = queue.Queue() 32 | self.expired_cmds = set() 33 | 34 | self.s = requests.Session() 35 | 36 | self.slippage: float = 0.0 37 | 38 | def login(self, user=None, password=None, **kwargs): 39 | """ 40 | 登陆接口 41 | :param user: 用户名 42 | :param password: 密码 43 | :param kwargs: 其他参数 44 | :return: 45 | """ 46 | headers = self._generate_headers() 47 | self.s.headers.update(headers) 48 | 49 | # init cookie 50 | self.s.get(self.LOGIN_PAGE) 51 | 52 | # post for login 53 | params = self.create_login_params(user, password, **kwargs) 54 | rep = self.s.post(self.LOGIN_API, data=params) 55 | 56 | self.check_login_success(rep) 57 | log.info("登录成功") 58 | 59 | def _generate_headers(self): 60 | headers = { 61 | "Accept": "application/json, text/javascript, */*; q=0.01", 62 | "Accept-Encoding": "gzip, deflate, br", 63 | "Accept-Language": "en-US,en;q=0.8", 64 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) " 65 | "AppleWebKit/537.36 (KHTML, like Gecko) " 66 | "Chrome/54.0.2840.100 Safari/537.36", 67 | "Referer": self.WEB_REFERER, 68 | "X-Requested-With": "XMLHttpRequest", 69 | "Origin": self.WEB_ORIGIN, 70 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 71 | } 72 | return headers 73 | 74 | def check_login_success(self, rep): 75 | """检查登录状态是否成功 76 | :param rep: post login 接口返回的 response 对象 77 | :raise 如果登录失败应该抛出 NotLoginError """ 78 | pass 79 | 80 | def create_login_params(self, user, password, **kwargs) -> dict: 81 | """生成 post 登录接口的参数 82 | :param user: 用户名 83 | :param password: 密码 84 | :return dict 登录参数的字典 85 | """ 86 | return {} 87 | 88 | def follow( 89 | self, 90 | users, 91 | strategies, 92 | track_interval=1, 93 | trade_cmd_expire_seconds=120, 94 | cmd_cache=True, 95 | slippage: float = 0.0, 96 | **kwargs 97 | ): 98 | """跟踪平台对应的模拟交易,支持多用户多策略 99 | 100 | :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 101 | :param strategies: 雪球组合名, 类似 ZH123450 102 | :param total_assets: 雪球组合对应的总资产, 格式 [ 组合1对应资金, 组合2对应资金 ] 103 | 若 strategies=['ZH000001', 'ZH000002'] 设置 total_assets=[10000, 10000], 则表明每个组合对应的资产为 1w 元, 104 | 假设组合 ZH000001 加仓 价格为 p 股票 A 10%, 则对应的交易指令为 买入 股票 A 价格 P 股数 1w * 10% / p 并按 100 取整 105 | :param initial_assets:雪球组合对应的初始资产, 格式 [ 组合1对应资金, 组合2对应资金 ] 106 | 总资产由 初始资产 × 组合净值 算得, total_assets 会覆盖此参数 107 | :param track_interval: 轮询模拟交易时间,单位为秒 108 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 109 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 110 | :param slippage: 滑点,0.0 表示无滑点, 0.05 表示滑点为 5% 111 | """ 112 | self.slippage = slippage 113 | 114 | def _calculate_price_by_slippage(self, action: str, price: float) -> float: 115 | """ 116 | 计算考虑滑点之后的价格 117 | :param action: 交易动作, 支持 ['buy', 'sell'] 118 | :param price: 原始交易价格 119 | :return: 考虑滑点后的交易价格 120 | """ 121 | if action == "buy": 122 | return price * (1 + self.slippage) 123 | if action == "sell": 124 | return price * (1 - self.slippage) 125 | return price 126 | 127 | def load_expired_cmd_cache(self): 128 | if os.path.exists(self.CMD_CACHE_FILE): 129 | with open(self.CMD_CACHE_FILE, "rb") as f: 130 | self.expired_cmds = pickle.load(f) 131 | 132 | def start_trader_thread( 133 | self, 134 | users, 135 | trade_cmd_expire_seconds, 136 | entrust_prop="limit", 137 | send_interval=0, 138 | ): 139 | trader = threading.Thread( 140 | target=self.trade_worker, 141 | args=[users], 142 | kwargs={ 143 | "expire_seconds": trade_cmd_expire_seconds, 144 | "entrust_prop": entrust_prop, 145 | "send_interval": send_interval, 146 | }, 147 | ) 148 | trader.setDaemon(True) 149 | trader.start() 150 | 151 | @staticmethod 152 | def warp_list(value): 153 | if not isinstance(value, list): 154 | value = [value] 155 | return value 156 | 157 | @staticmethod 158 | def extract_strategy_id(strategy_url): 159 | """ 160 | 抽取 策略 id,一般用于获取策略相关信息 161 | :param strategy_url: 策略 url 162 | :return: str 策略 id 163 | """ 164 | pass 165 | 166 | def extract_strategy_name(self, strategy_url): 167 | """ 168 | 抽取 策略名,主要用于日志打印,便于识别 169 | :param strategy_url: 170 | :return: str 策略名 171 | """ 172 | pass 173 | 174 | def track_strategy_worker(self, strategy, name, interval=10, **kwargs): 175 | """跟踪下单worker 176 | :param strategy: 策略id 177 | :param name: 策略名字 178 | :param interval: 轮询策略的时间间隔,单位为秒""" 179 | while True: 180 | try: 181 | transactions = self.query_strategy_transaction( 182 | strategy, **kwargs 183 | ) 184 | # pylint: disable=broad-except 185 | except Exception as e: 186 | log.warning("无法获取策略 %s 调仓信息, 错误: %s, 跳过此次调仓查询", name, e) 187 | continue 188 | for transaction in transactions: 189 | trade_cmd = { 190 | "strategy": strategy, 191 | "strategy_name": name, 192 | "action": transaction["action"], 193 | "stock_code": transaction["stock_code"], 194 | "amount": transaction["amount"], 195 | "price": transaction["price"], 196 | "datetime": transaction["datetime"], 197 | } 198 | if self.is_cmd_expired(trade_cmd): 199 | continue 200 | log.info( 201 | "策略 [%s] 发送指令到交易队列, 股票: %s 动作: %s 数量: %s 价格: %s 信号产生时间: %s", 202 | name, 203 | trade_cmd["stock_code"], 204 | trade_cmd["action"], 205 | trade_cmd["amount"], 206 | trade_cmd["price"], 207 | trade_cmd["datetime"], 208 | ) 209 | self.trade_queue.put(trade_cmd) 210 | self.add_cmd_to_expired_cmds(trade_cmd) 211 | try: 212 | for _ in range(interval): 213 | time.sleep(1) 214 | except KeyboardInterrupt: 215 | log.info("程序退出") 216 | break 217 | 218 | @staticmethod 219 | def generate_expired_cmd_key(cmd): 220 | return "{}_{}_{}_{}_{}_{}".format( 221 | cmd["strategy_name"], 222 | cmd["stock_code"], 223 | cmd["action"], 224 | cmd["amount"], 225 | cmd["price"], 226 | cmd["datetime"], 227 | ) 228 | 229 | def is_cmd_expired(self, cmd): 230 | key = self.generate_expired_cmd_key(cmd) 231 | return key in self.expired_cmds 232 | 233 | def add_cmd_to_expired_cmds(self, cmd): 234 | key = self.generate_expired_cmd_key(cmd) 235 | self.expired_cmds.add(key) 236 | 237 | with open(self.CMD_CACHE_FILE, "wb") as f: 238 | pickle.dump(self.expired_cmds, f) 239 | 240 | @staticmethod 241 | def _is_number(s): 242 | try: 243 | float(s) 244 | return True 245 | except ValueError: 246 | return False 247 | 248 | def _execute_trade_cmd( 249 | self, trade_cmd, users, expire_seconds, entrust_prop, send_interval 250 | ): 251 | """分发交易指令到对应的 user 并执行 252 | :param trade_cmd: 253 | :param users: 254 | :param expire_seconds: 255 | :param entrust_prop: 256 | :param send_interval: 257 | :return: 258 | """ 259 | for user in users: 260 | # check expire 261 | now = datetime.datetime.now() 262 | expire = (now - trade_cmd["datetime"]).total_seconds() 263 | if expire > expire_seconds: 264 | log.warning( 265 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 超过设置的最大过期时间 %s 秒, 被丢弃", 266 | trade_cmd["strategy_name"], 267 | trade_cmd["stock_code"], 268 | trade_cmd["action"], 269 | trade_cmd["amount"], 270 | trade_cmd["price"], 271 | trade_cmd["datetime"], 272 | now, 273 | expire_seconds, 274 | ) 275 | break 276 | 277 | # check price 278 | price = trade_cmd["price"] 279 | if not self._is_number(price) or price <= 0: 280 | log.warning( 281 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 价格无效 , 被丢弃", 282 | trade_cmd["strategy_name"], 283 | trade_cmd["stock_code"], 284 | trade_cmd["action"], 285 | trade_cmd["amount"], 286 | trade_cmd["price"], 287 | trade_cmd["datetime"], 288 | now, 289 | ) 290 | break 291 | 292 | # check amount 293 | if trade_cmd["amount"] <= 0: 294 | log.warning( 295 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 买入股数无效 , 被丢弃", 296 | trade_cmd["strategy_name"], 297 | trade_cmd["stock_code"], 298 | trade_cmd["action"], 299 | trade_cmd["amount"], 300 | trade_cmd["price"], 301 | trade_cmd["datetime"], 302 | now, 303 | ) 304 | break 305 | 306 | actual_price = self._calculate_price_by_slippage( 307 | trade_cmd["action"], trade_cmd["price"] 308 | ) 309 | args = { 310 | "security": trade_cmd["stock_code"], 311 | "price": actual_price, 312 | "amount": trade_cmd["amount"], 313 | "entrust_prop": entrust_prop, 314 | } 315 | try: 316 | response = getattr(user, trade_cmd["action"])(**args) 317 | except exceptions.TradeError as e: 318 | trader_name = type(user).__name__ 319 | err_msg = "{}: {}".format(type(e).__name__, e.args) 320 | log.error( 321 | "%s 执行 策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格(考虑滑点): %s 指令产生时间: %s) 失败, 错误信息: %s", 322 | trader_name, 323 | trade_cmd["strategy_name"], 324 | trade_cmd["stock_code"], 325 | trade_cmd["action"], 326 | trade_cmd["amount"], 327 | actual_price, 328 | trade_cmd["datetime"], 329 | err_msg, 330 | ) 331 | else: 332 | log.info( 333 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格(考虑滑点): %s 指令产生时间: %s) 执行成功, 返回: %s", 334 | trade_cmd["strategy_name"], 335 | trade_cmd["stock_code"], 336 | trade_cmd["action"], 337 | trade_cmd["amount"], 338 | actual_price, 339 | trade_cmd["datetime"], 340 | response, 341 | ) 342 | 343 | def trade_worker( 344 | self, users, expire_seconds=120, entrust_prop="limit", send_interval=0 345 | ): 346 | """ 347 | :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时买出单没有及时成交导致的买入金额不足 348 | """ 349 | while True: 350 | trade_cmd = self.trade_queue.get() 351 | self._execute_trade_cmd( 352 | trade_cmd, users, expire_seconds, entrust_prop, send_interval 353 | ) 354 | time.sleep(send_interval) 355 | 356 | def query_strategy_transaction(self, strategy, **kwargs): 357 | params = self.create_query_transaction_params(strategy) 358 | 359 | rep = self.s.get(self.TRANSACTION_API, params=params) 360 | history = rep.json() 361 | 362 | transactions = self.extract_transactions(history) 363 | self.project_transactions(transactions, **kwargs) 364 | return self.order_transactions_sell_first(transactions) 365 | 366 | def extract_transactions(self, history) -> List[str]: 367 | """ 368 | 抽取接口返回中的调仓记录列表 369 | :param history: 调仓接口返回信息的字典对象 370 | :return: [] 调参历史记录的列表 371 | """ 372 | return [] 373 | 374 | def create_query_transaction_params(self, strategy) -> dict: 375 | """ 376 | 生成用于查询调参记录的参数 377 | :param strategy: 策略 id 378 | :return: dict 调参记录参数 379 | """ 380 | return {} 381 | 382 | @staticmethod 383 | def re_find(pattern, string, dtype=str): 384 | return dtype(re.search(pattern, string).group()) 385 | 386 | def project_transactions(self, transactions, **kwargs): 387 | """ 388 | 修证调仓记录为内部使用的统一格式 389 | :param transactions: [] 调仓记录的列表 390 | :return: [] 修整后的调仓记录 391 | """ 392 | pass 393 | 394 | def order_transactions_sell_first(self, transactions): 395 | # 调整调仓记录的顺序为先卖再买 396 | sell_first_transactions = [] 397 | for transaction in transactions: 398 | if transaction["action"] == "sell": 399 | sell_first_transactions.insert(0, transaction) 400 | else: 401 | sell_first_transactions.append(transaction) 402 | return sell_first_transactions 403 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns=\d{4}.+\.py, 15 | test, 16 | apps.py, 17 | __init__.py, 18 | urls.py, 19 | manage.py 20 | 21 | # Python code to execute, usually for sys.path manipulation such as 22 | # pygtk.require(). 23 | #init-hook= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=0 27 | 28 | # List of plugins (as comma separated values of python modules names) to load, 29 | # usually to register additional checkers. 30 | load-plugins= 31 | 32 | # Pickle collected data for later comparisons. 33 | persistent=yes 34 | 35 | # Specify a configuration file. 36 | #rcfile= 37 | 38 | # When enabled, pylint would attempt to guess common misconfiguration and emit 39 | # user-friendly hints instead of false-positive error messages 40 | suggestion-mode=yes 41 | 42 | # Allow loading of arbitrary C extensions. Extensions are imported into the 43 | # active Python interpreter and may run arbitrary code. 44 | unsafe-load-any-extension=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Disable the message, report, category or checker with the given id(s). You 54 | # can either give multiple identifiers separated by comma (,) or put this 55 | # option multiple times (only on the command line, not in the configuration 56 | # file where it should appear only once).You can also use "--disable=all" to 57 | # disable everything first and then reenable specific checks. For example, if 58 | # you want to run only the similarities checker, you can use "--disable=all 59 | # --enable=similarities". If you want to run only the classes checker, but have 60 | # no Warning level messages displayed, use"--disable=all --enable=classes 61 | # --disable=W" 62 | disable=too-many-public-methods, 63 | len-as-condition, 64 | unused-argument, 65 | too-many-arguments, 66 | arguments-differ, 67 | line-too-long, 68 | fixme, 69 | missing-docstring, 70 | invalid-envvar-default, 71 | ungrouped-imports, 72 | bad-continuation, 73 | too-many-ancestors, 74 | too-few-public-methods, 75 | no-self-use, 76 | #print-statement, 77 | #parameter-unpacking, 78 | #unpacking-in-except, 79 | #old-raise-syntax, 80 | #backtick, 81 | #long-suffix, 82 | #old-ne-operator, 83 | #old-octal-literal, 84 | #import-star-module-level, 85 | #non-ascii-bytes-literal, 86 | #raw-checker-failed, 87 | #bad-inline-option, 88 | #locally-disabled, 89 | #locally-enabled, 90 | #file-ignored, 91 | #suppressed-message, 92 | #useless-suppression, 93 | #deprecated-pragma, 94 | #apply-builtin, 95 | #basestring-builtin, 96 | #buffer-builtin, 97 | #cmp-builtin, 98 | #coerce-builtin, 99 | #execfile-builtin, 100 | #file-builtin, 101 | #long-builtin, 102 | #raw_input-builtin, 103 | #reduce-builtin, 104 | #standarderror-builtin, 105 | #unicode-builtin, 106 | #xrange-builtin, 107 | #coerce-method, 108 | #delslice-method, 109 | #getslice-method, 110 | #setslice-method, 111 | #no-absolute-import, 112 | #old-division, 113 | #dict-iter-method, 114 | #dict-view-method, 115 | #next-method-called, 116 | #metaclass-assignment, 117 | #indexing-exception, 118 | #raising-string, 119 | #reload-builtin, 120 | #oct-method, 121 | #hex-method, 122 | #nonzero-method, 123 | #cmp-method, 124 | #input-builtin, 125 | #round-builtin, 126 | #intern-builtin, 127 | #unichr-builtin, 128 | #map-builtin-not-iterating, 129 | #zip-builtin-not-iterating, 130 | #range-builtin-not-iterating, 131 | #filter-builtin-not-iterating, 132 | #using-cmp-argument, 133 | #eq-without-hash, 134 | #div-method, 135 | #idiv-method, 136 | #rdiv-method, 137 | #exception-message-attribute, 138 | #invalid-str-codec, 139 | #sys-max-int, 140 | #bad-python3-import, 141 | #deprecated-string-function, 142 | #deprecated-str-translate-call, 143 | #deprecated-itertools-function, 144 | #deprecated-types-field, 145 | #next-method-defined, 146 | #dict-items-not-iterating, 147 | #dict-keys-not-iterating, 148 | #dict-values-not-iterating 149 | 150 | # Enable the message, report, category or checker with the given id(s). You can 151 | # either give multiple identifier separated by comma (,) or put this option 152 | # multiple time (only on the command line, not in the configuration file where 153 | # it should appear only once). See also the "--disable" option for examples. 154 | enable=c-extension-no-member 155 | 156 | 157 | [REPORTS] 158 | 159 | # Python expression which should return a note less than 10 (10 is the highest 160 | # note). You have access to the variables errors warning, statement which 161 | # respectively contain the number of errors / warnings messages and the total 162 | # number of statements analyzed. This is used by the global evaluation report 163 | # (RP0004). 164 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 165 | 166 | # Template used to display messages. This is a python new-style format string 167 | # used to format the message information. See doc for all details 168 | #msg-template= 169 | 170 | # Set the output format. Available formats are text, parseable, colorized, json 171 | # and msvs (visual studio).You can also give a reporter class, eg 172 | # mypackage.mymodule.MyReporterClass. 173 | output-format=text 174 | 175 | # Tells whether to display a full report or only the messages 176 | reports=no 177 | 178 | # Activate the evaluation score. 179 | score=yes 180 | 181 | 182 | [REFACTORING] 183 | 184 | # Maximum number of nested blocks for function / method body 185 | max-nested-blocks=5 186 | 187 | # Complete name of functions that never returns. When checking for 188 | # inconsistent-return-statements if a never returning function is called then 189 | # it will be considered as an explicit return statement and no message will be 190 | # printed. 191 | never-returning-functions=optparse.Values,sys.exit 192 | 193 | 194 | [BASIC] 195 | 196 | # Naming style matching correct argument names 197 | argument-naming-style=snake_case 198 | 199 | # Regular expression matching correct argument names. Overrides argument- 200 | # naming-style 201 | #argument-rgx= 202 | 203 | # Naming style matching correct attribute names 204 | attr-naming-style=snake_case 205 | 206 | # Regular expression matching correct attribute names. Overrides attr-naming- 207 | # style 208 | #attr-rgx= 209 | 210 | # Bad variable names which should always be refused, separated by a comma 211 | bad-names=foo, 212 | bar, 213 | baz, 214 | toto, 215 | tutu, 216 | tata 217 | 218 | # Naming style matching correct class attribute names 219 | class-attribute-naming-style=any 220 | 221 | # Regular expression matching correct class attribute names. Overrides class- 222 | # attribute-naming-style 223 | #class-attribute-rgx= 224 | 225 | # Naming style matching correct class names 226 | class-naming-style=PascalCase 227 | 228 | # Regular expression matching correct class names. Overrides class-naming-style 229 | #class-rgx= 230 | 231 | # Naming style matching correct constant names 232 | const-naming-style=any 233 | 234 | # Regular expression matching correct constant names. Overrides const-naming- 235 | # style 236 | #const-rgx= 237 | 238 | # Minimum line length for functions/classes that require docstrings, shorter 239 | # ones are exempt. 240 | docstring-min-length=5 241 | 242 | # Naming style matching correct function names 243 | function-naming-style=snake_case 244 | 245 | # Regular expression matching correct function names. Overrides function- 246 | # naming-style 247 | #function-rgx= 248 | 249 | # Good variable names which should always be accepted, separated by a comma 250 | good-names=i, 251 | do, 252 | f, 253 | df, 254 | s, 255 | j, 256 | k, 257 | ex, 258 | Run, 259 | _, 260 | db, 261 | r, 262 | x, 263 | y, 264 | e 265 | 266 | # Include a hint for the correct naming format with invalid-name 267 | include-naming-hint=no 268 | 269 | # Naming style matching correct inline iteration names 270 | inlinevar-naming-style=any 271 | 272 | # Regular expression matching correct inline iteration names. Overrides 273 | # inlinevar-naming-style 274 | #inlinevar-rgx= 275 | 276 | # Naming style matching correct method names 277 | method-naming-style=snake_case 278 | 279 | # Regular expression matching correct method names. Overrides method-naming- 280 | # style 281 | #method-rgx= 282 | 283 | # Naming style matching correct module names 284 | module-naming-style=snake_case 285 | 286 | # Regular expression matching correct module names. Overrides module-naming- 287 | # style 288 | #module-rgx= 289 | 290 | # Colon-delimited sets of names that determine each other's naming style when 291 | # the name regexes allow several styles. 292 | name-group= 293 | 294 | # Regular expression which should only match function or class names that do 295 | # not require a docstring. 296 | no-docstring-rgx=^_ 297 | 298 | # List of decorators that produce properties, such as abc.abstractproperty. Add 299 | # to this list to register other decorators that produce valid properties. 300 | property-classes=abc.abstractproperty 301 | 302 | # Naming style matching correct variable names 303 | variable-naming-style=snake_case 304 | 305 | # Regular expression matching correct variable names. Overrides variable- 306 | # naming-style 307 | #variable-rgx= 308 | 309 | 310 | [FORMAT] 311 | 312 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 313 | expected-line-ending-format= 314 | 315 | # Regexp for a line that is allowed to be longer than the limit. 316 | ignore-long-lines=^\s*(# )??$ 317 | 318 | # Number of spaces of indent required inside a hanging or continued line. 319 | indent-after-paren=4 320 | 321 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 322 | # tab). 323 | indent-string=' ' 324 | 325 | # Maximum number of characters on a single line. 326 | max-line-length=79 327 | 328 | # Maximum number of lines in a module 329 | max-module-lines=1000 330 | 331 | # List of optional constructs for which whitespace checking is disabled. `dict- 332 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 333 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 334 | # `empty-line` allows space-only lines. 335 | no-space-check=trailing-comma, 336 | dict-separator 337 | 338 | # Allow the body of a class to be on the same line as the declaration if body 339 | # contains single statement. 340 | single-line-class-stmt=no 341 | 342 | # Allow the body of an if to be on the same line as the test if there is no 343 | # else. 344 | single-line-if-stmt=no 345 | 346 | 347 | [LOGGING] 348 | 349 | # Logging modules to check that the string format arguments are in logging 350 | # function parameter format 351 | logging-modules=logging 352 | 353 | 354 | [MISCELLANEOUS] 355 | 356 | # List of note tags to take in consideration, separated by a comma. 357 | notes=FIXME, 358 | XXX, 359 | TODO 360 | 361 | 362 | [SIMILARITIES] 363 | 364 | # Ignore comments when computing similarities. 365 | ignore-comments=yes 366 | 367 | # Ignore docstrings when computing similarities. 368 | ignore-docstrings=yes 369 | 370 | # Ignore imports when computing similarities. 371 | ignore-imports=no 372 | 373 | # Minimum lines number of a similarity. 374 | min-similarity-lines=4 375 | 376 | 377 | [SPELLING] 378 | 379 | # Limits count of emitted suggestions for spelling mistakes 380 | max-spelling-suggestions=4 381 | 382 | # Spelling dictionary name. Available dictionaries: none. To make it working 383 | # install python-enchant package. 384 | spelling-dict= 385 | 386 | # List of comma separated words that should not be checked. 387 | spelling-ignore-words= 388 | 389 | # A path to a file that contains private dictionary; one word per line. 390 | spelling-private-dict-file= 391 | 392 | # Tells whether to store unknown words to indicated private dictionary in 393 | # --spelling-private-dict-file option instead of raising a message. 394 | spelling-store-unknown-words=no 395 | 396 | 397 | [TYPECHECK] 398 | 399 | # List of decorators that produce context managers, such as 400 | # contextlib.contextmanager. Add to this list to register other decorators that 401 | # produce valid context managers. 402 | contextmanager-decorators=contextlib.contextmanager 403 | 404 | # List of members which are set dynamically and missed by pylint inference 405 | # system, and so shouldn't trigger E1101 when accessed. Python regular 406 | # expressions are accepted. 407 | generated-members= 408 | 409 | # Tells whether missing members accessed in mixin class should be ignored. A 410 | # mixin class is detected if its name ends with "mixin" (case insensitive). 411 | ignore-mixin-members=yes 412 | 413 | # This flag controls whether pylint should warn about no-member and similar 414 | # checks whenever an opaque object is returned when inferring. The inference 415 | # can return multiple potential results while evaluating a Python object, but 416 | # some branches might not be evaluated, which results in partial inference. In 417 | # that case, it might be useful to still emit no-member and other checks for 418 | # the rest of the inferred objects. 419 | ignore-on-opaque-inference=yes 420 | 421 | # List of class names for which member attributes should not be checked (useful 422 | # for classes with dynamically set attributes). This supports the use of 423 | # qualified names. 424 | ignored-classes=optparse.Values,thread._local,_thread._local 425 | 426 | # List of module names for which member attributes should not be checked 427 | # (useful for modules/projects where namespaces are manipulated during runtime 428 | # and thus existing member attributes cannot be deduced by static analysis. It 429 | # supports qualified module names, as well as Unix pattern matching. 430 | ignored-modules= 431 | 432 | # Show a hint with possible names when a member name was not found. The aspect 433 | # of finding the hint is based on edit distance. 434 | missing-member-hint=yes 435 | 436 | # The minimum edit distance a name should have in order to be considered a 437 | # similar match for a missing member name. 438 | missing-member-hint-distance=1 439 | 440 | # The total number of similar names that should be taken in consideration when 441 | # showing a hint for a missing member. 442 | missing-member-max-choices=1 443 | 444 | 445 | [VARIABLES] 446 | 447 | # List of additional names supposed to be defined in builtins. Remember that 448 | # you should avoid to define new builtins when possible. 449 | additional-builtins= 450 | 451 | # Tells whether unused global variables should be treated as a violation. 452 | allow-global-unused-variables=yes 453 | 454 | # List of strings which can identify a callback function by name. A callback 455 | # name must start or end with one of those strings. 456 | callbacks=cb_, 457 | _cb 458 | 459 | # A regular expression matching the name of dummy variables (i.e. expectedly 460 | # not used). 461 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 462 | 463 | # Argument names that match this expression will be ignored. Default to name 464 | # with leading underscore 465 | ignored-argument-names=_.*|^ignored_|^unused_ 466 | 467 | # Tells whether we should check for unused import in __init__ files. 468 | init-import=no 469 | 470 | # List of qualified module names which can have objects that can redefine 471 | # builtins. 472 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 473 | 474 | 475 | [CLASSES] 476 | 477 | # List of method names used to declare (i.e. assign) instance attributes. 478 | defining-attr-methods=__init__, 479 | __new__, 480 | setUp 481 | 482 | # List of member names, which should be excluded from the protected access 483 | # warning. 484 | exclude-protected=_asdict, 485 | _fields, 486 | _replace, 487 | _source, 488 | _make 489 | 490 | # List of valid names for the first argument in a class method. 491 | valid-classmethod-first-arg=cls 492 | 493 | # List of valid names for the first argument in a metaclass class method. 494 | valid-metaclass-classmethod-first-arg=mcs 495 | 496 | 497 | [DESIGN] 498 | 499 | # Maximum number of arguments for function / method 500 | max-args=5 501 | 502 | # Maximum number of attributes for a class (see R0902). 503 | max-attributes=7 504 | 505 | # Maximum number of boolean expressions in a if statement 506 | max-bool-expr=5 507 | 508 | # Maximum number of branch for function / method body 509 | max-branches=20 510 | 511 | # Maximum number of locals for function / method body 512 | max-locals=20 513 | 514 | # Maximum number of parents for a class (see R0901). 515 | max-parents=7 516 | 517 | # Maximum number of public methods for a class (see R0904). 518 | max-public-methods=20 519 | 520 | # Maximum number of return / yield for function / method body 521 | max-returns=6 522 | 523 | # Maximum number of statements in function / method body 524 | max-statements=50 525 | 526 | # Minimum number of public methods for a class (see R0903). 527 | min-public-methods=2 528 | 529 | 530 | [IMPORTS] 531 | 532 | # Allow wildcard imports from modules that define __all__. 533 | allow-wildcard-with-all=no 534 | 535 | # Analyse import fallback blocks. This can be used to support both Python 2 and 536 | # 3 compatible code, which means that the block might have code that exists 537 | # only in one or another interpreter, leading to false positives when analysed. 538 | analyse-fallback-blocks=no 539 | 540 | # Deprecated modules which should not be used, separated by a comma 541 | deprecated-modules=regsub, 542 | TERMIOS, 543 | Bastion, 544 | rexec 545 | 546 | # Create a graph of external dependencies in the given file (report RP0402 must 547 | # not be disabled) 548 | ext-import-graph= 549 | 550 | # Create a graph of every (i.e. internal and external) dependencies in the 551 | # given file (report RP0402 must not be disabled) 552 | import-graph= 553 | 554 | # Create a graph of internal dependencies in the given file (report RP0402 must 555 | # not be disabled) 556 | int-import-graph= 557 | 558 | # Force import order to recognize a module as part of the standard 559 | # compatibility libraries. 560 | known-standard-library= 561 | 562 | # Force import order to recognize a module as part of a third party library. 563 | known-third-party=enchant 564 | 565 | 566 | [EXCEPTIONS] 567 | 568 | # Exceptions that will emit a warning when being caught. Defaults to 569 | # "Exception" 570 | overgeneral-exceptions=Exception 571 | 572 | -------------------------------------------------------------------------------- /easytrader/xqtrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import numbers 4 | import os 5 | import re 6 | import time 7 | 8 | import requests 9 | 10 | from . import exceptions, helpers, webtrader 11 | from .log import log 12 | 13 | 14 | class XueQiuTrader(webtrader.WebTrader): 15 | config_path = os.path.dirname(__file__) + "/config/xq.json" 16 | 17 | _HEADERS = { 18 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) " 19 | "AppleWebKit/537.36 (KHTML, like Gecko) " 20 | "Chrome/64.0.3282.167 Safari/537.36", 21 | "Host": "xueqiu.com", 22 | "Pragma": "no-cache", 23 | "Connection": "keep-alive", 24 | "Accept": "*/*", 25 | "Accept-Encoding": "gzip, deflate, br", 26 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 27 | "Cache-Control": "no-cache", 28 | "Referer": "https://xueqiu.com/P/ZH004612", 29 | "X-Requested-With": "XMLHttpRequest", 30 | } 31 | 32 | def __init__(self, **kwargs): 33 | super(XueQiuTrader, self).__init__() 34 | 35 | # 资金换算倍数 36 | self.multiple = ( 37 | kwargs["initial_assets"] if "initial_assets" in kwargs else 1000000 38 | ) 39 | if not isinstance(self.multiple, numbers.Number): 40 | raise TypeError("initial assets must be number(int, float)") 41 | if self.multiple < 1e3: 42 | raise ValueError("雪球初始资产不能小于1000元,当前预设值 {}".format(self.multiple)) 43 | 44 | self.s = requests.Session() 45 | self.s.headers.update(self._HEADERS) 46 | self.account_config = None 47 | 48 | def autologin(self, **kwargs): 49 | """ 50 | 使用 cookies 之后不需要自动登陆 51 | :return: 52 | """ 53 | self._set_cookies(self.account_config["cookies"]) 54 | 55 | def _set_cookies(self, cookies): 56 | """设置雪球 cookies,代码来自于 57 | https://github.com/shidenggui/easytrader/issues/269 58 | :param cookies: 雪球 cookies 59 | :type cookies: str 60 | """ 61 | cookie_dict = helpers.parse_cookies_str(cookies) 62 | self.s.cookies.update(cookie_dict) 63 | 64 | def _prepare_account(self, user="", password="", **kwargs): 65 | """ 66 | 转换参数到登录所需的字典格式 67 | :param cookies: 雪球登陆需要设置 cookies, 具体见 68 | https://smalltool.github.io/2016/08/02/cookie/ 69 | :param portfolio_code: 组合代码 70 | :param portfolio_market: 交易市场, 可选['cn', 'us', 'hk'] 默认 'cn' 71 | :return: 72 | """ 73 | if "portfolio_code" not in kwargs: 74 | raise TypeError("雪球登录需要设置 portfolio_code(组合代码) 参数") 75 | if "portfolio_market" not in kwargs: 76 | kwargs["portfolio_market"] = "cn" 77 | if "cookies" not in kwargs: 78 | raise TypeError( 79 | "雪球登陆需要设置 cookies, 具体见" 80 | "https://smalltool.github.io/2016/08/02/cookie/" 81 | ) 82 | self.account_config = { 83 | "cookies": kwargs["cookies"], 84 | "portfolio_code": kwargs["portfolio_code"], 85 | "portfolio_market": kwargs["portfolio_market"], 86 | } 87 | 88 | def _virtual_to_balance(self, virtual): 89 | """ 90 | 虚拟净值转化为资金 91 | :param virtual: 雪球组合净值 92 | :return: 换算的资金 93 | """ 94 | return virtual * self.multiple 95 | 96 | def _get_html(self, url): 97 | return self.s.get(url).text 98 | 99 | def _search_stock_info(self, code): 100 | """ 101 | 通过雪球的接口获取股票详细信息 102 | :param code: 股票代码 000001 103 | :return: 查询到的股票 {u'stock_id': 1000279, u'code': u'SH600325', 104 | u'name': u'华发股份', u'ind_color': u'#d9633b', u'chg': -1.09, 105 | u'ind_id': 100014, u'percent': -9.31, u'current': 10.62, 106 | u'hasexist': None, u'flag': 1, u'ind_name': u'房地产', u'type': None, 107 | u'enName': None} 108 | ** flag : 未上市(0)、正常(1)、停牌(2)、涨跌停(3)、退市(4) 109 | """ 110 | data = { 111 | "code": str(code), 112 | "size": "300", 113 | "key": "47bce5c74f", 114 | "market": self.account_config["portfolio_market"], 115 | } 116 | r = self.s.get(self.config["search_stock_url"], params=data) 117 | stocks = json.loads(r.text) 118 | stocks = stocks["stocks"] 119 | stock = None 120 | if len(stocks) > 0: 121 | stock = stocks[0] 122 | return stock 123 | 124 | def _get_portfolio_info(self, portfolio_code): 125 | """ 126 | 获取组合信息 127 | :return: 字典 128 | """ 129 | url = self.config["portfolio_url"] + portfolio_code 130 | html = self._get_html(url) 131 | match_info = re.search(r"(?<=SNB.cubeInfo = ).*(?=;\n)", html) 132 | if match_info is None: 133 | raise Exception( 134 | "cant get portfolio info, portfolio html : {}".format(html) 135 | ) 136 | try: 137 | portfolio_info = json.loads(match_info.group()) 138 | except Exception as e: 139 | raise Exception("get portfolio info error: {}".format(e)) 140 | return portfolio_info 141 | 142 | def get_balance(self): 143 | """ 144 | 获取账户资金状况 145 | :return: 146 | """ 147 | portfolio_code = self.account_config.get("portfolio_code", "ch") 148 | portfolio_info = self._get_portfolio_info(portfolio_code) 149 | asset_balance = self._virtual_to_balance( 150 | float(portfolio_info["net_value"]) 151 | ) # 总资产 152 | position = portfolio_info["view_rebalancing"] # 仓位结构 153 | cash = asset_balance * float(position["cash"]) / 100 154 | market = asset_balance - cash 155 | return [ 156 | { 157 | "asset_balance": asset_balance, 158 | "current_balance": cash, 159 | "enable_balance": cash, 160 | "market_value": market, 161 | "money_type": u"人民币", 162 | "pre_interest": 0.25, 163 | } 164 | ] 165 | 166 | def _get_position(self): 167 | """ 168 | 获取雪球持仓 169 | :return: 170 | """ 171 | portfolio_code = self.account_config["portfolio_code"] 172 | portfolio_info = self._get_portfolio_info(portfolio_code) 173 | position = portfolio_info["view_rebalancing"] # 仓位结构 174 | stocks = position["holdings"] # 持仓股票 175 | return stocks 176 | 177 | @staticmethod 178 | def _time_strftime(time_stamp): 179 | try: 180 | local_time = time.localtime(time_stamp / 1000) 181 | return time.strftime("%Y-%m-%d %H:%M:%S", local_time) 182 | # pylint: disable=broad-except 183 | except Exception: 184 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 185 | 186 | def get_position(self): 187 | """ 188 | 获取持仓 189 | :return: 190 | """ 191 | xq_positions = self._get_position() 192 | balance = self.get_balance()[0] 193 | position_list = [] 194 | for pos in xq_positions: 195 | volume = pos["weight"] * balance["asset_balance"] / 100 196 | position_list.append( 197 | { 198 | "cost_price": volume / 100, 199 | "current_amount": 100, 200 | "enable_amount": 100, 201 | "income_balance": 0, 202 | "keep_cost_price": volume / 100, 203 | "last_price": volume / 100, 204 | "market_value": volume, 205 | "position_str": "random", 206 | "stock_code": pos["stock_symbol"], 207 | "stock_name": pos["stock_name"], 208 | } 209 | ) 210 | return position_list 211 | 212 | def _get_xq_history(self): 213 | """ 214 | 获取雪球调仓历史 215 | :param instance: 216 | :param owner: 217 | :return: 218 | """ 219 | data = { 220 | "cube_symbol": str(self.account_config["portfolio_code"]), 221 | "count": 20, 222 | "page": 1, 223 | } 224 | resp = self.s.get(self.config["history_url"], params=data) 225 | res = json.loads(resp.text) 226 | return res["list"] 227 | 228 | @property 229 | def history(self): 230 | return self._get_xq_history() 231 | 232 | def get_entrust(self): 233 | """ 234 | 获取委托单(目前返回20次调仓的结果) 235 | 操作数量都按1手模拟换算的 236 | :return: 237 | """ 238 | xq_entrust_list = self._get_xq_history() 239 | entrust_list = [] 240 | replace_none = lambda s: s or 0 241 | for xq_entrusts in xq_entrust_list: 242 | status = xq_entrusts["status"] # 调仓状态 243 | if status == "pending": 244 | status = "已报" 245 | elif status in ["canceled", "failed"]: 246 | status = "废单" 247 | else: 248 | status = "已成" 249 | for entrust in xq_entrusts["rebalancing_histories"]: 250 | price = entrust["price"] 251 | entrust_list.append( 252 | { 253 | "entrust_no": entrust["id"], 254 | "entrust_bs": u"买入" 255 | if entrust["target_weight"] 256 | > replace_none(entrust["prev_weight"]) 257 | else u"卖出", 258 | "report_time": self._time_strftime( 259 | entrust["updated_at"] 260 | ), 261 | "entrust_status": status, 262 | "stock_code": entrust["stock_symbol"], 263 | "stock_name": entrust["stock_name"], 264 | "business_amount": 100, 265 | "business_price": price, 266 | "entrust_amount": 100, 267 | "entrust_price": price, 268 | } 269 | ) 270 | return entrust_list 271 | 272 | def cancel_entrust(self, entrust_no): 273 | """ 274 | 对未成交的调仓进行伪撤单 275 | :param entrust_no: 276 | :return: 277 | """ 278 | xq_entrust_list = self._get_xq_history() 279 | is_have = False 280 | for xq_entrusts in xq_entrust_list: 281 | status = xq_entrusts["status"] # 调仓状态 282 | for entrust in xq_entrusts["rebalancing_histories"]: 283 | if entrust["id"] == entrust_no and status == "pending": 284 | is_have = True 285 | buy_or_sell = ( 286 | "buy" 287 | if entrust["target_weight"] < entrust["weight"] 288 | else "sell" 289 | ) 290 | if ( 291 | entrust["target_weight"] == 0 292 | and entrust["weight"] == 0 293 | ): 294 | raise exceptions.TradeError(u"移除的股票操作无法撤销,建议重新买入") 295 | balance = self.get_balance()[0] 296 | volume = ( 297 | abs(entrust["target_weight"] - entrust["weight"]) 298 | * balance["asset_balance"] 299 | / 100 300 | ) 301 | r = self._trade( 302 | security=entrust["stock_symbol"], 303 | volume=volume, 304 | entrust_bs=buy_or_sell, 305 | ) 306 | if len(r) > 0 and "error_info" in r[0]: 307 | raise exceptions.TradeError( 308 | u"撤销失败!%s" % ("error_info" in r[0]) 309 | ) 310 | if not is_have: 311 | raise exceptions.TradeError(u"撤销对象已失效") 312 | return True 313 | 314 | def adjust_weight(self, stock_code, weight): 315 | """ 316 | 雪球组合调仓, weight 为调整后的仓位比例 317 | :param stock_code: str 股票代码 318 | :param weight: float 调整之后的持仓百分比, 0 - 100 之间的浮点数 319 | """ 320 | 321 | stock = self._search_stock_info(stock_code) 322 | if stock is None: 323 | raise exceptions.TradeError(u"没有查询要操作的股票信息") 324 | if stock["flag"] != 1: 325 | raise exceptions.TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") 326 | 327 | # 仓位比例向下取两位数 328 | weight = round(weight, 2) 329 | # 获取原有仓位信息 330 | position_list = self._get_position() 331 | 332 | # 调整后的持仓 333 | for position in position_list: 334 | if position["stock_id"] == stock["stock_id"]: 335 | position["proactive"] = True 336 | position["weight"] = weight 337 | 338 | if weight != 0 and stock["stock_id"] not in [ 339 | k["stock_id"] for k in position_list 340 | ]: 341 | position_list.append( 342 | { 343 | "code": stock["code"], 344 | "name": stock["name"], 345 | "enName": stock["enName"], 346 | "hasexist": stock["hasexist"], 347 | "flag": stock["flag"], 348 | "type": stock["type"], 349 | "current": stock["current"], 350 | "chg": stock["chg"], 351 | "percent": str(stock["percent"]), 352 | "stock_id": stock["stock_id"], 353 | "ind_id": stock["ind_id"], 354 | "ind_name": stock["ind_name"], 355 | "ind_color": stock["ind_color"], 356 | "textname": stock["name"], 357 | "segment_name": stock["ind_name"], 358 | "weight": weight, 359 | "url": "/S/" + stock["code"], 360 | "proactive": True, 361 | "price": str(stock["current"]), 362 | } 363 | ) 364 | 365 | remain_weight = 100 - sum(i.get("weight") for i in position_list) 366 | cash = round(remain_weight, 2) 367 | log.debug("调仓比例:%f, 剩余持仓 :%f", weight, remain_weight) 368 | data = { 369 | "cash": cash, 370 | "holdings": str(json.dumps(position_list)), 371 | "cube_symbol": str(self.account_config["portfolio_code"]), 372 | "segment": "true", 373 | "comment": "", 374 | } 375 | 376 | try: 377 | resp = self.s.post(self.config["rebalance_url"], data=data) 378 | # pylint: disable=broad-except 379 | except Exception as e: 380 | log.warning("调仓失败: %s ", e) 381 | return None 382 | log.debug("调仓 %s: 持仓比例%d", stock["name"], weight) 383 | resp_json = json.loads(resp.text) 384 | if "error_description" in resp_json and resp.status_code != 200: 385 | log.error("调仓错误: %s", resp_json["error_description"]) 386 | return [ 387 | { 388 | "error_no": resp_json["error_code"], 389 | "error_info": resp_json["error_description"], 390 | } 391 | ] 392 | log.debug("调仓成功 %s: 持仓比例%d", stock["name"], weight) 393 | return None 394 | 395 | def _trade(self, security, price=0, amount=0, volume=0, entrust_bs="buy"): 396 | """ 397 | 调仓 398 | :param security: 399 | :param price: 400 | :param amount: 401 | :param volume: 402 | :param entrust_bs: 403 | :return: 404 | """ 405 | stock = self._search_stock_info(security) 406 | balance = self.get_balance()[0] 407 | if stock is None: 408 | raise exceptions.TradeError(u"没有查询要操作的股票信息") 409 | if not volume: 410 | volume = int(float(price) * amount) # 可能要取整数 411 | if balance["current_balance"] < volume and entrust_bs == "buy": 412 | raise exceptions.TradeError(u"没有足够的现金进行操作") 413 | if stock["flag"] != 1: 414 | raise exceptions.TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") 415 | if volume == 0: 416 | raise exceptions.TradeError(u"操作金额不能为零") 417 | 418 | # 计算调仓调仓份额 419 | weight = volume / balance["asset_balance"] * 100 420 | weight = round(weight, 2) 421 | 422 | # 获取原有仓位信息 423 | position_list = self._get_position() 424 | 425 | # 调整后的持仓 426 | is_have = False 427 | for position in position_list: 428 | if position["stock_id"] == stock["stock_id"]: 429 | is_have = True 430 | position["proactive"] = True 431 | old_weight = position["weight"] 432 | if entrust_bs == "buy": 433 | position["weight"] = weight + old_weight 434 | else: 435 | if weight > old_weight: 436 | raise exceptions.TradeError(u"操作数量大于实际可卖出数量") 437 | else: 438 | position["weight"] = old_weight - weight 439 | position["weight"] = round(position["weight"], 2) 440 | if not is_have: 441 | if entrust_bs == "buy": 442 | position_list.append( 443 | { 444 | "code": stock["code"], 445 | "name": stock["name"], 446 | "enName": stock["enName"], 447 | "hasexist": stock["hasexist"], 448 | "flag": stock["flag"], 449 | "type": stock["type"], 450 | "current": stock["current"], 451 | "chg": stock["chg"], 452 | "percent": str(stock["percent"]), 453 | "stock_id": stock["stock_id"], 454 | "ind_id": stock["ind_id"], 455 | "ind_name": stock["ind_name"], 456 | "ind_color": stock["ind_color"], 457 | "textname": stock["name"], 458 | "segment_name": stock["ind_name"], 459 | "weight": round(weight, 2), 460 | "url": "/S/" + stock["code"], 461 | "proactive": True, 462 | "price": str(stock["current"]), 463 | } 464 | ) 465 | else: 466 | raise exceptions.TradeError(u"没有持有要卖出的股票") 467 | 468 | if entrust_bs == "buy": 469 | cash = ( 470 | (balance["current_balance"] - volume) 471 | / balance["asset_balance"] 472 | * 100 473 | ) 474 | else: 475 | cash = ( 476 | (balance["current_balance"] + volume) 477 | / balance["asset_balance"] 478 | * 100 479 | ) 480 | cash = round(cash, 2) 481 | log.debug("weight:%f, cash:%f", weight, cash) 482 | 483 | data = { 484 | "cash": cash, 485 | "holdings": str(json.dumps(position_list)), 486 | "cube_symbol": str(self.account_config["portfolio_code"]), 487 | "segment": 1, 488 | "comment": "", 489 | } 490 | 491 | try: 492 | resp = self.s.post(self.config["rebalance_url"], data=data) 493 | # pylint: disable=broad-except 494 | except Exception as e: 495 | log.warning("调仓失败: %s ", e) 496 | return None 497 | else: 498 | log.debug( 499 | "调仓 %s%s: %d", entrust_bs, stock["name"], resp.status_code 500 | ) 501 | resp_json = json.loads(resp.text) 502 | if "error_description" in resp_json and resp.status_code != 200: 503 | log.error("调仓错误: %s", resp_json["error_description"]) 504 | return [ 505 | { 506 | "error_no": resp_json["error_code"], 507 | "error_info": resp_json["error_description"], 508 | } 509 | ] 510 | return [ 511 | { 512 | "entrust_no": resp_json["id"], 513 | "init_date": self._time_strftime(resp_json["created_at"]), 514 | "batch_no": "委托批号", 515 | "report_no": "申报号", 516 | "seat_no": "席位编号", 517 | "entrust_time": self._time_strftime( 518 | resp_json["updated_at"] 519 | ), 520 | "entrust_price": price, 521 | "entrust_amount": amount, 522 | "stock_code": security, 523 | "entrust_bs": "买入", 524 | "entrust_type": "雪球虚拟委托", 525 | "entrust_status": "-", 526 | } 527 | ] 528 | 529 | def buy(self, security, price=0, amount=0, volume=0, entrust_prop=0): 530 | """买入卖出股票 531 | :param security: 股票代码 532 | :param price: 买入价格 533 | :param amount: 买入股数 534 | :param volume: 买入总金额 由 volume / price 取整, 若指定 price 则此参数无效 535 | :param entrust_prop: 536 | """ 537 | return self._trade(security, price, amount, volume, "buy") 538 | 539 | def sell(self, security, price=0, amount=0, volume=0, entrust_prop=0): 540 | """卖出股票 541 | :param security: 股票代码 542 | :param price: 卖出价格 543 | :param amount: 卖出股数 544 | :param volume: 卖出总金额 由 volume / price 取整, 若指定 price 则此参数无效 545 | :param entrust_prop: 546 | """ 547 | return self._trade(security, price, amount, volume, "sell") 548 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e2a2ba761a3628e4851f250cc8882bca58d22c9ebfa11a6923549503a00d577a" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "http://mirrors.aliyun.com/pypi/simple/", 14 | "verify_ssl": false 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "beautifulsoup4": { 20 | "hashes": [ 21 | "sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", 22 | "sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", 23 | "sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" 24 | ], 25 | "version": "==4.6.0" 26 | }, 27 | "bs4": { 28 | "hashes": [ 29 | "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a" 30 | ], 31 | "index": "pypi", 32 | "version": "==0.0.1" 33 | }, 34 | "certifi": { 35 | "hashes": [ 36 | "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", 37 | "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" 38 | ], 39 | "version": "==2018.4.16" 40 | }, 41 | "chardet": { 42 | "hashes": [ 43 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 44 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 45 | ], 46 | "version": "==3.0.4" 47 | }, 48 | "click": { 49 | "hashes": [ 50 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 51 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 52 | ], 53 | "index": "pypi", 54 | "version": "==6.7" 55 | }, 56 | "cssselect": { 57 | "hashes": [ 58 | "sha256:066d8bc5229af09617e24b3ca4d52f1f9092d9e061931f4184cd572885c23204", 59 | "sha256:3b5103e8789da9e936a68d993b70df732d06b8bb9a337a05ed4eb52c17ef7206" 60 | ], 61 | "markers": "python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.3.*'", 62 | "version": "==1.0.3" 63 | }, 64 | "dill": { 65 | "hashes": [ 66 | "sha256:624dc244b94371bb2d6e7f40084228a2edfff02373fe20e018bef1ee92fdd5b3" 67 | ], 68 | "index": "pypi", 69 | "version": "==0.2.8.2" 70 | }, 71 | "easyutils": { 72 | "hashes": [ 73 | "sha256:45b46748e20dd3c0e840fa9c1fa7d7f3dc295e58a81796d10329957c20b7f20a" 74 | ], 75 | "index": "pypi", 76 | "version": "==0.1.7" 77 | }, 78 | "flask": { 79 | "hashes": [ 80 | "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", 81 | "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" 82 | ], 83 | "index": "pypi", 84 | "version": "==1.0.2" 85 | }, 86 | "idna": { 87 | "hashes": [ 88 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 89 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 90 | ], 91 | "version": "==2.7" 92 | }, 93 | "itsdangerous": { 94 | "hashes": [ 95 | "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" 96 | ], 97 | "version": "==0.24" 98 | }, 99 | "jinja2": { 100 | "hashes": [ 101 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 102 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 103 | ], 104 | "version": "==2.10" 105 | }, 106 | "lxml": { 107 | "hashes": [ 108 | "sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d", 109 | "sha256:0b18890aa5730f9d847bc5469e8820f782d72af9985a15a7552109a86b01c113", 110 | "sha256:21f427945f612ac75576632b1bb8c21233393c961f2da890d7be3927a4b6085f", 111 | "sha256:24cf6f622a4d49851afcf63ac4f0f3419754d4e98a7a548ab48dd03c635d9bd3", 112 | "sha256:2dc6705486b8abee1af9e2a3761e30a3cb19e8276f20ca7e137ee6611b93707c", 113 | "sha256:2e43b2e5b7d2b9abe6e0301eef2c2c122ab45152b968910eae68bdee2c4cfae0", 114 | "sha256:329a6d8b6d36f7d6f8b6c6a1db3b2c40f7e30a19d3caf62023c9d6a677c1b5e1", 115 | "sha256:423cde55430a348bda6f1021faad7235c2a95a6bdb749e34824e5758f755817a", 116 | "sha256:4651ea05939374cfb5fe87aab5271ed38c31ea47997e17ec3834b75b94bd9f15", 117 | "sha256:4be3bbfb2968d7da6e5c2cd4104fc5ec1caf9c0794f6cae724da5a53b4d9f5a3", 118 | "sha256:622f7e40faef13d232fb52003661f2764ce6cdef3edb0a59af7c1559e4cc36d1", 119 | "sha256:664dfd4384d886b239ef0d7ee5cff2b463831079d250528b10e394a322f141f9", 120 | "sha256:697c0f58ac637b11991a1bc92e07c34da4a72e2eda34d317d2c1c47e2f24c1b3", 121 | "sha256:6ec908b4c8a4faa7fe1a0080768e2ce733f268b287dfefb723273fb34141475f", 122 | "sha256:7ec3fe795582b75bb49bb1685ffc462dbe38d74312dac07ce386671a28b5316b", 123 | "sha256:8c39babd923c431dcf1e5874c0f778d3a5c745a62c3a9b6bd755efd489ee8a1d", 124 | "sha256:949ca5bc56d6cb73d956f4862ba06ad3c5d2808eac76304284f53ae0c8b2334a", 125 | "sha256:9f0daddeefb0791a600e6195441910bdf01eac470be596b9467e6122b51239a6", 126 | "sha256:a359893b01c30e949eae0e8a85671a593364c9f0b8162afe0cb97317af0953bf", 127 | "sha256:ad5d5d8efed59e6b1d4c50c1eac59fb6ecec91b2073676af1e15fc4d43e9b6c5", 128 | "sha256:bc1a36f95a6b3667c09b34995fc3a46a82e4cf0dc3e7ab281e4c77b15bd7af05", 129 | "sha256:be37b3f55b6d7d923f43bf74c356fc1878eb36e28505f38e198cb432c19c7b1a", 130 | "sha256:c45bca5e544eb75f7500ffd730df72922eb878a2f0213b0dc5a5f357ded3a85d", 131 | "sha256:ccee7ebbb4735ebc341d347fca9ee09f2fa6c0580528c1414bc4e1d31372835c", 132 | "sha256:dc62c0840b2fc7753550b40405532a3e125c0d3761f34af948873393aa688160", 133 | "sha256:f7d9d5aa1c7e54167f1a3cba36b5c52c7c540f30952c9bd7d9302a1eda318424" 134 | ], 135 | "version": "==4.2.3" 136 | }, 137 | "markupsafe": { 138 | "hashes": [ 139 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 140 | ], 141 | "version": "==1.0" 142 | }, 143 | "numpy": { 144 | "hashes": [ 145 | "sha256:14fb76bde161c87dcec52d91c78f65aa8a23aa2e1530a71f412dabe03927d917", 146 | "sha256:21041014b7529237994a6b578701c585703fbb3b1bea356cdb12a5ea7804241c", 147 | "sha256:24f3bb9a5f6c3936a8ccd4ddfc1210d9511f4aeb879a12efd2e80bec647b8695", 148 | "sha256:34033b581bc01b1135ca2e3e93a94daea7c739f21a97a75cca93e29d9f0c8e71", 149 | "sha256:3fbccb399fe9095b1c1d7b41e7c7867db8aa0d2347fc44c87a7a180cedda112b", 150 | "sha256:50718eea8e77a1bedcc85befd22c8dbf5a24c9d2c0c1e36bbb8d7a38da847eb3", 151 | "sha256:55daf757e5f69aa75b4477cf4511bf1f96325c730e4ad32d954ccb593acd2585", 152 | "sha256:61efc65f325770bbe787f34e00607bc124f08e6c25fdf04723848585e81560dc", 153 | "sha256:62cb836506f40ce2529bfba9d09edc4b2687dd18c56cf4457e51c3e7145402fd", 154 | "sha256:64c6acf5175745fd1b7b7e17c74fdbfb7191af3b378bc54f44560279f41238d3", 155 | "sha256:674ea7917f0657ddb6976bd102ac341bc493d072c32a59b98e5b8c6eaa2d5ec0", 156 | "sha256:73a816e441dace289302e04a7a34ec4772ed234ab6885c968e3ca2fc2d06fe2d", 157 | "sha256:78c35dc7ad184aebf3714dbf43f054714c6e430e14b9c06c49a864fb9e262030", 158 | "sha256:7f17efe9605444fcbfd990ba9b03371552d65a3c259fc2d258c24fb95afdd728", 159 | "sha256:816645178f2180be257a576b735d3ae245b1982280b97ae819550ce8bcdf2b6b", 160 | "sha256:924f37e66db78464b4b85ed4b6d2e5cda0c0416e657cac7ccbef14b9fa2c40b5", 161 | "sha256:a17a8fd5df4fec5b56b4d11c9ba8b9ebfb883c90ec361628d07be00aaa4f009a", 162 | "sha256:aaa519335a71f87217ca8a680c3b66b61960e148407bdf5c209c42f50fe30f49", 163 | "sha256:ae3864816287d0e86ead580b69921daec568fe680857f07ee2a87bf7fd77ce24", 164 | "sha256:b5f8c15cb9173f6cdf0f994955e58d1265331029ae26296232379461a297e5f2", 165 | "sha256:c3ac359ace241707e5a48fe2922e566ac666aacacf4f8031f2994ac429c31344", 166 | "sha256:c7c660cc0209fdf29a4e50146ca9ac9d8664acaded6b6ae2f5d0ae2e91a0f0cd", 167 | "sha256:d690a2ff49f6c3bc35336693c9924fe5916be3cc0503fe1ea6c7e2bf951409ee", 168 | "sha256:e2317cf091c2e7f0dacdc2e72c693cc34403ca1f8e3807622d0bb653dc978616", 169 | "sha256:f28e73cf18d37a413f7d5de35d024e6b98f14566a10d82100f9dc491a7d449f9", 170 | "sha256:f2a778dd9bb3e4590dbe3bbac28e7c7134280c4ec97e3bf8678170ee58c67b21", 171 | "sha256:f5a758252502b466b9c2b201ea397dae5a914336c987f3a76c3741a82d43c96e", 172 | "sha256:fb4c33a404d9eff49a0cdc8ead0af6453f62f19e071b60d283f9dc05581e4134" 173 | ], 174 | "markers": "python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.3.*' and python_version != '3.2.*' and python_version >= '2.7'", 175 | "version": "==1.15.0" 176 | }, 177 | "pandas": { 178 | "hashes": [ 179 | "sha256:05ac350f8a35abe6a02054f8cf54e0c048f13423b2acb87d018845afd736f0b4", 180 | "sha256:174543cd68eaee60620146b38faaed950071f5665e0a4fa4adfdcfc23d7f7936", 181 | "sha256:1a62a237fb7223c11d09daaeaf7d15f234bb836bfaf3d4f85746cdf9b2582f99", 182 | "sha256:2c1ed1de5308918a7c6833df6db75a19c416c122921824e306c64a0626b3606c", 183 | "sha256:33825ad26ce411d6526f903b3d02c0edf627223af59cf4b5876aa925578eec74", 184 | "sha256:4c5f76fce8a4851f65374ea1d95ca24e9439540550e41e556c0879379517a6f5", 185 | "sha256:67504a96f72fb4d7f051cfe77b9a7bb0d094c4e2e5a6efb2769eb80f36e6b309", 186 | "sha256:683e0cc8c7faececbbc06aa4735709a07abad106099f165730c1015da916adec", 187 | "sha256:77cd1b485c6a860b950ab3a85be7b5683eaacbc51cadf096db967886607d2231", 188 | "sha256:814f8785f1ab412a7e9b9a8abb81dfe8727ebdeef850ecfaa262c04b1664000f", 189 | "sha256:894216edaf7dd0a92623cdad423bbec2a23fc06eb9c85483e21876d1ef8f47e9", 190 | "sha256:9331e20a07360b81d8c7b4b50223da387d264151d533a5a5853325800e6631a4", 191 | "sha256:9cd3614b4e31a0889388ff1bd19ae857ad52658b33f776065793c293a29cf612", 192 | "sha256:9d79e958adcd037eba3debbb66222804171197c0f5cd462315d1356aa72a5a30", 193 | "sha256:b90e5d5460f23607310cbd1688a7517c96ce7b284095a48340d249dfc429172e", 194 | "sha256:bc80c13ffddc7e269b706ed58002cc4c98cc135c36d827c99fb5ca54ced0eb7a", 195 | "sha256:cbb074efb2a5e4956b261a670bfc2126b0ccfbf5b96b6ed021bc8c8cb56cf4a8", 196 | "sha256:e8c62ab16feeda84d4732c42b7b67d7a89ad89df7e99efed80ea017bdc472f26", 197 | "sha256:ff5ef271805fe877fe0d1337b6b1861113c44c75b9badb595c713a72cd337371" 198 | ], 199 | "index": "pypi", 200 | "version": "==0.23.3" 201 | }, 202 | "pillow": { 203 | "hashes": [ 204 | "sha256:00def5b638994f888d1058e4d17c86dec8e1113c3741a0a8a659039aec59a83a", 205 | "sha256:026449b64e559226cdb8e6d8c931b5965d8fc90ec18ebbb0baa04c5b36503c72", 206 | "sha256:03dbb224ee196ef30ed2156d41b579143e1efeb422974719a5392fc035e4f574", 207 | "sha256:03eb0e04f929c102ae24bc436bf1c0c60a4e63b07ebd388e84d8b219df3e6acd", 208 | "sha256:1be66b9a89e367e7d20d6cae419794997921fe105090fafd86ef39e20a3baab2", 209 | "sha256:1e977a3ed998a599bda5021fb2c2889060617627d3ae228297a529a082a3cd5c", 210 | "sha256:22cf3406d135cfcc13ec6228ade774c8461e125c940e80455f500638429be273", 211 | "sha256:24adccf1e834f82718c7fc8e3ec1093738da95144b8b1e44c99d5fc7d3e9c554", 212 | "sha256:2a3e362c97a5e6a259ee9cd66553292a1f8928a5bdfa3622fdb1501570834612", 213 | "sha256:3832e26ecbc9d8a500821e3a1d3765bda99d04ae29ffbb2efba49f5f788dc934", 214 | "sha256:4fd1f0c2dc02aaec729d91c92cd85a2df0289d88e9f68d1e8faba750bb9c4786", 215 | "sha256:4fda62030f2c515b6e2e673c57caa55cb04026a81968f3128aae10fc28e5cc27", 216 | "sha256:5044d75a68b49ce36a813c82d8201384207112d5d81643937fc758c05302f05b", 217 | "sha256:522184556921512ec484cb93bd84e0bab915d0ac5a372d49571c241a7f73db62", 218 | "sha256:5914cff11f3e920626da48e564be6818831713a3087586302444b9c70e8552d9", 219 | "sha256:6661a7908d68c4a133e03dac8178287aa20a99f841ea90beeb98a233ae3fd710", 220 | "sha256:79258a8df3e309a54c7ef2ef4a59bb8e28f7e4a8992a3ad17c24b1889ced44f3", 221 | "sha256:7d74c20b8f1c3e99d3f781d3b8ff5abfefdd7363d61e23bdeba9992ff32cc4b4", 222 | "sha256:81918afeafc16ba5d9d0d4e9445905f21aac969a4ebb6f2bff4b9886da100f4b", 223 | "sha256:8194d913ca1f459377c8a4ed8f9b7ad750068b8e0e3f3f9c6963fcc87a84515f", 224 | "sha256:84d5d31200b11b3c76fab853b89ac898bf2d05c8b3da07c1fcc23feb06359d6e", 225 | "sha256:989981db57abffb52026b114c9a1f114c7142860a6d30a352d28f8cbf186500b", 226 | "sha256:a3d7511d3fad1618a82299aab71a5fceee5c015653a77ffea75ced9ef917e71a", 227 | "sha256:b3ef168d4d6fd4fa6685aef7c91400f59f7ab1c0da734541f7031699741fb23f", 228 | "sha256:c1c5792b6e74bbf2af0f8e892272c2a6c48efa895903211f11b8342e03129fea", 229 | "sha256:c5dcb5a56aebb8a8f2585042b2f5c496d7624f0bcfe248f0cc33ceb2fd8d39e7", 230 | "sha256:e2bed4a04e2ca1050bb5f00865cf2f83c0b92fd62454d9244f690fcd842e27a4", 231 | "sha256:e87a527c06319428007e8c30511e1f0ce035cb7f14bb4793b003ed532c3b9333", 232 | "sha256:f63e420180cbe22ff6e32558b612e75f50616fc111c5e095a4631946c782e109", 233 | "sha256:f8b3d413c5a8f84b12cd4c5df1d8e211777c9852c6be3ee9c094b626644d3eab" 234 | ], 235 | "index": "pypi", 236 | "version": "==5.2.0" 237 | }, 238 | "pyperclip": { 239 | "hashes": [ 240 | "sha256:f70e83d27c445795b6bf98c2bc826bbf2d0d63d4c7f83091c8064439042ba0dc" 241 | ], 242 | "index": "pypi", 243 | "version": "==1.6.4" 244 | }, 245 | "pyquery": { 246 | "hashes": [ 247 | "sha256:07987c2ed2aed5cba29ff18af95e56e9eb04a2249f42ce47bddfb37f487229a3", 248 | "sha256:4771db76bd14352eba006463656aef990a0147a0eeaf094725097acfa90442bf" 249 | ], 250 | "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.2.*' and python_version != '3.0.*'", 251 | "version": "==1.4.0" 252 | }, 253 | "pytesseract": { 254 | "hashes": [ 255 | "sha256:9a9fae6331084f588c0cf2ad9ed50fca47e20429407e63389eb42d4e64460013" 256 | ], 257 | "index": "pypi", 258 | "version": "==0.2.4" 259 | }, 260 | "python-dateutil": { 261 | "hashes": [ 262 | "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", 263 | "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" 264 | ], 265 | "version": "==2.7.3" 266 | }, 267 | "python-xlib": { 268 | "hashes": [ 269 | "sha256:2ffa01fa51bdf53842fa4e3f9e2501f8147d4abf546a83e9c2b091982da2e1a8", 270 | "sha256:c3deb8329038620d07b21be05673fa5a495dd8b04a2d9f4dca37a3811d192ae4" 271 | ], 272 | "version": "==0.23" 273 | }, 274 | "pytz": { 275 | "hashes": [ 276 | "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053", 277 | "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277" 278 | ], 279 | "version": "==2018.5" 280 | }, 281 | "pywinauto": { 282 | "hashes": [ 283 | "sha256:75fdfdea3f018c0efc9196cb184ecd14df8b35734889df9624610b8e74812807" 284 | ], 285 | "index": "pypi", 286 | "version": "==0.6.4" 287 | }, 288 | "requests": { 289 | "hashes": [ 290 | "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", 291 | "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" 292 | ], 293 | "index": "pypi", 294 | "version": "==2.19.1" 295 | }, 296 | "rqopen-client": { 297 | "hashes": [ 298 | "sha256:9bda6a1ceac7453ff66ba0ee61ac56e1dd88bfcbd5eb27c98f49c460bf6d5ff7" 299 | ], 300 | "index": "pypi", 301 | "version": "==0.0.5" 302 | }, 303 | "six": { 304 | "hashes": [ 305 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 306 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 307 | ], 308 | "index": "pypi", 309 | "version": "==1.11.0" 310 | }, 311 | "urllib3": { 312 | "hashes": [ 313 | "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", 314 | "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" 315 | ], 316 | "markers": "python_version != '3.2.*' and python_version < '4' and python_version != '3.3.*' and python_version >= '2.6' and python_version != '3.0.*' and python_version != '3.1.*'", 317 | "version": "==1.23" 318 | }, 319 | "werkzeug": { 320 | "hashes": [ 321 | "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", 322 | "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" 323 | ], 324 | "version": "==0.14.1" 325 | } 326 | }, 327 | "develop": { 328 | "appdirs": { 329 | "hashes": [ 330 | "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", 331 | "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" 332 | ], 333 | "version": "==1.4.3" 334 | }, 335 | "appnope": { 336 | "hashes": [ 337 | "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", 338 | "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" 339 | ], 340 | "markers": "sys_platform == 'darwin'", 341 | "version": "==0.1.0" 342 | }, 343 | "aspy.yaml": { 344 | "hashes": [ 345 | "sha256:04d26279513618f1024e1aba46471db870b3b33aef204c2d09bcf93bea9ba13f", 346 | "sha256:0a77e23fafe7b242068ffc0252cee130d3e509040908fc678d9d1060e7494baa" 347 | ], 348 | "version": "==1.1.1" 349 | }, 350 | "astroid": { 351 | "hashes": [ 352 | "sha256:0a0c484279a5f08c9bcedd6fa9b42e378866a7dcc695206b92d59dc9f2d9760d", 353 | "sha256:218e36cf8d98a42f16214e8670819ce307fa707d1dcf7f9af84c7aede1febc7f" 354 | ], 355 | "version": "==2.0.1" 356 | }, 357 | "atomicwrites": { 358 | "hashes": [ 359 | "sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585", 360 | "sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6" 361 | ], 362 | "version": "==1.1.5" 363 | }, 364 | "attrs": { 365 | "hashes": [ 366 | "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", 367 | "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" 368 | ], 369 | "version": "==18.1.0" 370 | }, 371 | "backcall": { 372 | "hashes": [ 373 | "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4", 374 | "sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2" 375 | ], 376 | "version": "==0.1.0" 377 | }, 378 | "better-exceptions": { 379 | "hashes": [ 380 | "sha256:0a73efef96b48f867ea980227ac3b00d36a92754e6d316ad2ee472f136014580" 381 | ], 382 | "index": "pypi", 383 | "version": "==0.2.1" 384 | }, 385 | "black": { 386 | "hashes": [ 387 | "sha256:22158b89c1a6b4eb333a1e65e791a3f8b998cf3b11ae094adb2570f31f769a44", 388 | "sha256:4b475bbd528acce094c503a3d2dbc2d05a4075f6d0ef7d9e7514518e14cc5191" 389 | ], 390 | "index": "pypi", 391 | "version": "==18.6b4" 392 | }, 393 | "cached-property": { 394 | "hashes": [ 395 | "sha256:630fdbf0f4ac7d371aa866016eba1c3ac43e9032246748d4994e67cb05f99bc4", 396 | "sha256:f1f9028757dc40b4cb0fd2234bd7b61a302d7b84c683cb8c2c529238a24b8938" 397 | ], 398 | "version": "==1.4.3" 399 | }, 400 | "cfgv": { 401 | "hashes": [ 402 | "sha256:73f48a752bd7aab103c4b882d6596c6360b7aa63b34073dd2c35c7b4b8f93010", 403 | "sha256:d1791caa9ff5c0c7bce80e7ecc1921752a2eb7c2463a08ed9b6c96b85a2f75aa" 404 | ], 405 | "version": "==1.1.0" 406 | }, 407 | "click": { 408 | "hashes": [ 409 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 410 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 411 | ], 412 | "index": "pypi", 413 | "version": "==6.7" 414 | }, 415 | "coverage": { 416 | "hashes": [ 417 | "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba", 418 | "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed", 419 | "sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95", 420 | "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640", 421 | "sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd", 422 | "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162", 423 | "sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1", 424 | "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508", 425 | "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249", 426 | "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694", 427 | "sha256:3c79a6f7b95751cdebcd9037e4d06f8d5a9b60e4ed0cd231342aa8ad7124882a", 428 | "sha256:3d72c20bd105022d29b14a7d628462ebdc61de2f303322c0212a054352f3b287", 429 | "sha256:3eb42bf89a6be7deb64116dd1cc4b08171734d721e7a7e57ad64cc4ef29ed2f1", 430 | "sha256:4635a184d0bbe537aa185a34193898eee409332a8ccb27eea36f262566585000", 431 | "sha256:56e448f051a201c5ebbaa86a5efd0ca90d327204d8b059ab25ad0f35fbfd79f1", 432 | "sha256:5a13ea7911ff5e1796b6d5e4fbbf6952381a611209b736d48e675c2756f3f74e", 433 | "sha256:69bf008a06b76619d3c3f3b1983f5145c75a305a0fea513aca094cae5c40a8f5", 434 | "sha256:6bc583dc18d5979dc0f6cec26a8603129de0304d5ae1f17e57a12834e7235062", 435 | "sha256:701cd6093d63e6b8ad7009d8a92425428bc4d6e7ab8d75efbb665c806c1d79ba", 436 | "sha256:7608a3dd5d73cb06c531b8925e0ef8d3de31fed2544a7de6c63960a1e73ea4bc", 437 | "sha256:76ecd006d1d8f739430ec50cc872889af1f9c1b6b8f48e29941814b09b0fd3cc", 438 | "sha256:7aa36d2b844a3e4a4b356708d79fd2c260281a7390d678a10b91ca595ddc9e99", 439 | "sha256:7d3f553904b0c5c016d1dad058a7554c7ac4c91a789fca496e7d8347ad040653", 440 | "sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c", 441 | "sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558", 442 | "sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f", 443 | "sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9", 444 | "sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd", 445 | "sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d", 446 | "sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6", 447 | "sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80" 448 | ], 449 | "markers": "python_version < '4' and python_version >= '2.6' and python_version != '3.2.*' and python_version != '3.0.*' and python_version != '3.1.*'", 450 | "version": "==4.5.1" 451 | }, 452 | "decorator": { 453 | "hashes": [ 454 | "sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82", 455 | "sha256:c39efa13fbdeb4506c476c9b3babf6a718da943dab7811c206005a4a956c080c" 456 | ], 457 | "version": "==4.3.0" 458 | }, 459 | "identify": { 460 | "hashes": [ 461 | "sha256:49845e70fc6b1ec3694ab930a2c558912d7de24548eebcd448f65567dc757c43", 462 | "sha256:68daab16a3db364fa204591f97dc40bfffd1a7739f27788a4895b4d8fd3516e5" 463 | ], 464 | "version": "==1.1.4" 465 | }, 466 | "ipython": { 467 | "hashes": [ 468 | "sha256:a0c96853549b246991046f32d19db7140f5b1a644cc31f0dc1edc86713b7676f", 469 | "sha256:eca537aa61592aca2fef4adea12af8e42f5c335004dfa80c78caf80e8b525e5c" 470 | ], 471 | "index": "pypi", 472 | "version": "==6.4.0" 473 | }, 474 | "ipython-genutils": { 475 | "hashes": [ 476 | "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", 477 | "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" 478 | ], 479 | "version": "==0.2.0" 480 | }, 481 | "isort": { 482 | "hashes": [ 483 | "sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af", 484 | "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", 485 | "sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497" 486 | ], 487 | "index": "pypi", 488 | "version": "==4.3.4" 489 | }, 490 | "jedi": { 491 | "hashes": [ 492 | "sha256:b409ed0f6913a701ed474a614a3bb46e6953639033e31f769ca7581da5bd1ec1", 493 | "sha256:c254b135fb39ad76e78d4d8f92765ebc9bf92cbc76f49e97ade1d5f5121e1f6f" 494 | ], 495 | "version": "==0.12.1" 496 | }, 497 | "lazy-object-proxy": { 498 | "hashes": [ 499 | "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", 500 | "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", 501 | "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", 502 | "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", 503 | "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", 504 | "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", 505 | "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", 506 | "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", 507 | "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", 508 | "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", 509 | "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", 510 | "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", 511 | "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", 512 | "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", 513 | "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", 514 | "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", 515 | "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", 516 | "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", 517 | "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", 518 | "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", 519 | "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", 520 | "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", 521 | "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", 522 | "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", 523 | "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", 524 | "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", 525 | "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", 526 | "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a", 527 | "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b" 528 | ], 529 | "version": "==1.3.1" 530 | }, 531 | "mccabe": { 532 | "hashes": [ 533 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 534 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 535 | ], 536 | "version": "==0.6.1" 537 | }, 538 | "more-itertools": { 539 | "hashes": [ 540 | "sha256:2b6b9893337bfd9166bee6a62c2b0c9fe7735dcf85948b387ec8cba30e85d8e8", 541 | "sha256:6703844a52d3588f951883005efcf555e49566a48afd4db4e965d69b883980d3", 542 | "sha256:a18d870ef2ffca2b8463c0070ad17b5978056f403fb64e3f15fe62a52db21cc0" 543 | ], 544 | "version": "==4.2.0" 545 | }, 546 | "mypy": { 547 | "hashes": [ 548 | "sha256:673ea75fb750289b7d1da1331c125dc62fc1c3a8db9129bb372ae7b7d5bf300a", 549 | "sha256:c770605a579fdd4a014e9f0a34b6c7a36ce69b08100ff728e96e27445cef3b3c" 550 | ], 551 | "index": "pypi", 552 | "version": "==0.620" 553 | }, 554 | "nodeenv": { 555 | "hashes": [ 556 | "sha256:aa040ab5189bae17d272175609010be6c5b589ec4b8dbd832cc50c9e9cb7496f" 557 | ], 558 | "version": "==1.3.2" 559 | }, 560 | "parso": { 561 | "hashes": [ 562 | "sha256:35704a43a3c113cce4de228ddb39aab374b8004f4f2407d070b6a2ca784ce8a2", 563 | "sha256:895c63e93b94ac1e1690f5fdd40b65f07c8171e3e53cbd7793b5b96c0e0a7f24" 564 | ], 565 | "version": "==0.3.1" 566 | }, 567 | "pexpect": { 568 | "hashes": [ 569 | "sha256:2a8e88259839571d1251d278476f3eec5db26deb73a70be5ed5dc5435e418aba", 570 | "sha256:3fbd41d4caf27fa4a377bfd16fef87271099463e6fa73e92a52f92dfee5d425b" 571 | ], 572 | "markers": "sys_platform != 'win32'", 573 | "version": "==4.6.0" 574 | }, 575 | "pickleshare": { 576 | "hashes": [ 577 | "sha256:84a9257227dfdd6fe1b4be1319096c20eb85ff1e82c7932f36efccfe1b09737b", 578 | "sha256:c9a2541f25aeabc070f12f452e1f2a8eae2abd51e1cd19e8430402bdf4c1d8b5" 579 | ], 580 | "version": "==0.7.4" 581 | }, 582 | "pluggy": { 583 | "hashes": [ 584 | "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff", 585 | "sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c", 586 | "sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5" 587 | ], 588 | "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.0.*' and python_version != '3.1.*'", 589 | "version": "==0.6.0" 590 | }, 591 | "pre-commit": { 592 | "hashes": [ 593 | "sha256:99cb6313a8ea7d88871aa2875a12d3c3a7636edf8ce4634b056328966682c8ce", 594 | "sha256:c71e6cf84e812226f8dadbe346b5e6d6728fa65a364bbfe7624b219a18950540" 595 | ], 596 | "index": "pypi", 597 | "version": "==1.10.4" 598 | }, 599 | "prompt-toolkit": { 600 | "hashes": [ 601 | "sha256:1df952620eccb399c53ebb359cc7d9a8d3a9538cb34c5a1344bdbeb29fbcc381", 602 | "sha256:3f473ae040ddaa52b52f97f6b4a493cfa9f5920c255a12dc56a7d34397a398a4", 603 | "sha256:858588f1983ca497f1cf4ffde01d978a3ea02b01c8a26a8bbc5cd2e66d816917" 604 | ], 605 | "version": "==1.0.15" 606 | }, 607 | "ptyprocess": { 608 | "hashes": [ 609 | "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", 610 | "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f" 611 | ], 612 | "version": "==0.6.0" 613 | }, 614 | "py": { 615 | "hashes": [ 616 | "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", 617 | "sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e" 618 | ], 619 | "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.0.*' and python_version != '3.1.*'", 620 | "version": "==1.5.4" 621 | }, 622 | "pygments": { 623 | "hashes": [ 624 | "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", 625 | "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" 626 | ], 627 | "version": "==2.2.0" 628 | }, 629 | "pylint": { 630 | "hashes": [ 631 | "sha256:2c90a24bee8fae22ac98061c896e61f45c5b73c2e0511a4bf53f99ba56e90434", 632 | "sha256:454532779425098969b8f54ab0f056000b883909f69d05905ea114df886e3251" 633 | ], 634 | "index": "pypi", 635 | "version": "==2.0.1" 636 | }, 637 | "pytest": { 638 | "hashes": [ 639 | "sha256:341ec10361b64a24accaec3c7ba5f7d5ee1ca4cebea30f76fad3dd12db9f0541", 640 | "sha256:952c0389db115437f966c4c2079ae9d54714b9455190e56acebe14e8c38a7efa" 641 | ], 642 | "index": "pypi", 643 | "version": "==3.6.4" 644 | }, 645 | "pytest-cov": { 646 | "hashes": [ 647 | "sha256:03aa752cf11db41d281ea1d807d954c4eda35cfa1b21d6971966cc041bbf6e2d", 648 | "sha256:890fe5565400902b0c78b5357004aab1c814115894f4f21370e2433256a3eeec" 649 | ], 650 | "index": "pypi", 651 | "version": "==2.5.1" 652 | }, 653 | "pyyaml": { 654 | "hashes": [ 655 | "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", 656 | "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", 657 | "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", 658 | "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", 659 | "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", 660 | "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", 661 | "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", 662 | "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", 663 | "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", 664 | "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", 665 | "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" 666 | ], 667 | "version": "==3.13" 668 | }, 669 | "simplegeneric": { 670 | "hashes": [ 671 | "sha256:dc972e06094b9af5b855b3df4a646395e43d1c9d0d39ed345b7393560d0b9173" 672 | ], 673 | "version": "==0.8.1" 674 | }, 675 | "six": { 676 | "hashes": [ 677 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 678 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 679 | ], 680 | "index": "pypi", 681 | "version": "==1.11.0" 682 | }, 683 | "toml": { 684 | "hashes": [ 685 | "sha256:8e86bd6ce8cc11b9620cb637466453d94f5d57ad86f17e98a98d1f73e3baab2d" 686 | ], 687 | "version": "==0.9.4" 688 | }, 689 | "traitlets": { 690 | "hashes": [ 691 | "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", 692 | "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9" 693 | ], 694 | "version": "==4.3.2" 695 | }, 696 | "typed-ast": { 697 | "hashes": [ 698 | "sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58", 699 | "sha256:10703d3cec8dcd9eef5a630a04056bbc898abc19bac5691612acba7d1325b66d", 700 | "sha256:1f6c4bd0bdc0f14246fd41262df7dfc018d65bb05f6e16390b7ea26ca454a291", 701 | "sha256:25d8feefe27eb0303b73545416b13d108c6067b846b543738a25ff304824ed9a", 702 | "sha256:29464a177d56e4e055b5f7b629935af7f49c196be47528cc94e0a7bf83fbc2b9", 703 | "sha256:2e214b72168ea0275efd6c884b114ab42e316de3ffa125b267e732ed2abda892", 704 | "sha256:3e0d5e48e3a23e9a4d1a9f698e32a542a4a288c871d33ed8df1b092a40f3a0f9", 705 | "sha256:519425deca5c2b2bdac49f77b2c5625781abbaf9a809d727d3a5596b30bb4ded", 706 | "sha256:57fe287f0cdd9ceaf69e7b71a2e94a24b5d268b35df251a88fef5cc241bf73aa", 707 | "sha256:668d0cec391d9aed1c6a388b0d5b97cd22e6073eaa5fbaa6d2946603b4871efe", 708 | "sha256:68ba70684990f59497680ff90d18e756a47bf4863c604098f10de9716b2c0bdd", 709 | "sha256:6de012d2b166fe7a4cdf505eee3aaa12192f7ba365beeefaca4ec10e31241a85", 710 | "sha256:79b91ebe5a28d349b6d0d323023350133e927b4de5b651a8aa2db69c761420c6", 711 | "sha256:8550177fa5d4c1f09b5e5f524411c44633c80ec69b24e0e98906dd761941ca46", 712 | "sha256:898f818399cafcdb93cbbe15fc83a33d05f18e29fb498ddc09b0214cdfc7cd51", 713 | "sha256:94b091dc0f19291adcb279a108f5d38de2430411068b219f41b343c03b28fb1f", 714 | "sha256:a26863198902cda15ab4503991e8cf1ca874219e0118cbf07c126bce7c4db129", 715 | "sha256:a8034021801bc0440f2e027c354b4eafd95891b573e12ff0418dec385c76785c", 716 | "sha256:bc978ac17468fe868ee589c795d06777f75496b1ed576d308002c8a5756fb9ea", 717 | "sha256:c05b41bc1deade9f90ddc5d988fe506208019ebba9f2578c622516fd201f5863", 718 | "sha256:c9b060bd1e5a26ab6e8267fd46fc9e02b54eb15fffb16d112d4c7b1c12987559", 719 | "sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87", 720 | "sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6" 721 | ], 722 | "markers": "python_version < '3.7' and implementation_name == 'cpython'", 723 | "version": "==1.1.0" 724 | }, 725 | "virtualenv": { 726 | "hashes": [ 727 | "sha256:2ce32cd126117ce2c539f0134eb89de91a8413a29baac49cbab3eb50e2026669", 728 | "sha256:ca07b4c0b54e14a91af9f34d0919790b016923d157afda5efdde55c96718f752" 729 | ], 730 | "markers": "python_version != '3.1.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.0.*'", 731 | "version": "==16.0.0" 732 | }, 733 | "wcwidth": { 734 | "hashes": [ 735 | "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", 736 | "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" 737 | ], 738 | "version": "==0.1.7" 739 | }, 740 | "wrapt": { 741 | "hashes": [ 742 | "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" 743 | ], 744 | "version": "==1.10.11" 745 | } 746 | } 747 | } 748 | --------------------------------------------------------------------------------