diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..3ab275f2 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,6 @@ +[bumpversion] +current_version = 0.23.0 +commit = True +files = easytrader/__init__.py setup.py +tag = True +tag_name = {new_version} diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..65e90598 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +branch = True +include = easytrader/* +omit = tests/* + +[report] +fail_under = -1 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..65d340cf --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,13 @@ +## env + +OS: win7/ win10 / mac / linux +PYTHON_VERSION: 3.x +EASYTRADER_VERSION: 0.xx.xx +BROKER_TYPE: gj / ht / xq / xxx + +## problem + +## how to repeat + + + diff --git a/.gitignore b/.gitignore index ec2d826e..9c30b1f3 100755 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,15 @@ +cmd_cache.pk +bak +.mypy_cache +.pyre +.pytest_cache yjb_account.json htt.json gft.json test.py ht_account.json .idea +.vscode .ipynb_checkpoints Untitled.ipynb untitled.txt @@ -11,6 +17,7 @@ untitled.txt __pycache__/ *.py[cod] account.json +account.session # C extensions *.so diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..e5c5b89b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,571 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns=\d{4}.+\.py, + test, + apps.py, + __init__.py, + urls.py, + manage.py + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=0 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=too-many-public-methods, + len-as-condition, + unused-argument, + too-many-arguments, + arguments-differ, + line-too-long, + fixme, + missing-docstring, + invalid-envvar-default, + ungrouped-imports, + bad-continuation, + too-many-ancestors, + too-few-public-methods, + no-self-use, + #print-statement, + #parameter-unpacking, + #unpacking-in-except, + #old-raise-syntax, + #backtick, + #long-suffix, + #old-ne-operator, + #old-octal-literal, + #import-star-module-level, + #non-ascii-bytes-literal, + #raw-checker-failed, + #bad-inline-option, + #locally-disabled, + #locally-enabled, + #file-ignored, + #suppressed-message, + #useless-suppression, + #deprecated-pragma, + #apply-builtin, + #basestring-builtin, + #buffer-builtin, + #cmp-builtin, + #coerce-builtin, + #execfile-builtin, + #file-builtin, + #long-builtin, + #raw_input-builtin, + #reduce-builtin, + #standarderror-builtin, + #unicode-builtin, + #xrange-builtin, + #coerce-method, + #delslice-method, + #getslice-method, + #setslice-method, + #no-absolute-import, + #old-division, + #dict-iter-method, + #dict-view-method, + #next-method-called, + #metaclass-assignment, + #indexing-exception, + #raising-string, + #reload-builtin, + #oct-method, + #hex-method, + #nonzero-method, + #cmp-method, + #input-builtin, + #round-builtin, + #intern-builtin, + #unichr-builtin, + #map-builtin-not-iterating, + #zip-builtin-not-iterating, + #range-builtin-not-iterating, + #filter-builtin-not-iterating, + #using-cmp-argument, + #eq-without-hash, + #div-method, + #idiv-method, + #rdiv-method, + #exception-message-attribute, + #invalid-str-codec, + #sys-max-int, + #bad-python3-import, + #deprecated-string-function, + #deprecated-str-translate-call, + #deprecated-itertools-function, + #deprecated-types-field, + #next-method-defined, + #dict-items-not-iterating, + #dict-keys-not-iterating, + #dict-values-not-iterating + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=optparse.Values,sys.exit + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=any + +# Regular expression matching correct constant names. Overrides const-naming- +# style +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=5 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + do, + f, + df, + s, + j, + k, + ex, + Run, + _, + db, + r, + x, + y, + e + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +#method-rgx= + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=79 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=20 + +# Maximum number of locals for function / method body +max-locals=20 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..b85e7215 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: python + +python: + - "3.6" + +install: + - pip install pipenv + - pipenv install --dev --system + +script: + - pipenv run test diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..6d2dafda --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 shidenggui + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..bde70097 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + pytest -vx --cov=easytrader tests diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..9670212d --- /dev/null +++ b/Pipfile @@ -0,0 +1,40 @@ +[[source]] +url = "http://mirrors.aliyun.com/pypi/simple/" +verify_ssl = false +name = "pypi" + +[packages] +pywinauto = "*" +"bs4" = "*" +requests = "*" +dill = "*" +click = "*" +six = "*" +flask = "*" +pillow = "*" +pytesseract = "*" +pandas = "*" +pyperclip = "*" +easyutils = "*" + +[dev-packages] +pytest-cov = "*" +pre-commit = "*" +pytest = "*" +pylint = "*" +mypy = "*" +isort = "*" +black = "==18.6b4" +ipython = "*" +better-exceptions = "*" + +[requires] +python_version = "3.6" + +[scripts] +sort_imports = "bash -c 'isort \"$@\"; git add -u' --" +format = "bash -c 'black -l 79 \"$@\"; git add -u' --" +lint = "pylint" +type_check = "mypy" +test = "bash -c 'pytest -vx --cov=easytrader tests'" +lock = "bash -c 'pipenv lock -r > requirements.txt'" diff --git a/README.md b/README.md index 76c1ca84..9321155d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,34 @@ # easytrader +[![Package](https://img.shields.io/pypi/v/easytrader.svg)](https://pypi.python.org/pypi/easytrader) +[![Travis](https://img.shields.io/travis/shidenggui/easytrader.svg)](https://travis-ci.org/shidenggui/easytrader) +[![License](https://img.shields.io/github/license/shidenggui/easytrader.svg)](https://github.com/shidenggui/easytrader/blob/master/LICENSE) + * 进行自动的程序化股票交易 -* 实现自动登录 * 支持跟踪 `joinquant`, `ricequant` 的模拟交易 * 支持跟踪 雪球组合 调仓 +* 支持通用的同花顺客户端模拟操作 +* 实现自动登录 +* 支持通过 webserver 远程操作客户端 * 支持命令行调用,方便其他语言适配 -* 支持 Python3 / Python2, Linux / Win, 推荐使用 `Python3` -* 有兴趣的可以加群 `556050652` 、`549879767`(已满) 、`429011814`(已满) 一起讨论 -* 捐助: [支付宝](http://7xqo8v.com1.z0.glb.clouddn.com/zhifubao2.png) [微信](http://7xqo8v.com1.z0.glb.clouddn.com/wx.png) 或者 银河开户可以加群找我 +* 基于 Python3.6, Win。注: Linux 仅支持雪球 -**开发环境** : `Ubuntu 16.04` / `Python 3.5` +### 微信群以及公众号 -### 相关 +欢迎大家扫码关注公众号「食灯鬼」,一起交流。进群可通过菜单加我好友,备注量化。 + +![公众号二维码](https://gitee.com/shidenggui/assets/raw/master/uPic/mp-qr.png) + +若二维码因 Github 网络无法打开,请点击[公众号二维码](https://gitee.com/shidenggui/assets/raw/master/uPic/mp-qr.png)直接打开图片。 -[量化交流论坛](http://www.celuetan.com) +### Author + +**easytrader** © [shidenggui](https://github.com/shidenggui), Released under the [MIT](./LICENSE) License.
+ +> Blog [@shidenggui](https://shidenggui.com) · Weibo [@食灯鬼](https://www.weibo.com/u/1651274491) · Twitter [@shidenggui](https://twitter.com/shidenggui) + +### 相关 [获取新浪免费实时行情的类库: easyquotation](https://github.com/shidenggui/easyquotation) @@ -23,10 +37,11 @@ ### 支持券商 -* 银河 -* 广发 -* 银河客户端(支持自动登陆), 须在 `windows` 平台下载 `银河双子星` 客户端 -* 湘财证券 +* 海通客户端(海通网上交易系统独立委托) +* 华泰客户端(网上交易系统(专业版Ⅱ)) +* 国金客户端(全能行证券交易终端PC版) +* 其他券商通用同花顺客户端(需要手动登陆) + ### 模拟交易 @@ -36,6 +51,7 @@ [中文文档](http://easytrader.readthedocs.io/zh/master/) -### 其他 -[软件实现原理](http://www.jisilu.cn/question/42707) +### 作者其他作品 +* [大数据网络小说推荐系统 - 推书君](https://www.tuishujun.com) +* [中文独立个人博客导航 - bloghub.fun](https://bloghub.fun) diff --git a/cli.py b/cli.py deleted file mode 100644 index 7e6a678e..00000000 --- a/cli.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -import click -import dill - -import easytrader - -ACCOUNT_OBJECT_FILE = 'account.session' - - -@click.command() -@click.option('--use', help='指定券商 [ht, yjb, yh]') -@click.option('--prepare', type=click.Path(exists=True), help='指定登录账户文件路径') -@click.option('--get', help='调用 easytrader 中对应的变量') -@click.option('--do', help='调用 easytrader 中对应的函数名') -@click.option('--debug', default=False, help='是否输出 easytrader 的 debug 日志') -@click.argument('params', nargs=-1) -def main(prepare, use, do, get, params, debug): - if get is not None: - do = get - if prepare is not None and use in ['ht', 'yjb', 'yh', 'gf', 'xq']: - user = easytrader.use(use, debug) - user.prepare(prepare) - with open(ACCOUNT_OBJECT_FILE, 'wb') as f: - dill.dump(user, f) - if do is not None: - with open(ACCOUNT_OBJECT_FILE, 'rb') as f: - user = dill.load(f) - - if len(params) > 0: - result = getattr(user, do)(*params) - else: - result = getattr(user, do) - - json_result = json.dumps(result, indent=4, ensure_ascii=False, sort_keys=True) - click.echo(json_result) - - -if __name__ == '__main__': - main() diff --git a/convert_jq.sh b/convert_jq.sh deleted file mode 100644 index e918642e..00000000 --- a/convert_jq.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash - -for config in xq.json yh.json global.json -do - cp easytrader/config/${config} . -done - -for file in easytrader/webtrader.py easytrader/xqtrader.py easytrader/yhtrader.py easytrader/api.py easytrader/helpers.py easytrader/log.py -do - sed -e 's/\/config\///g; s/from \. import/import/g; s/from \./from /g ' ${file} > `basename ${file}` -done - -# delete api.py invalid lines -delete_line_flag='_follower gftrader httrader yjbtrader' -sed_cmd='' -for flag in ${delete_line_flag} -do - sed_cmd="/${flag}/d;${sed_cmd}" -done - -sed -i ${sed_cmd} api.py -mv api.py jq_easytrader.py - -sed -i '/thirdlibrary/d' helpers.py -echo generate jq files success diff --git a/docs/help.md b/docs/help.md index aabf432f..d8bbcb1a 100644 --- a/docs/help.md +++ b/docs/help.md @@ -1,3 +1,40 @@ +# 某些同花顺客户端不允许拷贝 `Grid` 数据 + +现在默认获取 `Grid` 数据的策略是通过剪切板拷贝,有些券商不允许这种方式,导致无法获取持仓等数据。为解决此问题,额外实现了一种通过将 `Grid` 数据存为文件再读取的策略, +使用方式如下: + +```python +from easytrader import grid_strategies + +user.grid_strategy = grid_strategies.Xls +``` + +# 通过工具栏刷新按钮刷新数据 + +当前的刷新数据方式是通过切换菜单栏实现,通用但是比较缓慢,可以选择通过点击工具栏的刷新按钮来刷新 + +```python +from easytrader import refresh_strategies + +# refresh_btn_index 指的是刷新按钮在工具栏的排序,默认为第四个,根据客户端实际情况调整 +user.refresh_strategy = refresh_strategies.Toolbar(refresh_btn_index=4) +``` + +# 无法保存对应的 xls 文件 + +有些系统默认的临时文件目录过长,使用 xls 策略时无法正常保存,可通过如下方式修改为自定义目录 + +``` +user.grid_strategy_instance.tmp_folder = 'C:\\custom_folder' +``` + +# 某些券商客户端无法输入文本 + +有些客户端无法通过 set_edit_text 方法输入内容,可以通过使用 type_keys 方法绕过,开启方式 + +``` +user.enable_type_keys_for_editor() +``` # 如何关闭 debug 日志的输出 diff --git a/docs/index.md b/docs/index.md index a3efe09e..0f1fd337 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,32 +3,42 @@ * 进行自动的程序化股票交易 * 支持跟踪 `joinquant`, `ricequant` 的模拟交易 * 支持跟踪 雪球组合 调仓, 实盘雪球组合 +* 支持通用的同花顺客户端模拟操作 * 支持命令行调用,方便其他语言适配 +* 支持远程操作客户端 * 支持 Python3 , Linux / Win / Mac -* 有兴趣的可以加群 `556050652` 、`549879767`(已满) 、`429011814`(已满) 一起讨论 -* 捐助: [支付宝](http://7xqo8v.com1.z0.glb.clouddn.com/zhifubao2.png) [微信](http://7xqo8v.com1.z0.glb.clouddn.com/wx.png) 或者 银河开户可以加群找我 +### 加微信群以及公众号 + +欢迎大家扫码关注公众号"食灯鬼",通过菜单加我好友,备注量化进群 + +![JDRUhz](https://gitee.com/shidenggui/assets/raw/master/uPic/JDRUhz.jpg) -**开发环境** : `OSX 10.12.3` / `Python 3.5` ### 支持券商 -* 银河 -* 广发 -* 银河客户端(支持自动登陆), 须在 `windows` 平台下载 `银河双子星` 客户端 -* 湘财证券 + +* 海通客户端(海通网上交易系统独立委托) +* 华泰客户端(网上交易系统(专业版Ⅱ)) +* 国金客户端(全能行证券交易终端PC版) +* 通用同花顺客户端(同花顺免费版) +* 其他券商专用同花顺客户端(需要手动登陆) + ### 模拟交易 * 雪球组合 by @[haogefeifei](https://github.com/haogefeifei)([说明](other/xueqiu.md)) - ### 相关 -[量化交流论坛](http://www.celuetan.com) - [获取新浪免费实时行情的类库: easyquotation](https://github.com/shidenggui/easyquotation) [简单的股票量化交易框架 使用 easytrader 和 easyquotation](https://github.com/shidenggui/easyquant) + +### 作者其他作品 + +* [大数据网络小说推荐系统 - 推书君](https://www.tuishujun.com) +* [中文独立个人博客导航 - bloghub.fun](https://bloghub.fun) + diff --git a/docs/install.md b/docs/install.md index 838e5bc2..5d71030a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,15 +1,25 @@ ### requirements -银河可以直接自动登录, 广发的自动登录需要安装 tesseract: +### 客户端设置 -* `tesseract` : 非 `pytesseract`, 需要单独安装, [地址](https://github.com/tesseract-ocr/tesseract/wiki),保证在命令行下 `tesseract` 可用 - -##### 银河客户端设置 +需要对客户端按以下设置,不然会导致下单时价格出错以及客户端超时锁定 -* 系统设置 > 快速交易: 关闭所有的买卖,撤单等确认选项 * 系统设置 > 界面设置: 界面不操作超时时间设为 0 * 系统设置 > 交易设置: 默认买入价格/买入数量/卖出价格/卖出数量 都设置为 空 +同时客户端不能最小化也不能处于精简模式 + +### 云端部署建议 + +在云服务上部署时,使用自带的远程桌面会有问题,推荐使用 TightVNC + +### 登陆时的验证码识别 + +券商如果登陆需要识别验证码的话需要安装 tesseract: + +* `tesseract` : 非 `pytesseract`, 需要单独安装, [地址](https://github.com/tesseract-ocr/tesseract/wiki),保证在命令行下 `tesseract` 可用 + +或者你也可以手动登陆后在通过 `easytrader` 调用,此时 `easytrader` 在登陆过程中会直接识别到已登陆的窗口。 ### 安装 diff --git a/docs/other/INSTALL4Windows.md b/docs/other/INSTALL4Windows.md deleted file mode 100644 index e73c9421..00000000 --- a/docs/other/INSTALL4Windows.md +++ /dev/null @@ -1,26 +0,0 @@ - -**Python installation** - -* Install windows python3 from python.org - -** Python module installation** - -* pip install -r requirements.txt -* pip install Pillow -* pip install pytesseract - - **Tesseract installation** - -* wget https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/pytesser/pytesser_v0.0.1.zip -* unzip pytesser_v0.0.1.zip -* Put tesseract.exe tessdata\ under C:\Users\xxxx\AppData\Local\Programs\Python\Python35\Scripts\ - Config json file like gf.json -* Open https://trade.gf.com.cn -* Input Account and Password -* F12 | Console -* Copy userId from Cookie and paste into gf.json | username -* var e=document.getElementById("SecurePassword") -* e.GetPassword() -* Copy password and paste into gf.json | password - Run -* python cli.py --use gf --prepare gf.json diff --git a/docs/usage.md b/docs/usage.md index 8f2aec0b..559b70b1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,131 +1,130 @@ -# 引入 +## 一、引入 ```python import easytrader ``` -**设置账户**: +## 二、设置交易客户端类型 -**银河** +**海通客户端** ```python -user = easytrader.use('yh') # 银河支持 ['yh', 'YH', '银河'] +user = easytrader.use('htzq_client') ``` -** 银河客户端** +**华泰客户端** ```python -user = easytrader.use('yh_client') # 银河客户端支持 ['yh_client', 'YH_CLIENT', '银河客户端'] +user = easytrader.use('ht_client') ``` -** 广发** +**国金客户端** ```python -user = easytrader.use('gf') # 广发支持 ['gf', 'GF', '广发'] +user = easytrader.use('gj_client') ``` -**湘财证券** +**通用同花顺客户端** ```python -user = easytrader.use('xczq') # 湘财证券支持 ['xczq', '湘财证券'] +user = easytrader.use('universal_client') ``` +注: 通用同花顺客户端是指同花顺官网提供的客户端软件内的下单程序,内含对多个券商的交易支持,适用于券商不直接提供同花顺客户端时的后备方案。 -# 抓取登陆所需的密码 +**其他券商专用同花顺客户端** -使用 `easytrader` 的广发,银河 `web` 版本时,需要抓取对应券商的加密密码 +```python +user = easytrader.use('ths') +``` -** 银河 web 版本** +注: 其他券商专用同花顺客户端是指对应券商官网提供的基于同花顺修改的软件版本,类似银河的双子星(同花顺版本),国金证券网上交易独立下单程序(核新PC版)等。 -**推荐获取方法:** -在 IE 浏览器中打开下面这个网页, [一键获取银河加密密码](http://htmlpreview.github.io/?https://github.com/shidenggui/assets/blob/master/easytrader/get_yh_password.html), 若有弹框的话选择允许控件运行,按步骤操作就可以获得密码 -**其他方式** +**雪球** -* [银河web获取加密密码的图文教程, 其他类似](https://shimo.im/doc/kvazIHNTRvYr7iqe)(需要安装 fildder 软件) -* [如何获取配置所需信息, 也可参考此文章](https://www.jisilu.cn/question/42707) +```python +user = easytrader.use('xq') +``` -**广发** -参考此文档 [INSTALL4Windows.md](other/INSTALL4Windows.md) +## 三、启动并连接客户端 -参考银河的获取密码的其他方式 +### (一)其他券商专用同花顺客户端 +其他券商专用同花顺客户端不支持自动登录,需要先手动登录。 -**雪球** +请手动打开并登录客户端后,运用connect函数连接客户端。 - 雪球配置中 `username` 为邮箱, `account` 为手机, 填两者之一即可,另一项改为 `""`, 密码直接填写登录的明文密码即可,不需要抓取 `POST` 的密码 +```python +user.connect(r'客户端xiadan.exe路径') # 类似 r'C:\htzqzyb2\xiadan.exe' +``` + +### (二)通用同花顺客户端 -**银河客户端** +需要先手动登录一次:添加券商,填入账户号、密码、验证码,勾选“保存密码” -银河客户端直接使用明文的账号和密码即可 +第一次登录后,上述信息被缓存,可以调用prepare函数自动登录(仅需账户号、客户端路径,密码随意输入)。 +### (三)其它 -# 登录帐号 +非同花顺的客户端,可以调用prepare函数自动登录。 -登陆账号有两种方式,`使用参数` 和 `使用配置文件` +调用prepare时所需的参数,可以通过`函数参数` 或 `配置文件` 赋予。 -** 参数登录(推荐)** +**1. 函数参数(推荐)** ``` -user.prepare(user='用户名', password='银河,广发web端需要券商加密后的密码, 雪球、银河客户端为明文密码') +user.prepare(user='用户名', password='雪球、银河客户端为明文密码', comm_password='华泰通讯密码,其他券商不用') ``` -**注:**雪球额外有个 account 参数,见上文介绍 +注: 雪球比较特殊,见下列配置文件格式 -** 使用配置文件** +**2. 配置文件** ```python -user.prepare('/path/to/your/yh.json') // 或者 zq.json 或者 yh_client.json 等配置文件路径 +user.prepare('/path/to/your/yh_client.json') # 配置文件路径 ``` -**注**: 使用配置文件模式, 配置文件需要自己用编辑器编辑生成, 请勿使用记事本, 推荐使用 [notepad++](https://notepad-plus-plus.org/zh/) 或者 [sublime text](http://www.sublimetext.com/) +注: 配置文件需自己用编辑器编辑生成, **请勿使用记事本**, 推荐使用 [notepad++](https://notepad-plus-plus.org/zh/) 或者 [sublime text](http://www.sublimetext.com/) 。 -*格式如下* +**配置文件格式如下:** -银河 +银河/国金客户端 ``` { - "inputaccount": "客户号", - "trdpwd": "加密后的密码" -} -``` - -银河客户端 - -``` -{ - "user": "银河用户名", - "password": "银河明文密码" + "user": "用户名", + "password": "明文密码" } ``` -广发 +华泰客户端 ``` { - "username": "加密的客户号", - "password": "加密的密码" +  "user": "华泰用户名", +  "password": "华泰明文密码", +  "comm_password": "华泰通讯密码" } + ``` -湘菜证券 +雪球 ``` { - "account": "客户号", - "password": "密码" + "cookies": "雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/", + "portfolio_code": "组合代码(例:ZH818559)", + "portfolio_market": "交易市场(例:us 或者 cn 或者 hk)" } ``` -### 交易相关 - -以下用法以银河为例 +## 四、交易相关 -#### 获取资金状况: +### 1. 获取资金状况 ```python user.balance @@ -142,7 +141,7 @@ user.balance '资金帐号': 'xxx'}] ``` -#### 获取持仓: +### 2. 获取持仓 ```python user.position @@ -166,77 +165,55 @@ user.position '证券名称': '工商银行'}] ``` -#### 获取今日委托单 +### 3. 买入 + ```python -user.entrust +user.buy('162411', price=0.55, amount=100) ``` **return** ```python -[{'business_amount': '成交数量', - 'business_price': '成交价格', - 'entrust_amount': '委托数量', - 'entrust_bs': '买卖方向', - 'entrust_no': '委托编号', - 'entrust_price': '委托价格', - 'entrust_status': '委托状态', # 废单 / 已报 - 'report_time': '申报时间', - 'stock_code': '证券代码', - 'stock_name': '证券名称'}] - +{'entrust_no': 'xxxxxxxx'} ``` +注: 系统可以配置是否返回成交回报。如果没配的话默认返回 `{"message": "success"}` -#### 买入: +### 4. 卖出 ```python -user.buy('162411', price=0.55, amount=100) +user.sell('162411', price=0.55, amount=100) ``` **return** ```python -{'orderid': 'xxxxxxxx', 'ordersno': '1111'} -``` - -#### 卖出: - -```python -user.sell('162411', price=0.55, amount=100) +{'entrust_no': 'xxxxxxxx'} ``` -**return** +### 5. 一键打新 ```python -{'orderid': 'xxxxxxxx', 'ordersno': '1111'} +user.auto_ipo() ``` -#### 撤单 - -##### 银河 +### 6. 撤单 ```python -user.cancel_entrust('委托单号', '股票代码') +user.cancel_entrust('buy/sell 获取的 entrust_no') ``` **return** ``` -{'msgok': '撤单申报成功'} +{'message': '撤单申报成功'} ``` -##### 银河客户端 +### 7. 查询当日成交 ```python -user.cancel_entrust('股票6位代码,不带前缀', "撤单方向,可使用 ['buy', 'sell']" -``` - -#### 查询当日成交 - -```python -user.current_deal +user.today_trades ``` **return** @@ -255,10 +232,10 @@ user.current_deal '证券名称': '华宝油气'}] ``` -#### 今日委托 +### 8. 查询当日委托 ```python -user.entrust +user.today_entrusts ``` **return** @@ -292,144 +269,91 @@ user.entrust '证券名称': '华宝油气'}] ``` -#### ipo 打新 -*银河* +### 9. 查询今日可申购新股 ```python -user.get_ipo_info() +from easytrader.utils.stock import get_today_ipo_data +ipo_data = get_today_ipo_data() +print(ipo_data) ``` **return** - ```python -(df_taoday_ipo, df_ipo_limit), 分别是当日新股申购列表信息, 申购额度。 - df_today_ipo - 代码 名称 价格 账户额度 申购下限 申购上限 证券账号 交易所 发行日期 - 0 2830 名雕股份 16.53 17500 500 xxxxx xxxxxxxx 深A 20161201 - 1 732098 森特申购 9.18 27000 1000 xxxxx xxxxxxx 沪A 20161201 - - df_ipo_limit: - 市场 证券账号 账户额度 - 0 深圳 xxxxxxx xxxxx - 1 上海 xxxxxxx xxxxx +[{'stock_code': '股票代码', + 'stock_name': '股票名称', + 'price': 发行价, + 'apply_code': '申购代码'}] ``` -然后使用 `user.buy` 接口按返回的价格数量买入对应新股就可以了 - -#### 查询交割单 +### 10. 刷新数据 -需要注意通常券商只会返回有限天数最新的交割单,如查询2015年整年数据, 华泰只会返回年末的90天的交割单 - -```python -user.exchangebill # 查询最近30天的交割单 - -user.get_exchangebill('开始日期', '截止日期') # 指定查询时间段, 日期格式为 "20160214" ``` -**return** -```python -{["entrust_bs": "操作", # "1":"买入", "2":"卖出", " ":"其他" - "business_balance": "成交金额", - "stock_name": "证券名称", - "fare1": "印花税", - "occur_balance": "发生金额", - "stock_account": "股东帐户", - "business_name": "摘要", # "证券买入", "证券卖出", "基金拆分", "基金合并", "交收证券冻结", "交收证券冻结取消", "开放基金赎回", "开放基金赎回返款", "基金资金拨入", "基金资金拨出", "交收资金冻结取消", "开放基金申购" - "farex": "", - "fare0": "手续费", - "stock_code": "证券代码", - "occur_amount": "成交数量", - "date": "成交日期", - "post_balance": "本次余额", - "fare2": "其他杂费", - "fare3": "", - "entrust_no": "合同编号", - "business_price": "成交均价", -]} - +user.refresh() ``` -#### 基金申购 - -##### 银河 +### 11. 雪球组合比例调仓 ### -``` -user.fundpurchase(stock_code, amount): +```python +user.adjust_weight('股票代码', 目标比例) ``` -#### 基金赎回 +例如,`user.adjust_weight('000001', 10)`是将平安银行在组合中的持仓比例调整到10%。 -##### 银河 +## 五、退出客户端软件 -``` -user.fundredemption(stock_code, amount): +```python +user.exit() ``` -#### 基金认购 +## 六、远端服务器模式 -##### 银河 - -``` -user.fundsubscribe(stock_code, amount): -``` +远端服务器模式是交易服务端和量化策略端分离的模式。 +**交易服务端**通常是有固定`IP`地址的云服务器,该服务器上运行着`easytrader`交易服务。而**量化策略端**可能是`JoinQuant、RiceQuant、Vn.Py`,物理上与交易服务端不在同一台电脑上。交易服务端被动或主动获取交易信号,并驱动**交易软件**(交易软件包括运行在同一服务器上的下单软件,比如同花顺`xiadan.exe`,或者运行在另一台服务器上的雪球`xq`)。 -#### 基金分拆 +远端模式下,`easytrader`交易服务通过以下两种方式获得交易信号并驱动交易软件: -##### 银河 - -``` -user.fundsplit(stock_code, amount): -``` +### (一) 被动接收远端量化策略发送的交易相关指令 -#### 基金合并 +#### 交易服务端——启动服务 -##### 银河 +```python +from easytrader import server +server.run(port=1430) # 默认端口为 1430 ``` -user.fundmerge(stock_code, amount): -``` - - - -#### 查询今天可以申购的新股信息 +#### 量化策略端——调用服务 ```python -from easytrader import helpers -ipo_data = helpers.get_today_ipo_data() -print(ipo_data) -``` +from easytrader import remoteclient -**return** +user = remoteclient.use('使用客户端类型,可选 yh_client, ht_client, ths, xq等', host='服务器ip', port='服务器端口,默认为1430') -```python -[{'stock_code': '股票代码', - 'stock_name': '股票名称', - 'price': 发行价, - 'apply_code': '申购代码'}] -``` +user.buy(......) -#### 雪球组合调仓 +user.sell(......) -```python -user.adjust_weight('000001', 10) +# 交易函数用法同上,见“四、交易相关” ``` +### (二) 主动监控远端量化策略的成交记录或仓位变化 + -### 跟踪 joinquant / ricequant 的模拟交易 +#### 1. 跟踪 `joinquant` / `ricequant` 的模拟交易 -#### 初始化跟踪的 trader +##### 1) 初始化跟踪的 trader -这里以雪球为例, 也可以使用银河之类 easytrader 支持的券商 +这里以雪球为例, 也可以使用银河之类 `easytrader` 支持的券商 ``` xq_user = easytrader.use('xq') xq_user.prepare('xq.json') ``` -#### 初始化跟踪 joinquant / ricequant 的 follower +##### 2) 初始化跟踪 `joinquant` / `ricequant` 的 follower ``` target = 'jq' # joinquant @@ -438,7 +362,7 @@ follower = easytrader.follower(target) follower.login(user='rq/jq用户名', password='rq/jq密码') ``` -#### 连接 follower 和 trader +##### 3) 连接 follower 和 trader ##### joinquant ``` @@ -447,6 +371,23 @@ follower.follow(xq_user, 'jq的模拟交易url') 注: jq的模拟交易url指的是对应模拟交易对应的可以查看持仓, 交易记录的页面, 类似 `https://www.joinquant.com/algorithm/live/index?backtestId=xxx` +正常会输出 + +![enjoy it](https://raw.githubusercontent.com/shidenggui/assets/master/easytrader/joinquant.jpg) + +注: 启动后发现跟踪策略无输出,那是因为今天模拟交易没有调仓或者接收到的调仓信号过期了,默认只处理120s内的信号,想要测试的可以用下面的命令: + +```python +jq_follower.follow(user, '模拟交易url', + trade_cmd_expire_seconds=100000000000, cmd_cache=False) +``` + +- trade_cmd_expire_seconds 默认处理多少秒内的信号 + +- cmd_cache 是否读取已经执行过的命令缓存,以防止重复执行 + +目录下产生的 cmd_cache.pk,是用来存储历史执行过的交易指令,防止在重启程序时重复执行交易过的指令,可以通过 `follower.follow(xxx, cmd_cache=False)` 来关闭。 + ##### ricequant ``` @@ -454,26 +395,21 @@ follower.follow(xq_user, run_id) ``` 注:ricequant的run_id即PT列表中的ID。 -正常会输出 - -![](https://raw.githubusercontent.com/shidenggui/assets/master/easytrader/joinquant.jpg) -enjoy it +#### 2. 跟踪雪球的组合 -### 跟踪 雪球的组合 - -#### 初始化跟踪的 trader +##### 1) 初始化跟踪的 trader 同上 -#### 初始化跟踪 雪球组合 的 follower +##### 2) 初始化跟踪 雪球组合 的 follower ``` xq_follower = easytrader.follower('xq') -xq_follower.login(user='xq用户名', password='xq密码') +xq_follower.login(cookies='雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/') ``` -#### 连接 follower 和 trader +##### 3) 连接 follower 和 trader ``` xq_follower.follow(xq_user, 'xq组合ID,类似ZH123456', total_assets=100000) @@ -485,36 +421,40 @@ xq_follower.follow(xq_user, 'xq组合ID,类似ZH123456', total_assets=100000) * 这里可以设置 total_assets, 为当前组合的净值对应的总资金额度, 具体可以参考参数说明 * 或者设置 initial_assets, 这时候总资金额度为 initial_assets * 组合净值 +* 雪球额外支持 adjust_sell 参数,决定是否根据用户的实际持仓数调整卖出股票数量,解决雪球根据百分比调仓时计算出的股数有偏差的问题。当卖出股票数大于实际持仓数时,调整为实际持仓数。目前仅在银河客户端测试通过。 当 users 为多个时,根据第一个 user 的持仓数决定 + -#### 多用户跟踪多策略 +#### 3. 多用户跟踪多策略 ``` follower.follow(users=[xq_user, yh_user], strategies=['组合1', '组合2'], total_assets=[10000, 10000]) ``` -#### 目录下产生的 cmd_cache.pk - -这是用来存储历史执行过的交易指令,防止在重启程序时重复执行交易过的指令,可以通过 `follower.follow(xxx, cmd_cache=False)` 来关闭 +#### 4. 其它与跟踪有关的问题 -#### 使用市价单跟踪模式,目前仅支持银河 +使用市价单跟踪模式,目前仅支持银河 ``` follower.follow(***, entrust_prop='market') ``` -#### 调整下单间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 +调整下单间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 ``` follower.follow(***, send_interval=30) # 设置下单间隔为 30 s ``` +设置买卖时的滑点 +``` +follower.follow(***, slippage=0.05) # 设置滑点为 5% +``` -### 命令行模式 +## 七、命令行模式 #### 登录 ``` - python cli.py --use yh --prepare yh.json + python cli.py --use yh --prepare gf.json ``` 注: 此时会生成 `account.session` 文件保存生成的 `user` 对象 diff --git a/easytrader/__init__.py b/easytrader/__init__.py index 97fb5384..ecff7756 100644 --- a/easytrader/__init__.py +++ b/easytrader/__init__.py @@ -1,10 +1,11 @@ -# coding: utf-8 -from .api import * -from .webtrader import WebTrader -from .yhtrader import YHTrader -from .gftrader import GFTrader -from .joinquant_follower import JoinQuantFollower -from .ricequant_follower import RiceQuantFollower +# -*- coding: utf-8 -*- +import urllib3 -__version__ = '0.11.9' -__author__ = 'shidenggui' +from easytrader import exceptions +from easytrader.api import use, follower +from easytrader.log import logger + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +__version__ = "0.23.0" +__author__ = "shidenggui" diff --git a/easytrader/api.py b/easytrader/api.py index d636d181..fbdc37c3 100644 --- a/easytrader/api.py +++ b/easytrader/api.py @@ -1,19 +1,22 @@ -# coding=utf-8 +# -*- coding: utf-8 -*- import logging +import sys -from .gftrader import GFTrader -from .joinquant_follower import JoinQuantFollower -from .ricequant_follower import RiceQuantFollower -from .log import log -from .xq_follower import XueQiuFollower -from .xqtrader import XueQiuTrader -from .yhtrader import YHTrader -from .xczqtrader import XCZQTrader +import six +from easytrader.joinquant_follower import JoinQuantFollower +from easytrader.log import logger +from easytrader.ricequant_follower import RiceQuantFollower +from easytrader.xq_follower import XueQiuFollower +from easytrader.xqtrader import XueQiuTrader -def use(broker, debug=True, **kwargs): +if sys.version_info <= (3, 5): + raise TypeError("不支持 Python3.5 及以下版本,请升级") + + +def use(broker, debug=False, **kwargs): """用于生成特定的券商对象 - :param broker:券商名支持 ['yh', 'YH', '银河'] ['gf', 'GF', '广发'] + :param broker:券商名支持 ['yh_client', '银河客户端'] ['ht_client', '华泰客户端'] :param debug: 控制 debug 日志的显示, 默认为 True :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一百万 :return the class of trader @@ -24,26 +27,62 @@ def use(broker, debug=True, **kwargs): >>> user = easytrader.use('xq') >>> user.prepare('xq.json') """ - if not debug: - log.setLevel(logging.INFO) - if broker.lower() in ['yh', '银河']: - return YHTrader(debug=debug) - elif broker.lower() in ['xq', '雪球']: + if debug: + logger.setLevel(logging.DEBUG) + + if broker.lower() in ["xq", "雪球"]: return XueQiuTrader(**kwargs) - elif broker.lower() in ['gf', '广发']: - return GFTrader(debug=debug) - elif broker.lower() in ['yh_client', '银河客户端']: + + if broker.lower() in ["yh_client", "银河客户端"]: from .yh_clienttrader import YHClientTrader + return YHClientTrader() - elif broker.lower() in ['xczq', '湘财证券']: - return XCZQTrader() + + if broker.lower() in ["ht_client", "华泰客户端"]: + from .ht_clienttrader import HTClientTrader + + return HTClientTrader() + + if broker.lower() in ["wk_client", "五矿客户端"]: + from easytrader.wk_clienttrader import WKClientTrader + + return WKClientTrader() + + if broker.lower() in ["htzq_client", "海通证券客户端"]: + from easytrader.htzq_clienttrader import HTZQClientTrader + + return HTZQClientTrader() + + if broker.lower() in ["gj_client", "国金客户端"]: + from .gj_clienttrader import GJClientTrader + + return GJClientTrader() + + if broker.lower() in ["gf_client", "广发客户端"]: + from .gf_clienttrader import GFClientTrader + + return GFClientTrader() + + if broker.lower() in ["universal_client", "通用同花顺客户端"]: + from easytrader.universal_clienttrader import UniversalClientTrader + + return UniversalClientTrader() + + if broker.lower() in ["ths", "同花顺客户端"]: + from .clienttrader import ClientTrader + + return ClientTrader() + + raise NotImplementedError def follower(platform, **kwargs): """用于生成特定的券商对象 :param platform:平台支持 ['jq', 'joinquant', '聚宽’] - :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一万, 总资金由 initial_assets * 组合当前净值 得出 - :param total_assets: [雪球参数] 控制雪球总资金,无默认值, 若设置则覆盖 initial_assets + :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一万, + 总资金由 initial_assets * 组合当前净值 得出 + :param total_assets: [雪球参数] 控制雪球总资金,无默认值, + 若设置则覆盖 initial_assets :return the class of follower Usage:: @@ -55,9 +94,10 @@ def follower(platform, **kwargs): >>> jq.login(user='username', password='password') >>> jq.follow(users=user, strategies=['strategies_link']) """ - if platform.lower() in ['rq', 'ricequant', '米筐']: + if platform.lower() in ["rq", "ricequant", "米筐"]: return RiceQuantFollower() - if platform.lower() in ['jq', 'joinquant', '聚宽']: + if platform.lower() in ["jq", "joinquant", "聚宽"]: return JoinQuantFollower() - if platform.lower() in ['xq', 'xueqiu', '雪球']: + if platform.lower() in ["xq", "xueqiu", "雪球"]: return XueQiuFollower(**kwargs) + raise NotImplementedError diff --git a/easytrader/clienttrader.py b/easytrader/clienttrader.py new file mode 100644 index 00000000..b0a4b648 --- /dev/null +++ b/easytrader/clienttrader.py @@ -0,0 +1,617 @@ +# -*- coding: utf-8 -*- +import abc +import functools +import logging +import os +import re +import sys +import time +from typing import Type, Union + +import hashlib, binascii + +import easyutils +from pywinauto import findwindows, timings + +from easytrader import grid_strategies, pop_dialog_handler, refresh_strategies +from easytrader.config import client +from easytrader.grid_strategies import IGridStrategy +from easytrader.log import logger +from easytrader.refresh_strategies import IRefreshStrategy +from easytrader.utils.misc import file2dict +from easytrader.utils.perf import perf_clock + +if not sys.platform.startswith("darwin"): + import pywinauto + import pywinauto.clipboard + +class IClientTrader(abc.ABC): + @property + @abc.abstractmethod + def app(self): + """Return current app instance""" + pass + + @property + @abc.abstractmethod + def main(self): + """Return current main window instance""" + pass + + @property + @abc.abstractmethod + def config(self): + """Return current config instance""" + pass + + @abc.abstractmethod + def wait(self, seconds: float): + """Wait for operation return""" + pass + + @abc.abstractmethod + def refresh(self): + """Refresh data""" + pass + + @abc.abstractmethod + def is_exist_pop_dialog(self): + pass + + +class ClientTrader(IClientTrader): + _editor_need_type_keys = False + # The strategy to use for getting grid data + grid_strategy: Union[IGridStrategy, Type[IGridStrategy]] = grid_strategies.Copy + _grid_strategy_instance: IGridStrategy = None + refresh_strategy: IRefreshStrategy = refresh_strategies.Switch() + + def enable_type_keys_for_editor(self): + """ + 有些客户端无法通过 set_edit_text 方法输入内容,可以通过使用 type_keys 方法绕过 + """ + self._editor_need_type_keys = True + + @property + def grid_strategy_instance(self): + if self._grid_strategy_instance is None: + self._grid_strategy_instance = ( + self.grid_strategy + if isinstance(self.grid_strategy, IGridStrategy) + else self.grid_strategy() + ) + self._grid_strategy_instance.set_trader(self) + return self._grid_strategy_instance + + def __init__(self): + self._config = client.create(self.broker_type) + self._app = None + self._main = None + self._toolbar = None + + @property + def app(self): + return self._app + + @property + def main(self): + return self._main + + @property + def config(self): + return self._config + + def connect(self, exe_path=None, **kwargs): + """ + 直接连接登陆后的客户端 + :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' + :return: + """ + connect_path = exe_path or self._config.DEFAULT_EXE_PATH + if connect_path is None: + raise ValueError( + "参数 exe_path 未设置,请设置客户端对应的 exe 地址,类似 C:\\客户端安装目录\\xiadan.exe" + ) + + self._app = pywinauto.Application().connect(path=connect_path, timeout=10) + self._close_prompt_windows() + self._main = self._app.top_window() + self._init_toolbar() + + @property + def broker_type(self): + return "ths" + + @property + def balance(self): + self._switch_left_menus(["查询[F4]", "资金股票"]) + + return self._get_balance_from_statics() + + def _init_toolbar(self): + self._toolbar = self._main.child_window(class_name="ToolbarWindow32") + + def _get_balance_from_statics(self): + result = {} + for key, control_id in self._config.BALANCE_CONTROL_ID_GROUP.items(): + result[key] = float( + self._main.child_window( + control_id=control_id, class_name="Static" + ).window_text() + ) + return result + + @property + def position(self): + self._switch_left_menus(["查询[F4]", "资金股票"]) + + return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) + + @property + def today_entrusts(self): + self._switch_left_menus(["查询[F4]", "当日委托"]) + + return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) + + @property + def today_trades(self): + self._switch_left_menus(["查询[F4]", "当日成交"]) + + return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) + + @property + def cancel_entrusts(self): + self.refresh() + self._switch_left_menus(["撤单[F3]"]) + + return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) + + @perf_clock + def cancel_entrust(self, entrust_no): + self.refresh() + for i, entrust in enumerate(self.cancel_entrusts): + if entrust[self._config.CANCEL_ENTRUST_ENTRUST_FIELD] == entrust_no: + self._cancel_entrust_by_double_click(i) + return self._handle_pop_dialogs() + return {"message": "委托单状态错误不能撤单, 该委托单可能已经成交或者已撤"} + + def cancel_all_entrusts(self): + self.refresh() + self._switch_left_menus(["撤单[F3]"]) + + # 点击全部撤销控件 + self._app.top_window().child_window( + control_id=self._config.TRADE_CANCEL_ALL_ENTRUST_CONTROL_ID, class_name="Button", title_re="""全撤.*""" + ).click() + self.wait(0.2) + + # 等待出现 确认兑换框 + if self.is_exist_pop_dialog(): + # 点击是 按钮 + w = self._app.top_window() + if w is not None: + btn = w["是(Y)"] + if btn is not None: + btn.click() + self.wait(0.2) + + # 如果出现了确认窗口 + self.close_pop_dialog() + + @perf_clock + def repo(self, security, price, amount, **kwargs): + self._switch_left_menus(["债券回购", "融资回购(正回购)"]) + + return self.trade(security, price, amount) + + @perf_clock + def reverse_repo(self, security, price, amount, **kwargs): + self._switch_left_menus(["债券回购", "融劵回购(逆回购)"]) + + return self.trade(security, price, amount) + + @perf_clock + def buy(self, security, price, amount, **kwargs): + self._switch_left_menus(["买入[F1]"]) + + return self.trade(security, price, amount) + + @perf_clock + def sell(self, security, price, amount, **kwargs): + self._switch_left_menus(["卖出[F2]"]) + + return self.trade(security, price, amount) + + @perf_clock + def market_buy(self, security, amount, ttype=None, limit_price=None, **kwargs): + """ + 市价买入 + :param security: 六位证券代码 + :param amount: 交易数量 + :param ttype: 市价委托类型,默认客户端默认选择, + 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] + 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] + :param limit_price: 科创板 限价 + + :return: {'entrust_no': '委托单号'} + """ + self._switch_left_menus(["市价委托", "买入"]) + + return self.market_trade(security, amount, ttype, limit_price=limit_price) + + @perf_clock + def market_sell(self, security, amount, ttype=None, limit_price=None, **kwargs): + """ + 市价卖出 + :param security: 六位证券代码 + :param amount: 交易数量 + :param ttype: 市价委托类型,默认客户端默认选择, + 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] + 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] + :param limit_price: 科创板 限价 + :return: {'entrust_no': '委托单号'} + """ + self._switch_left_menus(["市价委托", "卖出"]) + + return self.market_trade(security, amount, ttype, limit_price=limit_price) + + def market_trade(self, security, amount, ttype=None, limit_price=None, **kwargs): + """ + 市价交易 + :param security: 六位证券代码 + :param amount: 交易数量 + :param ttype: 市价委托类型,默认客户端默认选择, + 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] + 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] + + :return: {'entrust_no': '委托单号'} + """ + code = security[-6:] + self._type_edit_control_keys(self._config.TRADE_SECURITY_CONTROL_ID, code) + if ttype is not None: + retry = 0 + retry_max = 10 + while retry < retry_max: + try: + self._set_market_trade_type(ttype) + break + except: + retry += 1 + self.wait(0.1) + self._set_market_trade_params(security, amount, limit_price=limit_price) + self._submit_trade() + + return self._handle_pop_dialogs( + handler_class=pop_dialog_handler.TradePopDialogHandler + ) + + def _set_market_trade_type(self, ttype): + """根据选择的市价交易类型选择对应的下拉选项""" + selects = self._main.child_window( + control_id=self._config.TRADE_MARKET_TYPE_CONTROL_ID, class_name="ComboBox" + ) + for i, text in enumerate(selects.texts()): + # skip 0 index, because 0 index is current select index + if i == 0: + if re.search(ttype, text): # 当前已经选中 + return + else: + continue + if re.search(ttype, text): + selects.select(i - 1) + return + raise TypeError("不支持对应的市价类型: {}".format(ttype)) + + def _set_stock_exchange_type(self, ttype): + """根据选择的市价交易类型选择对应的下拉选项""" + selects = self._main.child_window( + control_id=self._config.TRADE_STOCK_EXCHANGE_CONTROL_ID, class_name="ComboBox" + ) + + for i, text in enumerate(selects.texts()): + # skip 0 index, because 0 index is current select index + if i == 0: + if ttype.strip() == text.strip(): # 当前已经选中 + return + else: + continue + if ttype.strip() == text.strip(): + selects.select(i - 1) + return + raise TypeError("不支持对应的市场类型: {}".format(ttype)) + + def auto_ipo(self): + self._switch_left_menus(self._config.AUTO_IPO_MENU_PATH) + + stock_list = self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) + + if len(stock_list) == 0: + return {"message": "今日无新股"} + invalid_list_idx = [ + i for i, v in enumerate(stock_list) if v[self.config.AUTO_IPO_NUMBER] <= 0 + ] + + if len(stock_list) == len(invalid_list_idx): + return {"message": "没有发现可以申购的新股"} + + self._click(self._config.AUTO_IPO_SELECT_ALL_BUTTON_CONTROL_ID) + self.wait(0.1) + + for row in invalid_list_idx: + self._click_grid_by_row(row) + self.wait(0.1) + + self._click(self._config.AUTO_IPO_BUTTON_CONTROL_ID) + self.wait(0.1) + + return self._handle_pop_dialogs() + + def _click_grid_by_row(self, row): + x = self._config.COMMON_GRID_LEFT_MARGIN + y = ( + self._config.COMMON_GRID_FIRST_ROW_HEIGHT + + self._config.COMMON_GRID_ROW_HEIGHT * row + ) + self._app.top_window().child_window( + control_id=self._config.COMMON_GRID_CONTROL_ID, + class_name="CVirtualGridCtrl", + ).click(coords=(x, y)) + + @perf_clock + def is_exist_pop_dialog(self): + self.wait(0.5) # wait dialog display + try: + return ( + self._main.wrapper_object() != self._app.top_window().wrapper_object() + ) + except ( + findwindows.ElementNotFoundError, + timings.TimeoutError, + RuntimeError, + ) as ex: + logger.exception("check pop dialog timeout") + return False + + @perf_clock + def close_pop_dialog(self): + try: + if self._main.wrapper_object() != self._app.top_window().wrapper_object(): + w = self._app.top_window() + if w is not None: + w.close() + self.wait(0.2) + except ( + findwindows.ElementNotFoundError, + timings.TimeoutError, + RuntimeError, + ) as ex: + pass + + def _run_exe_path(self, exe_path): + return os.path.join(os.path.dirname(exe_path), "xiadan.exe") + + def wait(self, seconds): + time.sleep(seconds) + + def exit(self): + self._app.kill() + + def _close_prompt_windows(self): + self.wait(1) + for window in self._app.windows(class_name="#32770", visible_only=True): + title = window.window_text() + if title != self._config.TITLE: + logging.info("close " + title) + window.close() + self.wait(0.2) + self.wait(1) + + def close_pormpt_window_no_wait(self): + for window in self._app.windows(class_name="#32770"): + if window.window_text() != self._config.TITLE: + window.close() + + def trade(self, security, price, amount): + self._set_trade_params(security, price, amount) + + self._submit_trade() + + return self._handle_pop_dialogs( + handler_class=pop_dialog_handler.TradePopDialogHandler + ) + + def _click(self, control_id): + self._app.top_window().child_window( + control_id=control_id, class_name="Button" + ).click() + + @perf_clock + def _submit_trade(self): + time.sleep(0.2) + self._main.child_window( + control_id=self._config.TRADE_SUBMIT_CONTROL_ID, class_name="Button" + ).click() + + @perf_clock + def __get_top_window_pop_dialog(self): + return self._app.top_window().window( + control_id=self._config.POP_DIALOD_TITLE_CONTROL_ID + ) + + @perf_clock + def _get_pop_dialog_title(self): + return ( + self._app.top_window() + .child_window(control_id=self._config.POP_DIALOD_TITLE_CONTROL_ID) + .window_text() + ) + + def _set_trade_params(self, security, price, amount): + code = security[-6:] + + self._type_edit_control_keys(self._config.TRADE_SECURITY_CONTROL_ID, code) + + # wait security input finish + self.wait(0.1) + + # 设置交易所 + if security.lower().startswith("sz"): + self._set_stock_exchange_type("深圳A股") + if security.lower().startswith("sh"): + self._set_stock_exchange_type("上海A股") + + self.wait(0.1) + + self._type_edit_control_keys( + self._config.TRADE_PRICE_CONTROL_ID, + easyutils.round_price_by_code(price, code), + ) + self._type_edit_control_keys( + self._config.TRADE_AMOUNT_CONTROL_ID, str(int(amount)) + ) + + def _set_market_trade_params(self, security, amount, limit_price=None): + self._type_edit_control_keys( + self._config.TRADE_AMOUNT_CONTROL_ID, str(int(amount)) + ) + self.wait(0.1) + price_control = None + if str(security).startswith("68"): # 科创板存在限价 + try: + price_control = self._main.child_window( + control_id=self._config.TRADE_PRICE_CONTROL_ID, class_name="Edit" + ) + except: + pass + if price_control is not None: + price_control.set_edit_text(limit_price) + + def _get_grid_data(self, control_id): + return self.grid_strategy_instance.get(control_id) + + def _type_keys(self, control_id, text): + self._main.child_window(control_id=control_id, class_name="Edit").set_edit_text( + text + ) + + def _type_edit_control_keys(self, control_id, text): + if not self._editor_need_type_keys: + self._main.child_window( + control_id=control_id, class_name="Edit" + ).set_edit_text(text) + else: + editor = self._main.child_window(control_id=control_id, class_name="Edit") + editor.select() + editor.type_keys(text) + + def type_edit_control_keys(self, editor, text): + if not self._editor_need_type_keys: + editor.set_edit_text(text) + else: + editor.select() + editor.type_keys(text) + + def _collapse_left_menus(self): + items = self._get_left_menus_handle().roots() + for item in items: + item.collapse() + + @perf_clock + def _switch_left_menus(self, path, sleep=0.2): + self.close_pop_dialog() + self._get_left_menus_handle().get_item(path).select() + self._app.top_window().type_keys('{F5}') + self.wait(sleep) + + def _switch_left_menus_by_shortcut(self, shortcut, sleep=0.5): + self.close_pop_dialog() + self._app.top_window().type_keys(shortcut) + self.wait(sleep) + + @functools.lru_cache() + def _get_left_menus_handle(self): + count = 2 + while True: + try: + handle = self._main.child_window( + control_id=129, class_name="SysTreeView32" + ) + if count <= 0: + return handle + # sometime can't find handle ready, must retry + handle.wait("ready", 2) + return handle + # pylint: disable=broad-except + except Exception as ex: + logger.exception("error occurred when trying to get left menus") + count = count - 1 + + def _cancel_entrust_by_double_click(self, row): + x = self._config.CANCEL_ENTRUST_GRID_LEFT_MARGIN + y = ( + self._config.CANCEL_ENTRUST_GRID_FIRST_ROW_HEIGHT + + self._config.CANCEL_ENTRUST_GRID_ROW_HEIGHT * row + ) + self._app.top_window().child_window( + control_id=self._config.COMMON_GRID_CONTROL_ID, + class_name="CVirtualGridCtrl", + ).double_click(coords=(x, y)) + + def refresh(self): + self.refresh_strategy.set_trader(self) + self.refresh_strategy.refresh() + + @perf_clock + def _handle_pop_dialogs(self, handler_class=pop_dialog_handler.PopDialogHandler): + handler = handler_class(self._app) + + while self.is_exist_pop_dialog(): + try: + title = self._get_pop_dialog_title() + except pywinauto.findwindows.ElementNotFoundError: + return {"message": "success"} + + result = handler.handle(title) + if result: + return result + return {"message": "success"} + + +class BaseLoginClientTrader(ClientTrader): + @abc.abstractmethod + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """Login Client Trader""" + pass + + def prepare( + self, + config_path=None, + user=None, + password=None, + exe_path=None, + comm_password=None, + **kwargs + ): + """ + 登陆客户端 + :param config_path: 登陆配置文件,跟参数登陆方式二选一 + :param user: 账号 + :param password: 明文密码 + :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' + :param comm_password: 通讯密码 + :return: + """ + if config_path is not None: + account = file2dict(config_path) + user = account["user"] + password = account["password"] + comm_password = account.get("comm_password") + exe_path = account.get("exe_path") + self.login( + user, + password, + exe_path or self._config.DEFAULT_EXE_PATH, + comm_password, + **kwargs + ) + self._init_toolbar() diff --git a/easytrader/config/client.py b/easytrader/config/client.py new file mode 100644 index 00000000..a028ecef --- /dev/null +++ b/easytrader/config/client.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +def create(broker): + if broker == "yh": + return YH + if broker == "ht": + return HT + if broker == "gj": + return GJ + if broker == "gf": + return GF + if broker == "ths": + return CommonConfig + if broker == "wk": + return WK + if broker == "htzq": + return HTZQ + if broker == "universal": + return UNIVERSAL + raise NotImplementedError + + +class CommonConfig: + DEFAULT_EXE_PATH: str = "" + TITLE = "网上股票交易系统5.0" + + # 交易所类型。 深圳A股、上海A股 + TRADE_STOCK_EXCHANGE_CONTROL_ID = 1003 + + # 撤销界面上, 全部撤销按钮 + TRADE_CANCEL_ALL_ENTRUST_CONTROL_ID = 30001 + + TRADE_SECURITY_CONTROL_ID = 1032 + TRADE_PRICE_CONTROL_ID = 1033 + TRADE_AMOUNT_CONTROL_ID = 1034 + + TRADE_SUBMIT_CONTROL_ID = 1006 + + TRADE_MARKET_TYPE_CONTROL_ID = 1541 + + COMMON_GRID_CONTROL_ID = 1047 + + COMMON_GRID_LEFT_MARGIN = 10 + COMMON_GRID_FIRST_ROW_HEIGHT = 30 + COMMON_GRID_ROW_HEIGHT = 16 + + BALANCE_MENU_PATH = ["查询[F4]", "资金股票"] + POSITION_MENU_PATH = ["查询[F4]", "资金股票"] + TODAY_ENTRUSTS_MENU_PATH = ["查询[F4]", "当日委托"] + TODAY_TRADES_MENU_PATH = ["查询[F4]", "当日成交"] + + BALANCE_CONTROL_ID_GROUP = { + "资金余额": 1012, + "可用金额": 1016, + "可取金额": 1017, + "股票市值": 1014, + "总资产": 1015, + } + + POP_DIALOD_TITLE_CONTROL_ID = 1365 + + GRID_DTYPE = { + "操作日期": str, + "委托编号": str, + "申请编号": str, + "合同编号": str, + "证券代码": str, + "股东代码": str, + "资金帐号": str, + "资金帐户": str, + "发生日期": str, + } + + CANCEL_ENTRUST_ENTRUST_FIELD = "合同编号" + CANCEL_ENTRUST_GRID_LEFT_MARGIN = 50 + CANCEL_ENTRUST_GRID_FIRST_ROW_HEIGHT = 30 + CANCEL_ENTRUST_GRID_ROW_HEIGHT = 16 + + AUTO_IPO_SELECT_ALL_BUTTON_CONTROL_ID = 1098 + AUTO_IPO_BUTTON_CONTROL_ID = 1006 + AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] + AUTO_IPO_NUMBER = '申购数量' + + +class YH(CommonConfig): + DEFAULT_EXE_PATH = r"C:\双子星-中国银河证券\Binarystar.exe" + + BALANCE_GRID_CONTROL_ID = 1308 + + GRID_DTYPE = { + "操作日期": str, + "委托编号": str, + "申请编号": str, + "合同编号": str, + "证券代码": str, + "股东代码": str, + "资金帐号": str, + "资金帐户": str, + "发生日期": str, + } + + AUTO_IPO_MENU_PATH = ["新股申购", "一键打新"] + + +class HT(CommonConfig): + DEFAULT_EXE_PATH = r"C:\htzqzyb2\xiadan.exe" + + BALANCE_CONTROL_ID_GROUP = { + "资金余额": 1012, + "冻结资金": 1013, + "可用金额": 1016, + "可取金额": 1017, + "股票市值": 1014, + "总资产": 1015, + } + + GRID_DTYPE = { + "操作日期": str, + "委托编号": str, + "申请编号": str, + "合同编号": str, + "证券代码": str, + "股东代码": str, + "资金帐号": str, + "资金帐户": str, + "发生日期": str, + } + + AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] + + +class GJ(CommonConfig): + DEFAULT_EXE_PATH = "C:\\全能行证券交易终端\\xiadan.exe" + + GRID_DTYPE = { + "操作日期": str, + "委托编号": str, + "申请编号": str, + "合同编号": str, + "证券代码": str, + "股东代码": str, + "资金帐号": str, + "资金帐户": str, + "发生日期": str, + } + + AUTO_IPO_MENU_PATH = ["新股申购", "新股批量申购"] + +class GF(CommonConfig): + DEFAULT_EXE_PATH = "C:\\gfzqrzrq\\xiadan.exe" + TITLE = "核新网上交易系统" + + GRID_DTYPE = { + "操作日期": str, + "委托编号": str, + "申请编号": str, + "合同编号": str, + "证券代码": str, + "股东代码": str, + "资金帐号": str, + "资金帐户": str, + "发生日期": str, + } + + AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] + +class WK(HT): + pass + + +class HTZQ(CommonConfig): + DEFAULT_EXE_PATH = r"c:\\海通证券委托\\xiadan.exe" + + BALANCE_CONTROL_ID_GROUP = { + "资金余额": 1012, + "可用金额": 1016, + "可取金额": 1017, + "总资产": 1015, + } + + AUTO_IPO_NUMBER = '可申购数量' + + +class UNIVERSAL(CommonConfig): + DEFAULT_EXE_PATH = r"c:\\ths\\xiadan.exe" + + BALANCE_CONTROL_ID_GROUP = { + "资金余额": 1012, + "可用金额": 1016, + "可取金额": 1017, + "总资产": 1015, + } + + AUTO_IPO_NUMBER = '可申购数量' diff --git a/easytrader/config/gf.json b/easytrader/config/gf.json deleted file mode 100644 index 461fb487..00000000 --- a/easytrader/config/gf.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "login_api": "https://trade.gf.com.cn/login", - "login_page": "https://trade.gf.com.cn/", - "verify_code_api": "https://trade.gf.com.cn/yzm.jpgx", - "prefix": "https://trade.gf.com.cn/entry", - "logout_api": "https://trade.gf.com.cn/entry", - "login": { - "authtype": 2, - "disknum": "1SVEYNFA915146", - "loginType": 2, - "origin": "web" - }, - "balance":{ - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryAssert" - }, - "position":{ - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryCC", - "request_num": 500, - "start": 0, - "limit": 10 - }, - "entrust":{ - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryDRWT", - "action_in": 0, - "request_num": 100, - "query_direction": 0, - "start": 0, - "limit": 10 - }, - "cancel_entrust": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "cancel", - "exchange_type": 1, - "batch_flag": 0 - }, - "accountinfo": { - "classname": "com.gf.etrade.control.FrameWorkControl", - "method": "getMainJS" - }, - "buy": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "entrust", - "entrust_bs": 1 - }, - "sell": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "entrust", - "entrust_bs": 2 - }, - "cnjj_apply": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "CNJJSS", - "entrust_bs": 1 - }, - "cnjj_redeem": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "CNJJSS", - "entrust_bs": 2 - }, - "fundsubscribe": { - "classname": "com.gf.etrade.control.SHLOFFundControl", - "method": "assetSecuprtTrade", - "entrust_bs": 1 - }, - "fundpurchase": { - "classname": "com.gf.etrade.control.SHLOFFundControl", - "method": "assetSecuprtTrade", - "entrust_bs": 1 - }, - "fundredemption": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "doDZJYEntrust", - "entrust_bs": 2 - }, - "fundmerge": { - "classname": "com.gf.etrade.control.SHLOFFundControl", - "method": "assetSecuprtTrade", - "entrust_bs": "" - }, - "fundsplit": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "doDZJYEntrust", - "entrust_bs": "" - }, - "nxbQueryPrice": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "nxbQueryPrice" - }, - "nxbentrust": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "nxbentrust" - }, - "nxbQueryDeliver": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "nxbQueryDeliver" - }, - "nxbQueryHisDeliver": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "nxbQueryHisDeliver" - }, - "nxbQueryEntrust": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "nxbQueryEntrust" - }, - "queryOfStkCodes": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "queryOfStkCodes" - }, - "queryNXBOfStock": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "queryNXBOfStock" - }, - "nxbentrustcancel": { - "classname": "com.gf.etrade.control.NXBUF2Control", - "method": "nxbentrustcancel" - }, - "exchangebill": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryDeliver", - "request_num": 50, - "query_direction": 0, - "start_date": "", - "end_date": "", - "deliver_type": 1 - }, - "queryStockInfo": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "getStockHQ" - }, - "today_ipo_list": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryNewStkcode", - "request_num": 50, - "query_direction": 1 - }, - "entrust_his": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryLSWT", - "request_num": 50, - "query_direction": 0, - "start_date": "", - "end_date": "", - "deliver_type": 1, - "query_mode": 0 - }, - "today_ipo_limit": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "querySecuSubequity", - "limit": 50 - }, - "rzrq": { - "classname": "com.gf.etrade.control.RZRQUF2Control", - "method": "ValidataLogin" - }, - "rzrq_exchangebill": { - "classname": "com.gf.etrade.control.RZRQUF2Control", - "method": "queryDeliver", - "request_num": 50, - "query_direction": 1, - "start_date": "", - "end_date": "", - "deliver_type": 1 - }, - "rzrq_entrust_his": { - "classname": "com.gf.etrade.control.RZRQUF2Control", - "method": "queryLSWT", - "request_num": 50, - "query_direction": 1, - "start_date": "", - "end_date": "", - "deliver_type": 1, - "query_mode": 0 - }, - "capitalflow": { - "classname": "com.gf.etrade.control.StockUF2Control", - "method": "queryFundjour", - "limit": 50, - "query_direction": 0, - "start_date": "", - "end_date": "", - "deliver_type": 1, - "query_mode": 0 - }, - "exit": { - "classname": "com.gf.etrade.control.AuthenticateControl", - "method": "logout" - } -} diff --git a/easytrader/config/xczq.json b/easytrader/config/xczq.json deleted file mode 100644 index e4b2fb0e..00000000 --- a/easytrader/config/xczq.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "login_page": "https://webtrade.xcsc.com/winner/xcsc/", - "login_api": "https://webtrade.xcsc.com/winner/xcsc/user/exchange.action", - "verify_code_api": "https://webtrade.xcsc.com/winner/xcsc/user/extraCode.jsp", - "prefix": "https://webtrade.xcsc.com/winner/xcsc/user/exchange.action", - "logout_api": "https://webtrade.xcsc.com/winner/xcsc/exchange.action?function_id=20&login_type=stock", - "login": { - "function_id": 200, - "login_type": "stock", - "version": 200, - "identity_type": "", - "remember_me": "", - "input_content": 1, - "content_type": 0 - }, - "buy": { - "service_type": "stock", - "request_id": "buystock_302" - }, - "buymarket" :{ - "service_type": "stock", - "request_id": "buystockbymaketvalue_302" - }, - "sell": { - "service_type": "stock", - "request_id": "sellstock_302" - }, - "position": { - "request_id": "mystock_403" - }, - "balance": { - "request_id": "mystock_405" - }, - "entrust": { - "request_id": "trust_401", - "sort_direction": 1, - "deliver_type": "", - "service_type": "stock" - }, - "cancel_entrust": { - "request_id": "chedan_304" - }, - "can_cancel": { - "request_id": "chedan_401", - "sort_direction": 1, - "service_type": "stock", - "action_in":1 - }, - "current_deal": { - "request_id": "bargain_402", - "sort_direction": 1, - "service_type": "stock" - }, - "ipo_enable_amount": { - "request_id": "buystock_301" - }, - "exchangetype4stock": { - "service_type": "stock", - "function_id": "105" - }, - "account4stock": { - "service_type": "stock", - "function_id": "407", - "window_id": "StockMarketTrade" - }, - "exchangebill": { - "request_id": "prompt_308", - "sort_direction": 0, - "service_type": "stock", - "deliver_type":1 - } -} diff --git a/easytrader/config/yh.json b/easytrader/config/yh.json deleted file mode 100644 index d794ab82..00000000 --- a/easytrader/config/yh.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "login_page": "https://www.chinastock.com.cn/trade/webtrade/login.jsp", - "login_api": "https://www.chinastock.com.cn/trade/LoginServlet?ajaxFlag=mainlogin", - "heart_beat": "https://www.chinastock.com.cn/trade/AjaxServlet?ajaxFlag=heartbeat", - "unlock": "https://www.chinastock.com.cn/trade/AjaxServlet?ajaxFlag=unlockscreen", - "trade_api": "https://www.chinastock.com.cn/trade/AjaxServlet", - "trade_info_page": "https://www.chinastock.com.cn/trade/webtrade/tradeindex.jsp", - "verify_code_api": "https://www.chinastock.com.cn/trade/webtrade/verifyCodeImage.jsp", - "prefix": "https://www.chinastock.com.cn", - "logout_api": "https://www.chinastock.com.cn/trade/webtrade/commons/keepalive.jsp?type=go", - "ipo_api": "https://www.chinastock.com.cn/trade/webtrade/stock/newStockList.jsp", - "login": { - "logintype_rzrq": 0, - "orgid": "", - "inputtype": "C", - "identifytype": 0, - "isonlytrade": 1, - "trdpwdjtws": "", - "Authplain9320": "", - "Authsign9321": "", - "certdata9322": "", - "ftype": "bsn" - }, - "position": { - "service_jsp": "/trade/webtrade/stock/stock_zjgf_query.jsp", - "service_type": 1 - }, - "balance": { - "service_jsp": "/trade/webtrade/stock/stock_zjgf_query.jsp", - "service_type": 2 - }, - "entrust": { - "service_jsp": "/trade/webtrade/stock/stock_wt_query.jsp" - }, - "buy": { - "marktype": "", - "ajaxFlag": "wt" - }, - "sell": { - "ajaxFlag": "wt" - }, - "fundpurchase": { - "ajaxFlag": "wt", - "bsflag": 3 - }, - "fundredemption": { - "ajaxFlag": "wt", - "bsflag": 4 - }, - "fundsubscribe": { - "ajaxFlag": "wt", - "bsflag": 5 - }, - "fundsplit": { - "ajaxFlag": "wt", - "bsflag": 85 - }, - "fundmerge": { - "ajaxFlag": "wt", - "bsflag": 86 - }, - "cancel_entrust": { - "ajaxFlag": "stock_cancel" - }, - "current_deal": { - "service_jsp": "/trade/webtrade/stock/stock_cj_query.jsp" - }, - "account4stock": { - "service_jsp": "/trade/webtrade/zhgl/holderQuery.jsp" - } -} diff --git a/easytrader/exceptions.py b/easytrader/exceptions.py new file mode 100644 index 00000000..adbaa82a --- /dev/null +++ b/easytrader/exceptions.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + + +class TradeError(IOError): + pass + + +class NotLoginError(Exception): + def __init__(self, result=None): + super(NotLoginError, self).__init__() + self.result = result diff --git a/easytrader/follower.py b/easytrader/follower.py index c2d41652..dd3b7642 100644 --- a/easytrader/follower.py +++ b/easytrader/follower.py @@ -1,46 +1,50 @@ -# coding:utf8 -from __future__ import unicode_literals, print_function, division - +# -*- coding: utf-8 -*- +import abc +import datetime import os import pickle +import queue import re +import threading import time -from datetime import datetime -from threading import Thread +from typing import List import requests -# noinspection PyUnresolvedReferences -from six.moves.queue import Queue -from .log import log +from easytrader import exceptions +from easytrader.log import logger + +class BaseFollower(metaclass=abc.ABCMeta): + """ + slippage: 滑点,取值范围为 [0, 1] + """ -class BaseFollower(object): - LOGIN_PAGE = '' - LOGIN_API = '' - TRANSACTION_API = '' - CMD_CACHE_FILE = 'cmd_cache.pk' - WEB_REFERER = '' - WEB_ORIGIN = '' + LOGIN_PAGE = "" + LOGIN_API = "" + TRANSACTION_API = "" + CMD_CACHE_FILE = "cmd_cache.pk" + WEB_REFERER = "" + WEB_ORIGIN = "" def __init__(self): - self.trade_queue = Queue() + self.trade_queue = queue.Queue() self.expired_cmds = set() self.s = requests.Session() + self.s.verify = False - def login(self, user, password, **kwargs): - # mock headers - headers = { - 'Accept': 'application/json, text/javascript, */*; q=0.01', - 'Accept-Encoding': 'gzip, deflate, br', - 'Accept-Language': 'en-US,en;q=0.8', - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.100 Safari/537.36', - 'Referer': self.WEB_REFERER, - 'X-Requested-With': 'XMLHttpRequest', - 'Origin': self.WEB_ORIGIN, - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - } + self.slippage: float = 0.0 + + def login(self, user=None, password=None, **kwargs): + """ + 登陆接口 + :param user: 用户名 + :param password: 密码 + :param kwargs: 其他参数 + :return: + """ + headers = self._generate_headers() self.s.headers.update(headers) # init cookie @@ -51,7 +55,22 @@ def login(self, user, password, **kwargs): rep = self.s.post(self.LOGIN_API, data=params) self.check_login_success(rep) - log.info('登录成功') + logger.info("登录成功") + + def _generate_headers(self): + headers = { + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en-US,en;q=0.8", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/54.0.2840.100 Safari/537.36", + "Referer": self.WEB_REFERER, + "X-Requested-With": "XMLHttpRequest", + "Origin": self.WEB_ORIGIN, + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + } + return headers def check_login_success(self, rep): """检查登录状态是否成功 @@ -59,17 +78,26 @@ def check_login_success(self, rep): :raise 如果登录失败应该抛出 NotLoginError """ pass - def create_login_params(self, user, password, **kwargs): + def create_login_params(self, user, password, **kwargs) -> dict: """生成 post 登录接口的参数 :param user: 用户名 :param password: 密码 :return dict 登录参数的字典 """ - pass - - def follow(self, users, strategies, track_interval=1, - trade_cmd_expire_seconds=120, cmd_cache=True, **kwargs): + return {} + + def follow( + self, + users, + strategies, + track_interval=1, + trade_cmd_expire_seconds=120, + cmd_cache=True, + slippage: float = 0.0, + **kwargs + ): """跟踪平台对应的模拟交易,支持多用户多策略 + :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 :param strategies: 雪球组合名, 类似 ZH123450 :param total_assets: 雪球组合对应的总资产, 格式 [ 组合1对应资金, 组合2对应资金 ] @@ -80,18 +108,44 @@ def follow(self, users, strategies, track_interval=1, :param track_interval: 轮询模拟交易时间,单位为秒 :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 + :param slippage: 滑点,0.0 表示无滑点, 0.05 表示滑点为 5% """ - raise NotImplementedError + self.slippage = slippage + + def _calculate_price_by_slippage(self, action: str, price: float) -> float: + """ + 计算考虑滑点之后的价格 + :param action: 交易动作, 支持 ['buy', 'sell'] + :param price: 原始交易价格 + :return: 考虑滑点后的交易价格 + """ + if action == "buy": + return price * (1 + self.slippage) + if action == "sell": + return price * (1 - self.slippage) + return price def load_expired_cmd_cache(self): if os.path.exists(self.CMD_CACHE_FILE): - with open(self.CMD_CACHE_FILE, 'rb') as f: + with open(self.CMD_CACHE_FILE, "rb") as f: self.expired_cmds = pickle.load(f) - def start_trader_thread(self, users, trade_cmd_expire_seconds, entrust_prop='limit', send_interval=0): - trader = Thread(target=self.trade_worker, args=[users], kwargs={'expire_seconds': trade_cmd_expire_seconds, - 'entrust_prop': entrust_prop, - 'send_interval': send_interval}) + def start_trader_thread( + self, + users, + trade_cmd_expire_seconds, + entrust_prop="limit", + send_interval=0, + ): + trader = threading.Thread( + target=self.trade_worker, + args=[users], + kwargs={ + "expire_seconds": trade_cmd_expire_seconds, + "entrust_prop": entrust_prop, + "send_interval": send_interval, + }, + ) trader.setDaemon(True) trader.start() @@ -125,39 +179,54 @@ def track_strategy_worker(self, strategy, name, interval=10, **kwargs): :param interval: 轮询策略的时间间隔,单位为秒""" while True: try: - transactions = self.query_strategy_transaction(strategy, **kwargs) + transactions = self.query_strategy_transaction( + strategy, **kwargs + ) + # pylint: disable=broad-except except Exception as e: - log.warning('无法获取策略 {} 调仓信息, 错误: {}, 跳过此次调仓查询'.format(name, e)) + logger.exception("无法获取策略 %s 调仓信息, 错误: %s, 跳过此次调仓查询", name, e) + time.sleep(3) continue - for t in transactions: + for transaction in transactions: trade_cmd = { - 'strategy': strategy, - 'strategy_name': name, - 'action': t['action'], - 'stock_code': t['stock_code'], - 'amount': t['amount'], - 'price': t['price'], - 'datetime': t['datetime'] + "strategy": strategy, + "strategy_name": name, + "action": transaction["action"], + "stock_code": transaction["stock_code"], + "amount": transaction["amount"], + "price": transaction["price"], + "datetime": transaction["datetime"], } if self.is_cmd_expired(trade_cmd): continue - log.info('策略 [{}] 发送指令到交易队列, 股票: {} 动作: {} 数量: {} 价格: {} 信号产生时间: {}'.format( - name, trade_cmd['stock_code'], trade_cmd['action'], trade_cmd['amount'], trade_cmd['price'], - trade_cmd['datetime'] - )) + logger.info( + "策略 [%s] 发送指令到交易队列, 股票: %s 动作: %s 数量: %s 价格: %s 信号产生时间: %s", + name, + trade_cmd["stock_code"], + trade_cmd["action"], + trade_cmd["amount"], + trade_cmd["price"], + trade_cmd["datetime"], + ) self.trade_queue.put(trade_cmd) self.add_cmd_to_expired_cmds(trade_cmd) try: for _ in range(interval): time.sleep(1) except KeyboardInterrupt: - log.info('程序退出') + logger.info("程序退出") break @staticmethod def generate_expired_cmd_key(cmd): - return '{}_{}_{}_{}_{}_{}'.format( - cmd['strategy_name'], cmd['stock_code'], cmd['action'], cmd['amount'], cmd['price'], cmd['datetime']) + return "{}_{}_{}_{}_{}_{}".format( + cmd["strategy_name"], + cmd["stock_code"], + cmd["action"], + cmd["amount"], + cmd["price"], + cmd["datetime"], + ) def is_cmd_expired(self, cmd): key = self.generate_expired_cmd_key(cmd) @@ -167,7 +236,7 @@ def add_cmd_to_expired_cmds(self, cmd): key = self.generate_expired_cmd_key(cmd) self.expired_cmds.add(key) - with open(self.CMD_CACHE_FILE, 'wb') as f: + with open(self.CMD_CACHE_FILE, "wb") as f: pickle.dump(self.expired_cmds, f) @staticmethod @@ -178,66 +247,113 @@ def _is_number(s): except ValueError: return False - def trade_worker(self, users, expire_seconds=120, entrust_prop='limit', send_interval=0): + def _execute_trade_cmd( + self, trade_cmd, users, expire_seconds, entrust_prop, send_interval + ): + """分发交易指令到对应的 user 并执行 + :param trade_cmd: + :param users: + :param expire_seconds: + :param entrust_prop: + :param send_interval: + :return: + """ + for user in users: + # check expire + now = datetime.datetime.now() + expire = (now - trade_cmd["datetime"]).total_seconds() + if expire > expire_seconds: + logger.warning( + "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 超过设置的最大过期时间 %s 秒, 被丢弃", + trade_cmd["strategy_name"], + trade_cmd["stock_code"], + trade_cmd["action"], + trade_cmd["amount"], + trade_cmd["price"], + trade_cmd["datetime"], + now, + expire_seconds, + ) + break + + # check price + price = trade_cmd["price"] + if not self._is_number(price) or price <= 0: + logger.warning( + "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 价格无效 , 被丢弃", + trade_cmd["strategy_name"], + trade_cmd["stock_code"], + trade_cmd["action"], + trade_cmd["amount"], + trade_cmd["price"], + trade_cmd["datetime"], + now, + ) + break + + # check amount + if trade_cmd["amount"] <= 0: + logger.warning( + "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 买入股数无效 , 被丢弃", + trade_cmd["strategy_name"], + trade_cmd["stock_code"], + trade_cmd["action"], + trade_cmd["amount"], + trade_cmd["price"], + trade_cmd["datetime"], + now, + ) + break + + actual_price = self._calculate_price_by_slippage( + trade_cmd["action"], trade_cmd["price"] + ) + args = { + "security": trade_cmd["stock_code"], + "price": actual_price, + "amount": trade_cmd["amount"], + "entrust_prop": entrust_prop, + } + try: + response = getattr(user, trade_cmd["action"])(**args) + except exceptions.TradeError as e: + trader_name = type(user).__name__ + err_msg = "{}: {}".format(type(e).__name__, e.args) + logger.error( + "%s 执行 策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格(考虑滑点): %s 指令产生时间: %s) 失败, 错误信息: %s", + trader_name, + trade_cmd["strategy_name"], + trade_cmd["stock_code"], + trade_cmd["action"], + trade_cmd["amount"], + actual_price, + trade_cmd["datetime"], + err_msg, + ) + else: + logger.info( + "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格(考虑滑点): %s 指令产生时间: %s) 执行成功, 返回: %s", + trade_cmd["strategy_name"], + trade_cmd["stock_code"], + trade_cmd["action"], + trade_cmd["amount"], + actual_price, + trade_cmd["datetime"], + response, + ) + + def trade_worker( + self, users, expire_seconds=120, entrust_prop="limit", send_interval=0 + ): """ :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时买出单没有及时成交导致的买入金额不足 """ while True: trade_cmd = self.trade_queue.get() - for user in users: - # check expire - now = datetime.now() - expire = (now - trade_cmd['datetime']).total_seconds() - if expire > expire_seconds: - log.warning( - '策略 [{}] 指令(股票: {} 动作: {} 数量: {} 价格: {})超时,指令产生时间: {} 当前时间: {}, 超过设置的最大过期时间 {} 秒, 被丢弃'.format( - trade_cmd['strategy_name'], trade_cmd['stock_code'], trade_cmd['action'], - trade_cmd['amount'], - trade_cmd['price'], trade_cmd['datetime'], now, expire_seconds)) - break - - # check price - price = trade_cmd['price'] - if not self._is_number(price) or price <= 0: - log.warning( - '策略 [{}] 指令(股票: {} 动作: {} 数量: {} 价格: {})超时,指令产生时间: {} 当前时间: {}, 价格无效 , 被丢弃'.format( - trade_cmd['strategy_name'], trade_cmd['stock_code'], trade_cmd['action'], - trade_cmd['amount'], - trade_cmd['price'], trade_cmd['datetime'], now)) - break - - # check amount - if trade_cmd['amount'] <= 0: - log.warning( - '策略 [{}] 指令(股票: {} 动作: {} 数量: {} 价格: {})超时,指令产生时间: {} 当前时间: {}, 买入股数无效 , 被丢弃'.format( - trade_cmd['strategy_name'], trade_cmd['stock_code'], trade_cmd['action'], - trade_cmd['amount'], - trade_cmd['price'], trade_cmd['datetime'], now)) - break - - args = { - 'stock_code': trade_cmd['stock_code'], - 'price': trade_cmd['price'], - 'amount': trade_cmd['amount'], - 'entrust_prop': entrust_prop - } - try: - response = getattr(user, trade_cmd['action'])(**args) - except Exception as e: - trader_name = type(user).__name__ - err_msg = '{}: {}'.format(type(e).__name__, e.message) - log.error( - '{} 执行 策略 [{}] 指令(股票: {} 动作: {} 数量: {} 价格: {} 指令产生时间: {}) 失败, 错误信息: {}'.format( - trader_name, trade_cmd['strategy_name'], trade_cmd['stock_code'], trade_cmd['action'], - trade_cmd['amount'], - trade_cmd['price'], trade_cmd['datetime'], err_msg)) - continue - log.info( - '策略 [{}] 指令(股票: {} 动作: {} 数量: {} 价格: {} 指令产生时间: {}) 执行成功, 返回: {}'.format( - trade_cmd['strategy_name'], trade_cmd['stock_code'], trade_cmd['action'], - trade_cmd['amount'], - trade_cmd['price'], trade_cmd['datetime'], response)) - time.sleep(send_interval) + self._execute_trade_cmd( + trade_cmd, users, expire_seconds, entrust_prop, send_interval + ) + time.sleep(send_interval) def query_strategy_transaction(self, strategy, **kwargs): params = self.create_query_transaction_params(strategy) @@ -249,26 +365,30 @@ def query_strategy_transaction(self, strategy, **kwargs): self.project_transactions(transactions, **kwargs) return self.order_transactions_sell_first(transactions) - def extract_transactions(self, history): + def extract_transactions(self, history) -> List[str]: """ 抽取接口返回中的调仓记录列表 :param history: 调仓接口返回信息的字典对象 :return: [] 调参历史记录的列表 """ - pass + return [] - def create_query_transaction_params(self, strategy): + def create_query_transaction_params(self, strategy) -> dict: """ 生成用于查询调参记录的参数 :param strategy: 策略 id :return: dict 调参记录参数 """ - pass + return {} @staticmethod def re_find(pattern, string, dtype=str): return dtype(re.search(pattern, string).group()) + @staticmethod + def re_search(pattern, string, dtype=str): + return dtype(re.search(pattern,string).group(1)) + def project_transactions(self, transactions, **kwargs): """ 修证调仓记录为内部使用的统一格式 @@ -280,9 +400,9 @@ def project_transactions(self, transactions, **kwargs): def order_transactions_sell_first(self, transactions): # 调整调仓记录的顺序为先卖再买 sell_first_transactions = [] - for t in transactions: - if t['action'] == 'sell': - sell_first_transactions.insert(0, t) + for transaction in transactions: + if transaction["action"] == "sell": + sell_first_transactions.insert(0, transaction) else: - sell_first_transactions.append(t) + sell_first_transactions.append(transaction) return sell_first_transactions diff --git a/easytrader/gf_clienttrader.py b/easytrader/gf_clienttrader.py new file mode 100644 index 00000000..ab5e6e8d --- /dev/null +++ b/easytrader/gf_clienttrader.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +import re +import tempfile +import time +import os + +import pywinauto +import pywinauto.clipboard + +from easytrader import clienttrader +from easytrader.utils.captcha import recognize_verify_code + + +class GFClientTrader(clienttrader.BaseLoginClientTrader): + @property + def broker_type(self): + return "gf" + + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """ + 登陆客户端 + + :param user: 账号 + :param password: 明文密码 + :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', + 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' + :param comm_password: 通讯密码, 华泰需要,可不设 + :return: + """ + try: + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + + # wait login window ready + while True: + try: + self._app.top_window().Edit1.wait("ready") + break + except RuntimeError: + pass + + self.type_edit_control_keys(self._app.top_window().Edit1, user) + self.type_edit_control_keys(self._app.top_window().Edit2, password) + edit3 = self._app.top_window().window(control_id=0x3eb) + while True: + try: + code = self._handle_verify_code() + self.type_edit_control_keys(edit3, code) + time.sleep(1) + self._app.top_window()["登录(Y)"].click() + # detect login is success or not + try: + self._app.top_window().wait_not("exists", 5) + break + + # pylint: disable=broad-except + except Exception: + self._app.top_window()["确定"].click() + + # pylint: disable=broad-except + except Exception: + pass + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + self._main = self._app.window(title_re="""{title}.*""".format(title=self._config.TITLE)) + self.close_pop_dialog() + + def _handle_verify_code(self): + control = self._app.top_window().window(control_id=0x5db) + control.click() + time.sleep(0.2) + file_path = tempfile.mktemp() + ".jpg" + control.capture_as_image().save(file_path) + time.sleep(0.2) + vcode = recognize_verify_code(file_path, "gf_client") + if os.path.exists(file_path): + os.remove(file_path) + return "".join(re.findall("[a-zA-Z0-9]+", vcode)) diff --git a/easytrader/gftrader.py b/easytrader/gftrader.py deleted file mode 100644 index 06a34788..00000000 --- a/easytrader/gftrader.py +++ /dev/null @@ -1,571 +0,0 @@ -# coding: utf-8 -from __future__ import division - -import json -import os -import re -import tempfile -import urllib - -import requests -import six - -from . import helpers -from .log import log -from .webtrader import NotLoginError -from .webtrader import WebTrader - -VERIFY_CODE_POS = 0 -TRADE_MARKET = 1 -SESSIONIDPOS = 32 -HOLDER_POS = 11 -SH = 0 -SZ = 1 - - -class GFTrader(WebTrader): - config_path = os.path.dirname(__file__) + '/config/gf.json' - - def __init__(self, debug=True): - super(GFTrader, self).__init__(debug=debug) - self.cookie = None - self.account_config = None - self.s = None - self.exchange_stock_account = dict() - self.sessionid = '' - self.holdername = list() - - def _prepare_account(self, user, password, **kwargs): - self.account_config = { - 'username': user, - 'password': password - } - - def __handle_recognize_code(self): - """获取并识别返回的验证码 - :return:失败返回 False 成功返回 验证码""" - # 获取验证码 - verify_code_response = self.s.get(self.config['verify_code_api']) - # 保存验证码 - image_path = tempfile.mktemp() - with open(image_path, 'wb') as f: - f.write(bytes(verify_code_response.content)) - - verify_code = helpers.recognize_verify_code(image_path, broker='gf') - log.debug('verify code detect result: %s' % verify_code) - os.remove(image_path) - - ht_verify_code_length = 5 - if len(verify_code) != ht_verify_code_length: - return False - return verify_code - - def __go_login_page(self): - """访问登录页面获取 cookie""" - if self.s is not None: - self.s.get(self.config['logout_api']) - self.s = requests.session() - self.s.get(self.config['login_page']) - - def login(self, throw=False): - """实现广发证券的自动登录""" - self.__go_login_page() - verify_code = self.__handle_recognize_code() - - if not verify_code: - return False - - login_status, result = self.post_login_data(verify_code) - if login_status is False: - return False - return True - - def post_login_data(self, verify_code): - login_params = dict( - self.config['login'], - mac=helpers.get_mac(), - username=self.account_config['username'], - password=self.account_config['password'], - tmp_yzm=verify_code - ) - login_response = self.s.post(self.config['login_api'], params=login_params) - log.info('login response: {}'.format(login_response.text)) - if login_response.json()['success'] is True: - v = login_response.headers - self.sessionid = v['Set-Cookie'][-SESSIONIDPOS:] - self.__set_trade_need_info() - return True, None - return False, login_response.text - - def create_basic_params(self): - basic_params = dict( - dse_sessionId=self.sessionid - ) - return basic_params - - def request(self, params): - if six.PY2: - params_str = urllib.urlencode(params) - unquote_str = urllib.unquote(params_str) - else: - params_str = urllib.parse.urlencode(params) - unquote_str = urllib.parse.unquote(params_str) - url = self.trade_prefix + '?' + unquote_str - r = self.s.post(url) - log.debug('raw response: {}'.format(r.text)) - return r.content - - def format_response_data(self, data): - if six.PY2: - return_data = json.loads(data.encode('utf-8')) - else: - return_data = json.loads(str(data, 'utf-8')) - return return_data - - def check_login_status(self, response): - if response is None or (not response.get('success') == True): - self.heart_active = False - raise NotLoginError - - def check_account_live(self, response): - if response is None or (not response.get('success') == True): - self.heart_active = False - raise NotLoginError - if hasattr(response, 'data') and response.get('error_no') == '-1': - self.heart_active = False - - def __set_trade_need_info(self): - """设置交易所需的一些基本参数 - """ - account_params = dict( - self.config['accountinfo'] - ) - if six.PY2: - params_str = urllib.urlencode(account_params) - unquote_str = urllib.unquote(params_str) - else: - params_str = urllib.parse.urlencode(account_params) - unquote_str = urllib.parse.unquote(params_str) - url = self.trade_prefix + '?' + unquote_str - r = self.s.get(url) - log.debug('get account info: {}'.format(r.text)) - jslist = r.text.split(';') - jsholder = jslist[HOLDER_POS] - jsholder = re.findall(r'\[(.*)\]', jsholder) - jsholder = eval(jsholder[0]) - for jsholder_sh in jsholder: - if jsholder_sh['exchange_name'] == '上海': - self.holdername.append(jsholder_sh) - for jsholder_sz in jsholder: - if jsholder_sz['exchange_name'] == '深圳': - self.holdername.append(jsholder_sz) - - def __get_trade_need_info(self, stock_code): - """获取股票对应的证券市场和帐号""" - # 获取股票对应的证券市场 - exchange_type = self.holdername[SH]['exchange_type'] if helpers.get_stock_type(stock_code) == 'sh' \ - else self.holdername[SZ]['exchange_type'] - # 获取股票对应的证券帐号 - stock_account = self.holdername[SH]['stock_account'] if exchange_type == '1' \ - else self.holdername[SZ]['stock_account'] - return dict( - exchange_type=exchange_type, - stock_account=stock_account - ) - - def buy(self, stock_code, price, amount=0, volume=0, entrust_prop=0): - """买入 - :param stock_code: 股票代码 - :param price: 买入价格 - :param amount: 买入股数 - :param volume: 买入总金额 由 volume / price 取 100 的整数, 若指定 amount 则此参数无效 - :param entrust_prop: 委托类型,暂未实现,默认为限价委托 - """ - params = dict( - self.config['buy'], - entrust_amount=amount if amount else volume // price // 100 * 100, - entrust_prop=0 - ) - return self.__trade(stock_code, price, other=params) - - def sell(self, stock_code, price, amount=0, volume=0, entrust_prop=0): - """卖出 - :param stock_code: 股票代码 - :param price: 卖出价格 - :param amount: 卖出股数 - :param volume: 卖出总金额 由 volume / price 取整, 若指定 amount 则此参数无效 - :param entrust_prop: 委托类型,暂未实现,默认为限价委托 - """ - params = dict( - self.config['sell'], - entrust_amount=amount if amount else volume // price, - entrust_prop=entrust_prop - ) - return self.__trade(stock_code, price, other=params) - - def cnjj_apply(self, stock_code, amount): - """场内基金申购 - :param stock_code: 基金代码 - :param amount: 申购金额 - """ - params = dict( - self.config['cnjj_apply'], - entrust_amount=amount - ) - return self.__trade(stock_code, 0, other=params) - - def cnjj_redemption(self, stock_code, amount=0): - """场内基金赎回 - :param stock_code: 基金代码 - :param amount: 赎回份额 - """ - params = dict( - self.config['cnjj_redeem'], - entrust_amount=amount - ) - return self.__trade(stock_code, 1, other=params) - - def fund_subscribe(self, stock_code, price=0, entrust_prop='LFS'): - """基金认购 - :param stock_code: 基金代码 - :param price: 认购金额 - """ - params = dict( - self.config['fundsubscribe'], - entrust_amount=1, - entrust_prop=entrust_prop - ) - return self.__trade(stock_code, price, other=params) - - def fund_purchase(self, stock_code, price=0, entrust_prop='LFC'): - """基金申购 - :param stock_code: 基金代码 - :param amount: 申购金额 - """ - params = dict( - self.config['fundpurchase'], - entrust_amount=1, - entrust_prop=entrust_prop - ) - return self.__trade(stock_code, price, other=params) - - def fund_redemption(self, stock_code, amount=0, entrust_prop='LFR'): - """基金赎回 - :param stock_code: 基金代码 - :param amount: 赎回份额 - """ - params = dict( - self.config['fundredemption'], - entrust_amount=amount, - entrust_prop=entrust_prop - ) - return self.__trade(stock_code, 1, other=params) - - def fund_merge(self, stock_code, amount=0, entrust_prop='LFM'): - """基金合并 - :param stock_code: 母份额基金代码 - :param amount: 合并份额 - """ - params = dict( - self.config['fundmerge'], - entrust_amount=amount, - entrust_prop=entrust_prop - ) - return self.__trade(stock_code, 1, other=params) - - def fund_split(self, stock_code, amount=0, entrust_prop='LFP'): - """基金分拆 - :param stock_code: 母份额基金代码 - :param amount: 分拆份额 - """ - params = dict( - self.config['fundsplit'], - entrust_amount=amount, - entrust_prop=entrust_prop - ) - return self.__trade(stock_code, 1, other=params) - - def nxbQueryPrice(self, fund_code): - """牛熊宝查询 - """ - params = dict( - self.config['nxbQueryPrice'], - fund_code=fund_code - ) - return self.do(params) - - def nxbentrust(self, fund_code, amount, price, bs, auto_deal="true"): - """牛熊宝单项申报 - :param fund_code: 转换代码 - :param amount: 转入数量, like n*1000, min 1000 - :param price: 转换比例 like 0.8 - :param bs: 转换方向,1为母转子,2为子转母 - """ - # TODO: What's auto_deal - params = dict( - self.config['nxbentrust'], - fund_code=fund_code, - entrust_amount=amount, - entrust_price=price, - entrust_bs=bs, - auto_deal=auto_deal - ) - return self.do(params) - - def nxbentrustcancel(self, entrust_no): - """牛熊宝撤单,撤单后再次调用nxbQueryEntrust确认撤单成功 - param: entrust_no: 单号,通过调用nxbQueryEntrust查询 - """ - params = dict( - self.config['nxbentrustcancel'], - entrust_no=entrust_no - ) - return self.do(params) - - def nxbQueryEntrust(self, start_date="0", end_date="0", query_type="1"): - """当日委托 - :param start_date: 开始日期20160515,0为当天 - :param end_date: 结束日期20160522,0为当天 - :param query_type: 委托查询类型,0为历史查询,1为当日查询 - """ - params = dict( - self.config['nxbQueryEntrust'], - query_type=query_type, - prodta_no="98", - entrust_no="0", - fund_code="", - start_date=start_date, - end_date=end_date, - position_str="0", - limit="10", - start="0" - ) - if query_type == "1": - params['query_mode'] = "1" - return self.do(params) - - def nxbQueryDeliverOfToday(self): - """当日转换 - """ - params = dict( - self.config['nxbQueryDeliver'], - query_type="2", - prodta_no="98", - fund_code="", - position_str="0", - limit="10", - start="0" - ) - return self.do(params) - - def nxbQueryHisDeliver(self, start_date, end_date): - """历史转换 - """ - params = dict( - self.config['nxbQueryHisDeliver'], - query_type="2", - prodta_no="98", - fund_code="", - position_str="0", - limit="50", - start="0", - start_date=start_date, - end_date=end_date - ) - return self.do(params) - - def queryOfStkCodes(self): - """牛熊宝代码查询? - """ - params = dict( - self.config['queryOfStkCodes'], - prodta_no="98", - business_type="2" - ) - return self.do(params) - - def queryNXBOfStock(self): - """牛熊宝持仓查询 - """ - params = dict( - self.config['queryNXBOfStock'], - fund_company="98", - query_mode="0", - start="0", - limit="10" - ) - return self.do(params) - - def __trade(self, stock_code, price, other): - # 检查是否已经掉线 - self.check_login(1) - need_info = self.__get_trade_need_info(stock_code) - trade_param = dict( - other, - stock_account=need_info['stock_account'], - exchange_type=need_info['exchange_type'], - stock_code=stock_code[-6:], - entrust_price=price, - dse_sessionId=self.sessionid - ) - return self.do(trade_param) - - def cancel_entrust(self, entrust_no): - """撤单 - :param entrust_no: 委单号""" - cancel_params = dict( - self.config['cancel_entrust'], - entrust_no=entrust_no, - dse_sessionId=self.sessionid - ) - return self.do(cancel_params) - - @property - def exchangebill(self): - start_date, end_date = helpers.get_30_date() - return self.get_exchangebill(start_date, end_date) - - def getStockQuotation(self, stockcode): - exchange_info = self.__get_trade_need_info(stockcode) - params = dict( - self.config['queryStockInfo'], - exchange_type=exchange_info['exchange_type'], - stock_code=stockcode - ) - request_params = self.create_basic_params() - request_params.update(params) - response_data = self.request(request_params) - response_data = str(response_data) - response_data = response_data[response_data.find('hq') + 3:response_data.find('hqtype') - 1] - response_data = response_data.replace('\\x', '\\u00') - return json.loads(response_data) - - def get_exchangebill(self, start_date, end_date): - """ - 查询指定日期内的交割单 - :param start_date: 20160211 - :param end_date: 20160211 - :return: - """ - params = self.config['exchangebill'].copy() - params.update({ - "start_date": start_date, - "end_date": end_date, - }) - return self.do(params) - - @property - def today_ipo_list(self): - ''' - - 查询今日ipo的股票列表 - :return: - ''' - params = self.config['today_ipo_list'].copy() - return self.do(params) - - def today_ipo_limit(self): - ''' - - 查询今日账户新股申购额度 - :return: - ''' - params = self.config['today_ipo_limit'].copy() - return self.do(params) - - def login_rzrq(self): - ''' - - 登录融资融券平台 - :return: - ''' - params = self.config['rzrq'].copy() - return self.do(params) - - def rzrq_exchangebill(self, start_date, end_date): - """ - 查询指定日期内的融资融券交割单 - :param start_date: 20160211 - :param end_date: 20160211 - :return: - """ - params = self.config['rzrq_exchangebill'].copy() - params.update({ - "start_date": start_date, - "end_date": end_date, - }) - return self.do(params) - - def entrust_his(self, start_date, end_date): - """ - 查询指定日期内的历史委托单 - :param start_date: 20160211 - :param end_date: 20160211 - :return: - """ - params = self.config['entrust_his'].copy() - params.update({ - "start_date": start_date, - "end_date": end_date, - }) - return self.do(params) - - def rzrq_entrust_his(self, start_date, end_date): - """ - 查询指定日期内的融资融券历史委托单 - :param start_date: 20160211 - :param end_date: 20160211 - :return: - """ - params = self.config['rzrq_entrust_his'].copy() - params.update({ - "start_date": start_date, - "end_date": end_date, - }) - return self.do(params) - - def do_job(self, request_type, **kwargs): - ''' - 直接输入请求类型,以及相关参数列表,返回执行结果 - :param request_type:请求类型,这个请求类型必须在config/gf.json里面,例如position - :param kwargs:请求相关的参数 - :return:返回请求结果。 - ''' - params = self.config[request_type].copy() - params.update(kwargs) - return self.do(params) - - def capitalflow(self, start_date, end_date): - """ - 查询指定日期内的资金流水 - :param start_date: 开始时间,例如:20160211 - :param end_date: 技术时间,例如:20160211 - :return: 指定时间段内的资金流水数据 - """ - params = self.config['capitalflow'].copy() - params.update({ - "start_date": start_date, - "end_date": end_date, - }) - return self.do(params) - - def exit(self): - ''' - 退出系统 - :return: - ''' - params = self.config['exit'].copy() - log.debug(self.do(params)) - self.heart_active = False - - def get_entrust(self, action_in=0): - ''' - - :param action_in: 当值为0,返回全部委托;当值为1时,返回可撤委托 - :return: - ''' - params = self.config['entrust'].copy() - params.update({ - "action_in": action_in, - }) - return self.do(params) diff --git a/easytrader/gj_clienttrader.py b/easytrader/gj_clienttrader.py new file mode 100644 index 00000000..00d865d9 --- /dev/null +++ b/easytrader/gj_clienttrader.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import re +import tempfile +import time + +import pywinauto +import pywinauto.clipboard + +from easytrader import clienttrader +from easytrader.utils.captcha import recognize_verify_code + + +class GJClientTrader(clienttrader.BaseLoginClientTrader): + @property + def broker_type(self): + return "gj" + + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """ + 登陆客户端 + + :param user: 账号 + :param password: 明文密码 + :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', + 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' + :param comm_password: 通讯密码, 华泰需要,可不设 + :return: + """ + try: + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + + # wait login window ready + while True: + try: + self._app.top_window().Edit1.wait("ready") + break + except RuntimeError: + pass + + self._app.top_window().Edit1.type_keys(user) + self._app.top_window().Edit2.type_keys(password) + edit3 = self._app.top_window().window(control_id=0x3eb) + while True: + try: + code = self._handle_verify_code() + edit3.type_keys(code) + time.sleep(1) + self._app.top_window()["确定(Y)"].click() + # detect login is success or not + try: + self._app.top_window().wait_not("exists", 5) + break + + # pylint: disable=broad-except + except Exception: + self._app.top_window()["确定"].click() + + # pylint: disable=broad-except + except Exception: + pass + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + self._main = self._app.window(title="网上股票交易系统5.0") + + def _handle_verify_code(self): + control = self._app.top_window().window(control_id=0x5db) + control.click() + time.sleep(0.2) + file_path = tempfile.mktemp() + ".jpg" + control.capture_as_image().save(file_path) + time.sleep(0.2) + vcode = recognize_verify_code(file_path, "gj_client") + return "".join(re.findall("[a-zA-Z0-9]+", vcode)) diff --git a/easytrader/grid_strategies.py b/easytrader/grid_strategies.py new file mode 100644 index 00000000..b294b967 --- /dev/null +++ b/easytrader/grid_strategies.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +import abc +import io +import tempfile +from io import StringIO +from typing import TYPE_CHECKING, Dict, List, Optional + +import pandas as pd +import pywinauto.keyboard +import pywinauto +import pywinauto.clipboard + +from easytrader.log import logger +from easytrader.utils.captcha import captcha_recognize +from easytrader.utils.win_gui import SetForegroundWindow, ShowWindow, win32defines + +if TYPE_CHECKING: + # pylint: disable=unused-import + from easytrader import clienttrader + + +class IGridStrategy(abc.ABC): + @abc.abstractmethod + def get(self, control_id: int) -> List[Dict]: + """ + 获取 gird 数据并格式化返回 + + :param control_id: grid 的 control id + :return: grid 数据 + """ + pass + + @abc.abstractmethod + def set_trader(self, trader: "clienttrader.IClientTrader"): + pass + + +class BaseStrategy(IGridStrategy): + def __init__(self): + self._trader = None + + def set_trader(self, trader: "clienttrader.IClientTrader"): + self._trader = trader + + @abc.abstractmethod + def get(self, control_id: int) -> List[Dict]: + """ + :param control_id: grid 的 control id + :return: grid 数据 + """ + pass + + def _get_grid(self, control_id: int): + grid = self._trader.main.child_window( + control_id=control_id, class_name="CVirtualGridCtrl" + ) + return grid + + def _set_foreground(self, grid=None): + try: + if grid is None: + grid = self._trader.main + if grid.has_style(win32defines.WS_MINIMIZE): # if minimized + ShowWindow(grid.wrapper_object(), 9) # restore window state + else: + SetForegroundWindow(grid.wrapper_object()) # bring to front + except: + pass + + +class Copy(BaseStrategy): + """ + 通过复制 grid 内容到剪切板再读取来获取 grid 内容 + """ + + _need_captcha_reg = True + + def get(self, control_id: int) -> List[Dict]: + grid = self._get_grid(control_id) + self._set_foreground(grid) + grid.type_keys("^A^C", set_foreground=False) + content = self._get_clipboard_data() + return self._format_grid_data(content) + + def _format_grid_data(self, data: str) -> List[Dict]: + try: + df = pd.read_csv( + io.StringIO(data), + delimiter="\t", + dtype=self._trader.config.GRID_DTYPE, + na_filter=False, + ) + return df.to_dict("records") + except: + Copy._need_captcha_reg = True + + def _get_clipboard_data(self) -> str: + if Copy._need_captcha_reg: + if ( + self._trader.app.top_window().window(class_name="Static", title_re="验证码").exists(timeout=1) + ): + file_path = "tmp.png" + count = 5 + found = False + while count > 0: + self._trader.app.top_window().window( + control_id=0x965, class_name="Static" + ).capture_as_image().save( + file_path + ) # 保存验证码 + + captcha_num = captcha_recognize(file_path).strip() # 识别验证码 + captcha_num = "".join(captcha_num.split()) + logger.info("captcha result-->" + captcha_num) + if len(captcha_num) == 4: + self._trader.app.top_window().window( + control_id=0x964, class_name="Edit" + ).set_text( + captcha_num + ) # 模拟输入验证码 + + self._trader.app.top_window().set_focus() + pywinauto.keyboard.SendKeys("{ENTER}") # 模拟发送enter,点击确定 + try: + logger.info( + self._trader.app.top_window() + .window(control_id=0x966, class_name="Static") + .window_text() + ) + except Exception as ex: # 窗体消失 + logger.exception(ex) + found = True + break + count -= 1 + self._trader.wait(0.1) + self._trader.app.top_window().window( + control_id=0x965, class_name="Static" + ).click() + if not found: + self._trader.app.top_window().Button2.click() # 点击取消 + else: + Copy._need_captcha_reg = False + count = 5 + while count > 0: + try: + return pywinauto.clipboard.GetData() + # pylint: disable=broad-except + except Exception as e: + count -= 1 + logger.exception("%s, retry ......", e) + + +class WMCopy(Copy): + """ + 通过复制 grid 内容到剪切板再读取来获取 grid 内容 + """ + + def get(self, control_id: int) -> List[Dict]: + grid = self._get_grid(control_id) + grid.post_message(win32defines.WM_COMMAND, 0xE122, 0) + self._trader.wait(0.1) + content = self._get_clipboard_data() + return self._format_grid_data(content) + + +class Xls(BaseStrategy): + """ + 通过将 Grid 另存为 xls 文件再读取的方式获取 grid 内容 + """ + + def __init__(self, tmp_folder: Optional[str] = None): + """ + :param tmp_folder: 用于保持临时文件的文件夹 + """ + super().__init__() + self.tmp_folder = tmp_folder + + def get(self, control_id: int) -> List[Dict]: + grid = self._get_grid(control_id) + + # ctrl+s 保存 grid 内容为 xls 文件 + self._set_foreground(grid) # setFocus buggy, instead of SetForegroundWindow + grid.type_keys("^s", set_foreground=False) + count = 10 + while count > 0: + if self._trader.is_exist_pop_dialog(): + break + self._trader.wait(0.2) + count -= 1 + + temp_path = tempfile.mktemp(suffix=".xls", dir=self.tmp_folder) + self._set_foreground(self._trader.app.top_window()) + + # alt+s保存,alt+y替换已存在的文件 + self._trader.app.top_window().Edit1.set_edit_text(temp_path) + self._trader.wait(0.1) + self._trader.app.top_window().type_keys("%{s}%{y}", set_foreground=False) + # Wait until file save complete otherwise pandas can not find file + self._trader.wait(0.2) + if self._trader.is_exist_pop_dialog(): + self._trader.app.top_window().Button2.click() + self._trader.wait(0.2) + + return self._format_grid_data(temp_path) + + def _format_grid_data(self, data: str) -> List[Dict]: + with open(data, encoding="gbk", errors="replace") as f: + content = f.read() + + df = pd.read_csv( + StringIO(content), + delimiter="\t", + dtype=self._trader.config.GRID_DTYPE, + na_filter=False, + ) + return df.to_dict("records") diff --git a/easytrader/helpers.py b/easytrader/helpers.py deleted file mode 100644 index 18438c51..00000000 --- a/easytrader/helpers.py +++ /dev/null @@ -1,271 +0,0 @@ -# coding: utf-8 -from __future__ import division - -import datetime -import json -import os -import re -import ssl -import sys -import uuid - -import requests -import six -from requests.adapters import HTTPAdapter -from requests.packages.urllib3.poolmanager import PoolManager -from six.moves import input - -from .log import log - -if six.PY2: - from io import open - - -class Ssl3HttpAdapter(HTTPAdapter): - def init_poolmanager(self, connections, maxsize, block=False): - self.poolmanager = PoolManager(num_pools=connections, - maxsize=maxsize, - block=block, - ssl_version=ssl.PROTOCOL_TLSv1) - - -def file2dict(path): - with open(path, encoding='utf-8') as f: - return json.load(f) - - -def get_stock_type(stock_code): - """判断股票ID对应的证券市场 - 匹配规则 - ['50', '51', '60', '90', '110'] 为 sh - ['00', '13', '18', '15', '16', '18', '20', '30', '39', '115'] 为 sz - ['5', '6', '9'] 开头的为 sh, 其余为 sz - :param stock_code:股票ID, 若以 'sz', 'sh' 开头直接返回对应类型,否则使用内置规则判断 - :return 'sh' or 'sz'""" - stock_code = str(stock_code) - if stock_code.startswith(('sh', 'sz')): - return stock_code[:2] - if stock_code.startswith(('50', '51', '60', '73', '90', '110', '113', '132', '204', '78')): - return 'sh' - if stock_code.startswith(('00', '13', '18', '15', '16', '18', '20', '30', '39', '115', '1318')): - return 'sz' - if stock_code.startswith(('5', '6', '9')): - return 'sh' - return 'sz' - - -def ht_verify_code_new(image_path): - """显示图片,人肉读取,手工输入""" - - from PIL import Image - - img = Image.open(image_path) - img.show() - - # 关闭图片后输入答案 - s = input('input the pics answer :') - - return s - - -def recognize_verify_code(image_path, broker='ht'): - """识别验证码,返回识别后的字符串,使用 tesseract 实现 - :param image_path: 图片路径 - :param broker: 券商 ['ht', 'yjb', 'gf', 'yh'] - :return recognized: verify code string""" - - if broker == 'ht': - return detect_ht_result(image_path) - elif broker == 'yjb': - return detect_yjb_result(image_path) - elif broker == 'gf': - return detect_gf_result(image_path) - elif broker == 'yh': - return detect_yh_result(image_path) - elif broker == 'xczq': - return default_verify_code_detect(image_path) - elif broker == 'yh_client': - return detect_yh_client_result(image_path) - # 调用 tesseract 识别 - return default_verify_code_detect(image_path) - - -def detect_yh_client_result(image_path): - """封装了tesseract的识别,部署在阿里云上,服务端源码地址为: https://github.com/shidenggui/yh_verify_code_docker""" - api = 'http://123.56.157.162:5000/yh_client' - with open(image_path, 'rb') as f: - rep = requests.post(api, files={ - 'image': f - }) - if rep.status_code != 201: - error = rep.json()['message'] - raise Exception('request {} error: {]'.format(api, error)) - return rep.json()['result'] - - -def input_verify_code_manual(image_path): - from PIL import Image - image = Image.open(image_path) - image.show() - code = input('image path: {}, input verify code answer:'.format(image_path)) - return code - - -def detect_verify_code_by_java(image_path, broker): - jars = { - 'ht': ('getcode_jdk1.5.jar', ''), - 'yjb': ('yjb_verify_code.jar', 'guojin') - } - verify_code_tool, param = jars[broker] - # 检查 java 环境,若有则调用 jar 包处理 (感谢空中园的贡献) - # noinspection PyGlobalUndefined - if six.PY2: - if sys.platform == 'win32': - from subprocess import PIPE, Popen, STDOUT - - def getoutput(cmd, input=None, cwd=None, env=None): - pipe = Popen(cmd, shell=True, cwd=cwd, env=env, stdout=PIPE, stderr=STDOUT) - (output, err_out) = pipe.communicate(input=input) - return output.decode().rstrip('\r\n') - else: - import commands - getoutput = commands.getoutput - else: - from subprocess import getoutput - out_put = getoutput('java -version') - log.debug('java detect result: %s' % out_put) - if out_put.find('java version') != -1 or out_put.find('openjdk') != -1: - tool_path = os.path.join(os.path.dirname(__file__), 'thirdlibrary', verify_code_tool) - out_put = getoutput('java -jar "{}" {} {}'.format(tool_path, param, image_path)) - log.debug('recognize output: %s' % out_put) - verify_code_start = -4 - return out_put[verify_code_start:] - - -def default_verify_code_detect(image_path): - from PIL import Image - img = Image.open(image_path) - return invoke_tesseract_to_recognize(img) - - -def detect_gf_result(image_path): - from PIL import ImageFilter, Image - img = Image.open(image_path) - if hasattr(img, "width"): - width, height = img.width, img.height - else: - width, height = img.size - for x in range(width): - for y in range(height): - if img.getpixel((x, y)) < (100, 100, 100): - img.putpixel((x, y), (256, 256, 256)) - gray = img.convert('L') - two = gray.point(lambda p: 0 if 68 < p < 90 else 256) - min_res = two.filter(ImageFilter.MinFilter) - med_res = min_res.filter(ImageFilter.MedianFilter) - for _ in range(2): - med_res = med_res.filter(ImageFilter.MedianFilter) - return invoke_tesseract_to_recognize(med_res) - - -def detect_yh_result(image_path): - """封装了tesseract的中文识别,部署在阿里云上,服务端源码地址为: https://github.com/shidenggui/yh_verify_code_docker""" - api = 'http://123.56.157.162:5000/yh' - with open(image_path, 'rb') as f: - try: - rep = requests.post(api, files={ - 'image': f - }) - if rep.status_code != 200: - raise Exception('request {} error'.format(api)) - except Exception as e: - log.error('自动识别银河验证码失败: {}, 请手动输入验证码'.format(e)) - return input_verify_code_manual(image_path) - return rep.text - - -def invoke_tesseract_to_recognize(img): - import pytesseract - try: - res = pytesseract.image_to_string(img) - except FileNotFoundError: - raise Exception('tesseract 未安装,请至 https://github.com/tesseract-ocr/tesseract/wiki 查看安装教程') - valid_chars = re.findall('[0-9a-z]', res, re.IGNORECASE) - return ''.join(valid_chars) - - -def get_mac(): - # 获取mac地址 link: http://stackoverflow.com/questions/28927958/python-get-mac-address - return ("".join(c + "-" if i % 2 else c for i, c in enumerate(hex( - uuid.getnode())[2:].zfill(12)))[:-1]).upper() - - -def grep_comma(num_str): - return num_str.replace(',', '') - - -def str2num(num_str, convert_type='float'): - num = float(grep_comma(num_str)) - return num if convert_type == 'float' else int(num) - - -def get_30_date(): - """ - 获得用于查询的默认日期, 今天的日期, 以及30天前的日期 - 用于查询的日期格式通常为 20160211 - :return: - """ - now = datetime.datetime.now() - end_date = now.date() - start_date = end_date - datetime.timedelta(days=30) - return start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d") - - -def get_today_ipo_data(): - """ - 查询今天可以申购的新股信息 - :return: 今日可申购新股列表 apply_code申购代码 price发行价格 - """ - - import random - import json - import datetime - import requests - - agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:43.0) Gecko/20100101 Firefox/43.0' - send_headers = { - 'Host': 'xueqiu.com', - 'User-Agent': agent, - 'Accept': 'application/json, text/javascript, */*; q=0.01', - 'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3', - 'Accept-Encoding': 'deflate', - 'Cache-Control': 'no-cache', - 'X-Requested-With': 'XMLHttpRequest', - 'Referer': 'https://xueqiu.com/hq', - 'Connection': 'keep-alive' - } - - sj = random.randint(1000000000000, 9999999999999) - home_page_url = 'https://xueqiu.com' - ipo_data_url = "https://xueqiu.com/proipo/query.json?column=symbol,name,onl_subcode,onl_subbegdate,actissqty,onl" \ - "_actissqty,onl_submaxqty,iss_price,onl_lotwiner_stpub_date,onl_lotwinrt,onl_lotwin_amount,stock_" \ - "income&orderBy=onl_subbegdate&order=desc&stockType=&page=1&size=30&_=%s" % (str(sj)) - - session = requests.session() - session.get(home_page_url, headers=send_headers) # 产生cookies - ipo_response = session.post(ipo_data_url, headers=send_headers) - - json_data = json.loads(ipo_response.text) - today_ipo = [] - - for line in json_data['data']: - # if datetime.datetime(2016, 9, 14).ctime()[:10] == line[3][:10]: - if datetime.datetime.now().strftime('%a %b %d') == line[3][:10]: - today_ipo.append({ - 'stock_code': line[0], - 'stock_name': line[1], - 'apply_code': line[2], - 'price': line[7] - }) - - return today_ipo diff --git a/easytrader/ht_clienttrader.py b/easytrader/ht_clienttrader.py new file mode 100644 index 00000000..b1b0e869 --- /dev/null +++ b/easytrader/ht_clienttrader.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +import pywinauto +import pywinauto.clipboard + +from easytrader import grid_strategies +from . import clienttrader + + +class HTClientTrader(clienttrader.BaseLoginClientTrader): + grid_strategy = grid_strategies.Xls + + @property + def broker_type(self): + return "ht" + + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """ + :param user: 用户名 + :param password: 密码 + :param exe_path: 客户端路径, 类似 + :param comm_password: + :param kwargs: + :return: + """ + self._editor_need_type_keys = False + if comm_password is None: + raise ValueError("华泰必须设置通讯密码") + + try: + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + + # wait login window ready + while True: + try: + self._app.top_window().Edit1.wait("ready") + break + except RuntimeError: + pass + self._app.top_window().Edit1.set_focus() + self._app.top_window().Edit1.type_keys(user) + self._app.top_window().Edit2.type_keys(password) + + self._app.top_window().Edit3.set_edit_text(comm_password) + + self._app.top_window().button0.click() + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + self._main = self._app.window(title="网上股票交易系统5.0") + self._main.wait ( "exists enabled visible ready" , timeout=100 ) + self._close_prompt_windows ( ) + + @property + def balance(self): + self._switch_left_menus(self._config.BALANCE_MENU_PATH) + + return self._get_balance_from_statics() + + def _get_balance_from_statics(self): + result = {} + for key, control_id in self._config.BALANCE_CONTROL_ID_GROUP.items(): + result[key] = float( + self._main.child_window( + control_id=control_id, class_name="Static" + ).window_text() + ) + return result + + diff --git a/easytrader/htzq_clienttrader.py b/easytrader/htzq_clienttrader.py new file mode 100644 index 00000000..4ffa4538 --- /dev/null +++ b/easytrader/htzq_clienttrader.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +import pywinauto +import pywinauto.clipboard + +from easytrader import grid_strategies +from . import clienttrader + + +class HTZQClientTrader(clienttrader.BaseLoginClientTrader): + grid_strategy = grid_strategies.Xls + + @property + def broker_type(self): + return "htzq" + + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """ + :param user: 用户名 + :param password: 密码 + :param exe_path: 客户端路径, 类似 + :param comm_password: + :param kwargs: + :return: + """ + self._editor_need_type_keys = False + if comm_password is None: + raise ValueError("必须设置通讯密码") + + try: + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + + # wait login window ready + while True: + try: + self._app.top_window().Edit1.wait("ready") + break + except RuntimeError: + pass + self._app.top_window().Edit1.set_focus() + self._app.top_window().Edit1.type_keys(user) + self._app.top_window().Edit2.type_keys(password) + + self._app.top_window().Edit3.type_keys(comm_password) + + self._app.top_window().button0.click() + + # detect login is success or not + self._app.top_window().wait_not("exists", 100) + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + self._close_prompt_windows() + self._main = self._app.window(title="网上股票交易系统5.0") + diff --git a/easytrader/joinquant_follower.py b/easytrader/joinquant_follower.py index 5c98e323..d1d55b58 100644 --- a/easytrader/joinquant_follower.py +++ b/easytrader/joinquant_follower.py @@ -1,40 +1,45 @@ -# coding:utf8 -from __future__ import unicode_literals - -import re +# -*- coding: utf-8 -*- from datetime import datetime from threading import Thread -from .follower import BaseFollower -from .log import log -from .webtrader import NotLoginError +from easytrader import exceptions +from easytrader.follower import BaseFollower +from easytrader.log import logger class JoinQuantFollower(BaseFollower): - LOGIN_PAGE = 'https://www.joinquant.com' - LOGIN_API = 'https://www.joinquant.com/user/login/doLogin?ajax=1' - TRANSACTION_API = 'https://www.joinquant.com/algorithm/live/transactionDetail' - WEB_REFERER = 'https://www.joinquant.com/user/login/index' - WEB_ORIGIN = 'https://www.joinquant.com' + LOGIN_PAGE = "https://www.joinquant.com" + LOGIN_API = "https://www.joinquant.com/user/login/doLogin?ajax=1" + TRANSACTION_API = ( + "https://www.joinquant.com/algorithm/live/transactionDetail" + ) + WEB_REFERER = "https://www.joinquant.com/user/login/index" + WEB_ORIGIN = "https://www.joinquant.com" def create_login_params(self, user, password, **kwargs): params = { - 'CyLoginForm[username]': user, - 'CyLoginForm[pwd]': password, - 'ajax': 1 + "CyLoginForm[username]": user, + "CyLoginForm[pwd]": password, + "ajax": 1, } return params def check_login_success(self, rep): - set_cookie = rep.headers['set-cookie'] - if len(set_cookie) < 100: - raise NotLoginError('登录失败,请检查用户名和密码') - self.s.headers.update({ - 'cookie': set_cookie - }) - - def follow(self, users, strategies, track_interval=1, trade_cmd_expire_seconds=120, cmd_cache=True, - entrust_prop='limit', send_interval=0): + set_cookie = rep.headers["set-cookie"] + if len(set_cookie) < 50: + raise exceptions.NotLoginError("登录失败,请检查用户名和密码") + self.s.headers.update({"cookie": set_cookie}) + + def follow( + self, + users, + strategies, + track_interval=1, + trade_cmd_expire_seconds=120, + cmd_cache=True, + entrust_prop="limit", + send_interval=0, + ): """跟踪joinquant对应的模拟交易,支持多用户多策略 :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 :param strategies: joinquant 的模拟交易地址,支持使用 [] 指定多个模拟交易, @@ -51,7 +56,9 @@ def follow(self, users, strategies, track_interval=1, trade_cmd_expire_seconds=1 if cmd_cache: self.load_expired_cmd_cache() - self.start_trader_thread(users, trade_cmd_expire_seconds, entrust_prop, send_interval) + self.start_trader_thread( + users, trade_cmd_expire_seconds, entrust_prop, send_interval + ) workers = [] for strategy_url in strategies: @@ -59,55 +66,72 @@ def follow(self, users, strategies, track_interval=1, trade_cmd_expire_seconds=1 strategy_id = self.extract_strategy_id(strategy_url) strategy_name = self.extract_strategy_name(strategy_url) except: - log.error('抽取交易id和策略名失败, 无效的模拟交易url: {}'.format(strategy_url)) + logger.error("抽取交易id和策略名失败, 无效的模拟交易url: %s", strategy_url) raise - strategy_worker = Thread(target=self.track_strategy_worker, args=[strategy_id, strategy_name], - kwargs={'interval': track_interval}) + strategy_worker = Thread( + target=self.track_strategy_worker, + args=[strategy_id, strategy_name], + kwargs={"interval": track_interval}, + ) strategy_worker.start() workers.append(strategy_worker) - log.info('开始跟踪策略: {}'.format(strategy_name)) + logger.info("开始跟踪策略: %s", strategy_name) for worker in workers: worker.join() - @staticmethod - def extract_strategy_id(strategy_url): - return re.search(r'(?<=backtestId=)\w+', strategy_url).group() + # @staticmethod + # def extract_strategy_id(strategy_url): + # return re.search(r"(?<=backtestId=)\w+", strategy_url).group() + # + # def extract_strategy_name(self, strategy_url): + # rep = self.s.get(strategy_url) + # return self.re_find( + # r'(?<=title="点击修改策略名称"\>).*(?=\', rep.content.decode("utf8")) def extract_strategy_name(self, strategy_url): rep = self.s.get(strategy_url) - return self.re_find(r'(?<=title="点击修改策略名称"\>).*(?=\(.*?)', rep.content.decode("utf8")) def create_query_transaction_params(self, strategy): - today_str = datetime.today().strftime('%Y-%m-%d') - params = { - 'backtestId': strategy, - 'date': today_str, - 'ajax': 1 - } + today_str = datetime.today().strftime("%Y-%m-%d") + params = {"backtestId": strategy, "date": today_str, "ajax": 1} return params def extract_transactions(self, history): - transactions = history['data']['transaction'] + transactions = history["data"]["transaction"] return transactions @staticmethod def stock_shuffle_to_prefix(stock): - assert len(stock) == 11, 'stock {} must like 123456.XSHG or 123456.XSHE'.format(stock) + assert ( + len(stock) == 11 + ), "stock {} must like 123456.XSHG or 123456.XSHE".format(stock) code = stock[:6] - if stock.find('XSHG') != -1: - return 'sh' + code - elif stock.find('XSHE') != -1: - return 'sz' + code - raise TypeError('not valid stock code: {}'.format(code)) + if stock.find("XSHG") != -1: + return "sh" + code - def project_transactions(self, transactions, **kwargs): - for t in transactions: - t['amount'] = self.re_find('\d+', t['amount'], dtype=int) - - time_str = '{} {}'.format(t['date'], t['time']) - t['datetime'] = datetime.strptime(time_str, '%Y-%m-%d %H:%M') + if stock.find("XSHE") != -1: + return "sz" + code + raise TypeError("not valid stock code: {}".format(code)) - stock = self.re_find(r'\d{6}\.\w{4}', t['stock']) - t['stock_code'] = self.stock_shuffle_to_prefix(stock) - - t['action'] = 'buy' if t['transaction'] == '买' else 'sell' + def project_transactions(self, transactions, **kwargs): + for transaction in transactions: + transaction["amount"] = self.re_find( + r"\d+", transaction["amount"], dtype=int + ) + + time_str = "{} {}".format(transaction["date"], transaction["time"]) + transaction["datetime"] = datetime.strptime( + time_str, "%Y-%m-%d %H:%M:%S" + ) + + stock = self.re_find(r"\d{6}\.\w{4}", transaction["stock"]) + transaction["stock_code"] = self.stock_shuffle_to_prefix(stock) + + transaction["action"] = ( + "buy" if transaction["transaction"] == "买" else "sell" + ) diff --git a/easytrader/log.py b/easytrader/log.py index 4e3baed9..27f8dc8b 100644 --- a/easytrader/log.py +++ b/easytrader/log.py @@ -1,12 +1,14 @@ -# coding:utf8 +# -*- coding: utf-8 -*- import logging -log = logging.getLogger('easytrader') -log.setLevel(logging.DEBUG) -log.propagate = False +logger = logging.getLogger("easytrader") +logger.setLevel(logging.INFO) +logger.propagate = False -fmt = logging.Formatter('%(asctime)s [%(levelname)s] %(filename)s %(lineno)s: %(message)s') +fmt = logging.Formatter( + "%(asctime)s [%(levelname)s] %(filename)s %(lineno)s: %(message)s" +) ch = logging.StreamHandler() ch.setFormatter(fmt) -log.handlers.append(ch) +logger.handlers.append(ch) diff --git a/easytrader/pop_dialog_handler.py b/easytrader/pop_dialog_handler.py new file mode 100644 index 00000000..bc4ad3fd --- /dev/null +++ b/easytrader/pop_dialog_handler.py @@ -0,0 +1,98 @@ +# coding:utf-8 +import re +import time +from typing import Optional + +from easytrader import exceptions +from easytrader.utils.perf import perf_clock +from easytrader.utils.win_gui import SetForegroundWindow, ShowWindow, win32defines + + +class PopDialogHandler: + def __init__(self, app): + self._app = app + + @staticmethod + def _set_foreground(window): + if window.has_style(win32defines.WS_MINIMIZE): # if minimized + ShowWindow(window.wrapper_object(), 9) # restore window state + else: + SetForegroundWindow(window.wrapper_object()) # bring to front + + @perf_clock + def handle(self, title): + if any(s in title for s in {"提示信息", "委托确认", "网上交易用户协议", "撤单确认"}): + self._submit_by_shortcut() + return None + + if "提示" in title: + content = self._extract_content() + self._submit_by_click() + return {"message": content} + + content = self._extract_content() + self._close() + return {"message": "unknown message: {}".format(content)} + + def _extract_content(self): + return self._app.top_window().Static.window_text() + + @staticmethod + def _extract_entrust_id(content): + return re.search(r"[\da-zA-Z]+", content).group() + + def _submit_by_click(self): + try: + self._app.top_window()["确定"].click() + except Exception as ex: + self._app.Window_(best_match="Dialog", top_level_only=True).ChildWindow( + best_match="确定" + ).click() + + def _submit_by_shortcut(self): + self._set_foreground(self._app.top_window()) + self._app.top_window().type_keys("%Y", set_foreground=False) + + def _close(self): + self._app.top_window().close() + + +class TradePopDialogHandler(PopDialogHandler): + @perf_clock + def handle(self, title) -> Optional[dict]: + if title == "委托确认": + self._submit_by_shortcut() + return None + + if title == "提示信息": + content = self._extract_content() + if "超出涨跌停" in content: + self._submit_by_shortcut() + return None + + if "委托价格的小数价格应为" in content: + self._submit_by_shortcut() + return None + + if "逆回购" in content: + self._submit_by_shortcut() + return None + + if "正回购" in content: + self._submit_by_shortcut() + return None + + return None + + if title == "提示": + content = self._extract_content() + if "成功" in content: + entrust_no = self._extract_entrust_id(content) + self._submit_by_click() + return {"entrust_no": entrust_no} + + self._submit_by_click() + time.sleep(0.05) + raise exceptions.TradeError(content) + self._close() + return None diff --git a/easytrader/refresh_strategies.py b/easytrader/refresh_strategies.py new file mode 100644 index 00000000..e638c359 --- /dev/null +++ b/easytrader/refresh_strategies.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +import abc +import io +import tempfile +from io import StringIO +from typing import TYPE_CHECKING, Dict, List, Optional + +import pandas as pd +import pywinauto.keyboard +import pywinauto +import pywinauto.clipboard + +from easytrader.log import logger +from easytrader.utils.captcha import captcha_recognize +from easytrader.utils.win_gui import SetForegroundWindow, ShowWindow, win32defines + +if TYPE_CHECKING: + # pylint: disable=unused-import + from easytrader import clienttrader + + +class IRefreshStrategy(abc.ABC): + _trader: "clienttrader.ClientTrader" + + @abc.abstractmethod + def refresh(self): + """ + 刷新数据 + """ + pass + + def set_trader(self, trader: "clienttrader.ClientTrader"): + self._trader = trader + + +# noinspection PyProtectedMember +class Switch(IRefreshStrategy): + """通过切换菜单栏刷新""" + + def __init__(self, sleep: float = 0.1): + self.sleep = sleep + + def refresh(self): + self._trader._switch_left_menus_by_shortcut("{F5}", sleep=self.sleep) + + +# noinspection PyProtectedMember +class Toolbar(IRefreshStrategy): + """通过点击工具栏刷新按钮刷新""" + + def __init__(self, refresh_btn_index: int = 4): + """ + :param refresh_btn_index: + 交易客户端工具栏中“刷新”排序,默认为第4个,请根据自己实际调整 + """ + self.refresh_btn_index = refresh_btn_index + + def refresh(self): + self._trader._toolbar.button(self.refresh_btn_index - 1).click() diff --git a/easytrader/remoteclient.py b/easytrader/remoteclient.py new file mode 100644 index 00000000..cfddab94 --- /dev/null +++ b/easytrader/remoteclient.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +import requests + +from easytrader.utils.misc import file2dict + + +def use(broker, host, port=1430, **kwargs): + return RemoteClient(broker, host, port) + + +class RemoteClient: + def __init__(self, broker, host, port=1430, **kwargs): + self._s = requests.session() + self._api = "http://{}:{}".format(host, port) + self._broker = broker + + def prepare( + self, + config_path=None, + user=None, + password=None, + exe_path=None, + comm_password=None, + **kwargs + ): + """ + 登陆客户端 + :param config_path: 登陆配置文件,跟参数登陆方式二选一 + :param user: 账号 + :param password: 明文密码 + :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', + 默认 r'C:\\htzqzyb2\\xiadan.exe' + :param comm_password: 通讯密码 + :return: + """ + params = locals().copy() + params.pop("self") + + if config_path is not None: + account = file2dict(config_path) + params["user"] = account["user"] + params["password"] = account["password"] + + params["broker"] = self._broker + + response = self._s.post(self._api + "/prepare", json=params) + if response.status_code >= 300: + raise Exception(response.json()["error"]) + return response.json() + + @property + def balance(self): + return self.common_get("balance") + + @property + def position(self): + return self.common_get("position") + + @property + def today_entrusts(self): + return self.common_get("today_entrusts") + + @property + def today_trades(self): + return self.common_get("today_trades") + + @property + def cancel_entrusts(self): + return self.common_get("cancel_entrusts") + + def auto_ipo(self): + return self.common_get("auto_ipo") + + def exit(self): + return self.common_get("exit") + + def common_get(self, endpoint): + response = self._s.get(self._api + "/" + endpoint) + if response.status_code >= 300: + raise Exception(response.json()["error"]) + return response.json() + + def buy(self, security, price, amount, **kwargs): + params = locals().copy() + params.pop("self") + + response = self._s.post(self._api + "/buy", json=params) + if response.status_code >= 300: + raise Exception(response.json()["error"]) + return response.json() + + def sell(self, security, price, amount, **kwargs): + params = locals().copy() + params.pop("self") + + response = self._s.post(self._api + "/sell", json=params) + if response.status_code >= 300: + raise Exception(response.json()["error"]) + return response.json() + + def cancel_entrust(self, entrust_no): + params = locals().copy() + params.pop("self") + + response = self._s.post(self._api + "/cancel_entrust", json=params) + if response.status_code >= 300: + raise Exception(response.json()["error"]) + return response.json() diff --git a/easytrader/ricequant_follower.py b/easytrader/ricequant_follower.py index 73764903..4c93e6f5 100644 --- a/easytrader/ricequant_follower.py +++ b/easytrader/ricequant_follower.py @@ -1,20 +1,32 @@ -# coding:utf8 -from __future__ import unicode_literals +# -*- coding: utf-8 -*- from datetime import datetime from threading import Thread -from .follower import BaseFollower -from .log import log +from easytrader.follower import BaseFollower +from easytrader.log import logger class RiceQuantFollower(BaseFollower): - def login(self, user, password, **kwargs): + def __init__(self): + super().__init__() + self.client = None + + def login(self, user=None, password=None, **kwargs): from rqopen_client import RQOpenClient - self.client = RQOpenClient(user, password, logger=log) - def follow(self, users, run_id, track_interval=1, - trade_cmd_expire_seconds=120, cmd_cache=True, entrust_prop='limit', send_interval=0): + self.client = RQOpenClient(user, password, logger=logger) + + def follow( + self, + users, + run_id, + track_interval=1, + trade_cmd_expire_seconds=120, + cmd_cache=True, + entrust_prop="limit", + send_interval=0, + ): """跟踪ricequant对应的模拟交易,支持多用户多策略 :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 :param run_id: ricequant 的模拟交易ID,支持使用 [] 指定多个模拟交易 @@ -25,35 +37,48 @@ def follow(self, users, run_id, track_interval=1, :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 """ users = self.warp_list(users) - run_id_list = self.warp_list(run_id) + run_ids = self.warp_list(run_id) if cmd_cache: self.load_expired_cmd_cache() - self.start_trader_thread(users, trade_cmd_expire_seconds, entrust_prop, send_interval) + self.start_trader_thread( + users, trade_cmd_expire_seconds, entrust_prop, send_interval + ) workers = [] - for run_id in run_id_list: - strategy_name = self.extract_strategy_name(run_id) - strategy_worker = Thread(target=self.track_strategy_worker, args=[run_id, strategy_name], - kwargs={'interval': track_interval}) + for id_ in run_ids: + strategy_name = self.extract_strategy_name(id_) + strategy_worker = Thread( + target=self.track_strategy_worker, + args=[id_, strategy_name], + kwargs={"interval": track_interval}, + ) strategy_worker.start() workers.append(strategy_worker) - log.info('开始跟踪策略: {}'.format(strategy_name)) + logger.info("开始跟踪策略: %s", strategy_name) for worker in workers: worker.join() def extract_strategy_name(self, run_id): ret_json = self.client.get_positions(run_id) if ret_json["code"] != 200: - log.error("fetch data from run_id {} fail, msg {}".format(run_id, ret_json["msg"])) + logger.error( + "fetch data from run_id %s fail, msg %s", + run_id, + ret_json["msg"], + ) raise RuntimeError(ret_json["msg"]) return ret_json["resp"]["name"] def extract_day_trades(self, run_id): ret_json = self.client.get_day_trades(run_id) if ret_json["code"] != 200: - log.error("fetch day trades from run_id {} fail, msg {}".format(run_id, ret_json["msg"])) + logger.error( + "fetch day trades from run_id %s fail, msg %s", + run_id, + ret_json["msg"], + ) raise RuntimeError(ret_json["msg"]) return ret_json["resp"]["trades"] @@ -64,23 +89,31 @@ def query_strategy_transaction(self, strategy, **kwargs): @staticmethod def stock_shuffle_to_prefix(stock): - assert len(stock) == 11, 'stock {} must like 123456.XSHG or 123456.XSHE'.format(stock) + assert ( + len(stock) == 11 + ), "stock {} must like 123456.XSHG or 123456.XSHE".format(stock) code = stock[:6] - if stock.find('XSHG') != -1: - return 'sh' + code - elif stock.find('XSHE') != -1: - return 'sz' + code - raise TypeError('not valid stock code: {}'.format(code)) + if stock.find("XSHG") != -1: + return "sh" + code + if stock.find("XSHE") != -1: + return "sz" + code + raise TypeError("not valid stock code: {}".format(code)) def project_transactions(self, transactions, **kwargs): new_transactions = [] - for t in transactions: - trans = {} - trans["price"] = t["price"] - trans["amount"] = int(abs(t["quantity"])) - trans["datetime"] = datetime.strptime(t["time"], '%Y-%m-%d %H:%M:%S') - trans["stock_code"] = self.stock_shuffle_to_prefix(t["order_book_id"]) - trans["action"] = 'buy' if t["quantity"] > 0 else 'sell' - new_transactions.append(trans) + for transaction in transactions: + new_transaction = {} + new_transaction["price"] = transaction["price"] + new_transaction["amount"] = int(abs(transaction["quantity"])) + new_transaction["datetime"] = datetime.strptime( + transaction["time"], "%Y-%m-%d %H:%M:%S" + ) + new_transaction["stock_code"] = self.stock_shuffle_to_prefix( + transaction["order_book_id"] + ) + new_transaction["action"] = ( + "buy" if transaction["quantity"] > 0 else "sell" + ) + new_transactions.append(new_transaction) return new_transactions diff --git a/easytrader/server.py b/easytrader/server.py new file mode 100644 index 00000000..02ce98d6 --- /dev/null +++ b/easytrader/server.py @@ -0,0 +1,135 @@ +import functools + +from flask import Flask, jsonify, request + +from . import api +from .log import logger + +app = Flask(__name__) + +global_store = {} + + +def error_handle(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + # pylint: disable=broad-except + except Exception as e: + logger.exception("server error") + message = "{}: {}".format(e.__class__, e) + return jsonify({"error": message}), 400 + + return wrapper + + +@app.route("/prepare", methods=["POST"]) +@error_handle +def post_prepare(): + json_data = request.get_json(force=True) + + user = api.use(json_data.pop("broker")) + user.prepare(**json_data) + + global_store["user"] = user + return jsonify({"msg": "login success"}), 201 + + +@app.route("/balance", methods=["GET"]) +@error_handle +def get_balance(): + user = global_store["user"] + balance = user.balance + + return jsonify(balance), 200 + + +@app.route("/position", methods=["GET"]) +@error_handle +def get_position(): + user = global_store["user"] + position = user.position + + return jsonify(position), 200 + + +@app.route("/auto_ipo", methods=["GET"]) +@error_handle +def get_auto_ipo(): + user = global_store["user"] + res = user.auto_ipo() + + return jsonify(res), 200 + + +@app.route("/today_entrusts", methods=["GET"]) +@error_handle +def get_today_entrusts(): + user = global_store["user"] + today_entrusts = user.today_entrusts + + return jsonify(today_entrusts), 200 + + +@app.route("/today_trades", methods=["GET"]) +@error_handle +def get_today_trades(): + user = global_store["user"] + today_trades = user.today_trades + + return jsonify(today_trades), 200 + + +@app.route("/cancel_entrusts", methods=["GET"]) +@error_handle +def get_cancel_entrusts(): + user = global_store["user"] + cancel_entrusts = user.cancel_entrusts + + return jsonify(cancel_entrusts), 200 + + +@app.route("/buy", methods=["POST"]) +@error_handle +def post_buy(): + json_data = request.get_json(force=True) + user = global_store["user"] + res = user.buy(**json_data) + + return jsonify(res), 201 + + +@app.route("/sell", methods=["POST"]) +@error_handle +def post_sell(): + json_data = request.get_json(force=True) + + user = global_store["user"] + res = user.sell(**json_data) + + return jsonify(res), 201 + + +@app.route("/cancel_entrust", methods=["POST"]) +@error_handle +def post_cancel_entrust(): + json_data = request.get_json(force=True) + + user = global_store["user"] + res = user.cancel_entrust(**json_data) + + return jsonify(res), 201 + + +@app.route("/exit", methods=["GET"]) +@error_handle +def get_exit(): + user = global_store["user"] + user.exit() + + return jsonify({"msg": "exit success"}), 200 + + +def run(port=1430): + app.run(host="0.0.0.0", port=port) diff --git a/easytrader/universal_clienttrader.py b/easytrader/universal_clienttrader.py new file mode 100644 index 00000000..afec1eae --- /dev/null +++ b/easytrader/universal_clienttrader.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +import pywinauto +import pywinauto.clipboard + +from easytrader import grid_strategies +from . import clienttrader + + +class UniversalClientTrader(clienttrader.BaseLoginClientTrader): + grid_strategy = grid_strategies.Xls + + @property + def broker_type(self): + return "universal" + + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """ + :param user: 用户名 + :param password: 密码 + :param exe_path: 客户端路径, 类似 + :param comm_password: + :param kwargs: + :return: + """ + self._editor_need_type_keys = False + + try: + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + + # wait login window ready + while True: + try: + login_window = pywinauto.findwindows.find_window(class_name='#32770', found_index=1) + break + except: + self.wait(1) + + self.wait(1) + self._app.window(handle=login_window).Edit1.set_focus() + self._app.window(handle=login_window).Edit1.type_keys(user) + + self._app.window(handle=login_window).button7.click() + + # detect login is success or not + # self._app.top_window().wait_not("exists", 100) + self.wait(5) + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + + self._close_prompt_windows() + self._main = self._app.window(title="网上股票交易系统5.0") + diff --git a/easytrader/utils/__init__.py b/easytrader/utils/__init__.py new file mode 100644 index 00000000..d3f5a12f --- /dev/null +++ b/easytrader/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/easytrader/utils/captcha.py b/easytrader/utils/captcha.py new file mode 100644 index 00000000..cfbfc009 --- /dev/null +++ b/easytrader/utils/captcha.py @@ -0,0 +1,103 @@ +import re + +import requests +from PIL import Image + +from easytrader import exceptions + + +def captcha_recognize(img_path): + import pytesseract + + im = Image.open(img_path).convert("L") + # 1. threshold the image + threshold = 200 + table = [] + for i in range(256): + if i < threshold: + table.append(0) + else: + table.append(1) + + out = im.point(table, "1") + # 2. recognize with tesseract + num = pytesseract.image_to_string(out) + return num + + +def recognize_verify_code(image_path, broker="ht"): + """识别验证码,返回识别后的字符串,使用 tesseract 实现 + :param image_path: 图片路径 + :param broker: 券商 ['ht', 'yjb', 'gf', 'yh'] + :return recognized: verify code string""" + + if broker == "gf": + return detect_gf_result(image_path) + if broker in ["yh_client", "gj_client"]: + return detect_yh_client_result(image_path) + # 调用 tesseract 识别 + return default_verify_code_detect(image_path) + + +def detect_yh_client_result(image_path): + """封装了tesseract的识别,部署在阿里云上, + 服务端源码地址为: https://github.com/shidenggui/yh_verify_code_docker""" + api = "http://yh.ez.shidenggui.com:5000/yh_client" + with open(image_path, "rb") as f: + rep = requests.post(api, files={"image": f}) + if rep.status_code != 201: + error = rep.json()["message"] + raise exceptions.TradeError("request {} error: {}".format(api, error)) + return rep.json()["result"] + + +def input_verify_code_manual(image_path): + from PIL import Image + + image = Image.open(image_path) + image.show() + code = input( + "image path: {}, input verify code answer:".format(image_path) + ) + return code + + +def default_verify_code_detect(image_path): + from PIL import Image + + img = Image.open(image_path) + return invoke_tesseract_to_recognize(img) + + +def detect_gf_result(image_path): + from PIL import ImageFilter, Image + + img = Image.open(image_path) + if hasattr(img, "width"): + width, height = img.width, img.height + else: + width, height = img.size + for x in range(width): + for y in range(height): + if img.getpixel((x, y)) < (100, 100, 100): + img.putpixel((x, y), (256, 256, 256)) + gray = img.convert("L") + two = gray.point(lambda p: 0 if 68 < p < 90 else 256) + min_res = two.filter(ImageFilter.MinFilter) + med_res = min_res.filter(ImageFilter.MedianFilter) + for _ in range(2): + med_res = med_res.filter(ImageFilter.MedianFilter) + return invoke_tesseract_to_recognize(med_res) + + +def invoke_tesseract_to_recognize(img): + import pytesseract + + try: + res = pytesseract.image_to_string(img) + except FileNotFoundError: + raise Exception( + "tesseract 未安装,请至 https://github.com/tesseract-ocr/tesseract/wiki 查看安装教程" + ) + valid_chars = re.findall("[0-9a-z]", res, re.IGNORECASE) + return "".join(valid_chars) diff --git a/easytrader/utils/misc.py b/easytrader/utils/misc.py new file mode 100644 index 00000000..a47592b5 --- /dev/null +++ b/easytrader/utils/misc.py @@ -0,0 +1,31 @@ +# coding:utf-8 +import json + + +def parse_cookies_str(cookies): + """ + parse cookies str to dict + :param cookies: cookies str + :type cookies: str + :return: cookie dict + :rtype: dict + """ + cookie_dict = {} + for record in cookies.split(";"): + key, value = record.strip().split("=", 1) + cookie_dict[key] = value + return cookie_dict + + +def file2dict(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def grep_comma(num_str): + return num_str.replace(",", "") + + +def str2num(num_str, convert_type="float"): + num = float(grep_comma(num_str)) + return num if convert_type == "float" else int(num) diff --git a/easytrader/utils/perf.py b/easytrader/utils/perf.py new file mode 100644 index 00000000..7d815ded --- /dev/null +++ b/easytrader/utils/perf.py @@ -0,0 +1,46 @@ +# coding:utf-8 +import functools +import logging +import timeit + +from easytrader import logger + +try: + from time import process_time +except: + from time import clock as process_time + + +def perf_clock(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + if not logger.isEnabledFor(logging.DEBUG): + return f(*args, **kwargs) + + ts = timeit.default_timer() + cs = process_time() + ex = None + result = None + + try: + result = f(*args, **kwargs) + except Exception as ex1: + ex = ex1 + + te = timeit.default_timer() + ce = process_time() + logger.debug( + "%r consume %2.4f sec, cpu %2.4f sec. args %s, extra args %s" + % ( + f.__name__, + te - ts, + ce - cs, + args[1:], + kwargs, + ) + ) + if ex is not None: + raise ex + return result + + return wrapper diff --git a/easytrader/utils/stock.py b/easytrader/utils/stock.py new file mode 100644 index 00000000..21acea23 --- /dev/null +++ b/easytrader/utils/stock.py @@ -0,0 +1,91 @@ +# coding:utf-8 +import datetime +import json +import random + +import requests + + +def get_stock_type(stock_code): + """判断股票ID对应的证券市场 + 匹配规则 + ['50', '51', '60', '90', '110'] 为 sh + ['00', '13', '18', '15', '16', '18', '20', '30', '39', '115'] 为 sz + ['5', '6', '9'] 开头的为 sh, 其余为 sz + :param stock_code:股票ID, 若以 'sz', 'sh' 开头直接返回对应类型,否则使用内置规则判断 + :return 'sh' or 'sz'""" + stock_code = str(stock_code) + if stock_code.startswith(("sh", "sz")): + return stock_code[:2] + if stock_code.startswith( + ("50", "51", "60", "73", "90", "110", "113", "132", "204", "78") + ): + return "sh" + if stock_code.startswith( + ("00", "13", "18", "15", "16", "18", "20", "30", "39", "115", "1318") + ): + return "sz" + if stock_code.startswith(("5", "6", "9")): + return "sh" + return "sz" + + +def get_30_date(): + """ + 获得用于查询的默认日期, 今天的日期, 以及30天前的日期 + 用于查询的日期格式通常为 20160211 + :return: + """ + now = datetime.datetime.now() + end_date = now.date() + start_date = end_date - datetime.timedelta(days=30) + return start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d") + + +def get_today_ipo_data(): + """ + 查询今天可以申购的新股信息 + :return: 今日可申购新股列表 apply_code申购代码 price发行价格 + """ + + agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:43.0) Gecko/20100101 Firefox/43.0" + send_headers = { + "Host": "xueqiu.com", + "User-Agent": agent, + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3", + "Accept-Encoding": "deflate", + "Cache-Control": "no-cache", + "X-Requested-With": "XMLHttpRequest", + "Referer": "https://xueqiu.com/hq", + "Connection": "keep-alive", + } + + timestamp = random.randint(1000000000000, 9999999999999) + home_page_url = "https://xueqiu.com" + ipo_data_url = ( + "https://xueqiu.com/proipo/query.json?column=symbol,name,onl_subcode,onl_subbegdate,actissqty,onl" + "_actissqty,onl_submaxqty,iss_price,onl_lotwiner_stpub_date,onl_lotwinrt,onl_lotwin_amount,stock_" + "income&orderBy=onl_subbegdate&order=desc&stockType=&page=1&size=30&_=%s" + % (str(timestamp)) + ) + + session = requests.session() + session.get(home_page_url, headers=send_headers) # 产生cookies + ipo_response = session.post(ipo_data_url, headers=send_headers) + + json_data = json.loads(ipo_response.text) + today_ipo = [] + + for line in json_data["data"]: + if datetime.datetime.now().strftime("%a %b %d") == line[3][:10]: + today_ipo.append( + { + "stock_code": line[0], + "stock_name": line[1], + "apply_code": line[2], + "price": line[7], + } + ) + + return today_ipo diff --git a/easytrader/utils/win_gui.py b/easytrader/utils/win_gui.py new file mode 100644 index 00000000..903c5f91 --- /dev/null +++ b/easytrader/utils/win_gui.py @@ -0,0 +1,3 @@ +# coding:utf-8 +from pywinauto import win32defines +from pywinauto.win32functions import SetForegroundWindow, ShowWindow diff --git a/easytrader/webtrader.py b/easytrader/webtrader.py index 6dc659fc..f591db3e 100644 --- a/easytrader/webtrader.py +++ b/easytrader/webtrader.py @@ -1,46 +1,29 @@ -# coding: utf-8 +# -*- coding: utf-8 -*- +import abc import logging import os import re import time from threading import Thread -import six +import requests +import requests.exceptions -from . import helpers -from .log import log +from easytrader import exceptions +from easytrader.log import logger +from easytrader.utils.misc import file2dict, str2num +from easytrader.utils.stock import get_30_date -if six.PY2: - import sys - stdi, stdo, stde = sys.stdin, sys.stdout, sys.stderr # 获取标准输入、标准输出和标准错误输出 - reload(sys) - sys.stdin, sys.stdout, sys.stderr = stdi, stdo, stde # 保持标准输入、标准输出和标准错误输出 - sys.setdefaultencoding('utf8') - - -class NotLoginError(Exception): - - def __init__(self, result=None): - super(NotLoginError, self).__init__() - self.result = result - - -class TradeError(Exception): - - def __init__(self, message=None): - super(TradeError, self).__init__() - self.message = message - - -class WebTrader(object): - global_config_path = os.path.dirname(__file__) + '/config/global.json' - config_path = '' +# noinspection PyIncorrectDocstring +class WebTrader(metaclass=abc.ABCMeta): + global_config_path = os.path.dirname(__file__) + "/config/global.json" + config_path = "" def __init__(self, debug=True): self.__read_config() - self.trade_prefix = self.config['prefix'] - self.account_config = '' + self.trade_prefix = self.config["prefix"] + self.account_config = "" self.heart_active = True self.heart_thread = Thread(target=self.send_heartbeat) self.heart_thread.setDaemon(True) @@ -49,21 +32,22 @@ def __init__(self, debug=True): def read_config(self, path): try: - self.account_config = helpers.file2dict(path) + self.account_config = file2dict(path) except ValueError: - log.error('配置文件格式有误,请勿使用记事本编辑,推荐使用 notepad++ 或者 sublime text') - for v in self.account_config: - if type(v) is int: - log.warn('配置文件的值最好使用双引号包裹,使用字符串类型,否则可能导致不可知的问题') + logger.error("配置文件格式有误,请勿使用记事本编辑,推荐 sublime text") + for value in self.account_config: + if isinstance(value, int): + logger.warning("配置文件的值最好使用双引号包裹,使用字符串,否则可能导致不可知问题") def prepare(self, config_file=None, user=None, password=None, **kwargs): """登录的统一接口 :param config_file 登录数据文件,若无则选择参数登录模式 - :param user: 各家券商的账号或者雪球的用户名 - :param password: 密码, 券商为加密后的密码,雪球为明文密码 - :param account: [雪球登录需要]雪球手机号(邮箱手机二选一) + :param user: 各家券商的账号 + :param password: 密码, 券商为加密后的密码 + :param cookies: [雪球登录需要]雪球登录需要设置对应的 cookies :param portfolio_code: [雪球登录需要]组合代码 - :param portfolio_market: [雪球登录需要]交易市场, 可选['cn', 'us', 'hk'] 默认 'cn' + :param portfolio_market: [雪球登录需要]交易市场, + 可选['cn', 'us', 'hk'] 默认 'cn' """ if config_file is not None: self.read_config(config_file) @@ -73,7 +57,7 @@ def prepare(self, config_file=None, user=None, password=None, **kwargs): def _prepare_account(self, user, password, **kwargs): """映射用户名密码到对应的字段""" - raise Exception('支持参数登录需要实现此方法') + raise Exception("支持参数登录需要实现此方法") def autologin(self, limit=10): """实现自动登录 @@ -83,7 +67,9 @@ def autologin(self, limit=10): if self.login(): break else: - raise NotLoginError('登录失败次数过多, 请检查密码是否正确 / 券商服务器是否处于维护中 / 网络连接是否正常') + raise exceptions.NotLoginError( + "登录失败次数过多, 请检查密码是否正确 / 券商服务器是否处于维护中 / 网络连接是否正常" + ) self.keepalive() def login(self): @@ -105,16 +91,18 @@ def send_heartbeat(self): time.sleep(1) def check_login(self, sleepy=30): - log.setLevel(logging.ERROR) + logger.setLevel(logging.ERROR) try: response = self.heartbeat() self.check_account_live(response) - except Exception as e: - log.setLevel(self.log_level) - log.error('心跳线程发现账户出现错误: {}, 尝试重新登陆'.format(e)) + except requests.exceptions.ConnectionError: + pass + except requests.exceptions.RequestException as e: + logger.setLevel(self.log_level) + logger.error("心跳线程发现账户出现错误: %s %s, 尝试重新登陆", e.__class__, e) self.autologin() finally: - log.setLevel(self.log_level) + logger.setLevel(self.log_level) time.sleep(sleepy) def heartbeat(self): @@ -129,8 +117,8 @@ def exit(self): def __read_config(self): """读取 config""" - self.config = helpers.file2dict(self.config_path) - self.global_config = helpers.file2dict(self.global_config_path) + self.config = file2dict(self.config_path) + self.global_config = file2dict(self.global_config_path) self.config.update(self.global_config) @property @@ -139,7 +127,7 @@ def balance(self): def get_balance(self): """获取账户资金状况""" - return self.do(self.config['balance']) + return self.do(self.config["balance"]) @property def position(self): @@ -147,7 +135,7 @@ def position(self): def get_position(self): """获取持仓""" - return self.do(self.config['position']) + return self.do(self.config["position"]) @property def entrust(self): @@ -155,7 +143,7 @@ def entrust(self): def get_entrust(self): """获取当日委托列表""" - return self.do(self.config['entrust']) + return self.do(self.config["entrust"]) @property def current_deal(self): @@ -164,7 +152,7 @@ def current_deal(self): def get_current_deal(self): """获取当日委托列表""" # return self.do(self.config['current_deal']) - log.warning('目前仅在 佣金宝/银河子类 中实现, 其余券商需要补充') + logger.warning("目前仅在 佣金宝/银河子类 中实现, 其余券商需要补充") @property def exchangebill(self): @@ -173,7 +161,7 @@ def exchangebill(self): :return: """ # TODO 目前仅在 华泰子类 中实现 - start_date, end_date = helpers.get_30_date() + start_date, end_date = get_30_date() return self.get_exchangebill(start_date, end_date) def get_exchangebill(self, start_date, end_date): @@ -183,7 +171,7 @@ def get_exchangebill(self, start_date, end_date): :param end_date: 20160211 :return: """ - log.warning('目前仅在 华泰子类 中实现, 其余券商需要补充') + logger.warning("目前仅在 华泰子类 中实现, 其余券商需要补充") def get_ipo_limit(self, stock_code): """ @@ -191,7 +179,7 @@ def get_ipo_limit(self, stock_code): :param stock_code: 申购代码 ID :return: """ - log.warning('目前仅在 佣金宝子类 中实现, 其余券商需要补充') + logger.warning("目前仅在 佣金宝子类 中实现, 其余券商需要补充") def do(self, params): """发起对 api 的请求并过滤返回结果 @@ -201,29 +189,30 @@ def do(self, params): response_data = self.request(request_params) try: format_json_data = self.format_response_data(response_data) - except: + # pylint: disable=broad-except + except Exception: # Caused by server force logged out return None return_data = self.fix_error_data(format_json_data) try: self.check_login_status(return_data) - except NotLoginError: + except exceptions.NotLoginError: self.autologin() return return_data - def create_basic_params(self): + def create_basic_params(self) -> dict: """生成基本的参数""" - pass + return {} - def request(self, params): + def request(self, params) -> dict: """请求并获取 JSON 数据 :param params: Get 参数""" - pass + return {} def format_response_data(self, data): """格式化返回的 json 数据 :param data: 请求返回的数据 """ - pass + return data def fix_error_data(self, data): """若是返回错误移除外层的列表 @@ -234,18 +223,20 @@ def format_response_data_type(self, response_data): """格式化返回的值为正确的类型 :param response_data: 返回的数据 """ - if type(response_data) is not list: + if isinstance(response_data, list) and not isinstance( + response_data, str + ): return response_data - int_match_str = '|'.join(self.config['response_format']['int']) - float_match_str = '|'.join(self.config['response_format']['float']) + int_match_str = "|".join(self.config["response_format"]["int"]) + float_match_str = "|".join(self.config["response_format"]["float"]) for item in response_data: for key in item: try: if re.search(int_match_str, key) is not None: - item[key] = helpers.str2num(item[key], 'int') + item[key] = str2num(item[key], "int") elif re.search(float_match_str, key) is not None: - item[key] = helpers.str2num(item[key], 'float') + item[key] = str2num(item[key], "float") except ValueError: continue return response_data diff --git a/easytrader/wk_clienttrader.py b/easytrader/wk_clienttrader.py new file mode 100644 index 00000000..8906d8ce --- /dev/null +++ b/easytrader/wk_clienttrader.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +import pywinauto + +from easytrader.ht_clienttrader import HTClientTrader + + +class WKClientTrader(HTClientTrader): + @property + def broker_type(self): + return "wk" + + def login(self, user, password, exe_path, comm_password=None, **kwargs): + """ + :param user: 用户名 + :param password: 密码 + :param exe_path: 客户端路径, 类似 + :param comm_password: + :param kwargs: + :return: + """ + self._editor_need_type_keys = False + if comm_password is None: + raise ValueError("五矿必须设置通讯密码") + + try: + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + + # wait login window ready + while True: + try: + self._app.top_window().Edit1.wait("ready") + break + except RuntimeError: + pass + + self._app.top_window().Edit1.set_focus() + self._app.top_window().Edit1.set_edit_text(user) + self._app.top_window().Edit2.set_edit_text(password) + + self._app.top_window().Edit3.set_edit_text(comm_password) + + self._app.top_window().Button1.click() + + # detect login is success or not + self._app.top_window().wait_not("exists", 100) + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + self._close_prompt_windows() + self._main = self._app.window(title="网上股票交易系统5.0") \ No newline at end of file diff --git a/easytrader/xczqtrader.py b/easytrader/xczqtrader.py deleted file mode 100644 index 6550b207..00000000 --- a/easytrader/xczqtrader.py +++ /dev/null @@ -1,302 +0,0 @@ -# coding: utf-8 -from __future__ import division - -import json -import os -import random -import tempfile -import urllib - -import demjson -import requests -import six - -from . import helpers -from .log import log -from .webtrader import NotLoginError -from .webtrader import WebTrader - -# handle error: SSLError: [SSL: SSL_NEGATIVE_LENGTH] dh key too small (_ssl.c:720) -requests.packages.urllib3.disable_warnings() -requests.packages.urllib3.util.ssl_.DEFAULT_CIPHERS += 'HIGH:!DH:!aNULL' - - -class XCZQTrader(WebTrader): - config_path = os.path.dirname(__file__) + '/config/xczq.json' - - def __init__(self): - super(XCZQTrader, self).__init__() - self.account_config = None - self.s = requests.session() - self.s.mount('https://', helpers.Ssl3HttpAdapter()) - - def login(self, throw=False): - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko' - } - self.s.headers.update(headers) - - self.s.get(self.config['login_page'], verify=False) - - - verify_code = self.handle_recognize_code() - if not verify_code: - return False - login_status, result = self.post_login_data(verify_code) - if login_status is False and throw: - raise NotLoginError(result) - return login_status - - def handle_recognize_code(self): - """获取并识别返回的验证码 - :return:失败返回 False 成功返回 验证码""" - # 获取验证码 - verify_code_response = self.s.get(self.config['verify_code_api'], params=dict(randomStamp=random.random())) - # 保存验证码 - image_path = os.path.join(tempfile.gettempdir(), 'vcode_%d' % os.getpid()) - with open(image_path, 'wb') as f: - f.write(verify_code_response.content) - - # 自动识别验证码, 如果自动识别验证码无法使用,可以切换成手动识别。 - verify_code = helpers.recognize_verify_code(image_path, 'xczq') - # 手动输入验证码 - # verify_code = helpers.input_verify_code_manual(image_path) - log.debug('verify code detect result: %s' % verify_code) - os.remove(image_path) - - ht_verify_code_length = 4 - if len(verify_code) != ht_verify_code_length: - return False - return verify_code - - def post_login_data(self, verify_code): - if six.PY2: - password = urllib.unquote(self.account_config['password']) - else: - password = urllib.parse.unquote(self.account_config['password']) - login_params = dict( - self.config['login'], - mac_addr=helpers.get_mac(), - account_content=self.account_config['account'], - password=password, - validateCode=verify_code - ) - login_response = self.s.post(self.config['login_api'], params=login_params) - log.debug('login response: %s' % login_response.text) - - if login_response.text.find('湘财证券股份有限公司') != -1: - # v = login_response.headers - # self.sessionid = v['Set-Cookie'][11:43] - return True, None - return False, login_response.text - - def cancel_entrust(self, entrust_no, stock_code): - """撤单 - :param entrust_no: 委托单号 - :param stock_code: 股票代码""" - cancel_params = dict( - self.config['cancel_entrust'], - entrust_no=entrust_no, - stock_code=stock_code - ) - return self.do(cancel_params) - - @property - def can_cancel(self): - return self.get_can_cancel() - - def get_can_cancel(self): - """获取可撤单的股票""" - """ - [{ - 'bs_name' : '买入', - 'business_amount': '0', - 'entrust_amount': '委托数量', - 'entrust_price': '价格', - 'entrust_prop_name': '买卖', - 'entrust_time': '时间' - 'position_str': '定位串', - 'status_name': '已报', - 'stock_code': '证券代码', - 'stock_name': '证券名称'}] - """ - - return self.do(self.config['can_cancel']) - - @property - def current_deal(self): - return self.get_current_deal() - - def get_current_deal(self): - """获取当日成交列表""" - """ - [{'business_amount': '成交数量', - 'business_price': '成交价格', - 'entrust_amount': '委托数量', - 'entrust_bs': '买卖方向', - 'stock_account': '证券帐号', - 'fund_account': '资金帐号', - 'position_str': '定位串', - 'business_status': '成交状态', - 'date': '发生日期', - 'business_type': '成交类别', - 'business_time': '成交时间', - 'stock_code': '证券代码', - 'stock_name': '证券名称'}] - """ - return self.do(self.config['current_deal']) - - # TODO: 实现买入卖出的各种委托类型 - def buy(self, stock_code, price, amount=0, volume=0, order_type='limit'): - """买入卖出股票 - :param stock_code: 股票代码 - :param price: 卖出价格 - :param amount: 卖出股数 - :param volume: 卖出总金额 由 volume / price 取整, 若指定 price 则此参数无效 - :param order_type: 委托类型,默认为limit - 限价委托, 也可以为makret - 最优五档即时成交剩余转限价。 - """ - if order_type == 'limit': - entrust_prop = 0 - params = dict( - self.config['buy'], - entrust_bs=1, # 买入1 卖出2 - entrust_amount=amount if amount else volume // price // 100 * 100 - ) - elif order_type == 'market': - entrust_prop = 'R' - params = dict( - self.config['buymarket'], - entrust_bs=1, # 买入1 卖出2 - entrust_amount=amount if amount else volume // price // 100 * 100 - ) - else: - log.debug('订单类型不支持: %s' % (order_type)) - return None - return self.__trade(stock_code, price, entrust_prop=entrust_prop, other=params) - - def sell(self, stock_code, price, amount=0, volume=0, entrust_prop=0): - """卖出股票 - :param stock_code: 股票代码 - :param price: 卖出价格 - :param amount: 卖出股数 - :param volume: 卖出总金额 由 volume / price 取整, 若指定 amount 则此参数无效 - :param entrust_prop: 委托类型,暂未实现,默认为限价委托 - """ - params = dict( - self.config['sell'], - entrust_bs=2, # 买入1 卖出2 - entrust_amount=amount if amount else volume // price - ) - return self.__trade(stock_code, price, entrust_prop=entrust_prop, other=params) - - def get_ipo_limit(self, stock_code): - """ - 查询新股申购额度申购上限 - :param stock_code: 申购代码!!! - :return: high_amount(最高申购股数) enable_amount(申购额度) last_price(发行价) - """ - need_info = self.__get_trade_need_info(stock_code) - params = dict( - self.config['ipo_enable_amount'], - timestamp=random.random(), - stock_account=need_info['stock_account'], # '沪深帐号' - exchange_type=need_info['exchange_type'], # '沪市1 深市2' - entrust_prop=0, - stock_code=stock_code - ) - data = self.do(params) - if 'error_no' in data.keys() and data['error_no'] != "0": - log.debug('查询错误: %s' % (data['error_info'])) - return None - return dict(high_amount=float(data['high_amount']), enable_amount=data['enable_amount'], - last_price=float(data['last_price'])) - - def __trade(self, stock_code, price, entrust_prop, other): - # 检查是否已经掉线 - if not self.heart_thread.is_alive(): - check_data = self.get_balance() - if type(check_data) == dict: - return check_data - need_info = self.__get_trade_need_info(stock_code) - return self.do(dict( - other, - stock_account=need_info['stock_account'], # '沪深帐号' - exchange_type=need_info['exchange_type'], # '沪市1 深市2' - entrust_prop=entrust_prop, # 委托方式 - stock_code='{:0>6}'.format(stock_code), # 股票代码, 右对齐宽为6左侧填充0 - elig_riskmatch_flag=1, # 用户风险等级 - entrust_price=price, - )) - - def __get_trade_need_info(self, stock_code): - """获取股票对应的证券市场和帐号""" - # 获取股票对应的证券市场 - sh_exchange_type = 1 - sz_exchange_type = 2 - exchange_type = sh_exchange_type if helpers.get_stock_type(stock_code) == 'sh' else sz_exchange_type - # 获取股票对应的证券帐号 - if not hasattr(self, 'exchange_stock_account'): - self.exchange_stock_account = dict() - if exchange_type not in self.exchange_stock_account: - stock_account_index = 0 - response_data = self.do(dict( - self.config['account4stock'], - exchange_type=exchange_type, - stock_code=stock_code - ))[stock_account_index] - self.exchange_stock_account[exchange_type] = response_data['stock_account'] - return dict( - exchange_type=exchange_type, - stock_account=self.exchange_stock_account[exchange_type] - ) - - def create_basic_params(self): - basic_params = dict( - timestamp=random.random(), - ) - return basic_params - - def request(self, params): - r = self.s.get(self.trade_prefix, params=params) - return r.text - - def format_response_data(self, data): - # 获取 returnJSON - return_json = json.loads(data)['returnJson'] - raw_json_data = demjson.decode(return_json) - fun_data = raw_json_data['Func%s' % raw_json_data['function_id']] - header_index = 1 - remove_header_data = fun_data[header_index:] - return self.format_response_data_type(remove_header_data) - - def fix_error_data(self, data): - error_index = 0 - return data[error_index] if type(data) == list and data[error_index].get('error_no') is not None else data - - def check_login_status(self, return_data): - if hasattr(return_data, 'get') and return_data.get('error_no') == '-1': - raise NotLoginError - - def check_account_live(self, response): - if hasattr(response, 'get') and response.get('error_no') == '-1': - self.heart_active = False - - @property - def exchangebill(self): - start_date, end_date = helpers.get_30_date() - return self.get_exchangebill(start_date, end_date) - - def get_exchangebill(self, start_date, end_date): - """ - 查询指定日期内的交割单 - :param start_date: 20160211 - :param end_date: 20160211 - :return: - """ - params = self.config['exchangebill'].copy() - params.update({ - "start_date": start_date, - "end_date": end_date, - }) - return self.do(params) - diff --git a/easytrader/xq_follower.py b/easytrader/xq_follower.py index 5cdb47db..e084d0ca 100644 --- a/easytrader/xq_follower.py +++ b/easytrader/xq_follower.py @@ -1,5 +1,5 @@ -# coding:utf8 -from __future__ import unicode_literals, print_function, division +# -*- coding: utf-8 -*- +from __future__ import division, print_function, unicode_literals import json import re @@ -7,50 +7,90 @@ from numbers import Number from threading import Thread -from .follower import BaseFollower -from .log import log -from .webtrader import NotLoginError +from easytrader.follower import BaseFollower +from easytrader.log import logger +from easytrader.utils.misc import parse_cookies_str class XueQiuFollower(BaseFollower): - LOGIN_PAGE = 'https://www.xueqiu.com' - LOGIN_API = 'https://xueqiu.com/user/login' - TRANSACTION_API = 'https://xueqiu.com/cubes/rebalancing/history.json' - PORTFOLIO_URL = 'https://xueqiu.com/p/' - WEB_REFERER = 'https://www.xueqiu.com' + LOGIN_PAGE = "https://www.xueqiu.com" + LOGIN_API = "https://xueqiu.com/snowman/login" + TRANSACTION_API = "https://xueqiu.com/cubes/rebalancing/history.json" + PORTFOLIO_URL = "https://xueqiu.com/p/" + WEB_REFERER = "https://www.xueqiu.com" def __init__(self): - super(XueQiuFollower, self).__init__() - - def check_login_success(self, login_status): - if 'error_description' in login_status: - raise NotLoginError(login_status['error_description']) - - def create_login_params(self, user, password, **kwargs): - params = { - 'username': user, - 'areacode': '86', - 'telephone': kwargs.get('account', ''), - 'remember_me': '0', - 'password': password - } - return params + super().__init__() + self._adjust_sell = None + self._users = None - def follow(self, users, strategies, total_assets=10000, initial_assets=None, track_interval=10, - trade_cmd_expire_seconds=120, cmd_cache=True): - """跟踪joinquant对应的模拟交易,支持多用户多策略 - :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 + def login(self, user=None, password=None, **kwargs): + """ + 雪球登陆, 需要设置 cookies + :param cookies: 雪球登陆需要设置 cookies, 具体见 + https://smalltool.github.io/2016/08/02/cookie/ + :return: + """ + cookies = kwargs.get("cookies") + if cookies is None: + raise TypeError( + "雪球登陆需要设置 cookies, 具体见" "https://smalltool.github.io/2016/08/02/cookie/" + ) + headers = self._generate_headers() + self.s.headers.update(headers) + + self.s.get(self.LOGIN_PAGE) + + cookie_dict = parse_cookies_str(cookies) + self.s.cookies.update(cookie_dict) + + logger.info("登录成功") + + def follow( # type: ignore + self, + users, + strategies, + total_assets=10000, + initial_assets=None, + adjust_sell=False, + track_interval=10, + trade_cmd_expire_seconds=120, + cmd_cache=True, + slippage: float = 0.0, + ): + """跟踪 joinquant 对应的模拟交易,支持多用户多策略 + :param users: 支持 easytrader 的用户对象,支持使用 [] 指定多个用户 :param strategies: 雪球组合名, 类似 ZH123450 - :param total_assets: 雪球组合对应的总资产, 格式 [ 组合1对应资金, 组合2对应资金 ] - 若 strategies=['ZH000001', 'ZH000002'] 设置 total_assets=[10000, 10000], 则表明每个组合对应的资产为 1w 元, - 假设组合 ZH000001 加仓 价格为 p 股票 A 10%, 则对应的交易指令为 买入 股票 A 价格 P 股数 1w * 10% / p 并按 100 取整 - :param initial_assets:雪球组合对应的初始资产, 格式 [ 组合1对应资金, 组合2对应资金 ] + :param total_assets: 雪球组合对应的总资产, 格式 [组合1对应资金, 组合2对应资金] + 若 strategies=['ZH000001', 'ZH000002'], + 设置 total_assets=[10000, 10000], 则表明每个组合对应的资产为 1w 元 + 假设组合 ZH000001 加仓 价格为 p 股票 A 10%, + 则对应的交易指令为 买入 股票 A 价格 P 股数 1w * 10% / p 并按 100 取整 + :param adjust_sell: 是否根据用户的实际持仓数调整卖出股票数量, + 当卖出股票数大于实际持仓数时,调整为实际持仓数。目前仅在银河客户端测试通过。 + 当 users 为多个时,根据第一个 user 的持仓数决定 + :type adjust_sell: bool + :param initial_assets: 雪球组合对应的初始资产, + 格式 [ 组合1对应资金, 组合2对应资金 ] 总资产由 初始资产 × 组合净值 算得, total_assets 会覆盖此参数 :param track_interval: 轮训模拟交易时间,单位为秒 :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 + :param slippage: 滑点,0.0 表示无滑点, 0.05 表示滑点为 5% """ - users = self.warp_list(users) + super().follow( + users=users, + strategies=strategies, + track_interval=track_interval, + trade_cmd_expire_seconds=trade_cmd_expire_seconds, + cmd_cache=cmd_cache, + slippage=slippage, + ) + + self._adjust_sell = adjust_sell + + self._users = self.warp_list(users) + strategies = self.warp_list(strategies) total_assets = self.warp_list(total_assets) initial_assets = self.warp_list(initial_assets) @@ -58,21 +98,27 @@ def follow(self, users, strategies, total_assets=10000, initial_assets=None, tra if cmd_cache: self.load_expired_cmd_cache() - self.start_trader_thread(users, trade_cmd_expire_seconds) + self.start_trader_thread(self._users, trade_cmd_expire_seconds) - for strategy_url, strategy_total_assets, strategy_initial_assets in zip(strategies, total_assets, - initial_assets): - assets = self.calculate_assets(strategy_url, strategy_total_assets, strategy_initial_assets) + for strategy_url, strategy_total_assets, strategy_initial_assets in zip( + strategies, total_assets, initial_assets + ): + assets = self.calculate_assets( + strategy_url, strategy_total_assets, strategy_initial_assets + ) try: strategy_id = self.extract_strategy_id(strategy_url) strategy_name = self.extract_strategy_name(strategy_url) except: - log.error('抽取交易id和策略名失败, 无效的模拟交易url: {}'.format(strategy_url)) + logger.error("抽取交易id和策略名失败, 无效模拟交易url: %s", strategy_url) raise - strategy_worker = Thread(target=self.track_strategy_worker, args=[strategy_id, strategy_name], - kwargs={'interval': track_interval, 'assets': assets}) + strategy_worker = Thread( + target=self.track_strategy_worker, + args=[strategy_id, strategy_name], + kwargs={"interval": track_interval, "assets": assets}, + ) strategy_worker.start() - log.info('开始跟踪策略: {}'.format(strategy_name)) + logger.info("开始跟踪策略: %s", strategy_name) def calculate_assets(self, strategy_url, total_assets=None, initial_assets=None): # 都设置时优先选择 total_assets @@ -80,52 +126,105 @@ def calculate_assets(self, strategy_url, total_assets=None, initial_assets=None) net_value = self._get_portfolio_net_value(strategy_url) total_assets = initial_assets * net_value if not isinstance(total_assets, Number): - raise TypeError('input assets type must be number(int, float)') + raise TypeError("input assets type must be number(int, float)") if total_assets < 1e3: - raise ValueError('雪球总资产不能小于1000元,当前预设值 {}'.format(total_assets)) + raise ValueError("雪球总资产不能小于1000元,当前预设值 {}".format(total_assets)) return total_assets @staticmethod def extract_strategy_id(strategy_url): - if len(strategy_url) != 8: - raise ValueError('雪球组合名格式不对, 类似 ZH123456, 设置值: {}'.format(strategy_url)) return strategy_url def extract_strategy_name(self, strategy_url): - url = 'https://xueqiu.com/cubes/nav_daily/all.json?cube_symbol={}'.format(strategy_url) + base_url = "https://xueqiu.com/cubes/nav_daily/all.json?cube_symbol={}" + url = base_url.format(strategy_url) rep = self.s.get(url) info_index = 0 - return rep.json()[info_index]['name'] + return rep.json()[info_index]["name"] def extract_transactions(self, history): - print(history) - if history['count'] <= 0: + if history["count"] <= 0: return [] rebalancing_index = 0 - transactions = history['list'][rebalancing_index]['rebalancing_histories'] + raw_transactions = history["list"][rebalancing_index]["rebalancing_histories"] + transactions = [] + for transaction in raw_transactions: + if transaction["price"] is None: + logger.info("该笔交易无法获取价格,疑似未成交,跳过。交易详情: %s", transaction) + continue + transactions.append(transaction) + return transactions def create_query_transaction_params(self, strategy): - params = { - 'cube_symbol': strategy, - 'page': 1, - 'count': 1 - } + params = {"cube_symbol": strategy, "page": 1, "count": 1} return params + # noinspection PyMethodOverriding + def none_to_zero(self, data): + if data is None: + return 0 + return data + # noinspection PyMethodOverriding def project_transactions(self, transactions, assets): - for t in transactions: - weight_diff = t['weight'] - t['prev_weight'] + for transaction in transactions: + weight_diff = self.none_to_zero(transaction["weight"]) - self.none_to_zero( + transaction["prev_weight"] + ) + + initial_amount = abs(weight_diff) / 100 * assets / transaction["price"] + + transaction["datetime"] = datetime.fromtimestamp( + transaction["created_at"] // 1000 + ) - initial_amount = abs(weight_diff) / 100 * assets / t['price'] - t['amount'] = int(round(initial_amount, -2)) + transaction["stock_code"] = transaction["stock_symbol"].lower() - t['datetime'] = datetime.fromtimestamp(t['created_at'] // 1000) + transaction["action"] = "buy" if weight_diff > 0 else "sell" - t['stock_code'] = t['stock_symbol'].lower() + transaction["amount"] = int(round(initial_amount, -2)) + if transaction["action"] == "sell" and self._adjust_sell: + transaction["amount"] = self._adjust_sell_amount( + transaction["stock_code"], transaction["amount"] + ) - t['action'] = 'buy' if weight_diff > 0 else 'sell' + def _adjust_sell_amount(self, stock_code, amount): + """ + 根据实际持仓值计算雪球卖出股数 + 因为雪球的交易指令是基于持仓百分比,在取近似值的情况下可能出现不精确的问题。 + 导致如下情况的产生,计算出的指令为买入 1049 股,取近似值买入 1000 股。 + 而卖出的指令计算出为卖出 1051 股,取近似值卖出 1100 股,超过 1000 股的买入量, + 导致卖出失败 + :param stock_code: 证券代码 + :type stock_code: str + :param amount: 卖出股份数 + :type amount: int + :return: 考虑实际持仓之后的卖出股份数 + :rtype: int + """ + stock_code = stock_code[-6:] + user = self._users[0] + position = user.position + try: + stock = next(s for s in position if s["证券代码"] == stock_code) + except StopIteration: + logger.info("根据持仓调整 %s 卖出额,发现未持有股票 %s, 不做任何调整", stock_code, stock_code) + return amount + + available_amount = stock["可用余额"] + if available_amount >= amount: + return amount + + adjust_amount = available_amount // 100 * 100 + logger.info( + "股票 %s 实际可用余额 %s, 指令卖出股数为 %s, 调整为 %s", + stock_code, + available_amount, + amount, + adjust_amount, + ) + return adjust_amount def _get_portfolio_info(self, portfolio_code): """ @@ -133,13 +232,13 @@ def _get_portfolio_info(self, portfolio_code): """ url = self.PORTFOLIO_URL + portfolio_code portfolio_page = self.s.get(url) - match_info = re.search(r'(?<=SNB.cubeInfo = ).*(?=;\n)', portfolio_page.text) + match_info = re.search(r"(?<=SNB.cubeInfo = ).*(?=;\n)", portfolio_page.text) if match_info is None: - raise Exception('cant get portfolio info, portfolio url : {}'.format(url)) + raise Exception("cant get portfolio info, portfolio url : {}".format(url)) try: portfolio_info = json.loads(match_info.group()) except Exception as e: - raise Exception('get portfolio info error: {}'.format(e)) + raise Exception("get portfolio info error: {}".format(e)) return portfolio_info def _get_portfolio_net_value(self, portfolio_code): @@ -147,4 +246,4 @@ def _get_portfolio_net_value(self, portfolio_code): 获取组合信息 """ portfolio_info = self._get_portfolio_info(portfolio_code) - return portfolio_info['net_value'] + return portfolio_info["net_value"] diff --git a/easytrader/xqtrader.py b/easytrader/xqtrader.py index f2798a1e..679f5c80 100644 --- a/easytrader/xqtrader.py +++ b/easytrader/xqtrader.py @@ -1,105 +1,93 @@ # -*- coding: utf-8 -*- import json +import numbers import os import re import time -from numbers import Number import requests -from .log import log -from .webtrader import NotLoginError, TradeError -from .webtrader import WebTrader - - -class XueQiuTrader(WebTrader): - config_path = os.path.dirname(__file__) + '/config/xq.json' +from easytrader import exceptions, webtrader +from easytrader.log import logger +from easytrader.utils.misc import parse_cookies_str + + +class XueQiuTrader(webtrader.WebTrader): + config_path = os.path.dirname(__file__) + "/config/xq.json" + + _HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/64.0.3282.167 Safari/537.36", + "Host": "xueqiu.com", + "Pragma": "no-cache", + "Connection": "keep-alive", + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Cache-Control": "no-cache", + "Referer": "https://xueqiu.com/P/ZH004612", + "X-Requested-With": "XMLHttpRequest", + } def __init__(self, **kwargs): super(XueQiuTrader, self).__init__() # 资金换算倍数 - self.multiple = kwargs['initial_assets'] if 'initial_assets' in kwargs else 1000000 - if not isinstance(self.multiple, Number): - raise TypeError('initial assets must be number(int, float)') + self.multiple = ( + kwargs["initial_assets"] if "initial_assets" in kwargs else 1000000 + ) + if not isinstance(self.multiple, numbers.Number): + raise TypeError("initial assets must be number(int, float)") if self.multiple < 1e3: - raise ValueError('雪球初始资产不能小于1000元,当前预设值 {}'.format(self.multiple)) - - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0', - 'Host': 'xueqiu.com', - 'Pragma': 'no-cache', - 'Connection': 'keep-alive', - 'Accept': '*/*', - 'Accept-Encoding': 'gzip,deflate,sdch', - 'Cache-Control': 'no-cache', - 'Referer': 'http://xueqiu.com/P/ZH003694', - 'X-Requested-With': 'XMLHttpRequest', - 'Accept-Language': 'zh-CN,zh;q=0.8' - } - self.session = requests.Session() - self.session.headers.update(headers) + raise ValueError("雪球初始资产不能小于1000元,当前预设值 {}".format(self.multiple)) + + self.s = requests.Session() + self.s.verify = False + self.s.headers.update(self._HEADERS) self.account_config = None def autologin(self, **kwargs): """ - 重写自动登录方法 - 避免重试导致的帐号封停 + 使用 cookies 之后不需要自动登陆 :return: """ - self.login() + self._set_cookies(self.account_config["cookies"]) - def login(self, throw=False): + def _set_cookies(self, cookies): + """设置雪球 cookies,代码来自于 + https://github.com/shidenggui/easytrader/issues/269 + :param cookies: 雪球 cookies + :type cookies: str """ - 登录 - :param throw: - :return: - """ - login_status, result = self.post_login_data() - if login_status is False and throw: - raise NotLoginError(result) - log.debug('login status: %s' % result) - return login_status + cookie_dict = parse_cookies_str(cookies) + self.s.cookies.update(cookie_dict) - def _prepare_account(self, user='', password='', **kwargs): + def _prepare_account(self, user="", password="", **kwargs): """ 转换参数到登录所需的字典格式 - :param user: 雪球邮箱(邮箱手机二选一) - :param password: 雪球密码 - :param account: 雪球手机号(邮箱手机二选一) + :param cookies: 雪球登陆需要设置 cookies, 具体见 + https://smalltool.github.io/2016/08/02/cookie/ :param portfolio_code: 组合代码 :param portfolio_market: 交易市场, 可选['cn', 'us', 'hk'] 默认 'cn' :return: """ - if 'portfolio_code' not in kwargs: - raise TypeError('雪球登录需要设置 portfolio_code(组合代码) 参数') - if 'portfolio_market' not in kwargs: - kwargs['portfolio_market'] = 'cn' - if 'account' not in kwargs: - kwargs['account'] = '' + if "portfolio_code" not in kwargs: + raise TypeError("雪球登录需要设置 portfolio_code(组合代码) 参数") + if "portfolio_market" not in kwargs: + kwargs["portfolio_market"] = "cn" + if "cookies" not in kwargs: + raise TypeError( + "雪球登陆需要设置 cookies, 具体见" + "https://smalltool.github.io/2016/08/02/cookie/" + ) self.account_config = { - 'username': user, - 'account': kwargs['account'], - 'password': password, - 'portfolio_code': kwargs['portfolio_code'], - 'portfolio_market': kwargs['portfolio_market'] - } - - def post_login_data(self): - login_post_data = { - 'username': self.account_config.get('username', ''), - 'areacode': '86', - 'telephone': self.account_config['account'], - 'remember_me': '0', - 'password': self.account_config['password'] + "cookies": kwargs["cookies"], + "portfolio_code": kwargs["portfolio_code"], + "portfolio_market": kwargs["portfolio_market"], } - login_response = self.session.post(self.config['login_api'], data=login_post_data) - login_status = login_response.json() - if 'error_description' in login_status: - return False, login_status['error_description'] - return True, "SUCCESS" - def __virtual_to_balance(self, virtual): + def _virtual_to_balance(self, virtual): """ 虚拟净值转化为资金 :param virtual: 雪球组合净值 @@ -107,47 +95,50 @@ def __virtual_to_balance(self, virtual): """ return virtual * self.multiple - def __get_html(self, url): - return self.session.get(url).text + def _get_html(self, url): + return self.s.get(url).text - def __search_stock_info(self, code): + def _search_stock_info(self, code): """ 通过雪球的接口获取股票详细信息 :param code: 股票代码 000001 :return: 查询到的股票 {u'stock_id': 1000279, u'code': u'SH600325', - u'name': u'华发股份', u'ind_color': u'#d9633b', u'chg': -1.09, - u'ind_id': 100014, u'percent': -9.31, u'current': 10.62, u'hasexist': None, - u'flag': 1, u'ind_name': u'房地产', u'type': None, u'enName': None} + u'name': u'华发股份', u'ind_color': u'#d9633b', u'chg': -1.09, + u'ind_id': 100014, u'percent': -9.31, u'current': 10.62, + u'hasexist': None, u'flag': 1, u'ind_name': u'房地产', u'type': None, + u'enName': None} ** flag : 未上市(0)、正常(1)、停牌(2)、涨跌停(3)、退市(4) """ data = { - 'code': str(code), - 'size': '300', - 'key': '47bce5c74f', - 'market': self.account_config['portfolio_market'], + "code": str(code), + "size": "300", + "key": "47bce5c74f", + "market": self.account_config["portfolio_market"], } - r = self.session.get(self.config['search_stock_url'], params=data) + r = self.s.get(self.config["search_stock_url"], params=data) stocks = json.loads(r.text) - stocks = stocks['stocks'] + stocks = stocks["stocks"] stock = None if len(stocks) > 0: stock = stocks[0] return stock - def __get_portfolio_info(self, portfolio_code): + def _get_portfolio_info(self, portfolio_code): """ 获取组合信息 :return: 字典 """ - url = self.config['portfolio_url'] + portfolio_code - html = self.__get_html(url) - match_info = re.search(r'(?<=SNB.cubeInfo = ).*(?=;\n)', html) + url = self.config["portfolio_url"] + portfolio_code + html = self._get_html(url) + match_info = re.search(r"(?<=SNB.cubeInfo = ).*(?=;\n)", html) if match_info is None: - raise Exception('cant get portfolio info, portfolio html : {}'.format(html)) + raise Exception( + "cant get portfolio info, portfolio html : {}".format(html) + ) try: portfolio_info = json.loads(match_info.group()) except Exception as e: - raise Exception('get portfolio info error: {}'.format(e)) + raise Exception("get portfolio info error: {}".format(e)) return portfolio_info def get_balance(self): @@ -155,36 +146,43 @@ def get_balance(self): 获取账户资金状况 :return: """ - portfolio_code = self.account_config.get('portfolio_code', 'ch') # 组合代码 - portfolio_info = self.__get_portfolio_info(portfolio_code) # 组合信息 - asset_balance = self.__virtual_to_balance(float(portfolio_info['net_value'])) # 总资产 - position = portfolio_info['view_rebalancing'] # 仓位结构 - cash = asset_balance * float(position['cash']) / 100 + portfolio_code = self.account_config.get("portfolio_code", "ch") + portfolio_info = self._get_portfolio_info(portfolio_code) + asset_balance = self._virtual_to_balance( + float(portfolio_info["net_value"]) + ) # 总资产 + position = portfolio_info["view_rebalancing"] # 仓位结构 + cash = asset_balance * float(position["cash"]) / 100 market = asset_balance - cash - return [{'asset_balance': asset_balance, - 'current_balance': cash, - 'enable_balance': cash, - 'market_value': market, - 'money_type': u'人民币', - 'pre_interest': 0.25}] - - def __get_position(self): + return [ + { + "asset_balance": asset_balance, + "current_balance": cash, + "enable_balance": cash, + "market_value": market, + "money_type": u"人民币", + "pre_interest": 0.25, + } + ] + + def _get_position(self): """ 获取雪球持仓 :return: """ - portfolio_code = self.account_config['portfolio_code'] # 组合代码 - portfolio_info = self.__get_portfolio_info(portfolio_code) # 组合信息 - position = portfolio_info['view_rebalancing'] # 仓位结构 - stocks = position['holdings'] # 持仓股票 + portfolio_code = self.account_config["portfolio_code"] + portfolio_info = self._get_portfolio_info(portfolio_code) + position = portfolio_info["view_rebalancing"] # 仓位结构 + stocks = position["holdings"] # 持仓股票 return stocks @staticmethod - def __time_strftime(time_stamp): + def _time_strftime(time_stamp): try: local_time = time.localtime(time_stamp / 1000) return time.strftime("%Y-%m-%d %H:%M:%S", local_time) - except: + # pylint: disable=broad-except + except Exception: return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) def get_position(self): @@ -192,25 +190,28 @@ def get_position(self): 获取持仓 :return: """ - xq_positions = self.__get_position() + xq_positions = self._get_position() balance = self.get_balance()[0] position_list = [] for pos in xq_positions: - volume = pos['weight'] * balance['asset_balance'] / 100 - position_list.append({'cost_price': volume / 100, - 'current_amount': 100, - 'enable_amount': 100, - 'income_balance': 0, - 'keep_cost_price': volume / 100, - 'last_price': volume / 100, - 'market_value': volume, - 'position_str': 'random', - 'stock_code': pos['stock_symbol'], - 'stock_name': pos['stock_name'] - }) + volume = pos["weight"] * balance["asset_balance"] / 100 + position_list.append( + { + "cost_price": volume / 100, + "current_amount": 100, + "enable_amount": 100, + "income_balance": 0, + "keep_cost_price": volume / 100, + "last_price": volume / 100, + "market_value": volume, + "position_str": "random", + "stock_code": pos["stock_symbol"], + "stock_name": pos["stock_name"], + } + ) return position_list - def __get_xq_history(self): + def _get_xq_history(self): """ 获取雪球调仓历史 :param instance: @@ -218,48 +219,56 @@ def __get_xq_history(self): :return: """ data = { - "cube_symbol": str(self.account_config['portfolio_code']), - 'count': 5, - 'page': 1 + "cube_symbol": str(self.account_config["portfolio_code"]), + "count": 20, + "page": 1, } - r = self.session.get(self.config['history_url'], params=data) - r = json.loads(r.text) - return r['list'] + resp = self.s.get(self.config["history_url"], params=data) + res = json.loads(resp.text) + return res["list"] @property def history(self): - return self.__get_xq_history() + return self._get_xq_history() def get_entrust(self): """ - 获取委托单(目前返回5次调仓的结果) + 获取委托单(目前返回20次调仓的结果) 操作数量都按1手模拟换算的 :return: """ - xq_entrust_list = self.__get_xq_history() + xq_entrust_list = self._get_xq_history() entrust_list = [] + replace_none = lambda s: s or 0 for xq_entrusts in xq_entrust_list: - status = xq_entrusts['status'] # 调仓状态 - if status == 'pending': + status = xq_entrusts["status"] # 调仓状态 + if status == "pending": status = "已报" - elif status == 'canceled': + elif status in ["canceled", "failed"]: status = "废单" else: status = "已成" - for entrust in xq_entrusts['rebalancing_histories']: - volume = abs(entrust['target_weight'] - entrust['weight']) * self.multiple / 10000 - entrust_list.append({ - 'entrust_no': entrust['id'], - 'entrust_bs': u"买入" if entrust['target_weight'] > entrust['weight'] else u"卖出", - 'report_time': self.__time_strftime(entrust['updated_at']), - 'entrust_status': status, - 'stock_code': entrust['stock_symbol'], - 'stock_name': entrust['stock_name'], - 'business_amount': 100, - 'business_price': volume, - 'entrust_amount': 100, - 'entrust_price': volume, - }) + for entrust in xq_entrusts["rebalancing_histories"]: + price = entrust["price"] + entrust_list.append( + { + "entrust_no": entrust["id"], + "entrust_bs": u"买入" + if entrust["target_weight"] + > replace_none(entrust["prev_weight"]) + else u"卖出", + "report_time": self._time_strftime( + entrust["updated_at"] + ), + "entrust_status": status, + "stock_code": entrust["stock_symbol"], + "stock_name": entrust["stock_name"], + "business_amount": 100, + "business_price": price, + "entrust_amount": 100, + "entrust_price": price, + } + ) return entrust_list def cancel_entrust(self, entrust_no): @@ -268,23 +277,40 @@ def cancel_entrust(self, entrust_no): :param entrust_no: :return: """ - xq_entrust_list = self.__get_xq_history() + xq_entrust_list = self._get_xq_history() is_have = False for xq_entrusts in xq_entrust_list: - status = xq_entrusts['status'] # 调仓状态 - for entrust in xq_entrusts['rebalancing_histories']: - if entrust['id'] == entrust_no and status == 'pending': + status = xq_entrusts["status"] # 调仓状态 + for entrust in xq_entrusts["rebalancing_histories"]: + if entrust["id"] == entrust_no and status == "pending": is_have = True - bs = 'buy' if entrust['target_weight'] < entrust['weight'] else 'sell' - if entrust['target_weight'] == 0 and entrust['weight'] == 0: - raise TradeError(u"移除的股票操作无法撤销,建议重新买入") + buy_or_sell = ( + "buy" + if entrust["target_weight"] < entrust["weight"] + else "sell" + ) + if ( + entrust["target_weight"] == 0 + and entrust["weight"] == 0 + ): + raise exceptions.TradeError(u"移除的股票操作无法撤销,建议重新买入") balance = self.get_balance()[0] - volume = abs(entrust['target_weight'] - entrust['weight']) * balance['asset_balance'] / 100 - r = self.__trade(stock_code=entrust['stock_symbol'], volume=volume, entrust_bs=bs) - if len(r) > 0 and 'error_info' in r[0]: - raise TradeError(u"撤销失败!%s" % ('error_info' in r[0])) + volume = ( + abs(entrust["target_weight"] - entrust["weight"]) + * balance["asset_balance"] + / 100 + ) + r = self._trade( + security=entrust["stock_symbol"], + volume=volume, + entrust_bs=buy_or_sell, + ) + if len(r) > 0 and "error_info" in r[0]: + raise exceptions.TradeError( + u"撤销失败!%s" % ("error_info" in r[0]) + ) if not is_have: - raise TradeError(u"撤销对象已失效") + raise exceptions.TradeError(u"撤销对象已失效") return True def adjust_weight(self, stock_code, weight): @@ -294,200 +320,230 @@ def adjust_weight(self, stock_code, weight): :param weight: float 调整之后的持仓百分比, 0 - 100 之间的浮点数 """ - stock = self.__search_stock_info(stock_code) + stock = self._search_stock_info(stock_code) if stock is None: - raise TradeError(u"没有查询要操作的股票信息") - if stock['flag'] != 1: - raise TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") + raise exceptions.TradeError(u"没有查询要操作的股票信息") + if stock["flag"] != 1: + raise exceptions.TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") # 仓位比例向下取两位数 weight = round(weight, 2) # 获取原有仓位信息 - position_list = self.__get_position() + position_list = self._get_position() # 调整后的持仓 for position in position_list: - if position['stock_id'] == stock['stock_id']: - position['proactive'] = True - position['weight'] = weight - - if weight != 0 and stock['stock_id'] not in [k['stock_id'] for k in position_list]: - position_list.append({ - "code": stock['code'], - "name": stock['name'], - "enName": stock['enName'], - "hasexist": stock['hasexist'], - "flag": stock['flag'], - "type": stock['type'], - "current": stock['current'], - "chg": stock['chg'], - "percent": str(stock['percent']), - "stock_id": stock['stock_id'], - "ind_id": stock['ind_id'], - "ind_name": stock['ind_name'], - "ind_color": stock['ind_color'], - "textname": stock['name'], - "segment_name": stock['ind_name'], - "weight": weight, - "url": "/S/" + stock['code'], - "proactive": True, - "price": str(stock['current']) - }) - - remain_weight = 100 - sum(i.get('weight') for i in position_list) + if position["stock_id"] == stock["stock_id"]: + position["proactive"] = True + position["weight"] = weight + + if weight != 0 and stock["stock_id"] not in [ + k["stock_id"] for k in position_list + ]: + position_list.append( + { + "code": stock["code"], + "name": stock["name"], + "enName": stock["enName"], + "hasexist": stock["hasexist"], + "flag": stock["flag"], + "type": stock["type"], + "current": stock["current"], + "chg": stock["chg"], + "percent": str(stock["percent"]), + "stock_id": stock["stock_id"], + "ind_id": stock["ind_id"], + "ind_name": stock["ind_name"], + "ind_color": stock["ind_color"], + "textname": stock["name"], + "segment_name": stock["ind_name"], + "weight": weight, + "url": "/S/" + stock["code"], + "proactive": True, + "price": str(stock["current"]), + } + ) + + remain_weight = 100 - sum(i.get("weight") for i in position_list) cash = round(remain_weight, 2) - log.debug("调仓比例:%f, 剩余持仓 :%f" % (weight, remain_weight)) + logger.info("调仓比例:%f, 剩余持仓 :%f", weight, remain_weight) data = { "cash": cash, "holdings": str(json.dumps(position_list)), - "cube_symbol": str(self.account_config['portfolio_code']), - 'segment': 'true', - 'comment': "" + "cube_symbol": str(self.account_config["portfolio_code"]), + "segment": "true", + "comment": "", } try: - rebalance_res = self.session.post(self.config['rebalance_url'], data=data) + resp = self.s.post(self.config["rebalance_url"], data=data) + # pylint: disable=broad-except except Exception as e: - log.warn('调仓失败: %s ' % e) - return - else: - log.debug('调仓 %s: 持仓比例%d' % (stock['name'], weight)) - rebalance_status = json.loads(rebalance_res.text) - if 'error_description' in rebalance_status.keys() and rebalance_res.status_code != 200: - log.error('调仓错误: %s' % (rebalance_status['error_description'])) - return [{'error_no': rebalance_status['error_code'], - 'error_info': rebalance_status['error_description']}] - else: - log.debug('调仓成功 %s: 持仓比例%d' % (stock['name'], weight)) - - def __trade(self, stock_code, price=0, amount=0, volume=0, entrust_bs='buy'): + logger.warning("调仓失败: %s ", e) + return None + logger.info("调仓 %s: 持仓比例%d", stock["name"], weight) + resp_json = json.loads(resp.text) + if "error_description" in resp_json and resp.status_code != 200: + logger.error("调仓错误: %s", resp_json["error_description"]) + return [ + { + "error_no": resp_json["error_code"], + "error_info": resp_json["error_description"], + } + ] + logger.info("调仓成功 %s: 持仓比例%d", stock["name"], weight) + return None + + def _trade(self, security, price=0, amount=0, volume=0, entrust_bs="buy"): """ 调仓 - :param stock_code: + :param security: :param price: :param amount: :param volume: :param entrust_bs: :return: """ - stock = self.__search_stock_info(stock_code) + stock = self._search_stock_info(security) balance = self.get_balance()[0] if stock is None: - raise TradeError(u"没有查询要操作的股票信息") + raise exceptions.TradeError(u"没有查询要操作的股票信息") if not volume: volume = int(float(price) * amount) # 可能要取整数 - if balance['current_balance'] < volume and entrust_bs == 'buy': - raise TradeError(u"没有足够的现金进行操作") - if stock['flag'] != 1: - raise TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") + if balance["current_balance"] < volume and entrust_bs == "buy": + raise exceptions.TradeError(u"没有足够的现金进行操作") + if stock["flag"] != 1: + raise exceptions.TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") if volume == 0: - raise TradeError(u"操作金额不能为零") + raise exceptions.TradeError(u"操作金额不能为零") # 计算调仓调仓份额 - weight = volume / balance['asset_balance'] * 100 + weight = volume / balance["asset_balance"] * 100 weight = round(weight, 2) # 获取原有仓位信息 - position_list = self.__get_position() + position_list = self._get_position() # 调整后的持仓 is_have = False for position in position_list: - if position['stock_id'] == stock['stock_id']: + if position["stock_id"] == stock["stock_id"]: is_have = True - position['proactive'] = True - old_weight = position['weight'] - if entrust_bs == 'buy': - position['weight'] = weight + old_weight + position["proactive"] = True + old_weight = position["weight"] + if entrust_bs == "buy": + position["weight"] = weight + old_weight else: if weight > old_weight: - raise TradeError(u"操作数量大于实际可卖出数量") + raise exceptions.TradeError(u"操作数量大于实际可卖出数量") else: - position['weight'] = old_weight - weight - position['weight'] = round(position['weight'], 2) + position["weight"] = old_weight - weight + position["weight"] = round(position["weight"], 2) if not is_have: - if entrust_bs == 'buy': - position_list.append({ - "code": stock['code'], - "name": stock['name'], - "enName": stock['enName'], - "hasexist": stock['hasexist'], - "flag": stock['flag'], - "type": stock['type'], - "current": stock['current'], - "chg": stock['chg'], - "percent": str(stock['percent']), - "stock_id": stock['stock_id'], - "ind_id": stock['ind_id'], - "ind_name": stock['ind_name'], - "ind_color": stock['ind_color'], - "textname": stock['name'], - "segment_name": stock['ind_name'], - "weight": round(weight, 2), - "url": "/S/" + stock['code'], - "proactive": True, - "price": str(stock['current']) - }) + if entrust_bs == "buy": + position_list.append( + { + "code": stock["code"], + "name": stock["name"], + "enName": stock["enName"], + "hasexist": stock["hasexist"], + "flag": stock["flag"], + "type": stock["type"], + "current": stock["current"], + "chg": stock["chg"], + "percent": str(stock["percent"]), + "stock_id": stock["stock_id"], + "ind_id": stock["ind_id"], + "ind_name": stock["ind_name"], + "ind_color": stock["ind_color"], + "textname": stock["name"], + "segment_name": stock["ind_name"], + "weight": round(weight, 2), + "url": "/S/" + stock["code"], + "proactive": True, + "price": str(stock["current"]), + } + ) else: - raise TradeError(u"没有持有要卖出的股票") - - if entrust_bs == 'buy': - cash = (balance['current_balance'] - volume) / balance['asset_balance'] * 100 + raise exceptions.TradeError(u"没有持有要卖出的股票") + + if entrust_bs == "buy": + cash = ( + (balance["current_balance"] - volume) + / balance["asset_balance"] + * 100 + ) else: - cash = (balance['current_balance'] + volume) / balance['asset_balance'] * 100 + cash = ( + (balance["current_balance"] + volume) + / balance["asset_balance"] + * 100 + ) cash = round(cash, 2) - log.debug("weight:%f, cash:%f" % (weight, cash)) + logger.info("weight:%f, cash:%f", weight, cash) data = { "cash": cash, "holdings": str(json.dumps(position_list)), - "cube_symbol": str(self.account_config['portfolio_code']), - 'segment': 1, - 'comment': "" + "cube_symbol": str(self.account_config["portfolio_code"]), + "segment": 1, + "comment": "", } try: - rebalance_res = self.session.post(self.config['rebalance_url'], data=data) + resp = self.s.post(self.config["rebalance_url"], data=data) + # pylint: disable=broad-except except Exception as e: - log.warn('调仓失败: %s ' % e) - return + logger.warning("调仓失败: %s ", e) + return None else: - log.debug('调仓 %s%s: %d' % (entrust_bs, stock['name'], rebalance_res.status_code)) - rebalance_status = json.loads(rebalance_res.text) - if 'error_description' in rebalance_status and rebalance_res.status_code != 200: - log.error('调仓错误: %s' % (rebalance_status['error_description'])) - return [{'error_no': rebalance_status['error_code'], - 'error_info': rebalance_status['error_description']}] - else: - return [{'entrust_no': rebalance_status['id'], - 'init_date': self.__time_strftime(rebalance_status['created_at']), - 'batch_no': '委托批号', - 'report_no': '申报号', - 'seat_no': '席位编号', - 'entrust_time': self.__time_strftime(rebalance_status['updated_at']), - 'entrust_price': price, - 'entrust_amount': amount, - 'stock_code': stock_code, - 'entrust_bs': '买入', - 'entrust_type': '雪球虚拟委托', - 'entrust_status': '-'}] - - def buy(self, stock_code, price=0, amount=0, volume=0, entrust_prop=0): + logger.info( + "调仓 %s%s: %d", entrust_bs, stock["name"], resp.status_code + ) + resp_json = json.loads(resp.text) + if "error_description" in resp_json and resp.status_code != 200: + logger.error("调仓错误: %s", resp_json["error_description"]) + return [ + { + "error_no": resp_json["error_code"], + "error_info": resp_json["error_description"], + } + ] + return [ + { + "entrust_no": resp_json["id"], + "init_date": self._time_strftime(resp_json["created_at"]), + "batch_no": "委托批号", + "report_no": "申报号", + "seat_no": "席位编号", + "entrust_time": self._time_strftime( + resp_json["updated_at"] + ), + "entrust_price": price, + "entrust_amount": amount, + "stock_code": security, + "entrust_bs": "买入", + "entrust_type": "雪球虚拟委托", + "entrust_status": "-", + } + ] + + def buy(self, security, price=0, amount=0, volume=0, entrust_prop=0): """买入卖出股票 - :param stock_code: 股票代码 + :param security: 股票代码 :param price: 买入价格 :param amount: 买入股数 :param volume: 买入总金额 由 volume / price 取整, 若指定 price 则此参数无效 :param entrust_prop: """ - return self.__trade(stock_code, price, amount, volume, 'buy') + return self._trade(security, price, amount, volume, "buy") - def sell(self, stock_code, price=0, amount=0, volume=0, entrust_prop=0): + def sell(self, security, price=0, amount=0, volume=0, entrust_prop=0): """卖出股票 - :param stock_code: 股票代码 + :param security: 股票代码 :param price: 卖出价格 :param amount: 卖出股数 :param volume: 卖出总金额 由 volume / price 取整, 若指定 price 则此参数无效 :param entrust_prop: """ - return self.__trade(stock_code, price, amount, volume, 'sell') + return self._trade(security, price, amount, volume, "sell") diff --git a/easytrader/yh_clienttrader.py b/easytrader/yh_clienttrader.py index 077fde16..29343134 100644 --- a/easytrader/yh_clienttrader.py +++ b/easytrader/yh_clienttrader.py @@ -1,319 +1,129 @@ -# coding:utf8 -from __future__ import division - -import os -import subprocess +# -*- coding: utf-8 -*- +import re import tempfile -import time -import traceback -import win32api -import win32gui -from io import StringIO - -import pandas as pd -import pyperclip -import win32com.client -import win32con -from PIL import ImageGrab - -from . import helpers -from .log import log - - -class YHClientTrader(): - def __init__(self): - self.Title = '网上股票交易系统5.0' - - def prepare(self, config_path=None, user=None, password=None, exe_path='C:\中国银河证券双子星3.2\Binarystar.exe'): - """ - 登陆银河客户端 - :param config_path: 银河登陆配置文件,跟参数登陆方式二选一 - :param user: 银河账号 - :param password: 银河明文密码 - :param exe_path: 银河客户端路径 - :return: - """ - if config_path is not None: - account = helpers.file2dict(config_path) - user = account['user'] - password = account['password'] - self.login(user, password, exe_path) - def login(self, user, password, exe_path): - if self._has_main_window(): - self._get_handles() - log.info('检测到交易客户端已启动,连接完毕') - return - if not self._has_login_window(): - if not os.path.exists(exe_path): - raise FileNotFoundError('在 {} 未找到应用程序,请用 exe_path 指定应用程序目录'.format(exe_path)) - subprocess.Popen(exe_path) - # 检测登陆窗口 - for _ in range(30): - if self._has_login_window(): - break - time.sleep(1) - else: - raise Exception('启动客户端失败,无法检测到登陆窗口') - log.info('成功检测到客户端登陆窗口') - - # 登陆 - self._set_trade_mode() - self._set_login_name(user) - self._set_login_password(password) - for _ in range(10): - self._set_login_verify_code() - self._click_login_button() - time.sleep(3) - if not self._has_login_window(): - break - self._click_login_verify_code() +import pywinauto - for _ in range(60): - if self._has_main_window(): - self._get_handles() - break - time.sleep(1) - else: - raise Exception('启动交易客户端失败') - log.info('客户端登陆成功') - - def _set_login_verify_code(self): - verify_code_image = self._grab_verify_code() - image_path = tempfile.mktemp() + '.jpg' - verify_code_image.save(image_path) - result = helpers.recognize_verify_code(image_path, 'yh_client') - time.sleep(0.2) - self._input_login_verify_code(result) - time.sleep(0.4) - - def _set_trade_mode(self): - input_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x4f4d) - win32gui.SendMessage(input_hwnd, win32con.BM_CLICK, None, None) - - def _set_login_name(self, user): - time.sleep(0.5) - input_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x5523) - win32gui.SendMessage(input_hwnd, win32con.WM_SETTEXT, None, user) - - def _set_login_password(self, password): - time.sleep(0.5) - input_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x5534) - win32gui.SendMessage(input_hwnd, win32con.WM_SETTEXT, None, password) - - def _has_login_window(self): - for title in [' - 北京电信', ' - 北京电信 - 北京电信']: - self.login_hwnd = win32gui.FindWindow(None, title) - if self.login_hwnd != 0: - return True - return False - - def _input_login_verify_code(self, code): - input_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x56b9) - win32gui.SendMessage(input_hwnd, win32con.WM_SETTEXT, None, code) - - def _click_login_verify_code(self): - input_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x56ba) - rect = win32gui.GetWindowRect(input_hwnd) - self._mouse_click(rect[0] + 5, rect[1] + 5) - - @staticmethod - def _mouse_click(x, y): - win32api.SetCursorPos((x, y)) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, x, y, 0, 0) - win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, x, y, 0, 0) - - def _click_login_button(self): - time.sleep(1) - input_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x1) - win32gui.SendMessage(input_hwnd, win32con.BM_CLICK, None, None) - - def _has_main_window(self): - try: - self._get_handles() - except: - return False - return True +from easytrader import clienttrader, grid_strategies +from easytrader.utils.captcha import recognize_verify_code - def _grab_verify_code(self): - verify_code_hwnd = win32gui.GetDlgItem(self.login_hwnd, 0x56ba) - self._set_foreground_window(self.login_hwnd) - time.sleep(1) - rect = win32gui.GetWindowRect(verify_code_hwnd) - return ImageGrab.grab(rect) - def _get_handles(self): - trade_main_hwnd = win32gui.FindWindow(0, self.Title) # 交易窗口 - operate_frame_hwnd = win32gui.GetDlgItem(trade_main_hwnd, 59648) # 操作窗口框架 - operate_frame_afx_hwnd = win32gui.GetDlgItem(operate_frame_hwnd, 59648) # 操作窗口框架 - hexin_hwnd = win32gui.GetDlgItem(operate_frame_afx_hwnd, 129) - scroll_hwnd = win32gui.GetDlgItem(hexin_hwnd, 200) # 左部折叠菜单控件 - tree_view_hwnd = win32gui.GetDlgItem(scroll_hwnd, 129) # 左部折叠菜单控件 +class YHClientTrader(clienttrader.BaseLoginClientTrader): + """ + Changelog: - # 获取委托窗口所有控件句柄 - win32api.PostMessage(tree_view_hwnd, win32con.WM_KEYDOWN, win32con.VK_F1, 0) - time.sleep(0.5) + 2018.07.01: + 银河客户端 2018.5.11 更新后不再支持通过剪切板复制获取 Grid 内容, + 改为使用保存为 Xls 再读取的方式获取 + """ - # 买入相关 - entrust_window_hwnd = win32gui.GetDlgItem(operate_frame_hwnd, 59649) # 委托窗口框架 - self.buy_stock_code_hwnd = win32gui.GetDlgItem(entrust_window_hwnd, 1032) # 买入代码输入框 - self.buy_price_hwnd = win32gui.GetDlgItem(entrust_window_hwnd, 1033) # 买入价格输入框 - self.buy_amount_hwnd = win32gui.GetDlgItem(entrust_window_hwnd, 1034) # 买入数量输入框 - self.buy_btn_hwnd = win32gui.GetDlgItem(entrust_window_hwnd, 1006) # 买入确认按钮 - self.refresh_entrust_hwnd = win32gui.GetDlgItem(entrust_window_hwnd, 32790) # 刷新持仓按钮 - entrust_frame_hwnd = win32gui.GetDlgItem(entrust_window_hwnd, 1047) # 持仓显示框架 - entrust_sub_frame_hwnd = win32gui.GetDlgItem(entrust_frame_hwnd, 200) # 持仓显示框架 - self.position_list_hwnd = win32gui.GetDlgItem(entrust_sub_frame_hwnd, 1047) # 持仓列表 - win32api.PostMessage(tree_view_hwnd, win32con.WM_KEYDOWN, win32con.VK_F2, 0) - time.sleep(0.5) + grid_strategy = grid_strategies.Xls - # 卖出相关 - sell_entrust_frame_hwnd = win32gui.GetDlgItem(operate_frame_hwnd, 59649) # 委托窗口框架 - self.sell_stock_code_hwnd = win32gui.GetDlgItem(sell_entrust_frame_hwnd, 1032) # 卖出代码输入框 - self.sell_price_hwnd = win32gui.GetDlgItem(sell_entrust_frame_hwnd, 1033) # 卖出价格输入框 - self.sell_amount_hwnd = win32gui.GetDlgItem(sell_entrust_frame_hwnd, 1034) # 卖出数量输入框 - self.sell_btn_hwnd = win32gui.GetDlgItem(sell_entrust_frame_hwnd, 1006) # 卖出确认按钮 - - # 撤单窗口 - win32api.PostMessage(tree_view_hwnd, win32con.WM_KEYDOWN, win32con.VK_F3, 0) - time.sleep(0.5) - cancel_entrust_window_hwnd = win32gui.GetDlgItem(operate_frame_hwnd, 59649) # 撤单窗口框架 - self.cancel_stock_code_hwnd = win32gui.GetDlgItem(cancel_entrust_window_hwnd, 3348) # 卖出代码输入框 - self.cancel_query_hwnd = win32gui.GetDlgItem(cancel_entrust_window_hwnd, 3349) # 查询代码按钮 - self.cancel_buy_hwnd = win32gui.GetDlgItem(cancel_entrust_window_hwnd, 30002) # 撤买 - self.cancel_sell_hwnd = win32gui.GetDlgItem(cancel_entrust_window_hwnd, 30003) # 撤卖 - - chexin_hwnd = win32gui.GetDlgItem(cancel_entrust_window_hwnd, 1047) - chexin_sub_hwnd = win32gui.GetDlgItem(chexin_hwnd, 200) - self.entrust_list_hwnd = win32gui.GetDlgItem(chexin_sub_hwnd, 1047) # 委托列表 - - def buy(self, stock_code, price, amount, **kwargs): - """ - 买入股票 - :param stock_code: 股票代码 - :param price: 买入价格 - :param amount: 买入股数 - :return: bool: 买入信号是否成功发出 - """ - amount = str(amount // 100 * 100) - price = str(price) - - try: - win32gui.SendMessage(self.buy_stock_code_hwnd, win32con.WM_SETTEXT, None, stock_code) # 输入买入代码 - win32gui.SendMessage(self.buy_price_hwnd, win32con.WM_SETTEXT, None, price) # 输入买入价格 - time.sleep(0.2) - win32gui.SendMessage(self.buy_amount_hwnd, win32con.WM_SETTEXT, None, amount) # 输入买入数量 - time.sleep(0.2) - win32gui.SendMessage(self.buy_btn_hwnd, win32con.BM_CLICK, None, None) # 买入确定 - time.sleep(0.3) - except: - traceback.print_exc() - return False - return True + @property + def broker_type(self): + return "yh" - def sell(self, stock_code, price, amount, **kwargs): + def login(self, user, password, exe_path, comm_password=None, **kwargs): """ - 买出股票 - :param stock_code: 股票代码 - :param price: 卖出价格 - :param amount: 卖出股数 - :return: bool 卖出操作是否成功 + 登陆客户端 + :param user: 账号 + :param password: 明文密码 + :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', + 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' + :param comm_password: 通讯密码, 华泰需要,可不设 + :return: """ - amount = str(amount // 100 * 100) - price = str(price) - try: - win32gui.SendMessage(self.sell_stock_code_hwnd, win32con.WM_SETTEXT, None, stock_code) # 输入卖出代码 - win32gui.SendMessage(self.sell_price_hwnd, win32con.WM_SETTEXT, None, price) # 输入卖出价格 - win32gui.SendMessage(self.sell_price_hwnd, win32con.BM_CLICK, None, None) # 输入卖出价格 - time.sleep(0.2) - win32gui.SendMessage(self.sell_amount_hwnd, win32con.WM_SETTEXT, None, amount) # 输入卖出数量 - time.sleep(0.2) - win32gui.SendMessage(self.sell_btn_hwnd, win32con.BM_CLICK, None, None) # 卖出确定 - time.sleep(0.3) - except: - traceback.print_exc() - return False - return True - - def cancel_entrust(self, stock_code, direction): - """ - 撤单 - :param stock_code: str 股票代码 - :param direction: str 'buy' 撤买, 'sell' 撤卖 - :return: bool 撤单信号是否发出 - """ - direction = 0 if direction == 'buy' else 1 - + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=1 + ) + # pylint: disable=broad-except + except Exception: + self._app = pywinauto.Application().start(exe_path) + is_xiadan = True if "xiadan.exe" in exe_path else False + # wait login window ready + while True: + try: + self._app.top_window().Edit1.wait("ready") + break + except RuntimeError: + pass + + self._app.top_window().Edit1.type_keys(user) + self._app.top_window().Edit2.type_keys(password) + while True: + self._app.top_window().Edit3.type_keys( + self._handle_verify_code(is_xiadan) + ) + if is_xiadan: + self._app.top_window().child_window(control_id=1006, class_name="Button").click() + else: + self._app.top_window()["登录"].click() + + # detect login is success or not + try: + self._app.top_window().wait_not("exists visible", 10) + break + # pylint: disable=broad-except + except Exception: + if is_xiadan: + self._app.top_window()["确定"].click() + + self._app = pywinauto.Application().connect( + path=self._run_exe_path(exe_path), timeout=10 + ) + self._close_prompt_windows() + self._main = self._app.window(title="网上股票交易系统5.0") try: - win32gui.SendMessage(self.refresh_entrust_hwnd, win32con.BM_CLICK, None, None) # 刷新持仓 - time.sleep(0.2) - win32gui.SendMessage(self.cancel_stock_code_hwnd, win32con.WM_SETTEXT, None, stock_code) # 输入撤单 - win32gui.SendMessage(self.cancel_query_hwnd, win32con.BM_CLICK, None, None) # 查询代码 - time.sleep(0.2) - if direction == 0: - win32gui.SendMessage(self.cancel_buy_hwnd, win32con.BM_CLICK, None, None) # 撤买 - elif direction == 1: - win32gui.SendMessage(self.cancel_sell_hwnd, win32con.BM_CLICK, None, None) # 撤卖 - except: - traceback.print_exc() - return False - time.sleep(0.3) - return True - - @property - def position(self): - return self.get_position() - - def get_position(self): - win32gui.SendMessage(self.refresh_entrust_hwnd, win32con.BM_CLICK, None, None) # 刷新持仓 - time.sleep(0.1) - self._set_foreground_window(self.position_list_hwnd) - time.sleep(0.1) - data = self._read_clipboard() - return self.project_copy_data(data) - - @staticmethod - def project_copy_data(copy_data): - reader = StringIO(copy_data) - df = pd.read_csv(reader, delim_whitespace=True) - return df.to_dict('records') - - def _read_clipboard(self): - for _ in range(15): - try: - win32api.keybd_event(17, 0, 0, 0) - win32api.keybd_event(67, 0, 0, 0) - win32api.keybd_event(67, 0, win32con.KEYEVENTF_KEYUP, 0) - win32api.keybd_event(17, 0, win32con.KEYEVENTF_KEYUP, 0) - time.sleep(0.2) - return pyperclip.paste() - except Exception as e: - log.error('open clipboard failed: {}, retry...'.format(e)) - time.sleep(1) + self._main.child_window( + control_id=129, class_name="SysTreeView32" + ).wait("ready", 2) + # pylint: disable=broad-except + except Exception: + self.wait(2) + self._switch_window_to_normal_mode() + + def _switch_window_to_normal_mode(self): + self._app.top_window().child_window( + control_id=32812, class_name="Button" + ).click() + + def _handle_verify_code(self, is_xiadan): + control = self._app.top_window().child_window( + control_id=1499 if is_xiadan else 22202 + ) + control.click() + + file_path = tempfile.mktemp() + if is_xiadan: + rect = control.element_info.rectangle + rect.right = round( + rect.right + (rect.right - rect.left) * 0.3 + ) # 扩展验证码控件截图范围为4个字符 + control.capture_as_image(rect).save(file_path, "jpeg") else: - raise Exception('read clipbord failed') - - @staticmethod - def _project_position_str(raw): - reader = StringIO(raw) - df = pd.read_csv(reader, delim_whitespace=True) - return df - - @staticmethod - def _set_foreground_window(hwnd): - shell = win32com.client.Dispatch('WScript.Shell') - shell.SendKeys('%') - win32gui.SetForegroundWindow(hwnd) + control.capture_as_image().save(file_path, "jpeg") + verify_code = recognize_verify_code(file_path, "yh_client") + return "".join(re.findall(r"\d+", verify_code)) @property - def entrust(self): - return self.get_entrust() - - def get_entrust(self): - win32gui.SendMessage(self.refresh_entrust_hwnd, win32con.BM_CLICK, None, None) # 刷新持仓 - time.sleep(0.2) - self._set_foreground_window(self.entrust_list_hwnd) - time.sleep(0.2) - data = self._read_clipboard() - return self.project_copy_data(data) + def balance(self): + self._switch_left_menus(self._config.BALANCE_MENU_PATH) + return self._get_grid_data(self._config.BALANCE_GRID_CONTROL_ID) + + def auto_ipo(self): + self._switch_left_menus(self._config.AUTO_IPO_MENU_PATH) + stock_list = self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) + if len(stock_list) == 0: + return {"message": "今日无新股"} + invalid_list_idx = [ + i for i, v in enumerate(stock_list) if v["申购数量"] <= 0 + ] + if len(stock_list) == len(invalid_list_idx): + return {"message": "没有发现可以申购的新股"} + self.wait(0.1) + # for row in invalid_list_idx: + # self._click_grid_by_row(row) + self._click(self._config.AUTO_IPO_BUTTON_CONTROL_ID) + self.wait(0.1) + return self._handle_pop_dialogs() diff --git a/easytrader/yhtrader.py b/easytrader/yhtrader.py deleted file mode 100644 index dead616c..00000000 --- a/easytrader/yhtrader.py +++ /dev/null @@ -1,573 +0,0 @@ -# coding: utf-8 -from __future__ import division, unicode_literals - -import math -import logging -import os -import random -import re - -import requests - -from . import helpers -from .log import log -from .webtrader import WebTrader, NotLoginError - -VERIFY_CODE_POS = 0 -TRADE_MARKET = 1 -HOLDER_NAME = 0 - - -# 用于将一个list按一定步长切片,返回这个list切分后的list -def slice_list(step=None, num=None, data_list=None): - if not ((step is None) & (num is None)): - if num is not None: - step = math.ceil(len(data_list) / num) - return [data_list[i: i + step] for i in range(0, len(data_list), step)] - else: - print("step和num不能同时为空") - return False - - -class YHTrader(WebTrader): - config_path = os.path.dirname(__file__) + '/config/yh.json' - - def __init__(self, debug=True): - super(YHTrader, self).__init__(debug=debug) - self.cookie = None - self.account_config = None - self.s = None - self.exchange_stock_account = dict() - - def login(self, throw=False): - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko', - 'Referer': 'https://www.chinastock.com.cn/trade/webtrade/login.jsp' - } - if self.s is not None: - self.s.get(self.config['logout_api']) - self.s = requests.Session() - self.s.headers.update(headers) - data = self.s.get(self.config['login_page']) - - # 查找验证码 - verify_code = self.handle_recognize_code() - - if not verify_code: - return False - - login_status, result = self.post_login_data(verify_code) - if login_status is False and throw: - raise NotLoginError(result) - - accounts = self.do(self.config['account4stock']) - if accounts is False: - return False - if len(accounts) < 2: - raise Exception('无法获取沪深 A 股账户: %s' % accounts) - for account in accounts: - if account['交易市场'] == '深A' and account['股东代码'].startswith('0'): - self.exchange_stock_account['0'] = account['股东代码'][0:10] - elif account['交易市场'] == '沪A' and account['股东代码'].startswith('A'): - self.exchange_stock_account['1'] = account['股东代码'][0:10] - return login_status - - def handle_recognize_code(self): - """获取并识别返回的验证码 - :return:失败返回 False 成功返回 验证码""" - # 获取验证码 - verify_code_response = self.s.get(self.config['verify_code_api'], params=dict(updateverify=random.random())) - # 保存验证码 - image_path = os.path.join(os.getcwd(), 'vcode') - with open(image_path, 'wb') as f: - f.write(verify_code_response.content) - - verify_code = helpers.recognize_verify_code(image_path, 'yh') - log.debug('verify code detect result: %s' % verify_code) - - ht_verify_code_length = 4 - if len(verify_code) != ht_verify_code_length: - return False - return verify_code - - def post_login_data(self, verify_code): - login_params = dict( - self.config['login'], - mac=helpers.get_mac(), - clientip='', - inputaccount=self.account_config['inputaccount'], - trdpwd=self.account_config['trdpwd'], - checkword=verify_code - ) - log.debug('login params: %s' % login_params) - login_response = self.s.post(self.config['login_api'], params=login_params) - log.debug('login response: %s' % login_response.text) - - if login_response.text.find('success') != -1: - return True, None - return False, login_response.text - - def _prepare_account(self, user, password, **kwargs): - self.account_config = { - 'inputaccount': user, - 'trdpwd': password - } - - def check_available_cancels(self, parsed=True): - """ - @Contact: Emptyset <21324784@qq.com> - 检查撤单列表 - """ - try: - response = self.s.get("https://www.chinastock.com.cn/trade/webtrade/stock/StockEntrustCancel.jsp", - cookies=self.cookie) - if response.status_code != 200: - return False - html = response.text.replace("\t", "").replace("\n", "").replace("\r", "") - if html.find("请重新登录") != -1: - return False - pattern = r'(.+)' - result1 = re.findall(pattern, html)[0] - pattern = r'([\S]+)' - parsed_data = re.findall(pattern, result1) - cancel_list = slice_list(step=12, data_list=parsed_data) - except Exception as e: - return [] - result = list() - if parsed: - for item in cancel_list: - if len(item) == 12: - item_dict = { - "time": item[0], - "code": item[1], - "name": item[2], - "status": item[3], - "iotype": item[4], - "price": float(item[5]), - "volume": int(item[6]), - "entrust_num": item[7], - "trans_vol": int(item[8]), - "canceled_vol": int(item[9]), - "investor_code": item[10], - "account": item[11] - } - elif len(item) == 11: - item_dict = { - "time": item[0], - "code": item[1], - "name": item[2], - "status": item[3], - "iotype": "", - "price": float(item[4]), - "volume": int(item[5]), - "entrust_num": item[6], - "trans_vol": int(item[7]), - "canceled_vol": int(item[8]), - "investor_code": item[9], - "account": item[10] - } - else: - continue - result.append(item_dict) - return result - - def cancel_entrust(self, entrust_no, stock_code): - """撤单 - :param entrust_no: 委托单号 - :param stock_code: 股票代码""" - need_info = self.__get_trade_need_info(stock_code) - cancel_params = dict( - self.config['cancel_entrust'], - orderSno=entrust_no, - secuid=need_info['stock_account'] - ) - cancel_response = self.s.post(self.config['trade_api'], params=cancel_params) - log.debug('cancel trust: %s' % cancel_response.text) - return cancel_response.json() - - def cancel_entrusts(self, entrust_no): - """ - @Contact: Emptyset <21324784@qq.com> - 批量撤单 - @param - entrust_no: string类型 - 委托单号,用逗号隔开 - e.g:"8000,8001,8002" - @return - 返回格式是list,比如一个22个单子的批量撤单 - e.g.: - [{"success":15, "failed":0},{"success":7, "failed":0}] - """ - import time - list_entrust_no = entrust_no.split(",") - # 一次批量撤单不能超过15个 - list_entrust_no = slice_list(step=15, data_list=list_entrust_no) - result = list() - for item in list_entrust_no: - if item[-1] == "": - num = len(item) - 1 - else: - num = len(item) - cancel_data = { - "ajaxFlag": "stock_batch_cancel", - "num": num, - "orderSno": ",".join(item) - } - while True: - try: - cancel_response = self.s.post( - "https://www.chinastock.com.cn/trade/AjaxServlet", - data=cancel_data, - timeout=1, - ) - if cancel_response.status_code == 200: - cancel_response_json = cancel_response.json() - # 如果出现“系统超时请重新登录”之类的错误信息,直接返回False - if "result_type" in cancel_response_json and "result_type" == 'error': - return False - result.append(cancel_response_json) - break - else: - log.debug('{}'.format(cancel_response)) - except Exception as e: - log.debug('{}'.format(e)) - time.sleep(0.2) - return result - - @property - def current_deal(self): - return self.get_current_deal() - - def get_current_deal(self, date=None): - """ - 获取当日成交列表. - """ - return self.do(self.config['current_deal']) - - def get_deal(self, date=None): - """ - @Contact: Emptyset <21324784@qq.com> - 获取历史日成交列表 - e.g.: get_deal( "2016-07-14" ) - 如果不传递日期则取的是当天成交列表 - 返回值格式与get_current_deal相同 - 遇到提示“系统超时请重新登录”或者https返回状态码非200或者其他异常情况会返回False - """ - if date is None: - data = {} - else: - data = { - "sdate": date, - "edate": date - } - try: - response = self.s.post("https://www.chinastock.com.cn/trade/webtrade/stock/stock_cj_query.jsp", data=data, - cookies=self.cookie) - if response.status_code != 200: - return False - if response.text.find("重新登录") != -1: - return False - res = self.format_response_data(response.text) - return res - except Exception as e: - log.warning("撤单出错".format(e)) - return False - - def buy(self, stock_code, price, amount=0, volume=0, entrust_prop='limit'): - """买入股票 - :param stock_code: 股票代码 - :param price: 买入价格 - :param amount: 买入股数 - :param volume: 买入总金额 由 volume / price 取整, 若指定 price 则此参数无效 - :param entrust_prop: 委托类型 'limit' 限价单 , 'market' 市价单 - """ - market_type = helpers.get_stock_type(stock_code) - bsflag = None - if entrust_prop == 'limit': - bsflag = '0B' - elif market_type == 'sh': - bsflag = '0q' - elif market_type == 'sz': - bsflag = '0a' - assert bsflag is not None - - params = dict( - self.config['buy'], - bsflag=bsflag, - qty=int(amount) if amount else volume // price // 100 * 100 - ) - return self.__trade(stock_code, price, entrust_prop=entrust_prop, other=params) - - def sell(self, stock_code, price, amount=0, volume=0, entrust_prop='limit'): - """卖出股票 - :param stock_code: 股票代码 - :param price: 卖出价格 - :param amount: 卖出股数 - :param volume: 卖出总金额 由 volume / price 取整, 若指定 amount 则此参数无效 - :param entrust_prop: str 委托类型 'limit' 限价单 , 'market' 市价单 - """ - market_type = helpers.get_stock_type(stock_code) - bsflag = None - if entrust_prop == 'limit': - bsflag = '0S' - elif market_type == 'sh': - bsflag = '0r' - elif market_type == 'sz': - bsflag = '0f' - assert bsflag is not None - - params = dict( - self.config['sell'], - bsflag=bsflag, - qty=amount if amount else volume // price - ) - return self.__trade(stock_code, price, entrust_prop=entrust_prop, other=params) - - def fundpurchase(self, stock_code, amount=0): - """基金申购 - :param stock_code: 基金代码 - :param amount: 申购份额 - """ - params = dict( - self.config['fundpurchase'], - price=1, # 价格默认为1 - qty=amount - ) - return self.__tradefund(stock_code, other=params) - - def fundredemption(self, stock_code, amount=0): - """基金赎回 - :param stock_code: 基金代码 - :param amount: 赎回份额 - """ - params = dict( - self.config['fundredemption'], - price=1, # 价格默认为1 - qty=amount - ) - return self.__tradefund(stock_code, other=params) - - def fundsubscribe(self, stock_code, amount=0): - """基金认购 - :param stock_code: 基金代码 - :param amount: 认购份额 - """ - params = dict( - self.config['fundsubscribe'], - price=1, # 价格默认为1 - qty=amount - ) - return self.__tradefund(stock_code, other=params) - - def fundsplit(self, stock_code, amount=0): - """基金分拆 - :param stock_code: 母份额基金代码 - :param amount: 分拆份额 - """ - params = dict( - self.config['fundsplit'], - qty=amount - ) - return self.__tradefund(stock_code, other=params) - - def fundmerge(self, stock_code, amount=0): - """基金合并 - :param stock_code: 母份额基金代码 - :param amount: 合并份额 - """ - params = dict( - self.config['fundmerge'], - qty=amount - ) - return self.__tradefund(stock_code, other=params) - - def __tradefund(self, stock_code, other): - # 检查是否已经掉线 - if not self.heart_thread.is_alive(): - check_data = self.get_balance() - if type(check_data) == dict: - return check_data - need_info = self.__get_trade_need_info(stock_code) - trade_params = dict( - other, - stockCode=stock_code, - market=need_info['exchange_type'], - secuid=need_info['stock_account'] - ) - - trade_response = self.s.post(self.config['trade_api'], params=trade_params) - log.debug('trade response: %s' % trade_response.text) - return trade_response.json() - - def __trade(self, stock_code, price, entrust_prop, other): - # 检查是否已经掉线 - if not self.heart_thread.is_alive(): - check_data = self.get_balance() - if type(check_data) == dict: - return check_data - need_info = self.__get_trade_need_info(stock_code) - trade_params = dict( - other, - stockCode=stock_code[-6:], - price=price, - market=need_info['exchange_type'], - secuid=need_info['stock_account'] - ) - trade_response = self.s.post(self.config['trade_api'], params=trade_params) - log.debug("{}".format(self.config['trade_api'])) - log.debug("{}".format(trade_params)) - log.debug('trade response: %s' % trade_response.text) - return trade_response.json() - - def __get_trade_need_info(self, stock_code): - """获取股票对应的证券市场和帐号""" - sh_exchange_type = '1' - sz_exchange_type = '0' - exchange_type = sh_exchange_type if helpers.get_stock_type(stock_code) == 'sh' else sz_exchange_type - return dict( - exchange_type=exchange_type, - stock_account=self.exchange_stock_account[exchange_type] - ) - - def create_basic_params(self): - basic_params = dict( - CSRF_Token='undefined', - timestamp=random.random(), - ) - return basic_params - - def request(self, params): - url = self.trade_prefix + params['service_jsp'] - r = self.s.get(url, cookies=self.cookie) - if r.status_code != 200: - return False - if r.text.find('系统超时请重新登录') != -1: - return False - if params['service_jsp'] == '/trade/webtrade/stock/stock_zjgf_query.jsp': - if params['service_type'] == 2: - rptext = r.text[0:r.text.find('操作')] - return rptext - else: - rbtext = r.text[r.text.find('操作'):] - rbtext += 'yhposition' - return rbtext - else: - return r.text - - def format_response_data(self, data): - if not data: - return False - # 需要对于银河持仓情况特殊处理 - if data.find('yhposition') != -1: - search_result_name = re.findall(r'(.*)', data) - search_result_content = [] - search_result_content_tmp = re.findall(r'(.*)', data) - for item in search_result_content_tmp: - s = item[-1] if type(item) is not str else item - k = re.findall(">(.*)<", s) - if len(k) > 0: - s = k[-1] - search_result_content.append(s) - else: - # 获取原始data的html源码并且解析得到一个可读json格式 - search_result_name = re.findall(r'(.*)', data) - search_result_content = re.findall(r'([^~]*?)', data) - search_result_content = list(map(lambda x: x.replace(' ', '').replace(';', ''), search_result_content)) - - col_len = len(search_result_name) - if col_len == 0 or len(search_result_content) % col_len != 0: - if len(search_result_content) == 0: - return list() - raise Exception("Get Data Error: col_num: {}, Data: {}".format(col_len, search_result_content)) - else: - row_len = len(search_result_content) // col_len - res = list() - for row in range(row_len): - item = dict() - for col in range(col_len): - col_name = search_result_name[col] - item[col_name] = search_result_content[row * col_len + col] - res.append(item) - - return self.format_response_data_type(res) - - def check_account_live(self, response): - if hasattr(response, 'get'): - if response.get('error_no') == '-1' or response.get('result_type') == 'error': - self.heart_active = False - raise NotLoginError(response.get('result_msg')) - - def heartbeat(self): - heartbeat_params = dict( - ftype='bsn' - ) - res = self.s.post(self.config['heart_beat'], params=heartbeat_params) - - def unlockscreen(self): - unlock_params = dict( - password=self.account_config['trdpwd'], - mainAccount=self.account_config['inputaccount'], - ftype='bsn' - ) - log.debug('unlock params: %s' % unlock_params) - unlock_resp = self.s.post(self.config['unlock'], params=unlock_params) - log.debug('unlock resp: %s' % unlock_resp.text) - - def get_ipo_info(self): - """ - 查询新股申购信息 - :return: (df_taoday_ipo, df_ipo_limit), 分别是当日新股申购列表信息, 申购额度。 - df_today_ipo - 代码 名称 价格 账户额度 申购下限 申购上限 证券账号 交易所 发行日期 - 0 2830 名雕股份 16.53 17500 500 xxxxx xxxxxxxx 深A 20161201 - 1 732098 森特申购 9.18 27000 1000 xxxxx xxxxxxx 沪A 20161201 - - df_ipo_limit: - 市场 证券账号 账户额度 - 0 深圳 xxxxxxx xxxxx - 1 上海 xxxxxxx xxxxx - - """ - import pandas as pd - from bs4 import BeautifulSoup - - ipo_response = self.s.get( - self.config['ipo_api'], - params=dict(), - headers={ - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate", - "Accept-Language": "zh-CN", - "Connection": "Keep-Alive", - "Host": "www.chinastock.com.cn", - "Referer": "https://www.chinastock.com.cn/trade/webtrade/login.jsp", - "User-Agent": "Mozilla/4.0(compatible;MSIE,7.0;Windows NT 10.0; WOW64;Trident / 7.0;.NET4.0C;.NET4.0E;.NET CLR2.0.50727;.NET CLR 3.0.30729;.NET CLR 3.5.30729;InfoPath.3)" - }) - if ipo_response.status_code != 200: - return None, None - html = ipo_response.content - soup = BeautifulSoup(html, 'lxml') - tables = soup.findAll('table', attrs={'class': 'fee'}) - df_ipo_limit = pd.read_html(str(tables[0]), flavor='lxml', header=0, encoding='utf-8')[0] - df_today_ipo = pd.read_html(str(tables[1]), flavor='lxml', header=0, encoding='utf-8')[0] - df_today_ipo[['代码']] = df_today_ipo[['代码']].applymap(lambda x: '{:0>6}'.format(x)) - return df_today_ipo, df_ipo_limit - - def get_ipo_limit(self, stock_code): - """ - 查询当日某只新股申购额度、申购上限、价格。 - 仅为了兼容佣金宝同名方法。 不需要兼容,最好使用get_ipo_info()[0] - :param stock_code: 申购代码!!! - :return: high_amount(最高申购股数) enable_amount(申购额度) last_price(发行价) - - """ - (df1, df2) = self.get_ipo_info() - if df1 is None: - log.debug('查询错误: %s') - return None - df = df1[df1['代码'] == stock_code] - if len(df) == 0: - return dict() - ser = df.iloc[0] - return dict(high_amount=int(ser['申购上限']), enable_amount=int(ser['账户额度']), - last_price=float(ser['价格'])) diff --git a/gf.json b/gf.json deleted file mode 100644 index bea8a335..00000000 --- a/gf.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "username": "加密的客户号", - "password": "加密的密码" -} diff --git a/gj_client.json b/gj_client.json new file mode 100644 index 00000000..e20ebf09 --- /dev/null +++ b/gj_client.json @@ -0,0 +1,4 @@ +{ + "user": "国金用户名", + "password": "国金明文密码" +} \ No newline at end of file diff --git a/httpserver.py b/httpserver.py deleted file mode 100644 index ef091596..00000000 --- a/httpserver.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import print_function - -import json - -from flask import Flask, request, jsonify - -import easytrader - -app = Flask(__name__) -user = None - - -@app.route('/login') -def login(): - global user - args = request.args - user = easytrader.use(args['use']) - user.prepare(args['prepare']) - return jsonify({'message': 'login ok'}) - - -@app.route('/call') -def do(): - global user - target = request.args.get('func') - params_str = request.args.get('params', None) - - if params_str: - params = params_str.split(',') - if target in ['buy', 'sell']: - params[-1] = int(params[-1]) - params[-2] = float(params[-2]) - result = getattr(user, target)(*params) - else: - result = getattr(user, target) - return json.dumps({'return': result}, ensure_ascii=False) - - -if __name__ == '__main__': - app.run(debug=True) diff --git a/mkdocs.yml b/mkdocs.yml index 3e8fe06c..d1810457 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,9 +1,8 @@ site_name: easytrader pages: -- [index.md, Home] -- [install.md, 安装] -- [usage.md, 使用] -- [help.md, 常见问题] -- [other/xueqiu.md, 其他, '雪球模拟组合说明'] -- [other/INSTALL4Windows.md, 其他, 'windows下配置广发'] + - Home: index.md + - 安装: install.md + - 使用: usage.md + - 常见问题: help.md + - 其他: 'other/xueqiu.md' theme: readthedocs diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..976ba029 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +ignore_missing_imports = True diff --git a/readthedocs-requirements.txt b/readthedocs-requirements.txt new file mode 100644 index 00000000..e69de29b diff --git a/requirements.txt b/requirements.txt index 66d30707..a101ca4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,31 @@ -bs4 -requests -demjson -dill -click -six -flask -Pillow -pytesseract -pandas -pyperclip -rqopen-client +-i http://mirrors.aliyun.com/pypi/simple/ +--trusted-host mirrors.aliyun.com +beautifulsoup4==4.6.0 +bs4==0.0.1 +certifi==2018.4.16 +chardet==3.0.4 +click==6.7 +cssselect==1.0.3; python_version != '3.3.*' +dill==0.2.8.2 +easyutils==0.1.7 +flask==1.0.2 +idna==2.7 +itsdangerous==0.24 +jinja2==2.10 +lxml==4.2.3 +markupsafe==1.0 +numpy==1.15.0; python_version >= '2.7' +pandas==0.23.3 +pillow==5.2.0 +pyperclip==1.6.4 +pyquery==1.4.0; python_version != '3.0.*' +pytesseract==0.2.4 +python-dateutil==2.7.3 +python-xlib==0.23 +pytz==2018.5 +pywinauto==0.6.6 +requests==2.19.1 +six==1.11.0 +urllib3==1.23; python_version != '3.1.*' +werkzeug==0.14.1 + diff --git a/setup.py b/setup.py index 966976aa..61a540c2 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,6 @@ # coding:utf8 from setuptools import setup -import easytrader - long_desc = """ easytrader =============== @@ -78,29 +76,38 @@ """ setup( - name='easytrader', - version=easytrader.__version__, - description='A utility for China Stock Trade', + name="easytrader", + version="0.23.0", + description="A utility for China Stock Trade", long_description=long_desc, - author='shidenggui', - author_email='longlyshidenggui@gmail.com', - license='BSD', - url='https://github.com/shidenggui/easytrader', - keywords='China stock trade', + author="shidenggui", + author_email="longlyshidenggui@gmail.com", + license="BSD", + url="https://github.com/shidenggui/easytrader", + keywords="China stock trade", install_requires=[ - 'demjson', - 'requests', - 'six', - 'rqopen-client', + "requests", + "six", + "easyutils", + "flask", + "pywinauto==0.6.6", + "pillow", + "pandas", + ], + classifiers=[ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.2", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "License :: OSI Approved :: BSD License", ], - classifiers=['Development Status :: 4 - Beta', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'License :: OSI Approved :: BSD License'], - packages=['easytrader', 'easytrader.config', 'easytrader.thirdlibrary'], - package_data={'': ['*.jar', '*.json'], 'config': ['config/*.json'], 'thirdlibrary': ['thirdlibrary/*.jar']}, + packages=["easytrader", "easytrader.config", "easytrader.utils"], + package_data={ + "": ["*.jar", "*.json"], + "config": ["config/*.json"], + "thirdlibrary": ["thirdlibrary/*.jar"], + }, ) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..edfca411 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +pytest +pytest-cov + diff --git a/test_easytrader.py b/test_easytrader.py deleted file mode 100644 index 3625c911..00000000 --- a/test_easytrader.py +++ /dev/null @@ -1,210 +0,0 @@ -# coding: utf-8 - -import unittest -from unittest import mock -from datetime import datetime -import time - -import easytrader -from easytrader import JoinQuantFollower, RiceQuantFollower -from easytrader import helpers -from easytrader.follower import BaseFollower - - -class TestEasytrader(unittest.TestCase): - - def test_helpers(self): - result = helpers.get_stock_type('162411') - self.assertEqual(result, 'sz') - - result = helpers.get_stock_type('691777') - self.assertEqual(result, 'sh') - - result = helpers.get_stock_type('sz162411') - self.assertEqual(result, 'sz') - - def test_format_response_data_type(self): - user = easytrader.use('ht') - - test_data = [{ - 'current_amount': '187.00', - 'current_balance': '200.03', - 'stock_code': '000001' - }] - result = user.format_response_data_type(test_data) - - self.assertIs(type(result[0]['current_amount']), int) - self.assertIs(type(result[0]['current_balance']), float) - self.assertIs(type(result[0]['stock_code']), str) - - test_data = [{'position_str': '', - 'date': '', - 'fund_account': '', - 'stock_account': '', - 'stock_code': '', - 'entrust_bs': '', - 'business_price': '', - 'business_amount': '', - 'business_time': '', - 'stock_name': '', - 'business_status': '', - 'business_type': ''}] - result = user.format_response_data_type(test_data) - - def test_ht_fix_error_data(self): - user = easytrader.use('ht') - test_data = { - 'cssweb_code': 'error', - 'cssweb_type': 'GET_STOCK_POSITON' - } - - return_data = user.fix_error_data(test_data) - self.assertEqual(test_data, return_data) - - test_data = [{ - 'stock_code': '162411', - 'entrust_bs': '2'}, - {'no_use_index': 'hello'}] - - normal_return_data = [{ - 'stock_code': '162411', - 'entrust_bs': '2'}] - - return_data = user.fix_error_data(test_data) - self.assertEqual(return_data, normal_return_data) - - def test_helpers_grep_comma(self): - test_data = '123' - normal_data = '123' - result = helpers.grep_comma(test_data) - self.assertEqual(result, normal_data) - - test_data = '4,000' - normal_data = '4000' - result = helpers.grep_comma(test_data) - self.assertEqual(result, normal_data) - - def test_helpers_str2num(self): - test_data = '123' - normal_data = 123 - result = helpers.str2num(test_data, 'int') - self.assertEqual(result, normal_data) - - test_data = '1,000' - normal_data = 1000 - result = helpers.str2num(test_data, 'int') - self.assertEqual(result, normal_data) - - test_data = '123.05' - normal_data = 123.05 - result = helpers.str2num(test_data, 'float') - self.assertAlmostEqual(result, normal_data) - - test_data = '1,023.05' - normal_data = 1023.05 - result = helpers.str2num(test_data, 'float') - self.assertAlmostEqual(result, normal_data) - - def test_gf_check_account_live(self): - user = easytrader.use('gf') - - test_data = None - with self.assertRaises(easytrader.webtrader.NotLoginError): - user.check_account_live(test_data) - self.assertFalse(user.heart_active) - - test_data = {'success': False, 'data': [{}], 'total': 1} - with self.assertRaises(easytrader.webtrader.NotLoginError): - user.check_account_live(test_data) - self.assertFalse(user.heart_active) - - -class TestXueQiuTrader(unittest.TestCase): - - def test_set_initial_assets(self): - # default set to 1e6 - xq_user = easytrader.use('xq') - self.assertEqual(xq_user.multiple, 1e6) - - xq_user = easytrader.use('xq', initial_assets=1000) - self.assertEqual(xq_user.multiple, 1000) - - # cant low than 1000 - with self.assertRaises(ValueError): - xq_user = easytrader.use('xq', initial_assets=999) - - # initial_assets must be number - cases = [None, '', b'', bool] - for v in cases: - with self.assertRaises(TypeError): - xq_user = easytrader.use('xq', initial_assets=v) - - -class TestJoinQuantFollower(unittest.TestCase): - - def test_extract_strategy_id(self): - cases = [('https://www.joinquant.com/algorithm/live/index?backtestId=aaaabbbbcccc', - 'aaaabbbbcccc')] - for test, result in cases: - extracted_id = JoinQuantFollower.extract_strategy_id(test) - self.assertEqual(extracted_id, result) - - def test_stock_shuffle_to_prefix(self): - cases = [('123456.XSHG', 'sh123456'), - ('000001.XSHE', 'sz000001')] - for test, result in cases: - self.assertEqual( - JoinQuantFollower.stock_shuffle_to_prefix(test), - result - ) - - with self.assertRaises(AssertionError): - JoinQuantFollower.stock_shuffle_to_prefix('1234') - - def test_project_transactions(self): - cases = [([{'type': '市价单', 'price': 8.11, 'commission': 9.98, 'gains': 0, 'time': '14:50', 'date': '2016-11-18', - 'security': '股票', 'stock': '华纺股份(600448.XSHG)', 'transaction': '买', 'total': 33251, - 'status': '全部成交', - 'amount': "4100股"}], - [{'type': '市价单', 'price': 8.11, 'commission': 9.98, 'gains': 0, 'time': '14:50', 'date': '2016-11-18', - 'security': '股票', 'stock': '华纺股份(600448.XSHG)', 'transaction': '买', 'total': 33251, - 'status': '全部成交', - 'amount': 4100, - 'action': 'buy', - 'stock_code': 'sh600448', - 'datetime': - datetime.strptime('2016-11-18 14:50', '%Y-%m-%d %H:%M') - }])] - for test, result in cases: - JoinQuantFollower().project_transactions(test), - self.assertListEqual( - test, - result - ) - - -class TestFollower(unittest.TestCase): - - def test_is_number(self): - cases = [('1', True), - ('--', False)] - for string, result in cases: - test = BaseFollower._is_number(string) - self.assertEqual(test, result) - - @mock.patch.object(BaseFollower, 'trade_worker', autospec=True) - def test_send_interval(self, mock_trade_worker): - cases = [(1, 1), (2, 2)] - for follower_cls in [JoinQuantFollower, RiceQuantFollower]: - for test_data, truth in cases: - follower = follower_cls() - try: - follower.follow(None, None, send_interval=test_data) - except: - pass - print(test_data, truth) - self.assertEqual(mock_trade_worker.call_args[1]['send_interval'], truth) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..8288f42b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# coding:utf8 diff --git a/tests/test_easytrader.py b/tests/test_easytrader.py new file mode 100644 index 00000000..dd2f4599 --- /dev/null +++ b/tests/test_easytrader.py @@ -0,0 +1,182 @@ +# coding: utf-8 +import os +import sys +import time +import unittest + +sys.path.append(".") + +TEST_CLIENTS = set(os.environ.get("EZ_TEST_CLIENTS", "").split(",")) + +IS_WIN_PLATFORM = sys.platform != "darwin" + + +@unittest.skipUnless("yh" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip yh test") +class TestYhClientTrader(unittest.TestCase): + @classmethod + def setUpClass(cls): + import easytrader + + if "yh" not in TEST_CLIENTS: + return + + # input your test account and password + cls._ACCOUNT = os.environ.get("EZ_TEST_YH_ACCOUNT") or "your account" + cls._PASSWORD = os.environ.get("EZ_TEST_YH_PASSWORD") or "your password" + + cls._user = easytrader.use("yh_client") + cls._user.prepare(user=cls._ACCOUNT, password=cls._PASSWORD) + + def test_balance(self): + time.sleep(3) + result = self._user.balance + + def test_today_entrusts(self): + result = self._user.today_entrusts + + def test_today_trades(self): + result = self._user.today_trades + + def test_cancel_entrusts(self): + result = self._user.cancel_entrusts + + def test_cancel_entrust(self): + result = self._user.cancel_entrust("123456789") + + def test_invalid_buy(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.buy("511990", 1, 1e10) + + def test_invalid_sell(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.sell("162411", 200, 1e10) + + def test_auto_ipo(self): + self._user.auto_ipo() + + +@unittest.skipUnless("ht" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip ht test") +class TestHTClientTrader(unittest.TestCase): + @classmethod + def setUpClass(cls): + import easytrader + + if "ht" not in TEST_CLIENTS: + return + + # input your test account and password + cls._ACCOUNT = os.environ.get("EZ_TEST_HT_ACCOUNT") or "your account" + cls._PASSWORD = os.environ.get("EZ_TEST_HT_PASSWORD") or "your password" + cls._COMM_PASSWORD = ( + os.environ.get("EZ_TEST_HT_COMM_PASSWORD") or "your comm password" + ) + + cls._user = easytrader.use("ht_client") + cls._user.prepare( + user=cls._ACCOUNT, password=cls._PASSWORD, comm_password=cls._COMM_PASSWORD + ) + + def test_balance(self): + time.sleep(3) + result = self._user.balance + + def test_today_entrusts(self): + result = self._user.today_entrusts + + def test_today_trades(self): + result = self._user.today_trades + + def test_cancel_entrusts(self): + result = self._user.cancel_entrusts + + def test_cancel_entrust(self): + result = self._user.cancel_entrust("123456789") + + def test_invalid_buy(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.buy("511990", 1, 1e10) + + def test_invalid_sell(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.sell("162411", 200, 1e10) + + def test_auto_ipo(self): + self._user.auto_ipo() + + def test_invalid_repo(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.repo("204001", 100, 1) + + def test_invalid_reverse_repo(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.reverse_repo("204001", 1, 100) + + +@unittest.skipUnless("htzq" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip htzq test") +class TestHTZQClientTrader(unittest.TestCase): + @classmethod + def setUpClass(cls): + import easytrader + + if "htzq" not in TEST_CLIENTS: + return + + # input your test account and password + cls._ACCOUNT = os.environ.get("EZ_TEST_HTZQ_ACCOUNT") or "your account" + cls._PASSWORD = os.environ.get("EZ_TEST_HTZQ_PASSWORD") or "your password" + cls._COMM_PASSWORD = ( + os.environ.get("EZ_TEST_HTZQ_COMM_PASSWORD") or "your comm password" + ) + + cls._user = easytrader.use("htzq_client") + + cls._user.prepare( + user=cls._ACCOUNT, password=cls._PASSWORD, comm_password=cls._COMM_PASSWORD + ) + + def test_balance(self): + time.sleep(3) + result = self._user.balance + + def test_today_entrusts(self): + result = self._user.today_entrusts + + def test_today_trades(self): + result = self._user.today_trades + + def test_cancel_entrusts(self): + result = self._user.cancel_entrusts + + def test_cancel_entrust(self): + result = self._user.cancel_entrust("123456789") + + def test_invalid_buy(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.buy("511990", 1, 1e10) + + def test_invalid_sell(self): + import easytrader + + with self.assertRaises(easytrader.exceptions.TradeError): + result = self._user.sell("162411", 200, 1e10) + + def test_auto_ipo(self): + self._user.auto_ipo() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_xq_follower.py b/tests/test_xq_follower.py new file mode 100644 index 00000000..dec9a690 --- /dev/null +++ b/tests/test_xq_follower.py @@ -0,0 +1,240 @@ +# coding:utf-8 +import datetime +import os +import time +import unittest +from unittest import mock + +from easytrader.xq_follower import XueQiuFollower + + +class TestXueQiuTrader(unittest.TestCase): + def test_adjust_sell_amount_without_enable(self): + follower = XueQiuFollower() + + mock_user = mock.MagicMock() + follower._users = [mock_user] + + follower._adjust_sell = False + amount = follower._adjust_sell_amount("169101", 1000) + self.assertEqual(amount, amount) + + def test_adjust_sell_should_only_work_when_sell(self): + follower = XueQiuFollower() + follower._adjust_sell = True + test_transaction = { + "weight": 10, + "prev_weight": 0, + "price": 10, + "stock_symbol": "162411", + "created_at": int(time.time() * 1000), + } + test_assets = 1000 + + mock_adjust_sell_amount = mock.MagicMock() + follower._adjust_sell_amount = mock_adjust_sell_amount + + follower.project_transactions( + transactions=[test_transaction], assets=test_assets + ) + mock_adjust_sell_amount.assert_not_called() + + mock_adjust_sell_amount.reset_mock() + test_transaction["prev_weight"] = test_transaction["weight"] + 1 + follower.project_transactions( + transactions=[test_transaction], assets=test_assets + ) + mock_adjust_sell_amount.assert_called() + + def test_adjust_sell_amount(self): + follower = XueQiuFollower() + + mock_user = mock.MagicMock() + follower._users = [mock_user] + mock_user.position = TEST_POSITION + + follower._adjust_sell = True + test_cases = [ + ("169101", 600, 600), + ("169101", 700, 600), + ("000000", 100, 100), + ("sh169101", 700, 600), + ] + for stock_code, sell_amount, excepted_amount in test_cases: + amount = follower._adjust_sell_amount(stock_code, sell_amount) + self.assertEqual(amount, excepted_amount) + + def test_slippage_with_default(self): + follower = XueQiuFollower() + mock_user = mock.MagicMock() + + # test default no slippage + test_price = 1.0 + test_trade_cmd = { + "strategy": "test_strategy", + "strategy_name": "test_strategy", + "action": "buy", + "stock_code": "162411", + "amount": 100, + "price": 1.0, + "datetime": datetime.datetime.now(), + } + follower._execute_trade_cmd( + trade_cmd=test_trade_cmd, + users=[mock_user], + expire_seconds=10, + entrust_prop="limit", + send_interval=10, + ) + _, kwargs = getattr(mock_user, test_trade_cmd["action"]).call_args + self.assertAlmostEqual(kwargs["price"], test_price) + + def test_slippage(self): + follower = XueQiuFollower() + mock_user = mock.MagicMock() + + test_price = 1.0 + follower.slippage = 0.05 + + # test buy + test_trade_cmd = { + "strategy": "test_strategy", + "strategy_name": "test_strategy", + "action": "buy", + "stock_code": "162411", + "amount": 100, + "price": 1.0, + "datetime": datetime.datetime.now(), + } + follower._execute_trade_cmd( + trade_cmd=test_trade_cmd, + users=[mock_user], + expire_seconds=10, + entrust_prop="limit", + send_interval=10, + ) + excepted_price = test_price * (1 + follower.slippage) + _, kwargs = getattr(mock_user, test_trade_cmd["action"]).call_args + self.assertAlmostEqual(kwargs["price"], excepted_price) + + # test sell + test_trade_cmd["action"] = "sell" + follower._execute_trade_cmd( + trade_cmd=test_trade_cmd, + users=[mock_user], + expire_seconds=10, + entrust_prop="limit", + send_interval=10, + ) + excepted_price = test_price * (1 - follower.slippage) + _, kwargs = getattr(mock_user, test_trade_cmd["action"]).call_args + self.assertAlmostEqual(kwargs["price"], excepted_price) + + +class TestXqFollower(unittest.TestCase): + def setUp(self): + self.follower = XueQiuFollower() + cookies = os.getenv("EZ_TEST_XQ_COOKIES") + if not cookies: + return + self.follower.login(cookies=cookies) + + def test_extract_transactions(self): + result = self.follower.extract_transactions(TEST_XQ_PORTOFOLIO_HISTORY) + self.assertTrue(len(result) == 1) + + +TEST_POSITION = [ + { + "Unnamed: 14": "", + "买入冻结": 0, + "交易市场": "深A", + "卖出冻结": 0, + "参考市价": 1.464, + "参考市值": 919.39, + "参考成本价": 1.534, + "参考盈亏": -43.77, + "可用余额": 628, + "当前持仓": 628, + "盈亏比例(%)": -4.544, + "股东代码": "0000000000", + "股份余额": 628, + "证券代码": "169101", + } +] + +TEST_XQ_PORTOFOLIO_HISTORY = { + "count": 1, + "page": 1, + "totalCount": 17, + "list": [ + { + "id": 1, + "status": "pending", + "cube_id": 1, + "prev_bebalancing_id": 1, + "category": "user_rebalancing", + "exe_strategy": "intraday_all", + "created_at": 1, + "updated_at": 1, + "cash_value": 0.1, + "cash": 100.0, + "error_code": "1", + "error_message": None, + "error_status": None, + "holdings": None, + "rebalancing_histories": [ + { + "id": 1, + "rebalancing_id": 1, + "stock_id": 1023662, + "stock_name": "华宝油气", + "stock_symbol": "SZ162411", + "volume": 0.0, + "price": None, + "net_value": 0.0, + "weight": 0.0, + "target_weight": 0.1, + "prev_weight": None, + "prev_target_weight": None, + "prev_weight_adjusted": None, + "prev_volume": None, + "prev_price": None, + "prev_net_value": None, + "proactive": True, + "created_at": 1554339333333, + "updated_at": 1554339233333, + "target_volume": 0.00068325, + "prev_target_volume": None, + }, + { + "id": 2, + "rebalancing_id": 1, + "stock_id": 1023662, + "stock_name": "华宝油气", + "stock_symbol": "SZ162411", + "volume": 0.0, + "price": 0.55, + "net_value": 0.0, + "weight": 0.0, + "target_weight": 0.1, + "prev_weight": None, + "prev_target_weight": None, + "prev_weight_adjusted": None, + "prev_volume": None, + "prev_price": None, + "prev_net_value": None, + "proactive": True, + "created_at": 1554339333333, + "updated_at": 1554339233333, + "target_volume": 0.00068325, + "prev_target_volume": None, + }, + ], + "comment": "", + "diff": 0.0, + "new_buy_count": 0, + } + ], + "maxPage": 17, +} diff --git a/tests/test_xqtrader.py b/tests/test_xqtrader.py new file mode 100644 index 00000000..22abf274 --- /dev/null +++ b/tests/test_xqtrader.py @@ -0,0 +1,18 @@ +# coding: utf-8 +import unittest + +from easytrader.xqtrader import XueQiuTrader + + +class TestXueQiuTrader(unittest.TestCase): + def test_prepare_account(self): + user = XueQiuTrader() + params_without_cookies = dict( + portfolio_code="ZH123456", portfolio_market="cn" + ) + with self.assertRaises(TypeError): + user._prepare_account(**params_without_cookies) + + params_without_cookies.update(cookies="123") + user._prepare_account(**params_without_cookies) + self.assertEqual(params_without_cookies, user.account_config) diff --git a/xczq.json b/xczq.json deleted file mode 100644 index 9a22b100..00000000 --- a/xczq.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "account": "客户号", - "password": "密码" -} diff --git a/xq.json b/xq.json index a40f936e..9690b683 100644 --- a/xq.json +++ b/xq.json @@ -1,7 +1,5 @@ { - "username": "邮箱", - "account": "手机号", - "password": "密码", + "cookies": "雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/", "portfolio_code": "组合代码(例:ZH818559)", "portfolio_market": "交易市场(例:us 或者 cn 或者 hk)" } diff --git a/yh.json b/yh.json deleted file mode 100644 index 57b56167..00000000 --- a/yh.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "inputaccount": "客户号", - "trdpwd": "加密后的密码" -} -