├── .bumpversion.cfg ├── .coveragerc ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .pylintrc ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Pipfile ├── README.md ├── docs ├── follow.md ├── index.md ├── install.md ├── miniqmt.md ├── remote.md ├── usage.md └── xueqiu.md ├── easytrader ├── __init__.py ├── api.py ├── clienttrader.py ├── config │ ├── __init__.py │ ├── client.py │ ├── global.json │ └── xq.json ├── exceptions.py ├── follower.py ├── gf_clienttrader.py ├── gj_clienttrader.py ├── grid_strategies.py ├── ht_clienttrader.py ├── htzq_clienttrader.py ├── joinquant_follower.py ├── log.py ├── miniqmt │ ├── __init__.py │ └── miniqmt_trader.py ├── pop_dialog_handler.py ├── refresh_strategies.py ├── remoteclient.py ├── ricequant_follower.py ├── server.py ├── universal_clienttrader.py ├── utils │ ├── __init__.py │ ├── captcha.py │ ├── misc.py │ ├── perf.py │ ├── stock.py │ └── win_gui.py ├── webtrader.py ├── wk_clienttrader.py ├── xq_follower.py ├── xqtrader.py └── yh_clienttrader.py ├── gj_client.json ├── mkdocs.yml ├── mypy.ini ├── readthedocs-requirements.txt ├── requirements.txt ├── setup.py ├── test-requirements.txt ├── tests ├── __init__.py ├── test_easytrader.py ├── test_xq_follower.py └── test_xqtrader.py ├── xq.json └── yh_client.json /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.23.7 3 | commit = True 4 | files = easytrader/__init__.py setup.py 5 | tag = True 6 | tag_name = {new_version} 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = easytrader/* 4 | omit = tests/* 5 | 6 | [report] 7 | fail_under = -1 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## env 2 | 3 | OS: win7/ win10 / mac / linux 4 | PYTHON_VERSION: 3.x 5 | EASYTRADER_VERSION: 0.xx.xx 6 | BROKER_TYPE: gj / ht / xq / xxx 7 | 8 | ## problem 9 | 10 | ## how to repeat 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | site 2 | cmd_cache.pk 3 | bak 4 | .mypy_cache 5 | .pyre 6 | .pytest_cache 7 | yjb_account.json 8 | htt.json 9 | gft.json 10 | test.py 11 | ht_account.json 12 | .idea 13 | .vscode 14 | .ipynb_checkpoints 15 | Untitled.ipynb 16 | untitled.txt 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | account.json 21 | account.session 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | env/ 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *,cover 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # cache 77 | tmp/ 78 | 79 | secrets/ 80 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns=\d{4}.+\.py, 15 | test, 16 | apps.py, 17 | __init__.py, 18 | urls.py, 19 | manage.py 20 | 21 | # Python code to execute, usually for sys.path manipulation such as 22 | # pygtk.require(). 23 | #init-hook= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=0 27 | 28 | # List of plugins (as comma separated values of python modules names) to load, 29 | # usually to register additional checkers. 30 | load-plugins= 31 | 32 | # Pickle collected data for later comparisons. 33 | persistent=yes 34 | 35 | # Specify a configuration file. 36 | #rcfile= 37 | 38 | # When enabled, pylint would attempt to guess common misconfiguration and emit 39 | # user-friendly hints instead of false-positive error messages 40 | suggestion-mode=yes 41 | 42 | # Allow loading of arbitrary C extensions. Extensions are imported into the 43 | # active Python interpreter and may run arbitrary code. 44 | unsafe-load-any-extension=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Disable the message, report, category or checker with the given id(s). You 54 | # can either give multiple identifiers separated by comma (,) or put this 55 | # option multiple times (only on the command line, not in the configuration 56 | # file where it should appear only once).You can also use "--disable=all" to 57 | # disable everything first and then reenable specific checks. For example, if 58 | # you want to run only the similarities checker, you can use "--disable=all 59 | # --enable=similarities". If you want to run only the classes checker, but have 60 | # no Warning level messages displayed, use"--disable=all --enable=classes 61 | # --disable=W" 62 | disable=too-many-public-methods, 63 | len-as-condition, 64 | unused-argument, 65 | too-many-arguments, 66 | arguments-differ, 67 | line-too-long, 68 | fixme, 69 | missing-docstring, 70 | invalid-envvar-default, 71 | ungrouped-imports, 72 | bad-continuation, 73 | too-many-ancestors, 74 | too-few-public-methods, 75 | no-self-use, 76 | #print-statement, 77 | #parameter-unpacking, 78 | #unpacking-in-except, 79 | #old-raise-syntax, 80 | #backtick, 81 | #long-suffix, 82 | #old-ne-operator, 83 | #old-octal-literal, 84 | #import-star-module-level, 85 | #non-ascii-bytes-literal, 86 | #raw-checker-failed, 87 | #bad-inline-option, 88 | #locally-disabled, 89 | #locally-enabled, 90 | #file-ignored, 91 | #suppressed-message, 92 | #useless-suppression, 93 | #deprecated-pragma, 94 | #apply-builtin, 95 | #basestring-builtin, 96 | #buffer-builtin, 97 | #cmp-builtin, 98 | #coerce-builtin, 99 | #execfile-builtin, 100 | #file-builtin, 101 | #long-builtin, 102 | #raw_input-builtin, 103 | #reduce-builtin, 104 | #standarderror-builtin, 105 | #unicode-builtin, 106 | #xrange-builtin, 107 | #coerce-method, 108 | #delslice-method, 109 | #getslice-method, 110 | #setslice-method, 111 | #no-absolute-import, 112 | #old-division, 113 | #dict-iter-method, 114 | #dict-view-method, 115 | #next-method-called, 116 | #metaclass-assignment, 117 | #indexing-exception, 118 | #raising-string, 119 | #reload-builtin, 120 | #oct-method, 121 | #hex-method, 122 | #nonzero-method, 123 | #cmp-method, 124 | #input-builtin, 125 | #round-builtin, 126 | #intern-builtin, 127 | #unichr-builtin, 128 | #map-builtin-not-iterating, 129 | #zip-builtin-not-iterating, 130 | #range-builtin-not-iterating, 131 | #filter-builtin-not-iterating, 132 | #using-cmp-argument, 133 | #eq-without-hash, 134 | #div-method, 135 | #idiv-method, 136 | #rdiv-method, 137 | #exception-message-attribute, 138 | #invalid-str-codec, 139 | #sys-max-int, 140 | #bad-python3-import, 141 | #deprecated-string-function, 142 | #deprecated-str-translate-call, 143 | #deprecated-itertools-function, 144 | #deprecated-types-field, 145 | #next-method-defined, 146 | #dict-items-not-iterating, 147 | #dict-keys-not-iterating, 148 | #dict-values-not-iterating 149 | 150 | # Enable the message, report, category or checker with the given id(s). You can 151 | # either give multiple identifier separated by comma (,) or put this option 152 | # multiple time (only on the command line, not in the configuration file where 153 | # it should appear only once). See also the "--disable" option for examples. 154 | enable=c-extension-no-member 155 | 156 | 157 | [REPORTS] 158 | 159 | # Python expression which should return a note less than 10 (10 is the highest 160 | # note). You have access to the variables errors warning, statement which 161 | # respectively contain the number of errors / warnings messages and the total 162 | # number of statements analyzed. This is used by the global evaluation report 163 | # (RP0004). 164 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 165 | 166 | # Template used to display messages. This is a python new-style format string 167 | # used to format the message information. See doc for all details 168 | #msg-template= 169 | 170 | # Set the output format. Available formats are text, parseable, colorized, json 171 | # and msvs (visual studio).You can also give a reporter class, eg 172 | # mypackage.mymodule.MyReporterClass. 173 | output-format=text 174 | 175 | # Tells whether to display a full report or only the messages 176 | reports=no 177 | 178 | # Activate the evaluation score. 179 | score=yes 180 | 181 | 182 | [REFACTORING] 183 | 184 | # Maximum number of nested blocks for function / method body 185 | max-nested-blocks=5 186 | 187 | # Complete name of functions that never returns. When checking for 188 | # inconsistent-return-statements if a never returning function is called then 189 | # it will be considered as an explicit return statement and no message will be 190 | # printed. 191 | never-returning-functions=optparse.Values,sys.exit 192 | 193 | 194 | [BASIC] 195 | 196 | # Naming style matching correct argument names 197 | argument-naming-style=snake_case 198 | 199 | # Regular expression matching correct argument names. Overrides argument- 200 | # naming-style 201 | #argument-rgx= 202 | 203 | # Naming style matching correct attribute names 204 | attr-naming-style=snake_case 205 | 206 | # Regular expression matching correct attribute names. Overrides attr-naming- 207 | # style 208 | #attr-rgx= 209 | 210 | # Bad variable names which should always be refused, separated by a comma 211 | bad-names=foo, 212 | bar, 213 | baz, 214 | toto, 215 | tutu, 216 | tata 217 | 218 | # Naming style matching correct class attribute names 219 | class-attribute-naming-style=any 220 | 221 | # Regular expression matching correct class attribute names. Overrides class- 222 | # attribute-naming-style 223 | #class-attribute-rgx= 224 | 225 | # Naming style matching correct class names 226 | class-naming-style=PascalCase 227 | 228 | # Regular expression matching correct class names. Overrides class-naming-style 229 | #class-rgx= 230 | 231 | # Naming style matching correct constant names 232 | const-naming-style=any 233 | 234 | # Regular expression matching correct constant names. Overrides const-naming- 235 | # style 236 | #const-rgx= 237 | 238 | # Minimum line length for functions/classes that require docstrings, shorter 239 | # ones are exempt. 240 | docstring-min-length=5 241 | 242 | # Naming style matching correct function names 243 | function-naming-style=snake_case 244 | 245 | # Regular expression matching correct function names. Overrides function- 246 | # naming-style 247 | #function-rgx= 248 | 249 | # Good variable names which should always be accepted, separated by a comma 250 | good-names=i, 251 | do, 252 | f, 253 | df, 254 | s, 255 | j, 256 | k, 257 | ex, 258 | Run, 259 | _, 260 | db, 261 | r, 262 | x, 263 | y, 264 | e 265 | 266 | # Include a hint for the correct naming format with invalid-name 267 | include-naming-hint=no 268 | 269 | # Naming style matching correct inline iteration names 270 | inlinevar-naming-style=any 271 | 272 | # Regular expression matching correct inline iteration names. Overrides 273 | # inlinevar-naming-style 274 | #inlinevar-rgx= 275 | 276 | # Naming style matching correct method names 277 | method-naming-style=snake_case 278 | 279 | # Regular expression matching correct method names. Overrides method-naming- 280 | # style 281 | #method-rgx= 282 | 283 | # Naming style matching correct module names 284 | module-naming-style=snake_case 285 | 286 | # Regular expression matching correct module names. Overrides module-naming- 287 | # style 288 | #module-rgx= 289 | 290 | # Colon-delimited sets of names that determine each other's naming style when 291 | # the name regexes allow several styles. 292 | name-group= 293 | 294 | # Regular expression which should only match function or class names that do 295 | # not require a docstring. 296 | no-docstring-rgx=^_ 297 | 298 | # List of decorators that produce properties, such as abc.abstractproperty. Add 299 | # to this list to register other decorators that produce valid properties. 300 | property-classes=abc.abstractproperty 301 | 302 | # Naming style matching correct variable names 303 | variable-naming-style=snake_case 304 | 305 | # Regular expression matching correct variable names. Overrides variable- 306 | # naming-style 307 | #variable-rgx= 308 | 309 | 310 | [FORMAT] 311 | 312 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 313 | expected-line-ending-format= 314 | 315 | # Regexp for a line that is allowed to be longer than the limit. 316 | ignore-long-lines=^\s*(# )??$ 317 | 318 | # Number of spaces of indent required inside a hanging or continued line. 319 | indent-after-paren=4 320 | 321 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 322 | # tab). 323 | indent-string=' ' 324 | 325 | # Maximum number of characters on a single line. 326 | max-line-length=79 327 | 328 | # Maximum number of lines in a module 329 | max-module-lines=1000 330 | 331 | # List of optional constructs for which whitespace checking is disabled. `dict- 332 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 333 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 334 | # `empty-line` allows space-only lines. 335 | no-space-check=trailing-comma, 336 | dict-separator 337 | 338 | # Allow the body of a class to be on the same line as the declaration if body 339 | # contains single statement. 340 | single-line-class-stmt=no 341 | 342 | # Allow the body of an if to be on the same line as the test if there is no 343 | # else. 344 | single-line-if-stmt=no 345 | 346 | 347 | [LOGGING] 348 | 349 | # Logging modules to check that the string format arguments are in logging 350 | # function parameter format 351 | logging-modules=logging 352 | 353 | 354 | [MISCELLANEOUS] 355 | 356 | # List of note tags to take in consideration, separated by a comma. 357 | notes=FIXME, 358 | XXX, 359 | TODO 360 | 361 | 362 | [SIMILARITIES] 363 | 364 | # Ignore comments when computing similarities. 365 | ignore-comments=yes 366 | 367 | # Ignore docstrings when computing similarities. 368 | ignore-docstrings=yes 369 | 370 | # Ignore imports when computing similarities. 371 | ignore-imports=no 372 | 373 | # Minimum lines number of a similarity. 374 | min-similarity-lines=4 375 | 376 | 377 | [SPELLING] 378 | 379 | # Limits count of emitted suggestions for spelling mistakes 380 | max-spelling-suggestions=4 381 | 382 | # Spelling dictionary name. Available dictionaries: none. To make it working 383 | # install python-enchant package. 384 | spelling-dict= 385 | 386 | # List of comma separated words that should not be checked. 387 | spelling-ignore-words= 388 | 389 | # A path to a file that contains private dictionary; one word per line. 390 | spelling-private-dict-file= 391 | 392 | # Tells whether to store unknown words to indicated private dictionary in 393 | # --spelling-private-dict-file option instead of raising a message. 394 | spelling-store-unknown-words=no 395 | 396 | 397 | [TYPECHECK] 398 | 399 | # List of decorators that produce context managers, such as 400 | # contextlib.contextmanager. Add to this list to register other decorators that 401 | # produce valid context managers. 402 | contextmanager-decorators=contextlib.contextmanager 403 | 404 | # List of members which are set dynamically and missed by pylint inference 405 | # system, and so shouldn't trigger E1101 when accessed. Python regular 406 | # expressions are accepted. 407 | generated-members= 408 | 409 | # Tells whether missing members accessed in mixin class should be ignored. A 410 | # mixin class is detected if its name ends with "mixin" (case insensitive). 411 | ignore-mixin-members=yes 412 | 413 | # This flag controls whether pylint should warn about no-member and similar 414 | # checks whenever an opaque object is returned when inferring. The inference 415 | # can return multiple potential results while evaluating a Python object, but 416 | # some branches might not be evaluated, which results in partial inference. In 417 | # that case, it might be useful to still emit no-member and other checks for 418 | # the rest of the inferred objects. 419 | ignore-on-opaque-inference=yes 420 | 421 | # List of class names for which member attributes should not be checked (useful 422 | # for classes with dynamically set attributes). This supports the use of 423 | # qualified names. 424 | ignored-classes=optparse.Values,thread._local,_thread._local 425 | 426 | # List of module names for which member attributes should not be checked 427 | # (useful for modules/projects where namespaces are manipulated during runtime 428 | # and thus existing member attributes cannot be deduced by static analysis. It 429 | # supports qualified module names, as well as Unix pattern matching. 430 | ignored-modules= 431 | 432 | # Show a hint with possible names when a member name was not found. The aspect 433 | # of finding the hint is based on edit distance. 434 | missing-member-hint=yes 435 | 436 | # The minimum edit distance a name should have in order to be considered a 437 | # similar match for a missing member name. 438 | missing-member-hint-distance=1 439 | 440 | # The total number of similar names that should be taken in consideration when 441 | # showing a hint for a missing member. 442 | missing-member-max-choices=1 443 | 444 | 445 | [VARIABLES] 446 | 447 | # List of additional names supposed to be defined in builtins. Remember that 448 | # you should avoid to define new builtins when possible. 449 | additional-builtins= 450 | 451 | # Tells whether unused global variables should be treated as a violation. 452 | allow-global-unused-variables=yes 453 | 454 | # List of strings which can identify a callback function by name. A callback 455 | # name must start or end with one of those strings. 456 | callbacks=cb_, 457 | _cb 458 | 459 | # A regular expression matching the name of dummy variables (i.e. expectedly 460 | # not used). 461 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 462 | 463 | # Argument names that match this expression will be ignored. Default to name 464 | # with leading underscore 465 | ignored-argument-names=_.*|^ignored_|^unused_ 466 | 467 | # Tells whether we should check for unused import in __init__ files. 468 | init-import=no 469 | 470 | # List of qualified module names which can have objects that can redefine 471 | # builtins. 472 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 473 | 474 | 475 | [CLASSES] 476 | 477 | # List of method names used to declare (i.e. assign) instance attributes. 478 | defining-attr-methods=__init__, 479 | __new__, 480 | setUp 481 | 482 | # List of member names, which should be excluded from the protected access 483 | # warning. 484 | exclude-protected=_asdict, 485 | _fields, 486 | _replace, 487 | _source, 488 | _make 489 | 490 | # List of valid names for the first argument in a class method. 491 | valid-classmethod-first-arg=cls 492 | 493 | # List of valid names for the first argument in a metaclass class method. 494 | valid-metaclass-classmethod-first-arg=mcs 495 | 496 | 497 | [DESIGN] 498 | 499 | # Maximum number of arguments for function / method 500 | max-args=5 501 | 502 | # Maximum number of attributes for a class (see R0902). 503 | max-attributes=7 504 | 505 | # Maximum number of boolean expressions in a if statement 506 | max-bool-expr=5 507 | 508 | # Maximum number of branch for function / method body 509 | max-branches=20 510 | 511 | # Maximum number of locals for function / method body 512 | max-locals=20 513 | 514 | # Maximum number of parents for a class (see R0901). 515 | max-parents=7 516 | 517 | # Maximum number of public methods for a class (see R0904). 518 | max-public-methods=20 519 | 520 | # Maximum number of return / yield for function / method body 521 | max-returns=6 522 | 523 | # Maximum number of statements in function / method body 524 | max-statements=50 525 | 526 | # Minimum number of public methods for a class (see R0903). 527 | min-public-methods=2 528 | 529 | 530 | [IMPORTS] 531 | 532 | # Allow wildcard imports from modules that define __all__. 533 | allow-wildcard-with-all=no 534 | 535 | # Analyse import fallback blocks. This can be used to support both Python 2 and 536 | # 3 compatible code, which means that the block might have code that exists 537 | # only in one or another interpreter, leading to false positives when analysed. 538 | analyse-fallback-blocks=no 539 | 540 | # Deprecated modules which should not be used, separated by a comma 541 | deprecated-modules=regsub, 542 | TERMIOS, 543 | Bastion, 544 | rexec 545 | 546 | # Create a graph of external dependencies in the given file (report RP0402 must 547 | # not be disabled) 548 | ext-import-graph= 549 | 550 | # Create a graph of every (i.e. internal and external) dependencies in the 551 | # given file (report RP0402 must not be disabled) 552 | import-graph= 553 | 554 | # Create a graph of internal dependencies in the given file (report RP0402 must 555 | # not be disabled) 556 | int-import-graph= 557 | 558 | # Force import order to recognize a module as part of the standard 559 | # compatibility libraries. 560 | known-standard-library= 561 | 562 | # Force import order to recognize a module as part of a third party library. 563 | known-third-party=enchant 564 | 565 | 566 | [EXCEPTIONS] 567 | 568 | # Exceptions that will emit a warning when being caught. Defaults to 569 | # "Exception" 570 | overgeneral-exceptions=Exception 571 | 572 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.9" 7 | 8 | mkdocs: 9 | configuration: mkdocs.yml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 shidenggui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | pytest -vx --cov=easytrader tests 3 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "http://mirrors.aliyun.com/pypi/simple/" 3 | verify_ssl = false 4 | name = "pypi" 5 | 6 | [packages] 7 | pywinauto = "*" 8 | "bs4" = "*" 9 | requests = "*" 10 | dill = "*" 11 | click = "*" 12 | six = "*" 13 | flask = "*" 14 | pillow = "*" 15 | pytesseract = "*" 16 | pandas = "*" 17 | pyperclip = "*" 18 | easyutils = "*" 19 | 20 | [dev-packages] 21 | pytest-cov = "*" 22 | pre-commit = "*" 23 | pytest = "*" 24 | pylint = "*" 25 | mypy = "*" 26 | isort = "*" 27 | black = "==18.6b4" 28 | ipython = "*" 29 | better-exceptions = "*" 30 | 31 | [requires] 32 | python_version = "3.6" 33 | 34 | [scripts] 35 | sort_imports = "bash -c 'isort \"$@\"; git add -u' --" 36 | format = "bash -c 'black -l 79 \"$@\"; git add -u' --" 37 | lint = "pylint" 38 | type_check = "mypy" 39 | test = "bash -c 'pytest -vx --cov=easytrader tests'" 40 | lock = "bash -c 'pipenv lock -r > requirements.txt'" 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # easytrader 2 | 3 | [![Package](https://img.shields.io/pypi/v/easytrader.svg)](https://pypi.python.org/pypi/easytrader) 4 | [![License](https://img.shields.io/github/license/shidenggui/easytrader.svg)](https://github.com/shidenggui/easytrader/blob/master/LICENSE) 5 | 6 | * 进行股票量化交易 7 | * 通用的同花顺客户端模拟操作 8 | * 支持券商的 [miniqmt](https://easytrader.readthedocs.io/zh-cn/master/miniqmt/) 官方量化接口 9 | * 支持雪球组合调仓和跟踪 10 | * 支持远程操作客户端 11 | * 支持跟踪 `joinquant`, `ricequant` 的模拟交易 12 | 13 | 14 | ### 微信群以及公众号 15 | 16 | 欢迎大家扫码关注公众号「食灯鬼」,一起交流。进群可通过菜单加我好友,备注量化。 17 | 18 | ![公众号二维码](https://camo.githubusercontent.com/6fad032c27b30b68a9d942ae77f8cc73933b95cea58e684657d31b94a300afd5/68747470733a2f2f67697465652e636f6d2f73686964656e676775692f6173736574732f7261772f6d61737465722f755069632f6d702d71722e706e67) 19 | 20 | 若二维码因 Github 网络无法打开,请点击[公众号二维码](https://camo.githubusercontent.com/6fad032c27b30b68a9d942ae77f8cc73933b95cea58e684657d31b94a300afd5/68747470733a2f2f67697465652e636f6d2f73686964656e676775692f6173736574732f7261772f6d61737465722f755069632f6d702d71722e706e67)直接打开图片。 21 | 22 | ### Author 23 | 24 | > Blog [@shidenggui](https://shidenggui.com) · Weibo [@食灯鬼](https://www.weibo.com/u/1651274491) · Twitter [@shidenggui](https://twitter.com/shidenggui) 25 | 26 | ### 相关 27 | 28 | * [easyquotation 实时获取全市场股票行情](https://github.com/shidenggui/easyquotation) 29 | * [easyquant 简单的量化框架](https://github.com/shidenggui/easyqutant) 30 | 31 | 32 | ### 模拟交易 33 | 34 | * 雪球组合 by @[haogefeifei](https://github.com/haogefeifei)([说明](doc/xueqiu.md)) 35 | 36 | ### 使用文档 37 | 38 | [中文文档](https://easytrader.readthedocs.io/) 39 | -------------------------------------------------------------------------------- /docs/follow.md: -------------------------------------------------------------------------------- 1 | # 策略跟踪 2 | 3 | ## 跟踪 `joinquant` / `ricequant` 的模拟交易 4 | 5 | ##### 1) 初始化跟踪的 trader 6 | 7 | 这里以雪球为例, 也可以使用银河之类 `easytrader` 支持的券商 8 | 9 | ``` 10 | xq_user = easytrader.use('xq') 11 | xq_user.prepare('xq.json') 12 | ``` 13 | 14 | ##### 2) 初始化跟踪 `joinquant` / `ricequant` 的 follower 15 | 16 | ``` 17 | target = 'jq' # joinquant 18 | target = 'rq' # ricequant 19 | follower = easytrader.follower(target) 20 | follower.login(user='rq/jq用户名', password='rq/jq密码') 21 | ``` 22 | 23 | ##### 3) 连接 follower 和 trader 24 | 25 | ##### joinquant 26 | ``` 27 | follower.follow(xq_user, 'jq的模拟交易url') 28 | ``` 29 | 30 | 注: jq的模拟交易url指的是对应模拟交易对应的可以查看持仓, 交易记录的页面, 类似 `https://www.joinquant.com/algorithm/live/index?backtestId=xxx` 31 | 32 | 正常会输出 33 | 34 | ![enjoy it](https://raw.githubusercontent.com/shidenggui/assets/master/easytrader/joinquant.jpg) 35 | 36 | 注: 启动后发现跟踪策略无输出,那是因为今天模拟交易没有调仓或者接收到的调仓信号过期了,默认只处理120s内的信号,想要测试的可以用下面的命令: 37 | 38 | ```python 39 | jq_follower.follow(user, '模拟交易url', 40 | trade_cmd_expire_seconds=100000000000, cmd_cache=False) 41 | ``` 42 | 43 | - trade_cmd_expire_seconds 默认处理多少秒内的信号 44 | 45 | - cmd_cache 是否读取已经执行过的命令缓存,以防止重复执行 46 | 47 | 目录下产生的 cmd_cache.pk,是用来存储历史执行过的交易指令,防止在重启程序时重复执行交易过的指令,可以通过 `follower.follow(xxx, cmd_cache=False)` 来关闭。 48 | 49 | ##### ricequant 50 | 51 | ``` 52 | follower.follow(xq_user, run_id) 53 | ``` 54 | 注:ricequant的run_id即PT列表中的ID。 55 | 56 | 57 | ## 跟踪雪球的组合 58 | 59 | ##### 1) 初始化跟踪的 trader 60 | 61 | 同上 62 | 63 | ##### 2) 初始化跟踪 雪球组合 的 follower 64 | 65 | ``` 66 | xq_follower = easytrader.follower('xq') 67 | xq_follower.login(cookies='雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/') 68 | ``` 69 | 70 | ##### 3) 连接 follower 和 trader 71 | 72 | ``` 73 | xq_follower.follow(xq_user, 'xq组合ID,类似ZH123456', total_assets=100000) 74 | ``` 75 | 76 | 77 | 注: 雪球组合是以百分比调仓的, 所以需要额外设置组合对应的资金额度 78 | 79 | * 这里可以设置 total_assets, 为当前组合的净值对应的总资金额度, 具体可以参考参数说明 80 | * 或者设置 initial_assets, 这时候总资金额度为 initial_assets * 组合净值 81 | 82 | * 雪球额外支持 adjust_sell 参数,决定是否根据用户的实际持仓数调整卖出股票数量,解决雪球根据百分比调仓时计算出的股数有偏差的问题。当卖出股票数大于实际持仓数时,调整为实际持仓数。目前仅在银河客户端测试通过。 当 users 为多个时,根据第一个 user 的持仓数决定 83 | 84 | 85 | #### 3. 多用户跟踪多策略 86 | 87 | ``` 88 | follower.follow(users=[xq_user, yh_user], strategies=['组合1', '组合2'], total_assets=[10000, 10000]) 89 | ``` 90 | 91 | #### 4. 其它与跟踪有关的问题 92 | 93 | 使用市价单跟踪模式,目前仅支持银河 94 | 95 | ``` 96 | follower.follow(***, entrust_prop='market') 97 | ``` 98 | 99 | 调整下单间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 100 | 101 | ``` 102 | follower.follow(***, send_interval=30) # 设置下单间隔为 30 s 103 | ``` 104 | 设置买卖时的滑点 105 | 106 | ``` 107 | follower.follow(***, slippage=0.05) # 设置滑点为 5% 108 | ``` 109 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 简介 2 | 3 | * 通用的同花顺客户端模拟操作 4 | * 支持券商的 [miniqmt](miniqmt.md) 官方量化接口 5 | * 支持雪球组合调仓和跟踪 6 | * 支持远程操作客户端 7 | * 支持跟踪 `joinquant`, `ricequant` 的模拟交易 8 | 9 | ### 加微信群以及公众号 10 | 11 | 欢迎大家扫码关注公众号"食灯鬼",通过菜单加我好友,备注量化进群 12 | 13 | ![JDRUhz](https://camo.githubusercontent.com/6fad032c27b30b68a9d942ae77f8cc73933b95cea58e684657d31b94a300afd5/68747470733a2f2f67697465652e636f6d2f73686964656e676775692f6173736574732f7261772f6d61737465722f755069632f6d702d71722e706e67) 14 | 15 | 16 | ### 支持券商 17 | 18 | 19 | * 海通客户端(海通网上交易系统独立委托) 20 | * 华泰客户端(网上交易系统(专业版Ⅱ)) 21 | * 国金客户端(全能行证券交易终端PC版) 22 | * 通用同花顺客户端(同花顺免费版) 23 | * 其他券商专用同花顺客户端(需要手动登陆) 24 | 25 | 26 | ### 模拟交易 27 | 28 | * 雪球组合 by @[haogefeifei](https://github.com/haogefeifei)([说明](xueqiu.md)) 29 | 30 | 31 | 32 | ### 作者 33 | 34 | > Blog [@shidenggui](https://shidenggui.com) · Weibo [@食灯鬼](https://www.weibo.com/u/1651274491) · Twitter [@shidenggui](https://twitter.com/shidenggui) 35 | > 36 | 37 | **其他作品** 38 | 39 | * [easyquotation 实时获取全市场股票行情](https://github.com/shidenggui/easyquotation) 40 | * [easyquant 简单的量化框架](https://github.com/shidenggui/easyqutant) 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # 安装 2 | 3 | ### 同花顺客户端设置 4 | 5 | 需要对客户端按以下设置,不然会导致下单时价格出错以及客户端超时锁定 6 | 7 | * 系统设置 > 界面设置: 界面不操作超时时间设为 0 8 | * 系统设置 > 交易设置: 默认买入价格/买入数量/卖出价格/卖出数量 都设置为 空 9 | 10 | 同时客户端不能最小化也不能处于精简模式 11 | 12 | ### 云端部署建议 13 | 14 | 在云服务上部署时,使用自带的远程桌面会有问题,推荐使用 TightVNC 15 | 16 | ### 登陆时的验证码识别 17 | 18 | 券商如果登陆需要识别验证码的话需要安装 tesseract: 19 | 20 | * `tesseract` : 非 `pytesseract`, 需要单独安装, [地址](https://github.com/tesseract-ocr/tesseract/wiki),保证在命令行下 `tesseract` 可用 21 | 22 | 或者你也可以手动登陆后在通过 `easytrader` 调用,此时 `easytrader` 在登陆过程中会直接识别到已登陆的窗口。 23 | 24 | ### 安装 25 | 26 | ```shell 27 | pip install easytrader 28 | ``` 29 | 30 | ### 升级 31 | 32 | ```shell 33 | pip install easytrader -U 34 | ``` 35 | 36 | -------------------------------------------------------------------------------- /docs/miniqmt.md: -------------------------------------------------------------------------------- 1 | # miniqmt 2 | 3 | miniqmt 是券商官方的低门槛 Python 量化交易接口,基于券商的讯投 QMT 服务。详情可以[进群](https://easytrader.readthedocs.io/zh-cn/master/#_2)交流。 4 | 5 | ## 安装 miniqmt 组件 6 | 7 | miniqmt 功能依赖 `xtquant` 库,因为这个库比较大(100 MB+),所以需要单独安装 8 | 9 | ```python 10 | pip install easytrader[miniqmt] 11 | ``` 12 | 13 | ## 引入 14 | 15 | ```python 16 | import easytrader 17 | ``` 18 | 19 | ## 初始化客户端 20 | 21 | ```python 22 | user = easytrader.use('miniqmt') 23 | ``` 24 | 25 | ## 连接 QMT 客户端 26 | 27 | 需要通过 `connect` 方法连接到 QMT 客户端。 28 | 29 | **注意:登录 QMT 客户端时必须勾选极简模式/独立交易模式,否则无法连接** 30 | 31 | ```python 32 | user.connect( 33 | miniqmt_path=r"D:\国金证券QMT交易端\userdata_mini", # QMT 客户端下的 miniqmt 安装路径 34 | stock_account="你的资金账号", # 资金账号 35 | trader_callback=None, # 默认使用 `easytrader.miniqmt.DefaultXtQuantTraderCallback` 36 | ) 37 | ``` 38 | 39 | **参数说明:** 40 | 41 | - `miniqmt_path`: QMT 客户端下的 miniqmt 安装路径,例如 `r"D:\国金证券QMT交易端\userdata_mini"` 42 | - 注意:不建议安装在 C 盘。在 C 盘每次都需要用管理员权限运行客户端,才能正常连接 43 | - `stock_account`: 资金账号 44 | - `trader_callback`: 交易回调对象,默认使用 `easytrader.miniqmt.DefaultXtQuantTraderCallback` 45 | 46 | ## 交易相关 47 | 48 | ### 获取资金状况 49 | 50 | ```python 51 | user.balance 52 | 53 | # return 54 | # qmt 官网文档 https://dict.thinktrader.net/nativeApi/xttrader.html?id=7zqjlm#%E8%B5%84%E4%BA%A7xtasset 55 | [{ 56 | 'total_asset': 1000000.0, # 总资产 57 | 'market_value': 400000.0, # 持仓市值 58 | 'cash': 600000.0, # 可用资金 59 | 'frozen_cash': 0.0, # 冻结资金 60 | 'account_type': 2, # 账户类型 61 | 'account_id': '你的资金账号' # 账户ID 62 | }] 63 | ``` 64 | 65 | ### 获取持仓 66 | 67 | ```python 68 | user.position 69 | 70 | # return 71 | # qmt 官网文档 https://dict.thinktrader.net/nativeApi/xttrader.html?id=7zqjlm#%E6%8C%81%E4%BB%93xtposition 72 | [{'security': '162411', 73 | 'stock_code': '162411.SZ', 74 | 'volume': 100, 75 | 'can_use_volume': 100, 76 | 'open_price': 0.618, 77 | 'market_value': 63.8, 78 | 'frozen_volume': 0, 79 | 'on_road_volume': 0, 80 | 'yesterday_volume': 100, 81 | 'avg_price': 0.618, 82 | 'direction': 48, 83 | 'account_type': 2, 84 | 'account_id': '1111111111'}] 85 | 86 | ``` 87 | 88 | ### 限价买入 89 | 90 | ```python 91 | user.buy('600036', price=35.5, amount=100) 92 | 93 | # return 94 | {'entrust_no': 123456} 95 | ``` 96 | 97 | **注意事项** 98 | 99 | - 成功发送委托后的订单编号为大于 0 的正整数,如果为 -1 表示委托失败,失败具体原因请查看 `DefaultXtQuantTraderCallback.on_order_error` 回调 100 | - 注:非交易时间下单可以拿到订单编号,但 `on_order_error` 回调会报错: 101 | ``` 102 | 下单失败回调: order_id=10231, error_id=-61, error_msg=限价买入 [SZ162411] [COUNTER] [12313][当前时间不允许此类证券交易] 103 | ``` 104 | 105 | ### 限价卖出 106 | 107 | ```python 108 | user.sell('600036', price=36.0, amount=100) 109 | 110 | # return 111 | {'entrust_no': 123456} 112 | ``` 113 | 114 | ### 市价买入 115 | 116 | ```python 117 | user.market_buy('600036', amount=100, ttype='对手方最优价格委托') 118 | 119 | # return 120 | {'entrust_no': 123456} 121 | ``` 122 | 123 | **市价委托类型(ttype)可选值**: 124 | 125 | 深市可选: 126 | 127 | - 对手方最优价格委托(默认) 128 | - 本方最优价格委托 129 | - 即时成交剩余撤销委托 130 | - 最优五档即时成交剩余撤销 131 | - 全额成交或撤销委托 132 | 133 | 沪市可选: 134 | 135 | - 对手方最优价格委托(默认) 136 | - 最优五档即时成交剩余撤销 137 | - 最优五档即时成交剩转限价 138 | - 本方最优价格委托 139 | 140 | ### 市价卖出 141 | 142 | ```python 143 | user.market_sell('600036', amount=100, ttype='对手方最优价格委托') 144 | 145 | # return 146 | {'entrust_no': 123456} 147 | ``` 148 | 149 | ### 撤单 150 | 151 | ```python 152 | user.cancel_entrust(123456) # 传入之前买入或卖出时返回的订单编号 153 | 154 | # return 155 | {'success': True, 'message': 'success'} # 成功 156 | {'success': False, 'message': 'failed'} # 失败 157 | ``` 158 | 159 | ### 查询当日委托 160 | 161 | ```python 162 | user.today_entrusts 163 | 164 | # return 165 | # qmt 官网文档 https://dict.thinktrader.net/nativeApi/xttrader.html?id=7zqjlm#%E5%A7%94%E6%89%98xtorder 166 | [{'security': '162411', 167 | 'stock_code': '162411.SZ', 168 | 'order_id': 3456, 169 | 'order_sysid': '1111', 170 | 'order_time': 1634278451, 171 | 'order_type': 23, 172 | 'order_type_name': '买入', # ['买入', '卖出'] 173 | 'order_volume': 100, 174 | 'price_type': 50, 175 | 'price_type_name': '限价', 176 | 'price': 0.62, 177 | 'traded_volume': 100, 178 | 'traded_price': 0.613, 179 | 'order_status': 56, 180 | 'order_status_name': '已成', # ['已报', '已成', '部成', '已撤', '部撤'] 181 | 'status_msg': '', 182 | 'offset_flag': 48, 183 | 'offset_flag_name': '买入', # ['买入', '卖出'] 184 | 'strategy_name': '', 185 | 'order_remark': '', 186 | 'direction': 48, 187 | 'direction_name': '多', # ['多', '空'] 188 | 'account_type': 2, 189 | 'account_id': '1111111111'}] 190 | ``` 191 | 192 | ### 查询当日成交 193 | 194 | ```python 195 | user.today_trades 196 | 197 | # return 198 | # qmt 官网文档 https://dict.thinktrader.net/nativeApi/xttrader.html?id=7zqjlm#%E6%88%90%E4%BA%A4xttrade 199 | [{'security': '162411', 200 | 'stock_code': '162411.SZ', 201 | 'traded_id': '0303222200422222', 202 | 'traded_time': 1634278451, 203 | 'traded_price': 0.613, 204 | 'traded_volume': 100, 205 | 'traded_amount': 61.3, 206 | 'order_id': 1111, 207 | 'order_type': 23, 208 | 'order_type_name': '买入', 209 | 'offset_flag': 48, 210 | 'offset_flag_name': '买入', 211 | 'account_id': '1111111111', 212 | 'account_type': 2, 213 | 'order_sysid': '1111', 214 | 'strategy_name': '', 215 | 'order_remark': ''}] 216 | ``` 217 | 218 | 219 | ## 进阶功能 220 | 221 | ### 获取原始交易对象 222 | 223 | 通过获取原始对象,可以直接调用 miniqmt 的接口进行更多高级操作,具体请参考 [miniqmt 官方文档](https://dict.thinktrader.net/nativeApi/xttrader.html) 224 | 225 | ```python 226 | # 获取 XtQuantTrader 对象 227 | trader = user.trader 228 | 229 | # 获取 StockAccount 对象 230 | account = user.account 231 | ``` 232 | 233 | 234 | ### 2. 交易回调处理 235 | 236 | MiniqmtTrader 默认使用 `DefaultXtQuantTraderCallback` 类处理交易回调,但您可以通过继承 `XtQuantTraderCallback` 类来创建自定义回调处理: 237 | 238 | ```python 239 | from xtquant.xttrader import XtQuantTraderCallback 240 | 241 | class MyTraderCallback(XtQuantTraderCallback): 242 | def on_disconnected(self): 243 | print("连接断开") 244 | 245 | def on_account_status(self, status): 246 | print(f"账户状态: {status.account_id}, 状态: {status.status}") 247 | 248 | def on_stock_order(self, order): 249 | print(f"委托回调: {order.stock_code}, 状态: {order.order_status}") 250 | 251 | def on_stock_trade(self, trade): 252 | print(f"成交回调: {trade.stock_code}, 价格: {trade.traded_price}") 253 | 254 | def on_order_error(self, order_error): 255 | print(f"下单失败: {order_error.order_id}, 错误: {order_error.error_msg}") 256 | 257 | def on_cancel_error(self, cancel_error): 258 | print(f"撤单失败: {cancel_error.order_id}, 错误: {cancel_error.error_msg}") 259 | 260 | # 连接时使用自定义回调 261 | user.connect( 262 | miniqmt_path=r"D:\国金证券QMT交易端\userdata_mini", 263 | stock_account="你的资金账号", 264 | trader_callback=MyTraderCallback() 265 | ) 266 | ``` -------------------------------------------------------------------------------- /docs/remote.md: -------------------------------------------------------------------------------- 1 | # 远端服务模式 2 | 3 | 远端服务模式是交易服务端和量化策略端分离的模式。 4 | 5 | **交易服务端**通常是有固定`IP`地址的云服务器,该服务器上运行着`easytrader`交易服务。而**量化策略端**可能是`JoinQuant、RiceQuant、Vn.Py`,物理上与交易服务端不在同一台电脑上。交易服务端被动或主动获取交易信号,并驱动**交易软件**(交易软件包括运行在同一服务器上的下单软件,比如同花顺`xiadan.exe`,或者运行在另一台服务器上的雪球`xq`)。 6 | 7 | 8 | ## 交易服务端——启动服务 9 | 10 | ```python 11 | from easytrader import server 12 | 13 | server.run(port=1430) # 默认端口为 1430 14 | ``` 15 | 16 | ## 量化策略端——调用服务 17 | 18 | ```python 19 | from easytrader import remoteclient 20 | 21 | user = remoteclient.use('使用客户端类型,可选 yh_client, ht_client, ths, xq等', host='服务器ip', port='服务器端口,默认为1430') 22 | 23 | user.buy(......) 24 | 25 | user.sell(......) 26 | ``` 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # 使用 2 | 3 | ## 引入 4 | 5 | ```python 6 | import easytrader 7 | ``` 8 | 9 | ## 设置同花顺客户端类型 10 | 11 | **通用同花顺客户端** 12 | 13 | ```python 14 | user = easytrader.use('universal_client') 15 | ``` 16 | 17 | 注: 通用同花顺客户端是指同花顺官网提供的客户端软件内的下单程序,内含对多个券商的交易支持,适用于券商不直接提供同花顺客户端时的后备方案。 18 | 19 | **其他券商专用同花顺客户端** 20 | 21 | ```python 22 | user = easytrader.use('ths') 23 | ``` 24 | 25 | 注: 其他券商专用同花顺客户端是指对应券商官网提供的基于同花顺修改的软件版本,类似银河的双子星(同花顺版本),国金证券网上交易独立下单程序(核新PC版)等。 26 | 27 | 28 | **雪球组合** 29 | 30 | ```python 31 | user = easytrader.use('xq') 32 | ``` 33 | 34 | **国金客户端** 35 | 36 | ```python 37 | user = easytrader.use('gj_client') 38 | ``` 39 | 40 | **海通客户端** 41 | 42 | ```python 43 | user = easytrader.use('htzq_client') 44 | ``` 45 | 46 | **华泰客户端** 47 | 48 | ```python 49 | user = easytrader.use('ht_client') 50 | ``` 51 | 52 | 53 | ## 启动并连接客户端 54 | 55 | ### (一)其他券商专用同花顺客户端 56 | 57 | 其他券商专用同花顺客户端不支持自动登录,需要先手动登录。 58 | 59 | 请手动打开并登录客户端后,运用connect函数连接客户端。 60 | 61 | ```python 62 | user.connect(r'客户端xiadan.exe路径') # 类似 r'C:\htzqzyb2\xiadan.exe' 63 | ``` 64 | 65 | ### (二)通用同花顺客户端 66 | 67 | 需要先手动登录一次:添加券商,填入账户号、密码、验证码,勾选“保存密码” 68 | 69 | 第一次登录后,上述信息被缓存,可以调用prepare函数自动登录(仅需账户号、客户端路径,密码随意输入)。 70 | 71 | ### (三)其它 72 | 73 | 非同花顺的客户端,可以调用prepare函数自动登录。 74 | 75 | 调用prepare时所需的参数,可以通过`函数参数` 或 `配置文件` 赋予。 76 | 77 | **1. 函数参数(推荐)** 78 | 79 | ``` 80 | user.prepare(user='用户名', password='雪球、银河客户端为明文密码', comm_password='华泰通讯密码,其他券商不用') 81 | ``` 82 | 83 | 注: 雪球比较特殊,见下列配置文件格式 84 | 85 | **2. 配置文件** 86 | 87 | ```python 88 | user.prepare('/path/to/your/yh_client.json') # 配置文件路径 89 | ``` 90 | 91 | 注: 配置文件需自己用编辑器编辑生成, **请勿使用记事本**, 推荐使用 [notepad++](https://notepad-plus-plus.org/zh/) 或者 [sublime text](http://www.sublimetext.com/) 。 92 | 93 | **配置文件格式如下:** 94 | 95 | 银河/国金客户端 96 | 97 | ``` 98 | { 99 | "user": "用户名", 100 | "password": "明文密码" 101 | } 102 | 103 | ``` 104 | 105 | 华泰客户端 106 | 107 | ``` 108 | { 109 | "user": "华泰用户名", 110 | "password": "华泰明文密码", 111 | "comm_password": "华泰通讯密码" 112 | } 113 | 114 | ``` 115 | 116 | 雪球 117 | 118 | ``` 119 | { 120 | "cookies": "雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/", 121 | "portfolio_code": "组合代码(例:ZH818559)", 122 | "portfolio_market": "交易市场(例:us 或者 cn 或者 hk)" 123 | } 124 | ``` 125 | 126 | ## 交易相关 127 | 128 | 有些客户端无法通过默认方法输入文本,可以通过开启 type_keys 的方法绕过,开启方式 129 | 130 | ```python 131 | user.enable_type_keys_for_editor() 132 | ``` 133 | 134 | ### 获取资金状况 135 | 136 | ```python 137 | user.balance 138 | 139 | # return 140 | [{'参考市值': 21642.0, 141 | '可用资金': 28494.21, 142 | '币种': '0', 143 | '总资产': 50136.21, 144 | '股份参考盈亏': -90.21, 145 | '资金余额': 28494.21, 146 | '资金帐号': 'xxx'}] 147 | ``` 148 | 149 | ### 获取持仓 150 | 151 | ```python 152 | user.position 153 | 154 | # return 155 | [{'买入冻结': 0, 156 | '交易市场': '沪A', 157 | '卖出冻结': '0', 158 | '参考市价': 4.71, 159 | '参考市值': 10362.0, 160 | '参考成本价': 4.672, 161 | '参考盈亏': 82.79, 162 | '当前持仓': 2200, 163 | '盈亏比例(%)': '0.81%', 164 | '股东代码': 'xxx', 165 | '股份余额': 2200, 166 | '股份可用': 2200, 167 | '证券代码': '601398', 168 | '证券名称': '工商银行'}] 169 | ``` 170 | 171 | ### 买入 172 | 173 | ```python 174 | user.buy('162411', price=0.55, amount=100) 175 | 176 | # return 177 | {'entrust_no': 'xxxxxxxx'} 178 | ``` 179 | 180 | 注: 系统可以配置是否返回成交回报。如果没配的话默认返回 `{"message": "success"}` 181 | 182 | ### 卖出 183 | 184 | ```python 185 | user.sell('162411', price=0.55, amount=100) 186 | 187 | # return 188 | {'entrust_no': 'xxxxxxxx'} 189 | ``` 190 | 191 | 192 | ### 撤单 193 | 194 | ```python 195 | user.cancel_entrust('buy/sell 获取的 entrust_no') 196 | 197 | # return 198 | {'message': 'success'} 199 | ``` 200 | 201 | ### 查询当日成交 202 | 203 | ```python 204 | user.today_trades 205 | 206 | # return 207 | [{'买卖标志': '买入', 208 | '交易市场': '深A', 209 | '委托序号': '12345', 210 | '成交价格': 0.626, 211 | '成交数量': 100, 212 | '成交日期': '20170313', 213 | '成交时间': '09:50:30', 214 | '成交金额': 62.60, 215 | '股东代码': 'xxx', 216 | '证券代码': '162411', 217 | '证券名称': '华宝油气'}] 218 | ``` 219 | 220 | ### 查询当日委托 221 | 222 | ```python 223 | user.today_entrusts 224 | 225 | # return 226 | [{'买卖标志': '买入', 227 | '交易市场': '深A', 228 | '委托价格': 0.627, 229 | '委托序号': '111111', 230 | '委托数量': 100, 231 | '委托日期': '20170313', 232 | '委托时间': '09:50:30', 233 | '成交数量': 100, 234 | '撤单数量': 0, 235 | '状态说明': '已成', 236 | '股东代码': 'xxxxx', 237 | '证券代码': '162411', 238 | '证券名称': '华宝油气'}, 239 | {'买卖标志': '买入', 240 | '交易市场': '深A', 241 | '委托价格': 0.6, 242 | '委托序号': '1111', 243 | '委托数量': 100, 244 | '委托日期': '20170313', 245 | '委托时间': '09:40:30', 246 | '成交数量': 0, 247 | '撤单数量': 100, 248 | '状态说明': '已撤', 249 | '股东代码': 'xxx', 250 | '证券代码': '162411', 251 | '证券名称': '华宝油气'}] 252 | ``` 253 | 254 | 255 | ### 查询今日可申购新股 256 | 257 | ```python 258 | from easytrader.utils.stock import get_today_ipo_data 259 | get_today_ipo_data() 260 | 261 | # return 262 | [{'stock_code': '股票代码', 263 | 'stock_name': '股票名称', 264 | 'price': 发行价, 265 | 'apply_code': '申购代码'}] 266 | ``` 267 | 268 | ### 一键打新 269 | 270 | ```python 271 | user.auto_ipo() 272 | ``` 273 | 274 | ### 刷新数据 275 | 276 | ```python 277 | user.refresh() 278 | ``` 279 | 280 | ### 雪球组合比例调仓 281 | 282 | ```python 283 | user.adjust_weight('股票代码', 目标比例) 284 | ``` 285 | 286 | 例如 `user.adjust_weight('000001', 10)`是将平安银行在组合中的持仓比例调整到10%。 287 | 288 | ## 退出客户端软件 289 | 290 | ```python 291 | user.exit() 292 | ``` 293 | 294 | ## 常见问题 295 | 296 | ### 某些同花顺客户端不允许拷贝 `Grid` 数据 297 | 298 | 现在默认获取 `Grid` 数据的策略是通过剪切板拷贝,有些券商不允许这种方式,导致无法获取持仓等数据。为解决此问题,额外实现了一种通过将 `Grid` 数据存为文件再读取的策略, 299 | 使用方式如下: 300 | 301 | ```python 302 | from easytrader import grid_strategies 303 | 304 | user.grid_strategy = grid_strategies.Xls 305 | ``` 306 | 307 | ### 通过工具栏刷新按钮刷新数据 308 | 309 | 当前的刷新数据方式是通过切换菜单栏实现,通用但是比较缓慢,可以选择通过点击工具栏的刷新按钮来刷新 310 | 311 | ```python 312 | from easytrader import refresh_strategies 313 | 314 | # refresh_btn_index 指的是刷新按钮在工具栏的排序,默认为第四个,根据客户端实际情况调整 315 | user.refresh_strategy = refresh_strategies.Toolbar(refresh_btn_index=4) 316 | ``` 317 | 318 | ### 无法保存对应的 xls 文件 319 | 320 | 有些系统默认的临时文件目录过长,使用 xls 策略时无法正常保存,可通过如下方式修改为自定义目录 321 | 322 | ``` 323 | user.grid_strategy_instance.tmp_folder = 'C:\\custom_folder' 324 | ``` 325 | 326 | ### 如何关闭 debug 日志的输出 327 | 328 | ```python 329 | user = easytrader.use('yh', debug=False) 330 | 331 | ``` 332 | 333 | 334 | # 编辑配置文件,运行后出现 `json` 解码报错 335 | 336 | 337 | 出现如下错误 338 | 339 | ```python 340 | raise JSONDecodeError("Expecting value", s, err.value) from None 341 | 342 | JSONDecodeError: Expecting value 343 | ``` 344 | 345 | 请勿使用 `记事本` 编辑账户的 `json` 配置文件,推荐使用 [notepad++](https://notepad-plus-plus.org/zh/) 或者 [sublime text](http://www.sublimetext.com/) 346 | 347 | -------------------------------------------------------------------------------- /docs/xueqiu.md: -------------------------------------------------------------------------------- 1 | # 雪球组合模拟交易 2 | 3 | 因为雪球组合是按比例调仓的,所以模拟成券商实盘接口会有一些要注意的问题 4 | 5 | * 接口基本与其他券商接口调用参数返回一致 6 | * 委托单不支持挂高挂低(开盘时间都是直接市价成交的) 7 | * 初始资金是按组合净值 1:1000000 换算来的, 可以通过 `easytrader.use('xq', initial_assets=初始资金值)` 来调整 8 | * 委托单的委托价格和委托数量目前换算回来都是按1手拆的(雪球是按比例调仓的) 9 | * 持仓价格和持仓数量问题同上, 但持股市值是对的. 10 | * 一些不合理的操作会直接抛TradeError,注意看错误信息 11 | 12 | ---------------- 13 | 20160909 新增函数adjust_weight,用于雪球组合比例调仓 14 | 15 | adjust_weight函数包含两个参数,stock_code 指定调仓股票代码,weight 指定调仓比例 16 | 17 | -------------------------------------------------------------------------------- /easytrader/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urllib3 3 | 4 | from easytrader import exceptions 5 | from easytrader.api import use, follower 6 | from easytrader.log import logger 7 | 8 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 9 | 10 | __version__ = "0.23.7" 11 | __author__ = "shidenggui" 12 | -------------------------------------------------------------------------------- /easytrader/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import sys 4 | 5 | import six 6 | 7 | from easytrader.joinquant_follower import JoinQuantFollower 8 | from easytrader.log import logger 9 | from easytrader.ricequant_follower import RiceQuantFollower 10 | from easytrader.xq_follower import XueQiuFollower 11 | from easytrader.xqtrader import XueQiuTrader 12 | 13 | if sys.version_info <= (3, 5): 14 | raise TypeError("不支持 Python3.5 及以下版本,请升级") 15 | 16 | 17 | def use(broker, debug=False, **kwargs): 18 | """用于生成特定的券商对象 19 | :param broker: 券商名支持 20 | 例如 ['miniqmt', 'xq', '雪球', 'gj_client', '国金客户端', "universal_client", "通用同花顺客户端", "ths", "同花顺客户端"] 等 21 | :param debug: 控制 debug 日志的显示, 默认为 False 22 | :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一百万 23 | :return the class of trader 24 | 25 | Usage:: 26 | 27 | >>> import easytrader 28 | >>> user = easytrader.use('xq') 29 | >>> user.prepare('xq.json') 30 | """ 31 | if debug: 32 | logger.setLevel(logging.DEBUG) 33 | 34 | if broker.lower() in ["xq", "雪球"]: 35 | return XueQiuTrader(**kwargs) 36 | 37 | if broker.lower() in ["yh_client", "银河客户端"]: 38 | from .yh_clienttrader import YHClientTrader 39 | 40 | return YHClientTrader() 41 | 42 | if broker.lower() in ["ht_client", "华泰客户端"]: 43 | from .ht_clienttrader import HTClientTrader 44 | 45 | return HTClientTrader() 46 | 47 | if broker.lower() in ["wk_client", "五矿客户端"]: 48 | from easytrader.wk_clienttrader import WKClientTrader 49 | 50 | return WKClientTrader() 51 | 52 | if broker.lower() in ["htzq_client", "海通证券客户端"]: 53 | from easytrader.htzq_clienttrader import HTZQClientTrader 54 | 55 | return HTZQClientTrader() 56 | 57 | if broker.lower() in ["gj_client", "国金客户端"]: 58 | from .gj_clienttrader import GJClientTrader 59 | 60 | return GJClientTrader() 61 | 62 | if broker.lower() in ["gf_client", "广发客户端"]: 63 | from .gf_clienttrader import GFClientTrader 64 | 65 | return GFClientTrader() 66 | 67 | if broker.lower() in ["universal_client", "通用同花顺客户端"]: 68 | from easytrader.universal_clienttrader import UniversalClientTrader 69 | 70 | return UniversalClientTrader() 71 | 72 | if broker.lower() in ["ths", "同花顺客户端"]: 73 | from .clienttrader import ClientTrader 74 | 75 | return ClientTrader() 76 | 77 | if broker.lower() in ["miniqmt"]: 78 | try: 79 | import xtquant 80 | except: 81 | logger.error("miniqmt 相关组件 xtqimt 未安装, 请执行 pip install easytrader[xtquant]安装") 82 | from easytrader.miniqmt.miniqmt_trader import MiniqmtTrader 83 | 84 | return MiniqmtTrader() 85 | 86 | raise NotImplementedError 87 | 88 | 89 | def follower(platform, **kwargs): 90 | """用于生成特定的券商对象 91 | :param platform:平台支持 ['jq', 'joinquant', '聚宽’] 92 | :param initial_assets: [雪球参数] 控制雪球初始资金,默认为一万, 93 | 总资金由 initial_assets * 组合当前净值 得出 94 | :param total_assets: [雪球参数] 控制雪球总资金,无默认值, 95 | 若设置则覆盖 initial_assets 96 | :return the class of follower 97 | 98 | Usage:: 99 | 100 | >>> import easytrader 101 | >>> user = easytrader.use('xq') 102 | >>> user.prepare('xq.json') 103 | >>> jq = easytrader.follower('jq') 104 | >>> jq.login(user='username', password='password') 105 | >>> jq.follow(users=user, strategies=['strategies_link']) 106 | """ 107 | if platform.lower() in ["rq", "ricequant", "米筐"]: 108 | return RiceQuantFollower() 109 | if platform.lower() in ["jq", "joinquant", "聚宽"]: 110 | return JoinQuantFollower() 111 | if platform.lower() in ["xq", "xueqiu", "雪球"]: 112 | return XueQiuFollower(**kwargs) 113 | raise NotImplementedError 114 | -------------------------------------------------------------------------------- /easytrader/clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import functools 4 | import logging 5 | import os 6 | import re 7 | import sys 8 | import time 9 | from typing import Type, Union 10 | 11 | import hashlib, binascii 12 | 13 | import easyutils 14 | from pywinauto import findwindows, timings 15 | 16 | from easytrader import grid_strategies, pop_dialog_handler, refresh_strategies 17 | from easytrader.config import client 18 | from easytrader.grid_strategies import IGridStrategy 19 | from easytrader.log import logger 20 | from easytrader.refresh_strategies import IRefreshStrategy 21 | from easytrader.utils.misc import file2dict 22 | from easytrader.utils.perf import perf_clock 23 | 24 | if not sys.platform.startswith("darwin"): 25 | import pywinauto 26 | import pywinauto.clipboard 27 | 28 | class IClientTrader(abc.ABC): 29 | @property 30 | @abc.abstractmethod 31 | def app(self): 32 | """Return current app instance""" 33 | pass 34 | 35 | @property 36 | @abc.abstractmethod 37 | def main(self): 38 | """Return current main window instance""" 39 | pass 40 | 41 | @property 42 | @abc.abstractmethod 43 | def config(self): 44 | """Return current config instance""" 45 | pass 46 | 47 | @abc.abstractmethod 48 | def wait(self, seconds: float): 49 | """Wait for operation return""" 50 | pass 51 | 52 | @abc.abstractmethod 53 | def refresh(self): 54 | """Refresh data""" 55 | pass 56 | 57 | @abc.abstractmethod 58 | def is_exist_pop_dialog(self): 59 | pass 60 | 61 | 62 | class ClientTrader(IClientTrader): 63 | _editor_need_type_keys = False 64 | # The strategy to use for getting grid data 65 | grid_strategy: Union[IGridStrategy, Type[IGridStrategy]] = grid_strategies.Copy 66 | _grid_strategy_instance: IGridStrategy = None 67 | refresh_strategy: IRefreshStrategy = refresh_strategies.Switch() 68 | 69 | def enable_type_keys_for_editor(self): 70 | """ 71 | 有些客户端无法通过 set_edit_text 方法输入内容,可以通过使用 type_keys 方法绕过 72 | """ 73 | self._editor_need_type_keys = True 74 | 75 | @property 76 | def grid_strategy_instance(self): 77 | if self._grid_strategy_instance is None: 78 | self._grid_strategy_instance = ( 79 | self.grid_strategy 80 | if isinstance(self.grid_strategy, IGridStrategy) 81 | else self.grid_strategy() 82 | ) 83 | self._grid_strategy_instance.set_trader(self) 84 | return self._grid_strategy_instance 85 | 86 | def __init__(self): 87 | self._config = client.create(self.broker_type) 88 | self._app = None 89 | self._main = None 90 | self._toolbar = None 91 | 92 | @property 93 | def app(self): 94 | return self._app 95 | 96 | @property 97 | def main(self): 98 | return self._main 99 | 100 | @property 101 | def config(self): 102 | return self._config 103 | 104 | def connect(self, exe_path=None, **kwargs): 105 | """ 106 | 直接连接登陆后的客户端 107 | :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' 108 | :return: 109 | """ 110 | connect_path = exe_path or self._config.DEFAULT_EXE_PATH 111 | if connect_path is None: 112 | raise ValueError( 113 | "参数 exe_path 未设置,请设置客户端对应的 exe 地址,类似 C:\\客户端安装目录\\xiadan.exe" 114 | ) 115 | 116 | self._app = pywinauto.Application().connect(path=connect_path, timeout=10) 117 | self._close_prompt_windows() 118 | self._main = self._app.top_window() 119 | self._init_toolbar() 120 | 121 | @property 122 | def broker_type(self): 123 | return "ths" 124 | 125 | @property 126 | def balance(self): 127 | self._switch_left_menus(["查询[F4]", "资金股票"]) 128 | 129 | return self._get_balance_from_statics() 130 | 131 | def _init_toolbar(self): 132 | self._toolbar = self._main.child_window(class_name="ToolbarWindow32") 133 | 134 | def _get_balance_from_statics(self): 135 | result = {} 136 | for key, control_id in self._config.BALANCE_CONTROL_ID_GROUP.items(): 137 | result[key] = float( 138 | self._main.child_window( 139 | control_id=control_id, class_name="Static" 140 | ).window_text() 141 | ) 142 | return result 143 | 144 | @property 145 | def position(self): 146 | self._switch_left_menus(["查询[F4]", "资金股票"]) 147 | 148 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 149 | 150 | @property 151 | def today_entrusts(self): 152 | self._switch_left_menus(["查询[F4]", "当日委托"]) 153 | 154 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 155 | 156 | @property 157 | def today_trades(self): 158 | self._switch_left_menus(["查询[F4]", "当日成交"]) 159 | 160 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 161 | 162 | @property 163 | def cancel_entrusts(self): 164 | self.refresh() 165 | self._switch_left_menus(["撤单[F3]"]) 166 | 167 | return self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 168 | 169 | @perf_clock 170 | def cancel_entrust(self, entrust_no): 171 | self.refresh() 172 | for i, entrust in enumerate(self.cancel_entrusts): 173 | if entrust[self._config.CANCEL_ENTRUST_ENTRUST_FIELD] == entrust_no: 174 | self._cancel_entrust_by_double_click(i) 175 | return self._handle_pop_dialogs() 176 | return {"message": "委托单状态错误不能撤单, 该委托单可能已经成交或者已撤"} 177 | 178 | def cancel_all_entrusts(self): 179 | self.refresh() 180 | self._switch_left_menus(["撤单[F3]"]) 181 | 182 | # 点击全部撤销控件 183 | self._app.top_window().child_window( 184 | control_id=self._config.TRADE_CANCEL_ALL_ENTRUST_CONTROL_ID, class_name="Button", title_re="""全撤.*""" 185 | ).click() 186 | self.wait(0.2) 187 | 188 | # 等待出现 确认兑换框 189 | if self.is_exist_pop_dialog(): 190 | # 点击是 按钮 191 | w = self._app.top_window() 192 | if w is not None: 193 | btn = w["是(Y)"] 194 | if btn is not None: 195 | btn.click() 196 | self.wait(0.2) 197 | 198 | # 如果出现了确认窗口 199 | self.close_pop_dialog() 200 | 201 | @perf_clock 202 | def repo(self, security, price, amount, **kwargs): 203 | self._switch_left_menus(["债券回购", "融资回购(正回购)"]) 204 | 205 | return self.trade(security, price, amount) 206 | 207 | @perf_clock 208 | def reverse_repo(self, security, price, amount, **kwargs): 209 | self._switch_left_menus(["债券回购", "融劵回购(逆回购)"]) 210 | 211 | return self.trade(security, price, amount) 212 | 213 | @perf_clock 214 | def buy(self, security, price, amount, **kwargs): 215 | self._switch_left_menus(["买入[F1]"]) 216 | 217 | return self.trade(security, price, amount) 218 | 219 | @perf_clock 220 | def sell(self, security, price, amount, **kwargs): 221 | self._switch_left_menus(["卖出[F2]"]) 222 | 223 | return self.trade(security, price, amount) 224 | 225 | @perf_clock 226 | def market_buy(self, security, amount, ttype=None, limit_price=None, **kwargs): 227 | """ 228 | 市价买入 229 | :param security: 六位证券代码 230 | :param amount: 交易数量 231 | :param ttype: 市价委托类型,默认客户端默认选择, 232 | 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] 233 | 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] 234 | :param limit_price: 科创板 限价 235 | 236 | :return: {'entrust_no': '委托单号'} 237 | """ 238 | self._switch_left_menus(["市价委托", "买入"]) 239 | 240 | return self.market_trade(security, amount, ttype, limit_price=limit_price) 241 | 242 | @perf_clock 243 | def market_sell(self, security, amount, ttype=None, limit_price=None, **kwargs): 244 | """ 245 | 市价卖出 246 | :param security: 六位证券代码 247 | :param amount: 交易数量 248 | :param ttype: 市价委托类型,默认客户端默认选择, 249 | 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] 250 | 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] 251 | :param limit_price: 科创板 限价 252 | :return: {'entrust_no': '委托单号'} 253 | """ 254 | self._switch_left_menus(["市价委托", "卖出"]) 255 | 256 | return self.market_trade(security, amount, ttype, limit_price=limit_price) 257 | 258 | def market_trade(self, security, amount, ttype=None, limit_price=None, **kwargs): 259 | """ 260 | 市价交易 261 | :param security: 六位证券代码 262 | :param amount: 交易数量 263 | :param ttype: 市价委托类型,默认客户端默认选择, 264 | 深市可选 ['对手方最优价格', '本方最优价格', '即时成交剩余撤销', '最优五档即时成交剩余 '全额成交或撤销'] 265 | 沪市可选 ['最优五档成交剩余撤销', '最优五档成交剩余转限价'] 266 | 267 | :return: {'entrust_no': '委托单号'} 268 | """ 269 | code = security[-6:] 270 | self._type_edit_control_keys(self._config.TRADE_SECURITY_CONTROL_ID, code) 271 | if ttype is not None: 272 | retry = 0 273 | retry_max = 10 274 | while retry < retry_max: 275 | try: 276 | self._set_market_trade_type(ttype) 277 | break 278 | except: 279 | retry += 1 280 | self.wait(0.1) 281 | self._set_market_trade_params(security, amount, limit_price=limit_price) 282 | self._submit_trade() 283 | 284 | return self._handle_pop_dialogs( 285 | handler_class=pop_dialog_handler.TradePopDialogHandler 286 | ) 287 | 288 | def _set_market_trade_type(self, ttype): 289 | """根据选择的市价交易类型选择对应的下拉选项""" 290 | selects = self._main.child_window( 291 | control_id=self._config.TRADE_MARKET_TYPE_CONTROL_ID, class_name="ComboBox" 292 | ) 293 | for i, text in enumerate(selects.texts()): 294 | # skip 0 index, because 0 index is current select index 295 | if i == 0: 296 | if re.search(ttype, text): # 当前已经选中 297 | return 298 | else: 299 | continue 300 | if re.search(ttype, text): 301 | selects.select(i - 1) 302 | return 303 | raise TypeError("不支持对应的市价类型: {}".format(ttype)) 304 | 305 | def _set_stock_exchange_type(self, ttype): 306 | """根据选择的市价交易类型选择对应的下拉选项""" 307 | selects = self._main.child_window( 308 | control_id=self._config.TRADE_STOCK_EXCHANGE_CONTROL_ID, class_name="ComboBox" 309 | ) 310 | 311 | for i, text in enumerate(selects.texts()): 312 | # skip 0 index, because 0 index is current select index 313 | if i == 0: 314 | if ttype.strip() == text.strip(): # 当前已经选中 315 | return 316 | else: 317 | continue 318 | if ttype.strip() == text.strip(): 319 | selects.select(i - 1) 320 | return 321 | raise TypeError("不支持对应的市场类型: {}".format(ttype)) 322 | 323 | def auto_ipo(self): 324 | self._switch_left_menus(self._config.AUTO_IPO_MENU_PATH) 325 | 326 | stock_list = self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 327 | 328 | if len(stock_list) == 0: 329 | return {"message": "今日无新股"} 330 | invalid_list_idx = [ 331 | i for i, v in enumerate(stock_list) if v[self.config.AUTO_IPO_NUMBER] <= 0 332 | ] 333 | 334 | if len(stock_list) == len(invalid_list_idx): 335 | return {"message": "没有发现可以申购的新股"} 336 | 337 | self._click(self._config.AUTO_IPO_SELECT_ALL_BUTTON_CONTROL_ID) 338 | self.wait(0.1) 339 | 340 | for row in invalid_list_idx: 341 | self._click_grid_by_row(row) 342 | self.wait(0.1) 343 | 344 | self._click(self._config.AUTO_IPO_BUTTON_CONTROL_ID) 345 | self.wait(0.1) 346 | 347 | return self._handle_pop_dialogs() 348 | 349 | def _click_grid_by_row(self, row): 350 | x = self._config.COMMON_GRID_LEFT_MARGIN 351 | y = ( 352 | self._config.COMMON_GRID_FIRST_ROW_HEIGHT 353 | + self._config.COMMON_GRID_ROW_HEIGHT * row 354 | ) 355 | self._app.top_window().child_window( 356 | control_id=self._config.COMMON_GRID_CONTROL_ID, 357 | class_name="CVirtualGridCtrl", 358 | ).click(coords=(x, y)) 359 | 360 | @perf_clock 361 | def is_exist_pop_dialog(self): 362 | self.wait(0.5) # wait dialog display 363 | try: 364 | return ( 365 | self._main.wrapper_object() != self._app.top_window().wrapper_object() 366 | ) 367 | except ( 368 | findwindows.ElementNotFoundError, 369 | timings.TimeoutError, 370 | RuntimeError, 371 | ) as ex: 372 | logger.exception("check pop dialog timeout") 373 | return False 374 | 375 | @perf_clock 376 | def close_pop_dialog(self): 377 | try: 378 | if self._main.wrapper_object() != self._app.top_window().wrapper_object(): 379 | w = self._app.top_window() 380 | if w is not None: 381 | w.close() 382 | self.wait(0.2) 383 | except ( 384 | findwindows.ElementNotFoundError, 385 | timings.TimeoutError, 386 | RuntimeError, 387 | ) as ex: 388 | pass 389 | 390 | def _run_exe_path(self, exe_path): 391 | return os.path.join(os.path.dirname(exe_path), "xiadan.exe") 392 | 393 | def wait(self, seconds): 394 | time.sleep(seconds) 395 | 396 | def exit(self): 397 | self._app.kill() 398 | 399 | def _close_prompt_windows(self): 400 | self.wait(1) 401 | for window in self._app.windows(class_name="#32770", visible_only=True): 402 | title = window.window_text() 403 | if title != self._config.TITLE: 404 | logging.info("close window %s" % title) 405 | window.close() 406 | self.wait(0.2) 407 | self.wait(1) 408 | 409 | def close_pormpt_window_no_wait(self): 410 | for window in self._app.windows(class_name="#32770"): 411 | if window.window_text() != self._config.TITLE: 412 | window.close() 413 | 414 | def trade(self, security, price, amount): 415 | self._set_trade_params(security, price, amount) 416 | 417 | self._submit_trade() 418 | 419 | return self._handle_pop_dialogs( 420 | handler_class=pop_dialog_handler.TradePopDialogHandler 421 | ) 422 | 423 | def _click(self, control_id): 424 | self._app.top_window().child_window( 425 | control_id=control_id, class_name="Button" 426 | ).click() 427 | 428 | @perf_clock 429 | def _submit_trade(self): 430 | time.sleep(0.2) 431 | self._main.child_window( 432 | control_id=self._config.TRADE_SUBMIT_CONTROL_ID, class_name="Button" 433 | ).click() 434 | 435 | @perf_clock 436 | def __get_top_window_pop_dialog(self): 437 | return self._app.top_window().window( 438 | control_id=self._config.POP_DIALOD_TITLE_CONTROL_ID 439 | ) 440 | 441 | @perf_clock 442 | def _get_pop_dialog_title(self): 443 | return ( 444 | self._app.top_window() 445 | .child_window(control_id=self._config.POP_DIALOD_TITLE_CONTROL_ID) 446 | .window_text() 447 | ) 448 | 449 | def _set_trade_params(self, security, price, amount): 450 | code = security[-6:] 451 | 452 | self._type_edit_control_keys(self._config.TRADE_SECURITY_CONTROL_ID, code) 453 | 454 | # wait security input finish 455 | self.wait(0.1) 456 | 457 | # 设置交易所 458 | if security.lower().startswith("sz"): 459 | self._set_stock_exchange_type("深圳A股") 460 | if security.lower().startswith("sh"): 461 | self._set_stock_exchange_type("上海A股") 462 | 463 | self.wait(0.1) 464 | 465 | self._type_edit_control_keys( 466 | self._config.TRADE_PRICE_CONTROL_ID, 467 | easyutils.round_price_by_code(price, code), 468 | ) 469 | self._type_edit_control_keys( 470 | self._config.TRADE_AMOUNT_CONTROL_ID, str(int(amount)) 471 | ) 472 | 473 | def _set_market_trade_params(self, security, amount, limit_price=None): 474 | self._type_edit_control_keys( 475 | self._config.TRADE_AMOUNT_CONTROL_ID, str(int(amount)) 476 | ) 477 | self.wait(0.1) 478 | price_control = None 479 | if str(security).startswith("68"): # 科创板存在限价 480 | try: 481 | price_control = self._main.child_window( 482 | control_id=self._config.TRADE_PRICE_CONTROL_ID, class_name="Edit" 483 | ) 484 | except: 485 | pass 486 | if price_control is not None: 487 | price_control.set_edit_text(limit_price) 488 | 489 | def _get_grid_data(self, control_id): 490 | return self.grid_strategy_instance.get(control_id) 491 | 492 | def _type_keys(self, control_id, text): 493 | self._main.child_window(control_id=control_id, class_name="Edit").set_edit_text( 494 | text 495 | ) 496 | 497 | def _type_edit_control_keys(self, control_id, text): 498 | if not self._editor_need_type_keys: 499 | self._main.child_window( 500 | control_id=control_id, class_name="Edit" 501 | ).set_edit_text(text) 502 | else: 503 | editor = self._main.child_window(control_id=control_id, class_name="Edit") 504 | editor.select() 505 | editor.type_keys(text) 506 | 507 | def type_edit_control_keys(self, editor, text): 508 | if not self._editor_need_type_keys: 509 | editor.set_edit_text(text) 510 | else: 511 | editor.select() 512 | editor.type_keys(text) 513 | 514 | def _collapse_left_menus(self): 515 | items = self._get_left_menus_handle().roots() 516 | for item in items: 517 | item.collapse() 518 | 519 | @perf_clock 520 | def _switch_left_menus(self, path, sleep=0.2): 521 | self.close_pop_dialog() 522 | self._get_left_menus_handle().get_item(path).select() 523 | self._app.top_window().type_keys('{F5}') 524 | self.wait(sleep) 525 | 526 | def _switch_left_menus_by_shortcut(self, shortcut, sleep=0.5): 527 | self.close_pop_dialog() 528 | self._app.top_window().type_keys(shortcut) 529 | self.wait(sleep) 530 | 531 | @functools.lru_cache() 532 | def _get_left_menus_handle(self): 533 | count = 2 534 | while True: 535 | try: 536 | handle = self._main.child_window( 537 | control_id=129, class_name="SysTreeView32" 538 | ) 539 | if count <= 0: 540 | return handle 541 | # sometime can't find handle ready, must retry 542 | handle.wait("ready", 2) 543 | return handle 544 | # pylint: disable=broad-except 545 | except Exception as ex: 546 | logger.exception("error occurred when trying to get left menus") 547 | count = count - 1 548 | 549 | def _cancel_entrust_by_double_click(self, row): 550 | x = self._config.CANCEL_ENTRUST_GRID_LEFT_MARGIN 551 | y = ( 552 | self._config.CANCEL_ENTRUST_GRID_FIRST_ROW_HEIGHT 553 | + self._config.CANCEL_ENTRUST_GRID_ROW_HEIGHT * row 554 | ) 555 | self._app.top_window().child_window( 556 | control_id=self._config.COMMON_GRID_CONTROL_ID, 557 | class_name="CVirtualGridCtrl", 558 | ).double_click(coords=(x, y)) 559 | 560 | def refresh(self): 561 | self.refresh_strategy.set_trader(self) 562 | self.refresh_strategy.refresh() 563 | 564 | @perf_clock 565 | def _handle_pop_dialogs(self, handler_class=pop_dialog_handler.PopDialogHandler): 566 | handler = handler_class(self._app) 567 | 568 | while self.is_exist_pop_dialog(): 569 | try: 570 | title = self._get_pop_dialog_title() 571 | except pywinauto.findwindows.ElementNotFoundError: 572 | return {"message": "success"} 573 | 574 | result = handler.handle(title) 575 | if result: 576 | return result 577 | return {"message": "success"} 578 | 579 | 580 | class BaseLoginClientTrader(ClientTrader): 581 | @abc.abstractmethod 582 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 583 | """Login Client Trader""" 584 | pass 585 | 586 | def prepare( 587 | self, 588 | config_path=None, 589 | user=None, 590 | password=None, 591 | exe_path=None, 592 | comm_password=None, 593 | **kwargs 594 | ): 595 | """ 596 | 登陆客户端 597 | :param config_path: 登陆配置文件,跟参数登陆方式二选一 598 | :param user: 账号 599 | :param password: 明文密码 600 | :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 默认 r'C:\\htzqzyb2\\xiadan.exe' 601 | :param comm_password: 通讯密码 602 | :return: 603 | """ 604 | if config_path is not None: 605 | account = file2dict(config_path) 606 | user = account["user"] 607 | password = account["password"] 608 | comm_password = account.get("comm_password") 609 | exe_path = account.get("exe_path") 610 | self.login( 611 | user, 612 | password, 613 | exe_path or self._config.DEFAULT_EXE_PATH, 614 | comm_password, 615 | **kwargs 616 | ) 617 | self._init_toolbar() 618 | -------------------------------------------------------------------------------- /easytrader/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shidenggui/easytrader/ff88802a9b450ca58ed4935521dca28aed7cd900/easytrader/config/__init__.py -------------------------------------------------------------------------------- /easytrader/config/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def create(broker): 3 | if broker == "yh": 4 | return YH 5 | if broker == "ht": 6 | return HT 7 | if broker == "gj": 8 | return GJ 9 | if broker == "gf": 10 | return GF 11 | if broker == "ths": 12 | return CommonConfig 13 | if broker == "wk": 14 | return WK 15 | if broker == "htzq": 16 | return HTZQ 17 | if broker == "universal": 18 | return UNIVERSAL 19 | raise NotImplementedError 20 | 21 | 22 | class CommonConfig: 23 | DEFAULT_EXE_PATH: str = "" 24 | TITLE = "网上股票交易系统5.0" 25 | 26 | # 交易所类型。 深圳A股、上海A股 27 | TRADE_STOCK_EXCHANGE_CONTROL_ID = 1003 28 | 29 | # 撤销界面上, 全部撤销按钮 30 | TRADE_CANCEL_ALL_ENTRUST_CONTROL_ID = 30001 31 | 32 | TRADE_SECURITY_CONTROL_ID = 1032 33 | TRADE_PRICE_CONTROL_ID = 1033 34 | TRADE_AMOUNT_CONTROL_ID = 1034 35 | 36 | TRADE_SUBMIT_CONTROL_ID = 1006 37 | 38 | TRADE_MARKET_TYPE_CONTROL_ID = 1541 39 | 40 | COMMON_GRID_CONTROL_ID = 1047 41 | 42 | COMMON_GRID_LEFT_MARGIN = 10 43 | COMMON_GRID_FIRST_ROW_HEIGHT = 30 44 | COMMON_GRID_ROW_HEIGHT = 16 45 | 46 | BALANCE_MENU_PATH = ["查询[F4]", "资金股票"] 47 | POSITION_MENU_PATH = ["查询[F4]", "资金股票"] 48 | TODAY_ENTRUSTS_MENU_PATH = ["查询[F4]", "当日委托"] 49 | TODAY_TRADES_MENU_PATH = ["查询[F4]", "当日成交"] 50 | 51 | BALANCE_CONTROL_ID_GROUP = { 52 | "资金余额": 1012, 53 | "可用金额": 1016, 54 | "可取金额": 1017, 55 | "股票市值": 1014, 56 | "总资产": 1015, 57 | } 58 | 59 | POP_DIALOD_TITLE_CONTROL_ID = 1365 60 | 61 | GRID_DTYPE = { 62 | "操作日期": str, 63 | "委托编号": str, 64 | "申请编号": str, 65 | "合同编号": str, 66 | "证券代码": str, 67 | "股东代码": str, 68 | "资金帐号": str, 69 | "资金帐户": str, 70 | "发生日期": str, 71 | } 72 | 73 | CANCEL_ENTRUST_ENTRUST_FIELD = "合同编号" 74 | CANCEL_ENTRUST_GRID_LEFT_MARGIN = 50 75 | CANCEL_ENTRUST_GRID_FIRST_ROW_HEIGHT = 30 76 | CANCEL_ENTRUST_GRID_ROW_HEIGHT = 16 77 | 78 | AUTO_IPO_SELECT_ALL_BUTTON_CONTROL_ID = 1098 79 | AUTO_IPO_BUTTON_CONTROL_ID = 1006 80 | AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] 81 | AUTO_IPO_NUMBER = '申购数量' 82 | 83 | 84 | class YH(CommonConfig): 85 | DEFAULT_EXE_PATH = r"C:\双子星-中国银河证券\Binarystar.exe" 86 | 87 | BALANCE_GRID_CONTROL_ID = 1308 88 | 89 | GRID_DTYPE = { 90 | "操作日期": str, 91 | "委托编号": str, 92 | "申请编号": str, 93 | "合同编号": str, 94 | "证券代码": str, 95 | "股东代码": str, 96 | "资金帐号": str, 97 | "资金帐户": str, 98 | "发生日期": str, 99 | } 100 | 101 | AUTO_IPO_MENU_PATH = ["新股申购", "一键打新"] 102 | 103 | 104 | class HT(CommonConfig): 105 | DEFAULT_EXE_PATH = r"C:\htzqzyb2\xiadan.exe" 106 | 107 | BALANCE_CONTROL_ID_GROUP = { 108 | "资金余额": 1012, 109 | "冻结资金": 1013, 110 | "可用金额": 1016, 111 | "可取金额": 1017, 112 | "股票市值": 1014, 113 | "总资产": 1015, 114 | } 115 | 116 | GRID_DTYPE = { 117 | "操作日期": str, 118 | "委托编号": str, 119 | "申请编号": str, 120 | "合同编号": str, 121 | "证券代码": str, 122 | "股东代码": str, 123 | "资金帐号": str, 124 | "资金帐户": str, 125 | "发生日期": str, 126 | } 127 | 128 | AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] 129 | 130 | 131 | class GJ(CommonConfig): 132 | DEFAULT_EXE_PATH = "C:\\全能行证券交易终端\\xiadan.exe" 133 | 134 | GRID_DTYPE = { 135 | "操作日期": str, 136 | "委托编号": str, 137 | "申请编号": str, 138 | "合同编号": str, 139 | "证券代码": str, 140 | "股东代码": str, 141 | "资金帐号": str, 142 | "资金帐户": str, 143 | "发生日期": str, 144 | } 145 | 146 | AUTO_IPO_MENU_PATH = ["新股申购", "新股批量申购"] 147 | 148 | class GF(CommonConfig): 149 | DEFAULT_EXE_PATH = "C:\\gfzqrzrq\\xiadan.exe" 150 | TITLE = "核新网上交易系统" 151 | 152 | GRID_DTYPE = { 153 | "操作日期": str, 154 | "委托编号": str, 155 | "申请编号": str, 156 | "合同编号": str, 157 | "证券代码": str, 158 | "股东代码": str, 159 | "资金帐号": str, 160 | "资金帐户": str, 161 | "发生日期": str, 162 | } 163 | 164 | AUTO_IPO_MENU_PATH = ["新股申购", "批量新股申购"] 165 | 166 | class WK(HT): 167 | pass 168 | 169 | 170 | class HTZQ(CommonConfig): 171 | DEFAULT_EXE_PATH = r"c:\\海通证券委托\\xiadan.exe" 172 | 173 | BALANCE_CONTROL_ID_GROUP = { 174 | "资金余额": 1012, 175 | "可用金额": 1016, 176 | "可取金额": 1017, 177 | "总资产": 1015, 178 | } 179 | 180 | AUTO_IPO_NUMBER = '可申购数量' 181 | 182 | 183 | class UNIVERSAL(CommonConfig): 184 | DEFAULT_EXE_PATH = r"c:\\ths\\xiadan.exe" 185 | 186 | BALANCE_CONTROL_ID_GROUP = { 187 | "资金余额": 1012, 188 | "可用金额": 1016, 189 | "可取金额": 1017, 190 | "总资产": 1015, 191 | } 192 | 193 | AUTO_IPO_NUMBER = '可申购数量' 194 | -------------------------------------------------------------------------------- /easytrader/config/global.json: -------------------------------------------------------------------------------- 1 | { 2 | "response_format": { 3 | "int": [ 4 | "current_amount", 5 | "enable_amount", 6 | "entrust_amount", 7 | "business_amount", 8 | "成交数量", 9 | "撤单数量", 10 | "委托数量", 11 | "股份可用", 12 | "买入冻结", 13 | "卖出冻结", 14 | "当前持仓", 15 | "股份余额" 16 | ], 17 | "float": [ 18 | "current_balance", 19 | "enable_balance", 20 | "fetch_balance", 21 | "market_value", 22 | "asset_balance", 23 | "av_buy_price", 24 | "cost_price", 25 | "income_balance", 26 | "market_value", 27 | "entrust_price", 28 | "business_price", 29 | "business_balance", 30 | "fare1", 31 | "occur_balance", 32 | "farex", 33 | "fare0", 34 | "occur_amount", 35 | "post_balance", 36 | "fare2", 37 | "fare3", 38 | "资金余额", 39 | "可用资金", 40 | "参考市值", 41 | "总资产", 42 | "股份参考盈亏", 43 | "委托价格", 44 | "成交价格", 45 | "成交金额", 46 | "参考盈亏", 47 | "参考成本价", 48 | "参考市价", 49 | "参考市值" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /easytrader/config/xq.json: -------------------------------------------------------------------------------- 1 | { 2 | "login_api": "https://xueqiu.com/user/login", 3 | "prefix": "https://xueqiu.com/user/login", 4 | "portfolio_url": "https://xueqiu.com/p/", 5 | "search_stock_url": "https://xueqiu.com/stock/p/search.json", 6 | "rebalance_url": "https://xueqiu.com/cubes/rebalancing/create.json", 7 | "history_url": "https://xueqiu.com/cubes/rebalancing/history.json", 8 | "referer": "https://xueqiu.com/p/update?action=holdings&symbol=%s", 9 | "portfolio_url_new": "https://xueqiu.com/cubes/rebalancing/current.json", 10 | "portfolio_quote": "https://xueqiu.com/cubes/quote.json" 11 | } 12 | -------------------------------------------------------------------------------- /easytrader/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class TradeError(IOError): 5 | pass 6 | 7 | 8 | class NotLoginError(Exception): 9 | def __init__(self, result=None): 10 | super(NotLoginError, self).__init__() 11 | self.result = result 12 | -------------------------------------------------------------------------------- /easytrader/follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import datetime 4 | import os 5 | import pickle 6 | import queue 7 | import re 8 | import threading 9 | import time 10 | from typing import List 11 | 12 | import requests 13 | 14 | from easytrader import exceptions 15 | from easytrader.log import logger 16 | 17 | 18 | class BaseFollower(metaclass=abc.ABCMeta): 19 | """ 20 | slippage: 滑点,取值范围为 [0, 1] 21 | """ 22 | 23 | LOGIN_PAGE = "" 24 | LOGIN_API = "" 25 | TRANSACTION_API = "" 26 | CMD_CACHE_FILE = "cmd_cache.pk" 27 | WEB_REFERER = "" 28 | WEB_ORIGIN = "" 29 | 30 | def __init__(self): 31 | self.trade_queue = queue.Queue() 32 | self.expired_cmds = set() 33 | 34 | self.s = requests.Session() 35 | self.s.verify = False 36 | 37 | self.slippage: float = 0.0 38 | 39 | def login(self, user=None, password=None, **kwargs): 40 | """ 41 | 登陆接口 42 | :param user: 用户名 43 | :param password: 密码 44 | :param kwargs: 其他参数 45 | :return: 46 | """ 47 | headers = self._generate_headers() 48 | self.s.headers.update(headers) 49 | 50 | # init cookie 51 | self.s.get(self.LOGIN_PAGE) 52 | 53 | # post for login 54 | params = self.create_login_params(user, password, **kwargs) 55 | rep = self.s.post(self.LOGIN_API, data=params) 56 | 57 | self.check_login_success(rep) 58 | logger.info("登录成功") 59 | 60 | def _generate_headers(self): 61 | headers = { 62 | "Accept": "application/json, text/javascript, */*; q=0.01", 63 | "Accept-Encoding": "gzip, deflate, br", 64 | "Accept-Language": "en-US,en;q=0.8", 65 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) " 66 | "AppleWebKit/537.36 (KHTML, like Gecko) " 67 | "Chrome/54.0.2840.100 Safari/537.36", 68 | "Referer": self.WEB_REFERER, 69 | "X-Requested-With": "XMLHttpRequest", 70 | "Origin": self.WEB_ORIGIN, 71 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 72 | } 73 | return headers 74 | 75 | def check_login_success(self, rep): 76 | """检查登录状态是否成功 77 | :param rep: post login 接口返回的 response 对象 78 | :raise 如果登录失败应该抛出 NotLoginError """ 79 | pass 80 | 81 | def create_login_params(self, user, password, **kwargs) -> dict: 82 | """生成 post 登录接口的参数 83 | :param user: 用户名 84 | :param password: 密码 85 | :return dict 登录参数的字典 86 | """ 87 | return {} 88 | 89 | def follow( 90 | self, 91 | users, 92 | strategies, 93 | track_interval=1, 94 | trade_cmd_expire_seconds=120, 95 | cmd_cache=True, 96 | slippage: float = 0.0, 97 | **kwargs 98 | ): 99 | """跟踪平台对应的模拟交易,支持多用户多策略 100 | 101 | :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 102 | :param strategies: 雪球组合名, 类似 ZH123450 103 | :param total_assets: 雪球组合对应的总资产, 格式 [ 组合1对应资金, 组合2对应资金 ] 104 | 若 strategies=['ZH000001', 'ZH000002'] 设置 total_assets=[10000, 10000], 则表明每个组合对应的资产为 1w 元, 105 | 假设组合 ZH000001 加仓 价格为 p 股票 A 10%, 则对应的交易指令为 买入 股票 A 价格 P 股数 1w * 10% / p 并按 100 取整 106 | :param initial_assets:雪球组合对应的初始资产, 格式 [ 组合1对应资金, 组合2对应资金 ] 107 | 总资产由 初始资产 × 组合净值 算得, total_assets 会覆盖此参数 108 | :param track_interval: 轮询模拟交易时间,单位为秒 109 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 110 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 111 | :param slippage: 滑点,0.0 表示无滑点, 0.05 表示滑点为 5% 112 | """ 113 | self.slippage = slippage 114 | 115 | def _calculate_price_by_slippage(self, action: str, price: float) -> float: 116 | """ 117 | 计算考虑滑点之后的价格 118 | :param action: 交易动作, 支持 ['buy', 'sell'] 119 | :param price: 原始交易价格 120 | :return: 考虑滑点后的交易价格 121 | """ 122 | if action == "buy": 123 | return price * (1 + self.slippage) 124 | if action == "sell": 125 | return price * (1 - self.slippage) 126 | return price 127 | 128 | def load_expired_cmd_cache(self): 129 | if os.path.exists(self.CMD_CACHE_FILE): 130 | with open(self.CMD_CACHE_FILE, "rb") as f: 131 | self.expired_cmds = pickle.load(f) 132 | 133 | def start_trader_thread( 134 | self, 135 | users, 136 | trade_cmd_expire_seconds, 137 | entrust_prop="limit", 138 | send_interval=0, 139 | ): 140 | trader = threading.Thread( 141 | target=self.trade_worker, 142 | args=[users], 143 | kwargs={ 144 | "expire_seconds": trade_cmd_expire_seconds, 145 | "entrust_prop": entrust_prop, 146 | "send_interval": send_interval, 147 | }, 148 | ) 149 | trader.setDaemon(True) 150 | trader.start() 151 | 152 | @staticmethod 153 | def warp_list(value): 154 | if not isinstance(value, list): 155 | value = [value] 156 | return value 157 | 158 | @staticmethod 159 | def extract_strategy_id(strategy_url): 160 | """ 161 | 抽取 策略 id,一般用于获取策略相关信息 162 | :param strategy_url: 策略 url 163 | :return: str 策略 id 164 | """ 165 | pass 166 | 167 | def extract_strategy_name(self, strategy_url): 168 | """ 169 | 抽取 策略名,主要用于日志打印,便于识别 170 | :param strategy_url: 171 | :return: str 策略名 172 | """ 173 | pass 174 | 175 | def track_strategy_worker(self, strategy, name, interval=10, **kwargs): 176 | """跟踪下单worker 177 | :param strategy: 策略id 178 | :param name: 策略名字 179 | :param interval: 轮询策略的时间间隔,单位为秒""" 180 | while True: 181 | try: 182 | transactions = self.query_strategy_transaction( 183 | strategy, **kwargs 184 | ) 185 | # pylint: disable=broad-except 186 | except Exception as e: 187 | logger.exception("无法获取策略 %s 调仓信息, 错误: %s, 跳过此次调仓查询", name, e) 188 | time.sleep(3) 189 | continue 190 | for transaction in transactions: 191 | trade_cmd = { 192 | "strategy": strategy, 193 | "strategy_name": name, 194 | "action": transaction["action"], 195 | "stock_code": transaction["stock_code"], 196 | "amount": transaction["amount"], 197 | "price": transaction["price"], 198 | "datetime": transaction["datetime"], 199 | } 200 | if self.is_cmd_expired(trade_cmd): 201 | continue 202 | logger.info( 203 | "策略 [%s] 发送指令到交易队列, 股票: %s 动作: %s 数量: %s 价格: %s 信号产生时间: %s", 204 | name, 205 | trade_cmd["stock_code"], 206 | trade_cmd["action"], 207 | trade_cmd["amount"], 208 | trade_cmd["price"], 209 | trade_cmd["datetime"], 210 | ) 211 | self.trade_queue.put(trade_cmd) 212 | self.add_cmd_to_expired_cmds(trade_cmd) 213 | try: 214 | for _ in range(interval): 215 | time.sleep(1) 216 | except KeyboardInterrupt: 217 | logger.info("程序退出") 218 | break 219 | 220 | @staticmethod 221 | def generate_expired_cmd_key(cmd): 222 | return "{}_{}_{}_{}_{}_{}".format( 223 | cmd["strategy_name"], 224 | cmd["stock_code"], 225 | cmd["action"], 226 | cmd["amount"], 227 | cmd["price"], 228 | cmd["datetime"], 229 | ) 230 | 231 | def is_cmd_expired(self, cmd): 232 | key = self.generate_expired_cmd_key(cmd) 233 | return key in self.expired_cmds 234 | 235 | def add_cmd_to_expired_cmds(self, cmd): 236 | key = self.generate_expired_cmd_key(cmd) 237 | self.expired_cmds.add(key) 238 | 239 | with open(self.CMD_CACHE_FILE, "wb") as f: 240 | pickle.dump(self.expired_cmds, f) 241 | 242 | @staticmethod 243 | def _is_number(s): 244 | try: 245 | float(s) 246 | return True 247 | except ValueError: 248 | return False 249 | 250 | def _execute_trade_cmd( 251 | self, trade_cmd, users, expire_seconds, entrust_prop, send_interval 252 | ): 253 | """分发交易指令到对应的 user 并执行 254 | :param trade_cmd: 255 | :param users: 256 | :param expire_seconds: 257 | :param entrust_prop: 258 | :param send_interval: 259 | :return: 260 | """ 261 | for user in users: 262 | # check expire 263 | now = datetime.datetime.now() 264 | expire = (now - trade_cmd["datetime"]).total_seconds() 265 | if expire > expire_seconds: 266 | logger.warning( 267 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 超过设置的最大过期时间 %s 秒, 被丢弃", 268 | trade_cmd["strategy_name"], 269 | trade_cmd["stock_code"], 270 | trade_cmd["action"], 271 | trade_cmd["amount"], 272 | trade_cmd["price"], 273 | trade_cmd["datetime"], 274 | now, 275 | expire_seconds, 276 | ) 277 | break 278 | 279 | # check price 280 | price = trade_cmd["price"] 281 | if not self._is_number(price) or price <= 0: 282 | logger.warning( 283 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 价格无效 , 被丢弃", 284 | trade_cmd["strategy_name"], 285 | trade_cmd["stock_code"], 286 | trade_cmd["action"], 287 | trade_cmd["amount"], 288 | trade_cmd["price"], 289 | trade_cmd["datetime"], 290 | now, 291 | ) 292 | break 293 | 294 | # check amount 295 | if trade_cmd["amount"] <= 0: 296 | logger.warning( 297 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格: %s)超时,指令产生时间: %s 当前时间: %s, 买入股数无效 , 被丢弃", 298 | trade_cmd["strategy_name"], 299 | trade_cmd["stock_code"], 300 | trade_cmd["action"], 301 | trade_cmd["amount"], 302 | trade_cmd["price"], 303 | trade_cmd["datetime"], 304 | now, 305 | ) 306 | break 307 | 308 | actual_price = self._calculate_price_by_slippage( 309 | trade_cmd["action"], trade_cmd["price"] 310 | ) 311 | args = { 312 | "security": trade_cmd["stock_code"], 313 | "price": actual_price, 314 | "amount": trade_cmd["amount"], 315 | "entrust_prop": entrust_prop, 316 | } 317 | try: 318 | response = getattr(user, trade_cmd["action"])(**args) 319 | except exceptions.TradeError as e: 320 | trader_name = type(user).__name__ 321 | err_msg = "{}: {}".format(type(e).__name__, e.args) 322 | logger.error( 323 | "%s 执行 策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格(考虑滑点): %s 指令产生时间: %s) 失败, 错误信息: %s", 324 | trader_name, 325 | trade_cmd["strategy_name"], 326 | trade_cmd["stock_code"], 327 | trade_cmd["action"], 328 | trade_cmd["amount"], 329 | actual_price, 330 | trade_cmd["datetime"], 331 | err_msg, 332 | ) 333 | else: 334 | logger.info( 335 | "策略 [%s] 指令(股票: %s 动作: %s 数量: %s 价格(考虑滑点): %s 指令产生时间: %s) 执行成功, 返回: %s", 336 | trade_cmd["strategy_name"], 337 | trade_cmd["stock_code"], 338 | trade_cmd["action"], 339 | trade_cmd["amount"], 340 | actual_price, 341 | trade_cmd["datetime"], 342 | response, 343 | ) 344 | 345 | def trade_worker( 346 | self, users, expire_seconds=120, entrust_prop="limit", send_interval=0 347 | ): 348 | """ 349 | :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时买出单没有及时成交导致的买入金额不足 350 | """ 351 | while True: 352 | trade_cmd = self.trade_queue.get() 353 | self._execute_trade_cmd( 354 | trade_cmd, users, expire_seconds, entrust_prop, send_interval 355 | ) 356 | time.sleep(send_interval) 357 | 358 | def query_strategy_transaction(self, strategy, **kwargs): 359 | params = self.create_query_transaction_params(strategy) 360 | 361 | rep = self.s.get(self.TRANSACTION_API, params=params) 362 | history = rep.json() 363 | 364 | transactions = self.extract_transactions(history) 365 | self.project_transactions(transactions, **kwargs) 366 | return self.order_transactions_sell_first(transactions) 367 | 368 | def extract_transactions(self, history) -> List[str]: 369 | """ 370 | 抽取接口返回中的调仓记录列表 371 | :param history: 调仓接口返回信息的字典对象 372 | :return: [] 调参历史记录的列表 373 | """ 374 | return [] 375 | 376 | def create_query_transaction_params(self, strategy) -> dict: 377 | """ 378 | 生成用于查询调参记录的参数 379 | :param strategy: 策略 id 380 | :return: dict 调参记录参数 381 | """ 382 | return {} 383 | 384 | @staticmethod 385 | def re_find(pattern, string, dtype=str): 386 | return dtype(re.search(pattern, string).group()) 387 | 388 | @staticmethod 389 | def re_search(pattern, string, dtype=str): 390 | return dtype(re.search(pattern,string).group(1)) 391 | 392 | def project_transactions(self, transactions, **kwargs): 393 | """ 394 | 修证调仓记录为内部使用的统一格式 395 | :param transactions: [] 调仓记录的列表 396 | :return: [] 修整后的调仓记录 397 | """ 398 | pass 399 | 400 | def order_transactions_sell_first(self, transactions): 401 | # 调整调仓记录的顺序为先卖再买 402 | sell_first_transactions = [] 403 | for transaction in transactions: 404 | if transaction["action"] == "sell": 405 | sell_first_transactions.insert(0, transaction) 406 | else: 407 | sell_first_transactions.append(transaction) 408 | return sell_first_transactions 409 | -------------------------------------------------------------------------------- /easytrader/gf_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import tempfile 4 | import time 5 | import os 6 | 7 | import pywinauto 8 | import pywinauto.clipboard 9 | 10 | from easytrader import clienttrader 11 | from easytrader.utils.captcha import recognize_verify_code 12 | 13 | 14 | class GFClientTrader(clienttrader.BaseLoginClientTrader): 15 | @property 16 | def broker_type(self): 17 | return "gf" 18 | 19 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 20 | """ 21 | 登陆客户端 22 | 23 | :param user: 账号 24 | :param password: 明文密码 25 | :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', 26 | 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' 27 | :param comm_password: 通讯密码, 华泰需要,可不设 28 | :return: 29 | """ 30 | try: 31 | self._app = pywinauto.Application().connect( 32 | path=self._run_exe_path(exe_path), timeout=1 33 | ) 34 | # pylint: disable=broad-except 35 | except Exception: 36 | self._app = pywinauto.Application().start(exe_path) 37 | 38 | # wait login window ready 39 | while True: 40 | try: 41 | self._app.top_window().Edit1.wait("ready") 42 | break 43 | except RuntimeError: 44 | pass 45 | 46 | self.type_edit_control_keys(self._app.top_window().Edit1, user) 47 | self.type_edit_control_keys(self._app.top_window().Edit2, password) 48 | edit3 = self._app.top_window().window(control_id=0x3eb) 49 | while True: 50 | try: 51 | code = self._handle_verify_code() 52 | self.type_edit_control_keys(edit3, code) 53 | time.sleep(1) 54 | self._app.top_window()["登录(Y)"].click() 55 | # detect login is success or not 56 | try: 57 | self._app.top_window().wait_not("exists", 5) 58 | break 59 | 60 | # pylint: disable=broad-except 61 | except Exception: 62 | self._app.top_window()["确定"].click() 63 | 64 | # pylint: disable=broad-except 65 | except Exception: 66 | pass 67 | 68 | self._app = pywinauto.Application().connect( 69 | path=self._run_exe_path(exe_path), timeout=10 70 | ) 71 | self._main = self._app.window(title_re="""{title}.*""".format(title=self._config.TITLE)) 72 | self.close_pop_dialog() 73 | 74 | def _handle_verify_code(self): 75 | control = self._app.top_window().window(control_id=0x5db) 76 | control.click() 77 | time.sleep(0.2) 78 | file_path = tempfile.mktemp() + ".jpg" 79 | control.capture_as_image().save(file_path) 80 | time.sleep(0.2) 81 | vcode = recognize_verify_code(file_path, "gf_client") 82 | if os.path.exists(file_path): 83 | os.remove(file_path) 84 | return "".join(re.findall("[a-zA-Z0-9]+", vcode)) 85 | -------------------------------------------------------------------------------- /easytrader/gj_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import tempfile 4 | import time 5 | 6 | import pywinauto 7 | import pywinauto.clipboard 8 | 9 | from easytrader import clienttrader 10 | from easytrader.utils.captcha import recognize_verify_code 11 | 12 | 13 | class GJClientTrader(clienttrader.BaseLoginClientTrader): 14 | @property 15 | def broker_type(self): 16 | return "gj" 17 | 18 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 19 | """ 20 | 登陆客户端 21 | 22 | :param user: 账号 23 | :param password: 明文密码 24 | :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', 25 | 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' 26 | :param comm_password: 通讯密码, 华泰需要,可不设 27 | :return: 28 | """ 29 | try: 30 | self._app = pywinauto.Application().connect( 31 | path=self._run_exe_path(exe_path), timeout=1 32 | ) 33 | # pylint: disable=broad-except 34 | except Exception: 35 | self._app = pywinauto.Application().start(exe_path) 36 | 37 | # wait login window ready 38 | while True: 39 | try: 40 | self._app.top_window().Edit1.wait("ready") 41 | break 42 | except RuntimeError: 43 | pass 44 | 45 | self._app.top_window().Edit1.type_keys(user) 46 | self._app.top_window().Edit2.type_keys(password) 47 | edit3 = self._app.top_window().window(control_id=0x3eb) 48 | while True: 49 | try: 50 | code = self._handle_verify_code() 51 | edit3.type_keys(code) 52 | time.sleep(1) 53 | self._app.top_window()["确定(Y)"].click() 54 | # detect login is success or not 55 | try: 56 | self._app.top_window().wait_not("exists", 5) 57 | break 58 | 59 | # pylint: disable=broad-except 60 | except Exception: 61 | self._app.top_window()["确定"].click() 62 | 63 | # pylint: disable=broad-except 64 | except Exception: 65 | pass 66 | 67 | self._app = pywinauto.Application().connect( 68 | path=self._run_exe_path(exe_path), timeout=10 69 | ) 70 | self._main = self._app.window(title="网上股票交易系统5.0") 71 | 72 | def _handle_verify_code(self): 73 | control = self._app.top_window().window(control_id=0x5db) 74 | control.click() 75 | time.sleep(0.2) 76 | file_path = tempfile.mktemp() + ".jpg" 77 | control.capture_as_image().save(file_path) 78 | time.sleep(0.2) 79 | vcode = recognize_verify_code(file_path, "gj_client") 80 | return "".join(re.findall("[a-zA-Z0-9]+", vcode)) 81 | -------------------------------------------------------------------------------- /easytrader/grid_strategies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import io 4 | import tempfile 5 | from io import StringIO 6 | from typing import TYPE_CHECKING, Dict, List, Optional 7 | 8 | import pandas as pd 9 | import pywinauto.keyboard 10 | import pywinauto 11 | import pywinauto.clipboard 12 | 13 | from easytrader.log import logger 14 | from easytrader.utils.captcha import captcha_recognize 15 | from easytrader.utils.win_gui import SetForegroundWindow, ShowWindow, win32defines 16 | 17 | if TYPE_CHECKING: 18 | # pylint: disable=unused-import 19 | from easytrader import clienttrader 20 | 21 | 22 | class IGridStrategy(abc.ABC): 23 | @abc.abstractmethod 24 | def get(self, control_id: int) -> List[Dict]: 25 | """ 26 | 获取 grid 数据并格式化返回 27 | 28 | :param control_id: grid 的 control id 29 | :return: grid 数据 30 | """ 31 | pass 32 | 33 | @abc.abstractmethod 34 | def set_trader(self, trader: "clienttrader.IClientTrader"): 35 | pass 36 | 37 | 38 | class BaseStrategy(IGridStrategy): 39 | def __init__(self): 40 | self._trader = None 41 | 42 | def set_trader(self, trader: "clienttrader.IClientTrader"): 43 | self._trader = trader 44 | 45 | @abc.abstractmethod 46 | def get(self, control_id: int) -> List[Dict]: 47 | """ 48 | :param control_id: grid 的 control id 49 | :return: grid 数据 50 | """ 51 | pass 52 | 53 | def _get_grid(self, control_id: int): 54 | grid = self._trader.main.child_window( 55 | control_id=control_id, class_name="CVirtualGridCtrl" 56 | ) 57 | return grid 58 | 59 | def _set_foreground(self, grid=None): 60 | try: 61 | if grid is None: 62 | grid = self._trader.main 63 | if grid.has_style(win32defines.WS_MINIMIZE): # if minimized 64 | ShowWindow(grid.wrapper_object(), 9) # restore window state 65 | else: 66 | SetForegroundWindow(grid.wrapper_object()) # bring to front 67 | except: 68 | pass 69 | 70 | 71 | class Copy(BaseStrategy): 72 | """ 73 | 通过复制 grid 内容到剪切板再读取来获取 grid 内容 74 | """ 75 | 76 | _need_captcha_reg = True 77 | 78 | def get(self, control_id: int) -> List[Dict]: 79 | grid = self._get_grid(control_id) 80 | self._set_foreground(grid) 81 | grid.type_keys("^A^C", set_foreground=False) 82 | content = self._get_clipboard_data() 83 | return self._format_grid_data(content) 84 | 85 | def _format_grid_data(self, data: str) -> List[Dict]: 86 | try: 87 | df = pd.read_csv( 88 | io.StringIO(data), 89 | delimiter="\t", 90 | dtype=self._trader.config.GRID_DTYPE, 91 | na_filter=False, 92 | ) 93 | return df.to_dict("records") 94 | except: 95 | Copy._need_captcha_reg = True 96 | 97 | def _get_clipboard_data(self) -> str: 98 | if Copy._need_captcha_reg: 99 | if ( 100 | self._trader.app.top_window().window(class_name="Static", title_re="验证码").exists(timeout=1) 101 | ): 102 | file_path = "tmp.png" 103 | count = 5 104 | found = False 105 | while count > 0: 106 | self._trader.app.top_window().window( 107 | control_id=0x965, class_name="Static" 108 | ).capture_as_image().save( 109 | file_path 110 | ) # 保存验证码 111 | 112 | captcha_num = captcha_recognize(file_path).strip() # 识别验证码 113 | captcha_num = "".join(captcha_num.split()) 114 | logger.info("captcha result-->" + captcha_num) 115 | if len(captcha_num) == 4: 116 | self._trader.app.top_window().window( 117 | control_id=0x964, class_name="Edit" 118 | ).set_text( 119 | captcha_num 120 | ) # 模拟输入验证码 121 | 122 | self._trader.app.top_window().set_focus() 123 | pywinauto.keyboard.SendKeys("{ENTER}") # 模拟发送enter,点击确定 124 | try: 125 | logger.info( 126 | self._trader.app.top_window() 127 | .window(control_id=0x966, class_name="Static") 128 | .window_text() 129 | ) 130 | except Exception as ex: # 窗体消失 131 | logger.exception(ex) 132 | found = True 133 | break 134 | count -= 1 135 | self._trader.wait(0.1) 136 | self._trader.app.top_window().window( 137 | control_id=0x965, class_name="Static" 138 | ).click() 139 | if not found: 140 | self._trader.app.top_window().Button2.click() # 点击取消 141 | else: 142 | Copy._need_captcha_reg = False 143 | count = 5 144 | while count > 0: 145 | try: 146 | return pywinauto.clipboard.GetData() 147 | # pylint: disable=broad-except 148 | except Exception as e: 149 | count -= 1 150 | logger.exception("%s, retry ......", e) 151 | 152 | 153 | class WMCopy(Copy): 154 | """ 155 | 通过复制 grid 内容到剪切板再读取来获取 grid 内容 156 | """ 157 | 158 | def get(self, control_id: int) -> List[Dict]: 159 | grid = self._get_grid(control_id) 160 | grid.post_message(win32defines.WM_COMMAND, 0xE122, 0) 161 | self._trader.wait(0.1) 162 | content = self._get_clipboard_data() 163 | return self._format_grid_data(content) 164 | 165 | 166 | class Xls(BaseStrategy): 167 | """ 168 | 通过将 Grid 另存为 xls 文件再读取的方式获取 grid 内容 169 | """ 170 | 171 | def __init__(self, tmp_folder: Optional[str] = None): 172 | """ 173 | :param tmp_folder: 用于保持临时文件的文件夹 174 | """ 175 | super().__init__() 176 | self.tmp_folder = tmp_folder 177 | 178 | def get(self, control_id: int) -> List[Dict]: 179 | grid = self._get_grid(control_id) 180 | 181 | # ctrl+s 保存 grid 内容为 xls 文件 182 | self._set_foreground(grid) # setFocus buggy, instead of SetForegroundWindow 183 | grid.type_keys("^s", set_foreground=False) 184 | count = 10 185 | while count > 0: 186 | if self._trader.is_exist_pop_dialog(): 187 | break 188 | self._trader.wait(0.2) 189 | count -= 1 190 | 191 | temp_path = tempfile.mktemp(suffix=".xls", dir=self.tmp_folder) 192 | self._set_foreground(self._trader.app.top_window()) 193 | 194 | # alt+s保存,alt+y替换已存在的文件 195 | self._trader.app.top_window().Edit1.set_edit_text(temp_path) 196 | self._trader.wait(0.1) 197 | self._trader.app.top_window().type_keys("%{s}%{y}", set_foreground=False) 198 | # Wait until file save complete otherwise pandas can not find file 199 | self._trader.wait(0.2) 200 | if self._trader.is_exist_pop_dialog(): 201 | self._trader.app.top_window().Button2.click() 202 | self._trader.wait(0.2) 203 | 204 | return self._format_grid_data(temp_path) 205 | 206 | def _format_grid_data(self, data: str) -> List[Dict]: 207 | with open(data, encoding="gbk", errors="replace") as f: 208 | content = f.read() 209 | 210 | df = pd.read_csv( 211 | StringIO(content), 212 | delimiter="\t", 213 | dtype=self._trader.config.GRID_DTYPE, 214 | na_filter=False, 215 | ) 216 | return df.to_dict("records") 217 | -------------------------------------------------------------------------------- /easytrader/ht_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pywinauto 4 | import pywinauto.clipboard 5 | 6 | from easytrader import grid_strategies 7 | from . import clienttrader 8 | 9 | 10 | class HTClientTrader(clienttrader.BaseLoginClientTrader): 11 | grid_strategy = grid_strategies.Xls 12 | 13 | @property 14 | def broker_type(self): 15 | return "ht" 16 | 17 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 18 | """ 19 | :param user: 用户名 20 | :param password: 密码 21 | :param exe_path: 客户端路径, 类似 22 | :param comm_password: 23 | :param kwargs: 24 | :return: 25 | """ 26 | self._editor_need_type_keys = False 27 | if comm_password is None: 28 | raise ValueError("华泰必须设置通讯密码") 29 | 30 | try: 31 | self._app = pywinauto.Application().connect( 32 | path=self._run_exe_path(exe_path), timeout=1 33 | ) 34 | # pylint: disable=broad-except 35 | except Exception: 36 | self._app = pywinauto.Application().start(exe_path) 37 | 38 | # wait login window ready 39 | while True: 40 | try: 41 | self._app.top_window().Edit1.wait("ready") 42 | break 43 | except RuntimeError: 44 | pass 45 | self._app.top_window().Edit1.set_focus() 46 | self._app.top_window().Edit1.type_keys(user) 47 | self._app.top_window().Edit2.type_keys(password) 48 | 49 | self._app.top_window().Edit3.set_edit_text(comm_password) 50 | 51 | self._app.top_window().button0.click() 52 | 53 | self._app = pywinauto.Application().connect( 54 | path=self._run_exe_path(exe_path), timeout=10 55 | ) 56 | self._main = self._app.window(title="网上股票交易系统5.0") 57 | self._main.wait ( "exists enabled visible ready" , timeout=100 ) 58 | self._close_prompt_windows ( ) 59 | 60 | @property 61 | def balance(self): 62 | self._switch_left_menus(self._config.BALANCE_MENU_PATH) 63 | 64 | return self._get_balance_from_statics() 65 | 66 | def _get_balance_from_statics(self): 67 | result = {} 68 | for key, control_id in self._config.BALANCE_CONTROL_ID_GROUP.items(): 69 | result[key] = float( 70 | self._main.child_window( 71 | control_id=control_id, class_name="Static" 72 | ).window_text() 73 | ) 74 | return result 75 | 76 | 77 | -------------------------------------------------------------------------------- /easytrader/htzq_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pywinauto 4 | import pywinauto.clipboard 5 | 6 | from easytrader import grid_strategies 7 | from . import clienttrader 8 | 9 | 10 | class HTZQClientTrader(clienttrader.BaseLoginClientTrader): 11 | grid_strategy = grid_strategies.Xls 12 | 13 | @property 14 | def broker_type(self): 15 | return "htzq" 16 | 17 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 18 | """ 19 | :param user: 用户名 20 | :param password: 密码 21 | :param exe_path: 客户端路径, 类似 22 | :param comm_password: 23 | :param kwargs: 24 | :return: 25 | """ 26 | self._editor_need_type_keys = False 27 | if comm_password is None: 28 | raise ValueError("必须设置通讯密码") 29 | 30 | try: 31 | self._app = pywinauto.Application().connect( 32 | path=self._run_exe_path(exe_path), timeout=1 33 | ) 34 | # pylint: disable=broad-except 35 | except Exception: 36 | self._app = pywinauto.Application().start(exe_path) 37 | 38 | # wait login window ready 39 | while True: 40 | try: 41 | self._app.top_window().Edit1.wait("ready") 42 | break 43 | except RuntimeError: 44 | pass 45 | self._app.top_window().Edit1.set_focus() 46 | self._app.top_window().Edit1.type_keys(user) 47 | self._app.top_window().Edit2.type_keys(password) 48 | 49 | self._app.top_window().Edit3.type_keys(comm_password) 50 | 51 | self._app.top_window().button0.click() 52 | 53 | # detect login is success or not 54 | self._app.top_window().wait_not("exists", 100) 55 | 56 | self._app = pywinauto.Application().connect( 57 | path=self._run_exe_path(exe_path), timeout=10 58 | ) 59 | self._close_prompt_windows() 60 | self._main = self._app.window(title="网上股票交易系统5.0") 61 | 62 | -------------------------------------------------------------------------------- /easytrader/joinquant_follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | from threading import Thread 4 | 5 | from easytrader import exceptions 6 | from easytrader.follower import BaseFollower 7 | from easytrader.log import logger 8 | 9 | 10 | class JoinQuantFollower(BaseFollower): 11 | LOGIN_PAGE = "https://www.joinquant.com" 12 | LOGIN_API = "https://www.joinquant.com/user/login/doLogin?ajax=1" 13 | TRANSACTION_API = ( 14 | "https://www.joinquant.com/algorithm/live/transactionDetail" 15 | ) 16 | WEB_REFERER = "https://www.joinquant.com/user/login/index" 17 | WEB_ORIGIN = "https://www.joinquant.com" 18 | 19 | def create_login_params(self, user, password, **kwargs): 20 | params = { 21 | "CyLoginForm[username]": user, 22 | "CyLoginForm[pwd]": password, 23 | "ajax": 1, 24 | } 25 | return params 26 | 27 | def check_login_success(self, rep): 28 | set_cookie = rep.headers["set-cookie"] 29 | if len(set_cookie) < 50: 30 | raise exceptions.NotLoginError("登录失败,请检查用户名和密码") 31 | self.s.headers.update({"cookie": set_cookie}) 32 | 33 | def follow( 34 | self, 35 | users, 36 | strategies, 37 | track_interval=1, 38 | trade_cmd_expire_seconds=120, 39 | cmd_cache=True, 40 | entrust_prop="limit", 41 | send_interval=0, 42 | ): 43 | """跟踪joinquant对应的模拟交易,支持多用户多策略 44 | :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 45 | :param strategies: joinquant 的模拟交易地址,支持使用 [] 指定多个模拟交易, 46 | 地址类似 https://www.joinquant.com/algorithm/live/index?backtestId=xxx 47 | :param track_interval: 轮训模拟交易时间,单位为秒 48 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 49 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 50 | :param entrust_prop: 委托方式, 'limit' 为限价,'market' 为市价, 仅在银河实现 51 | :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 52 | """ 53 | users = self.warp_list(users) 54 | strategies = self.warp_list(strategies) 55 | 56 | if cmd_cache: 57 | self.load_expired_cmd_cache() 58 | 59 | self.start_trader_thread( 60 | users, trade_cmd_expire_seconds, entrust_prop, send_interval 61 | ) 62 | 63 | workers = [] 64 | for strategy_url in strategies: 65 | try: 66 | strategy_id = self.extract_strategy_id(strategy_url) 67 | strategy_name = self.extract_strategy_name(strategy_url) 68 | except: 69 | logger.error("抽取交易id和策略名失败, 无效的模拟交易url: %s", strategy_url) 70 | raise 71 | strategy_worker = Thread( 72 | target=self.track_strategy_worker, 73 | args=[strategy_id, strategy_name], 74 | kwargs={"interval": track_interval}, 75 | ) 76 | strategy_worker.start() 77 | workers.append(strategy_worker) 78 | logger.info("开始跟踪策略: %s", strategy_name) 79 | for worker in workers: 80 | worker.join() 81 | 82 | # @staticmethod 83 | # def extract_strategy_id(strategy_url): 84 | # return re.search(r"(?<=backtestId=)\w+", strategy_url).group() 85 | # 86 | # def extract_strategy_name(self, strategy_url): 87 | # rep = self.s.get(strategy_url) 88 | # return self.re_find( 89 | # r'(?<=title="点击修改策略名称"\>).*(?=\', rep.content.decode("utf8")) 94 | 95 | def extract_strategy_name(self, strategy_url): 96 | rep = self.s.get(strategy_url) 97 | return self.re_search(r'class="backtest_name".+?>(.*?)', rep.content.decode("utf8")) 98 | 99 | def create_query_transaction_params(self, strategy): 100 | today_str = datetime.today().strftime("%Y-%m-%d") 101 | params = {"backtestId": strategy, "date": today_str, "ajax": 1} 102 | return params 103 | 104 | def extract_transactions(self, history): 105 | transactions = history["data"]["transaction"] 106 | return transactions 107 | 108 | @staticmethod 109 | def stock_shuffle_to_prefix(stock): 110 | assert ( 111 | len(stock) == 11 112 | ), "stock {} must like 123456.XSHG or 123456.XSHE".format(stock) 113 | code = stock[:6] 114 | if stock.find("XSHG") != -1: 115 | return "sh" + code 116 | 117 | if stock.find("XSHE") != -1: 118 | return "sz" + code 119 | raise TypeError("not valid stock code: {}".format(code)) 120 | 121 | def project_transactions(self, transactions, **kwargs): 122 | for transaction in transactions: 123 | transaction["amount"] = self.re_find( 124 | r"\d+", transaction["amount"], dtype=int 125 | ) 126 | 127 | time_str = "{} {}".format(transaction["date"], transaction["time"]) 128 | transaction["datetime"] = datetime.strptime( 129 | time_str, "%Y-%m-%d %H:%M:%S" 130 | ) 131 | 132 | stock = self.re_find(r"\d{6}\.\w{4}", transaction["stock"]) 133 | transaction["stock_code"] = self.stock_shuffle_to_prefix(stock) 134 | 135 | transaction["action"] = ( 136 | "buy" if transaction["transaction"] == "买" else "sell" 137 | ) 138 | -------------------------------------------------------------------------------- /easytrader/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | logger = logging.getLogger("easytrader") 5 | logger.setLevel(logging.INFO) 6 | logger.propagate = False 7 | 8 | fmt = logging.Formatter( 9 | "%(asctime)s [%(levelname)s] %(filename)s %(lineno)s: %(message)s" 10 | ) 11 | ch = logging.StreamHandler() 12 | 13 | ch.setFormatter(fmt) 14 | logger.handlers.append(ch) 15 | -------------------------------------------------------------------------------- /easytrader/miniqmt/__init__.py: -------------------------------------------------------------------------------- 1 | from easytrader.miniqmt.miniqmt_trader import MiniqmtTrader, DefaultXtQuantTraderCallback -------------------------------------------------------------------------------- /easytrader/pop_dialog_handler.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import re 3 | import time 4 | from typing import Optional 5 | 6 | from easytrader import exceptions 7 | from easytrader.utils.perf import perf_clock 8 | from easytrader.utils.win_gui import SetForegroundWindow, ShowWindow, win32defines 9 | 10 | 11 | class PopDialogHandler: 12 | def __init__(self, app): 13 | self._app = app 14 | 15 | @staticmethod 16 | def _set_foreground(window): 17 | if window.has_style(win32defines.WS_MINIMIZE): # if minimized 18 | ShowWindow(window.wrapper_object(), 9) # restore window state 19 | else: 20 | SetForegroundWindow(window.wrapper_object()) # bring to front 21 | 22 | @perf_clock 23 | def handle(self, title): 24 | if any(s in title for s in {"提示信息", "委托确认", "网上交易用户协议", "撤单确认"}): 25 | self._submit_by_shortcut() 26 | return None 27 | 28 | if "提示" in title: 29 | content = self._extract_content() 30 | self._submit_by_click() 31 | return {"message": content} 32 | 33 | content = self._extract_content() 34 | self._close() 35 | return {"message": "unknown message: {}".format(content)} 36 | 37 | def _extract_content(self): 38 | return self._app.top_window().Static.window_text() 39 | 40 | @staticmethod 41 | def _extract_entrust_id(content): 42 | return re.search(r"[\da-zA-Z]+", content).group() 43 | 44 | def _submit_by_click(self): 45 | try: 46 | self._app.top_window()["确定"].click() 47 | except Exception as ex: 48 | self._app.Window_(best_match="Dialog", top_level_only=True).ChildWindow( 49 | best_match="确定" 50 | ).click() 51 | 52 | def _submit_by_shortcut(self): 53 | self._set_foreground(self._app.top_window()) 54 | self._app.top_window().type_keys("%Y", set_foreground=False) 55 | 56 | def _close(self): 57 | self._app.top_window().close() 58 | 59 | 60 | class TradePopDialogHandler(PopDialogHandler): 61 | @perf_clock 62 | def handle(self, title) -> Optional[dict]: 63 | if title == "委托确认": 64 | self._submit_by_shortcut() 65 | return None 66 | 67 | if title == "提示信息": 68 | content = self._extract_content() 69 | if "超出涨跌停" in content: 70 | self._submit_by_shortcut() 71 | return None 72 | 73 | if "委托价格的小数价格应为" in content: 74 | self._submit_by_shortcut() 75 | return None 76 | 77 | if "逆回购" in content: 78 | self._submit_by_shortcut() 79 | return None 80 | 81 | if "正回购" in content: 82 | self._submit_by_shortcut() 83 | return None 84 | 85 | return None 86 | 87 | if title == "提示": 88 | content = self._extract_content() 89 | if "成功" in content: 90 | entrust_no = self._extract_entrust_id(content) 91 | self._submit_by_click() 92 | return {"entrust_no": entrust_no} 93 | 94 | self._submit_by_click() 95 | time.sleep(0.05) 96 | raise exceptions.TradeError(content) 97 | self._close() 98 | return None 99 | -------------------------------------------------------------------------------- /easytrader/refresh_strategies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import io 4 | import tempfile 5 | from io import StringIO 6 | from typing import TYPE_CHECKING, Dict, List, Optional 7 | 8 | import pandas as pd 9 | import pywinauto.keyboard 10 | import pywinauto 11 | import pywinauto.clipboard 12 | 13 | from easytrader.log import logger 14 | from easytrader.utils.captcha import captcha_recognize 15 | from easytrader.utils.win_gui import SetForegroundWindow, ShowWindow, win32defines 16 | 17 | if TYPE_CHECKING: 18 | # pylint: disable=unused-import 19 | from easytrader import clienttrader 20 | 21 | 22 | class IRefreshStrategy(abc.ABC): 23 | _trader: "clienttrader.ClientTrader" 24 | 25 | @abc.abstractmethod 26 | def refresh(self): 27 | """ 28 | 刷新数据 29 | """ 30 | pass 31 | 32 | def set_trader(self, trader: "clienttrader.ClientTrader"): 33 | self._trader = trader 34 | 35 | 36 | # noinspection PyProtectedMember 37 | class Switch(IRefreshStrategy): 38 | """通过切换菜单栏刷新""" 39 | 40 | def __init__(self, sleep: float = 0.1): 41 | self.sleep = sleep 42 | 43 | def refresh(self): 44 | self._trader._switch_left_menus_by_shortcut("{F5}", sleep=self.sleep) 45 | 46 | 47 | # noinspection PyProtectedMember 48 | class Toolbar(IRefreshStrategy): 49 | """通过点击工具栏刷新按钮刷新""" 50 | 51 | def __init__(self, refresh_btn_index: int = 4): 52 | """ 53 | :param refresh_btn_index: 54 | 交易客户端工具栏中“刷新”排序,默认为第4个,请根据自己实际调整 55 | """ 56 | self.refresh_btn_index = refresh_btn_index 57 | 58 | def refresh(self): 59 | self._trader._toolbar.button(self.refresh_btn_index - 1).click() 60 | -------------------------------------------------------------------------------- /easytrader/remoteclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | 4 | from easytrader.utils.misc import file2dict 5 | 6 | 7 | def use(broker, host, port=1430, **kwargs): 8 | return RemoteClient(broker, host, port) 9 | 10 | 11 | class RemoteClient: 12 | def __init__(self, broker, host, port=1430, **kwargs): 13 | self._s = requests.session() 14 | self._api = "http://{}:{}".format(host, port) 15 | self._broker = broker 16 | 17 | def prepare( 18 | self, 19 | config_path=None, 20 | user=None, 21 | password=None, 22 | exe_path=None, 23 | comm_password=None, 24 | **kwargs 25 | ): 26 | """ 27 | 登陆客户端 28 | :param config_path: 登陆配置文件,跟参数登陆方式二选一 29 | :param user: 账号 30 | :param password: 明文密码 31 | :param exe_path: 客户端路径类似 r'C:\\htzqzyb2\\xiadan.exe', 32 | 默认 r'C:\\htzqzyb2\\xiadan.exe' 33 | :param comm_password: 通讯密码 34 | :return: 35 | """ 36 | params = locals().copy() 37 | params.pop("self") 38 | 39 | if config_path is not None: 40 | account = file2dict(config_path) 41 | params["user"] = account["user"] 42 | params["password"] = account["password"] 43 | 44 | params["broker"] = self._broker 45 | 46 | response = self._s.post(self._api + "/prepare", json=params) 47 | if response.status_code >= 300: 48 | raise Exception(response.json()["error"]) 49 | return response.json() 50 | 51 | @property 52 | def balance(self): 53 | return self.common_get("balance") 54 | 55 | @property 56 | def position(self): 57 | return self.common_get("position") 58 | 59 | @property 60 | def today_entrusts(self): 61 | return self.common_get("today_entrusts") 62 | 63 | @property 64 | def today_trades(self): 65 | return self.common_get("today_trades") 66 | 67 | @property 68 | def cancel_entrusts(self): 69 | return self.common_get("cancel_entrusts") 70 | 71 | def auto_ipo(self): 72 | return self.common_get("auto_ipo") 73 | 74 | def exit(self): 75 | return self.common_get("exit") 76 | 77 | def common_get(self, endpoint): 78 | response = self._s.get(self._api + "/" + endpoint) 79 | if response.status_code >= 300: 80 | raise Exception(response.json()["error"]) 81 | return response.json() 82 | 83 | def buy(self, security, price, amount, **kwargs): 84 | params = locals().copy() 85 | params.pop("self") 86 | 87 | response = self._s.post(self._api + "/buy", json=params) 88 | if response.status_code >= 300: 89 | raise Exception(response.json()["error"]) 90 | return response.json() 91 | 92 | def sell(self, security, price, amount, **kwargs): 93 | params = locals().copy() 94 | params.pop("self") 95 | 96 | response = self._s.post(self._api + "/sell", json=params) 97 | if response.status_code >= 300: 98 | raise Exception(response.json()["error"]) 99 | return response.json() 100 | 101 | def cancel_entrust(self, entrust_no): 102 | params = locals().copy() 103 | params.pop("self") 104 | 105 | response = self._s.post(self._api + "/cancel_entrust", json=params) 106 | if response.status_code >= 300: 107 | raise Exception(response.json()["error"]) 108 | return response.json() 109 | -------------------------------------------------------------------------------- /easytrader/ricequant_follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | from threading import Thread 5 | 6 | from easytrader.follower import BaseFollower 7 | from easytrader.log import logger 8 | 9 | 10 | class RiceQuantFollower(BaseFollower): 11 | def __init__(self): 12 | super().__init__() 13 | self.client = None 14 | 15 | def login(self, user=None, password=None, **kwargs): 16 | from rqopen_client import RQOpenClient 17 | 18 | self.client = RQOpenClient(user, password, logger=logger) 19 | 20 | def follow( 21 | self, 22 | users, 23 | run_id, 24 | track_interval=1, 25 | trade_cmd_expire_seconds=120, 26 | cmd_cache=True, 27 | entrust_prop="limit", 28 | send_interval=0, 29 | ): 30 | """跟踪ricequant对应的模拟交易,支持多用户多策略 31 | :param users: 支持easytrader的用户对象,支持使用 [] 指定多个用户 32 | :param run_id: ricequant 的模拟交易ID,支持使用 [] 指定多个模拟交易 33 | :param track_interval: 轮训模拟交易时间,单位为秒 34 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 35 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 36 | :param entrust_prop: 委托方式, 'limit' 为限价,'market' 为市价, 仅在银河实现 37 | :param send_interval: 交易发送间隔, 默认为0s。调大可防止卖出买入时卖出单没有及时成交导致的买入金额不足 38 | """ 39 | users = self.warp_list(users) 40 | run_ids = self.warp_list(run_id) 41 | 42 | if cmd_cache: 43 | self.load_expired_cmd_cache() 44 | 45 | self.start_trader_thread( 46 | users, trade_cmd_expire_seconds, entrust_prop, send_interval 47 | ) 48 | 49 | workers = [] 50 | for id_ in run_ids: 51 | strategy_name = self.extract_strategy_name(id_) 52 | strategy_worker = Thread( 53 | target=self.track_strategy_worker, 54 | args=[id_, strategy_name], 55 | kwargs={"interval": track_interval}, 56 | ) 57 | strategy_worker.start() 58 | workers.append(strategy_worker) 59 | logger.info("开始跟踪策略: %s", strategy_name) 60 | for worker in workers: 61 | worker.join() 62 | 63 | def extract_strategy_name(self, run_id): 64 | ret_json = self.client.get_positions(run_id) 65 | if ret_json["code"] != 200: 66 | logger.error( 67 | "fetch data from run_id %s fail, msg %s", 68 | run_id, 69 | ret_json["msg"], 70 | ) 71 | raise RuntimeError(ret_json["msg"]) 72 | return ret_json["resp"]["name"] 73 | 74 | def extract_day_trades(self, run_id): 75 | ret_json = self.client.get_day_trades(run_id) 76 | if ret_json["code"] != 200: 77 | logger.error( 78 | "fetch day trades from run_id %s fail, msg %s", 79 | run_id, 80 | ret_json["msg"], 81 | ) 82 | raise RuntimeError(ret_json["msg"]) 83 | return ret_json["resp"]["trades"] 84 | 85 | def query_strategy_transaction(self, strategy, **kwargs): 86 | transactions = self.extract_day_trades(strategy) 87 | transactions = self.project_transactions(transactions, **kwargs) 88 | return self.order_transactions_sell_first(transactions) 89 | 90 | @staticmethod 91 | def stock_shuffle_to_prefix(stock): 92 | assert ( 93 | len(stock) == 11 94 | ), "stock {} must like 123456.XSHG or 123456.XSHE".format(stock) 95 | code = stock[:6] 96 | if stock.find("XSHG") != -1: 97 | return "sh" + code 98 | if stock.find("XSHE") != -1: 99 | return "sz" + code 100 | raise TypeError("not valid stock code: {}".format(code)) 101 | 102 | def project_transactions(self, transactions, **kwargs): 103 | new_transactions = [] 104 | for transaction in transactions: 105 | new_transaction = {} 106 | new_transaction["price"] = transaction["price"] 107 | new_transaction["amount"] = int(abs(transaction["quantity"])) 108 | new_transaction["datetime"] = datetime.strptime( 109 | transaction["time"], "%Y-%m-%d %H:%M:%S" 110 | ) 111 | new_transaction["stock_code"] = self.stock_shuffle_to_prefix( 112 | transaction["order_book_id"] 113 | ) 114 | new_transaction["action"] = ( 115 | "buy" if transaction["quantity"] > 0 else "sell" 116 | ) 117 | new_transactions.append(new_transaction) 118 | 119 | return new_transactions 120 | -------------------------------------------------------------------------------- /easytrader/server.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from flask import Flask, jsonify, request 4 | 5 | from . import api 6 | from .log import logger 7 | 8 | app = Flask(__name__) 9 | 10 | global_store = {} 11 | 12 | 13 | def error_handle(func): 14 | @functools.wraps(func) 15 | def wrapper(*args, **kwargs): 16 | try: 17 | return func(*args, **kwargs) 18 | # pylint: disable=broad-except 19 | except Exception as e: 20 | logger.exception("server error") 21 | message = "{}: {}".format(e.__class__, e) 22 | return jsonify({"error": message}), 400 23 | 24 | return wrapper 25 | 26 | 27 | @app.route("/prepare", methods=["POST"]) 28 | @error_handle 29 | def post_prepare(): 30 | json_data = request.get_json(force=True) 31 | 32 | user = api.use(json_data.pop("broker")) 33 | user.prepare(**json_data) 34 | 35 | global_store["user"] = user 36 | return jsonify({"msg": "login success"}), 201 37 | 38 | 39 | @app.route("/balance", methods=["GET"]) 40 | @error_handle 41 | def get_balance(): 42 | user = global_store["user"] 43 | balance = user.balance 44 | 45 | return jsonify(balance), 200 46 | 47 | 48 | @app.route("/position", methods=["GET"]) 49 | @error_handle 50 | def get_position(): 51 | user = global_store["user"] 52 | position = user.position 53 | 54 | return jsonify(position), 200 55 | 56 | 57 | @app.route("/auto_ipo", methods=["GET"]) 58 | @error_handle 59 | def get_auto_ipo(): 60 | user = global_store["user"] 61 | res = user.auto_ipo() 62 | 63 | return jsonify(res), 200 64 | 65 | 66 | @app.route("/today_entrusts", methods=["GET"]) 67 | @error_handle 68 | def get_today_entrusts(): 69 | user = global_store["user"] 70 | today_entrusts = user.today_entrusts 71 | 72 | return jsonify(today_entrusts), 200 73 | 74 | 75 | @app.route("/today_trades", methods=["GET"]) 76 | @error_handle 77 | def get_today_trades(): 78 | user = global_store["user"] 79 | today_trades = user.today_trades 80 | 81 | return jsonify(today_trades), 200 82 | 83 | 84 | @app.route("/cancel_entrusts", methods=["GET"]) 85 | @error_handle 86 | def get_cancel_entrusts(): 87 | user = global_store["user"] 88 | cancel_entrusts = user.cancel_entrusts 89 | 90 | return jsonify(cancel_entrusts), 200 91 | 92 | 93 | @app.route("/buy", methods=["POST"]) 94 | @error_handle 95 | def post_buy(): 96 | json_data = request.get_json(force=True) 97 | user = global_store["user"] 98 | res = user.buy(**json_data) 99 | 100 | return jsonify(res), 201 101 | 102 | 103 | @app.route("/sell", methods=["POST"]) 104 | @error_handle 105 | def post_sell(): 106 | json_data = request.get_json(force=True) 107 | 108 | user = global_store["user"] 109 | res = user.sell(**json_data) 110 | 111 | return jsonify(res), 201 112 | 113 | 114 | @app.route("/cancel_entrust", methods=["POST"]) 115 | @error_handle 116 | def post_cancel_entrust(): 117 | json_data = request.get_json(force=True) 118 | 119 | user = global_store["user"] 120 | res = user.cancel_entrust(**json_data) 121 | 122 | return jsonify(res), 201 123 | 124 | 125 | @app.route("/exit", methods=["GET"]) 126 | @error_handle 127 | def get_exit(): 128 | user = global_store["user"] 129 | user.exit() 130 | 131 | return jsonify({"msg": "exit success"}), 200 132 | 133 | 134 | def run(port=1430): 135 | app.run(host="0.0.0.0", port=port) 136 | -------------------------------------------------------------------------------- /easytrader/universal_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pywinauto 4 | import pywinauto.clipboard 5 | 6 | from easytrader import grid_strategies 7 | from . import clienttrader 8 | 9 | 10 | class UniversalClientTrader(clienttrader.BaseLoginClientTrader): 11 | grid_strategy = grid_strategies.Xls 12 | 13 | @property 14 | def broker_type(self): 15 | return "universal" 16 | 17 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 18 | """ 19 | :param user: 用户名 20 | :param password: 密码 21 | :param exe_path: 客户端路径, 类似 22 | :param comm_password: 23 | :param kwargs: 24 | :return: 25 | """ 26 | self._editor_need_type_keys = False 27 | 28 | try: 29 | self._app = pywinauto.Application().connect( 30 | path=self._run_exe_path(exe_path), timeout=1 31 | ) 32 | # pylint: disable=broad-except 33 | except Exception: 34 | self._app = pywinauto.Application().start(exe_path) 35 | 36 | # wait login window ready 37 | while True: 38 | try: 39 | login_window = pywinauto.findwindows.find_window(class_name='#32770', found_index=1) 40 | break 41 | except: 42 | self.wait(1) 43 | 44 | self.wait(1) 45 | self._app.window(handle=login_window).Edit1.set_focus() 46 | self._app.window(handle=login_window).Edit1.type_keys(user) 47 | 48 | self._app.window(handle=login_window).button7.click() 49 | 50 | # detect login is success or not 51 | # self._app.top_window().wait_not("exists", 100) 52 | self.wait(5) 53 | 54 | self._app = pywinauto.Application().connect( 55 | path=self._run_exe_path(exe_path), timeout=10 56 | ) 57 | 58 | self._close_prompt_windows() 59 | self._main = self._app.window(title="网上股票交易系统5.0") 60 | 61 | -------------------------------------------------------------------------------- /easytrader/utils/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /easytrader/utils/captcha.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | from PIL import Image 5 | 6 | from easytrader import exceptions 7 | 8 | 9 | def captcha_recognize(img_path): 10 | import pytesseract 11 | 12 | im = Image.open(img_path).convert("L") 13 | # 1. threshold the image 14 | threshold = 200 15 | table = [] 16 | for i in range(256): 17 | if i < threshold: 18 | table.append(0) 19 | else: 20 | table.append(1) 21 | 22 | out = im.point(table, "1") 23 | # 2. recognize with tesseract 24 | num = pytesseract.image_to_string(out) 25 | return num 26 | 27 | 28 | def recognize_verify_code(image_path, broker="ht"): 29 | """识别验证码,返回识别后的字符串,使用 tesseract 实现 30 | :param image_path: 图片路径 31 | :param broker: 券商 ['ht', 'yjb', 'gf', 'yh'] 32 | :return recognized: verify code string""" 33 | 34 | if broker == "gf": 35 | return detect_gf_result(image_path) 36 | if broker in ["yh_client", "gj_client"]: 37 | return detect_yh_client_result(image_path) 38 | # 调用 tesseract 识别 39 | return default_verify_code_detect(image_path) 40 | 41 | 42 | def detect_yh_client_result(image_path): 43 | """封装了tesseract的识别,部署在阿里云上, 44 | 服务端源码地址为: https://github.com/shidenggui/yh_verify_code_docker""" 45 | api = "http://yh.ez.shidenggui.com:5000/yh_client" 46 | with open(image_path, "rb") as f: 47 | rep = requests.post(api, files={"image": f}) 48 | if rep.status_code != 201: 49 | error = rep.json()["message"] 50 | raise exceptions.TradeError("request {} error: {}".format(api, error)) 51 | return rep.json()["result"] 52 | 53 | 54 | def input_verify_code_manual(image_path): 55 | from PIL import Image 56 | 57 | image = Image.open(image_path) 58 | image.show() 59 | code = input( 60 | "image path: {}, input verify code answer:".format(image_path) 61 | ) 62 | return code 63 | 64 | 65 | def default_verify_code_detect(image_path): 66 | from PIL import Image 67 | 68 | img = Image.open(image_path) 69 | return invoke_tesseract_to_recognize(img) 70 | 71 | 72 | def detect_gf_result(image_path): 73 | from PIL import ImageFilter, Image 74 | 75 | img = Image.open(image_path) 76 | if hasattr(img, "width"): 77 | width, height = img.width, img.height 78 | else: 79 | width, height = img.size 80 | for x in range(width): 81 | for y in range(height): 82 | if img.getpixel((x, y)) < (100, 100, 100): 83 | img.putpixel((x, y), (256, 256, 256)) 84 | gray = img.convert("L") 85 | two = gray.point(lambda p: 0 if 68 < p < 90 else 256) 86 | min_res = two.filter(ImageFilter.MinFilter) 87 | med_res = min_res.filter(ImageFilter.MedianFilter) 88 | for _ in range(2): 89 | med_res = med_res.filter(ImageFilter.MedianFilter) 90 | return invoke_tesseract_to_recognize(med_res) 91 | 92 | 93 | def invoke_tesseract_to_recognize(img): 94 | import pytesseract 95 | 96 | try: 97 | res = pytesseract.image_to_string(img) 98 | except FileNotFoundError: 99 | raise Exception( 100 | "tesseract 未安装,请至 https://github.com/tesseract-ocr/tesseract/wiki 查看安装教程" 101 | ) 102 | valid_chars = re.findall("[0-9a-z]", res, re.IGNORECASE) 103 | return "".join(valid_chars) 104 | -------------------------------------------------------------------------------- /easytrader/utils/misc.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import json 3 | 4 | 5 | def parse_cookies_str(cookies): 6 | """ 7 | parse cookies str to dict 8 | :param cookies: cookies str 9 | :type cookies: str 10 | :return: cookie dict 11 | :rtype: dict 12 | """ 13 | cookie_dict = {} 14 | for record in cookies.split(";"): 15 | key, value = record.strip().split("=", 1) 16 | cookie_dict[key] = value 17 | return cookie_dict 18 | 19 | 20 | def file2dict(path): 21 | with open(path, encoding="utf-8") as f: 22 | return json.load(f) 23 | 24 | 25 | def grep_comma(num_str): 26 | return num_str.replace(",", "") 27 | 28 | 29 | def str2num(num_str, convert_type="float"): 30 | num = float(grep_comma(num_str)) 31 | return num if convert_type == "float" else int(num) 32 | -------------------------------------------------------------------------------- /easytrader/utils/perf.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import functools 3 | import inspect 4 | import logging 5 | import timeit 6 | 7 | from easytrader import logger 8 | 9 | try: 10 | from time import process_time 11 | except: 12 | from time import clock as process_time 13 | 14 | 15 | def perf_clock(f): 16 | @functools.wraps(f) 17 | def wrapper(*args, **kwargs): 18 | if not logger.isEnabledFor(logging.DEBUG): 19 | return f(*args, **kwargs) 20 | 21 | ts = timeit.default_timer() 22 | cs = process_time() 23 | ex = None 24 | result = None 25 | 26 | try: 27 | result = f(*args, **kwargs) 28 | except Exception as ex1: 29 | ex = ex1 30 | 31 | te = timeit.default_timer() 32 | ce = process_time() 33 | logger.debug( 34 | "%r consume %2.4f sec, cpu %2.4f sec. args %s, extra args %s" 35 | % ( 36 | f.__name__, 37 | te - ts, 38 | ce - cs, 39 | args[1:], 40 | kwargs, 41 | ) 42 | ) 43 | if ex is not None: 44 | raise ex 45 | return result 46 | 47 | wrapper.__signature__ = inspect.signature(f) 48 | return wrapper 49 | -------------------------------------------------------------------------------- /easytrader/utils/stock.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import datetime 3 | import json 4 | import random 5 | 6 | import requests 7 | 8 | 9 | def get_stock_type(stock_code): 10 | """判断股票ID对应的证券市场 11 | 匹配规则 12 | ['4', '8'] 为 bj 13 | ['5', '6', '7', '9', '110', '113', '118', '132', '204'] 为 sh 14 | 其余为 sz 15 | :param stock_code:股票ID, 若以 'sz', 'sh', 'bj' 开头直接返回对应类型,否则使用内置规则判断 16 | :return 'bj', 'sh' or 'sz'""" 17 | assert isinstance(stock_code, str), "stock code need str type" 18 | bj_head = ("43", "83", "87", "92") 19 | sh_head = ("5", "6", "7", "9", "110", "113", "118", "132", "204") 20 | if stock_code.startswith(("sh", "sz", "zz", "bj")): 21 | return stock_code[:2] 22 | elif stock_code.startswith(bj_head): 23 | return "bj" 24 | elif stock_code.startswith(sh_head): 25 | return "sh" 26 | return "sz" 27 | 28 | def get_30_date(): 29 | """ 30 | 获得用于查询的默认日期, 今天的日期, 以及30天前的日期 31 | 用于查询的日期格式通常为 20160211 32 | :return: 33 | """ 34 | now = datetime.datetime.now() 35 | end_date = now.date() 36 | start_date = end_date - datetime.timedelta(days=30) 37 | return start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d") 38 | 39 | 40 | def get_today_ipo_data(): 41 | """ 42 | 查询今天可以申购的新股信息 43 | :return: 今日可申购新股列表 apply_code申购代码 price发行价格 44 | """ 45 | 46 | agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:43.0) Gecko/20100101 Firefox/43.0" 47 | send_headers = { 48 | "Host": "xueqiu.com", 49 | "User-Agent": agent, 50 | "Accept": "application/json, text/javascript, */*; q=0.01", 51 | "Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3", 52 | "Accept-Encoding": "deflate", 53 | "Cache-Control": "no-cache", 54 | "X-Requested-With": "XMLHttpRequest", 55 | "Referer": "https://xueqiu.com/hq", 56 | "Connection": "keep-alive", 57 | } 58 | 59 | timestamp = random.randint(1000000000000, 9999999999999) 60 | home_page_url = "https://xueqiu.com" 61 | ipo_data_url = ( 62 | "https://xueqiu.com/proipo/query.json?column=symbol,name,onl_subcode,onl_subbegdate,actissqty,onl" 63 | "_actissqty,onl_submaxqty,iss_price,onl_lotwiner_stpub_date,onl_lotwinrt,onl_lotwin_amount,stock_" 64 | "income&orderBy=onl_subbegdate&order=desc&stockType=&page=1&size=30&_=%s" 65 | % (str(timestamp)) 66 | ) 67 | 68 | session = requests.session() 69 | session.get(home_page_url, headers=send_headers) # 产生cookies 70 | ipo_response = session.post(ipo_data_url, headers=send_headers) 71 | 72 | json_data = json.loads(ipo_response.text) 73 | today_ipo = [] 74 | 75 | for line in json_data["data"]: 76 | if datetime.datetime.now().strftime("%a %b %d") == line[3][:10]: 77 | today_ipo.append( 78 | { 79 | "stock_code": line[0], 80 | "stock_name": line[1], 81 | "apply_code": line[2], 82 | "price": line[7], 83 | } 84 | ) 85 | 86 | return today_ipo 87 | -------------------------------------------------------------------------------- /easytrader/utils/win_gui.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | from pywinauto import win32defines 3 | from pywinauto.win32functions import SetForegroundWindow, ShowWindow 4 | -------------------------------------------------------------------------------- /easytrader/webtrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import logging 4 | import os 5 | import re 6 | import time 7 | from threading import Thread 8 | 9 | import requests 10 | import requests.exceptions 11 | 12 | from easytrader import exceptions 13 | from easytrader.log import logger 14 | from easytrader.utils.misc import file2dict, str2num 15 | from easytrader.utils.stock import get_30_date 16 | 17 | 18 | # noinspection PyIncorrectDocstring 19 | class WebTrader(metaclass=abc.ABCMeta): 20 | global_config_path = os.path.dirname(__file__) + "/config/global.json" 21 | config_path = "" 22 | 23 | def __init__(self, debug=True): 24 | self.__read_config() 25 | self.trade_prefix = self.config["prefix"] 26 | self.account_config = "" 27 | self.heart_active = True 28 | self.heart_thread = Thread(target=self.send_heartbeat) 29 | self.heart_thread.setDaemon(True) 30 | 31 | self.log_level = logging.DEBUG if debug else logging.INFO 32 | 33 | def read_config(self, path): 34 | try: 35 | self.account_config = file2dict(path) 36 | except ValueError: 37 | logger.error("配置文件格式有误,请勿使用记事本编辑,推荐 sublime text") 38 | for value in self.account_config: 39 | if isinstance(value, int): 40 | logger.warning("配置文件的值最好使用双引号包裹,使用字符串,否则可能导致不可知问题") 41 | 42 | def prepare(self, config_file=None, user=None, password=None, **kwargs): 43 | """登录的统一接口 44 | :param config_file 登录数据文件,若无则选择参数登录模式 45 | :param user: 各家券商的账号 46 | :param password: 密码, 券商为加密后的密码 47 | :param cookies: [雪球登录需要]雪球登录需要设置对应的 cookies 48 | :param portfolio_code: [雪球登录需要]组合代码 49 | :param portfolio_market: [雪球登录需要]交易市场, 50 | 可选['cn', 'us', 'hk'] 默认 'cn' 51 | """ 52 | if config_file is not None: 53 | self.read_config(config_file) 54 | else: 55 | self._prepare_account(user, password, **kwargs) 56 | self.autologin() 57 | 58 | def _prepare_account(self, user, password, **kwargs): 59 | """映射用户名密码到对应的字段""" 60 | raise Exception("支持参数登录需要实现此方法") 61 | 62 | def autologin(self, limit=10): 63 | """实现自动登录 64 | :param limit: 登录次数限制 65 | """ 66 | for _ in range(limit): 67 | if self.login(): 68 | break 69 | else: 70 | raise exceptions.NotLoginError( 71 | "登录失败次数过多, 请检查密码是否正确 / 券商服务器是否处于维护中 / 网络连接是否正常" 72 | ) 73 | self.keepalive() 74 | 75 | def login(self): 76 | pass 77 | 78 | def keepalive(self): 79 | """启动保持在线的进程 """ 80 | if self.heart_thread.is_alive(): 81 | self.heart_active = True 82 | else: 83 | self.heart_thread.start() 84 | 85 | def send_heartbeat(self): 86 | """每隔10秒查询指定接口保持 token 的有效性""" 87 | while True: 88 | if self.heart_active: 89 | self.check_login() 90 | else: 91 | time.sleep(1) 92 | 93 | def check_login(self, sleepy=30): 94 | logger.setLevel(logging.ERROR) 95 | try: 96 | response = self.heartbeat() 97 | self.check_account_live(response) 98 | except requests.exceptions.ConnectionError: 99 | pass 100 | except requests.exceptions.RequestException as e: 101 | logger.setLevel(self.log_level) 102 | logger.error("心跳线程发现账户出现错误: %s %s, 尝试重新登陆", e.__class__, e) 103 | self.autologin() 104 | finally: 105 | logger.setLevel(self.log_level) 106 | time.sleep(sleepy) 107 | 108 | def heartbeat(self): 109 | return self.balance 110 | 111 | def check_account_live(self, response): 112 | pass 113 | 114 | def exit(self): 115 | """结束保持 token 在线的进程""" 116 | self.heart_active = False 117 | 118 | def __read_config(self): 119 | """读取 config""" 120 | self.config = file2dict(self.config_path) 121 | self.global_config = file2dict(self.global_config_path) 122 | self.config.update(self.global_config) 123 | 124 | @property 125 | def balance(self): 126 | return self.get_balance() 127 | 128 | def get_balance(self): 129 | """获取账户资金状况""" 130 | return self.do(self.config["balance"]) 131 | 132 | @property 133 | def position(self): 134 | return self.get_position() 135 | 136 | def get_position(self): 137 | """获取持仓""" 138 | return self.do(self.config["position"]) 139 | 140 | @property 141 | def entrust(self): 142 | return self.get_entrust() 143 | 144 | def get_entrust(self): 145 | """获取当日委托列表""" 146 | return self.do(self.config["entrust"]) 147 | 148 | @property 149 | def current_deal(self): 150 | return self.get_current_deal() 151 | 152 | def get_current_deal(self): 153 | """获取当日委托列表""" 154 | # return self.do(self.config['current_deal']) 155 | logger.warning("目前仅在 佣金宝/银河子类 中实现, 其余券商需要补充") 156 | 157 | @property 158 | def exchangebill(self): 159 | """ 160 | 默认提供最近30天的交割单, 通常只能返回查询日期内最新的 90 天数据。 161 | :return: 162 | """ 163 | # TODO 目前仅在 华泰子类 中实现 164 | start_date, end_date = get_30_date() 165 | return self.get_exchangebill(start_date, end_date) 166 | 167 | def get_exchangebill(self, start_date, end_date): 168 | """ 169 | 查询指定日期内的交割单 170 | :param start_date: 20160211 171 | :param end_date: 20160211 172 | :return: 173 | """ 174 | logger.warning("目前仅在 华泰子类 中实现, 其余券商需要补充") 175 | 176 | def get_ipo_limit(self, stock_code): 177 | """ 178 | 查询新股申购额度申购上限 179 | :param stock_code: 申购代码 ID 180 | :return: 181 | """ 182 | logger.warning("目前仅在 佣金宝子类 中实现, 其余券商需要补充") 183 | 184 | def do(self, params): 185 | """发起对 api 的请求并过滤返回结果 186 | :param params: 交易所需的动态参数""" 187 | request_params = self.create_basic_params() 188 | request_params.update(params) 189 | response_data = self.request(request_params) 190 | try: 191 | format_json_data = self.format_response_data(response_data) 192 | # pylint: disable=broad-except 193 | except Exception: 194 | # Caused by server force logged out 195 | return None 196 | return_data = self.fix_error_data(format_json_data) 197 | try: 198 | self.check_login_status(return_data) 199 | except exceptions.NotLoginError: 200 | self.autologin() 201 | return return_data 202 | 203 | def create_basic_params(self) -> dict: 204 | """生成基本的参数""" 205 | return {} 206 | 207 | def request(self, params) -> dict: 208 | """请求并获取 JSON 数据 209 | :param params: Get 参数""" 210 | return {} 211 | 212 | def format_response_data(self, data): 213 | """格式化返回的 json 数据 214 | :param data: 请求返回的数据 """ 215 | return data 216 | 217 | def fix_error_data(self, data): 218 | """若是返回错误移除外层的列表 219 | :param data: 需要判断是否包含错误信息的数据""" 220 | return data 221 | 222 | def format_response_data_type(self, response_data): 223 | """格式化返回的值为正确的类型 224 | :param response_data: 返回的数据 225 | """ 226 | if isinstance(response_data, list) and not isinstance( 227 | response_data, str 228 | ): 229 | return response_data 230 | 231 | int_match_str = "|".join(self.config["response_format"]["int"]) 232 | float_match_str = "|".join(self.config["response_format"]["float"]) 233 | for item in response_data: 234 | for key in item: 235 | try: 236 | if re.search(int_match_str, key) is not None: 237 | item[key] = str2num(item[key], "int") 238 | elif re.search(float_match_str, key) is not None: 239 | item[key] = str2num(item[key], "float") 240 | except ValueError: 241 | continue 242 | return response_data 243 | 244 | def check_login_status(self, return_data): 245 | pass 246 | -------------------------------------------------------------------------------- /easytrader/wk_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pywinauto 3 | 4 | from easytrader.ht_clienttrader import HTClientTrader 5 | 6 | 7 | class WKClientTrader(HTClientTrader): 8 | @property 9 | def broker_type(self): 10 | return "wk" 11 | 12 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 13 | """ 14 | :param user: 用户名 15 | :param password: 密码 16 | :param exe_path: 客户端路径, 类似 17 | :param comm_password: 18 | :param kwargs: 19 | :return: 20 | """ 21 | self._editor_need_type_keys = False 22 | if comm_password is None: 23 | raise ValueError("五矿必须设置通讯密码") 24 | 25 | try: 26 | self._app = pywinauto.Application().connect( 27 | path=self._run_exe_path(exe_path), timeout=1 28 | ) 29 | # pylint: disable=broad-except 30 | except Exception: 31 | self._app = pywinauto.Application().start(exe_path) 32 | 33 | # wait login window ready 34 | while True: 35 | try: 36 | self._app.top_window().Edit1.wait("ready") 37 | break 38 | except RuntimeError: 39 | pass 40 | 41 | self._app.top_window().Edit1.set_focus() 42 | self._app.top_window().Edit1.set_edit_text(user) 43 | self._app.top_window().Edit2.set_edit_text(password) 44 | 45 | self._app.top_window().Edit3.set_edit_text(comm_password) 46 | 47 | self._app.top_window().Button1.click() 48 | 49 | # detect login is success or not 50 | self._app.top_window().wait_not("exists", 100) 51 | 52 | self._app = pywinauto.Application().connect( 53 | path=self._run_exe_path(exe_path), timeout=10 54 | ) 55 | self._close_prompt_windows() 56 | self._main = self._app.window(title="网上股票交易系统5.0") -------------------------------------------------------------------------------- /easytrader/xq_follower.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, print_function, unicode_literals 3 | 4 | import json 5 | import re 6 | from datetime import datetime 7 | from numbers import Number 8 | from threading import Thread 9 | 10 | from easytrader.follower import BaseFollower 11 | from easytrader.log import logger 12 | from easytrader.utils.misc import parse_cookies_str 13 | 14 | 15 | class XueQiuFollower(BaseFollower): 16 | LOGIN_PAGE = "https://www.xueqiu.com" 17 | LOGIN_API = "https://xueqiu.com/snowman/login" 18 | TRANSACTION_API = "https://xueqiu.com/cubes/rebalancing/history.json" 19 | PORTFOLIO_URL = "https://xueqiu.com/p/" 20 | WEB_REFERER = "https://www.xueqiu.com" 21 | 22 | def __init__(self): 23 | super().__init__() 24 | self._adjust_sell = None 25 | self._users = None 26 | 27 | def login(self, user=None, password=None, **kwargs): 28 | """ 29 | 雪球登陆, 需要设置 cookies 30 | :param cookies: 雪球登陆需要设置 cookies, 具体见 31 | https://smalltool.github.io/2016/08/02/cookie/ 32 | :return: 33 | """ 34 | cookies = kwargs.get("cookies") 35 | if cookies is None: 36 | raise TypeError( 37 | "雪球登陆需要设置 cookies, 具体见" "https://smalltool.github.io/2016/08/02/cookie/" 38 | ) 39 | headers = self._generate_headers() 40 | self.s.headers.update(headers) 41 | 42 | self.s.get(self.LOGIN_PAGE) 43 | 44 | cookie_dict = parse_cookies_str(cookies) 45 | self.s.cookies.update(cookie_dict) 46 | 47 | logger.info("登录成功") 48 | 49 | def follow( # type: ignore 50 | self, 51 | users, 52 | strategies, 53 | total_assets=10000, 54 | initial_assets=None, 55 | adjust_sell=False, 56 | track_interval=10, 57 | trade_cmd_expire_seconds=120, 58 | cmd_cache=True, 59 | slippage: float = 0.0, 60 | ): 61 | """跟踪 joinquant 对应的模拟交易,支持多用户多策略 62 | :param users: 支持 easytrader 的用户对象,支持使用 [] 指定多个用户 63 | :param strategies: 雪球组合名, 类似 ZH123450 64 | :param total_assets: 雪球组合对应的总资产, 格式 [组合1对应资金, 组合2对应资金] 65 | 若 strategies=['ZH000001', 'ZH000002'], 66 | 设置 total_assets=[10000, 10000], 则表明每个组合对应的资产为 1w 元 67 | 假设组合 ZH000001 加仓 价格为 p 股票 A 10%, 68 | 则对应的交易指令为 买入 股票 A 价格 P 股数 1w * 10% / p 并按 100 取整 69 | :param adjust_sell: 是否根据用户的实际持仓数调整卖出股票数量, 70 | 当卖出股票数大于实际持仓数时,调整为实际持仓数。目前仅在银河客户端测试通过。 71 | 当 users 为多个时,根据第一个 user 的持仓数决定 72 | :type adjust_sell: bool 73 | :param initial_assets: 雪球组合对应的初始资产, 74 | 格式 [ 组合1对应资金, 组合2对应资金 ] 75 | 总资产由 初始资产 × 组合净值 算得, total_assets 会覆盖此参数 76 | :param track_interval: 轮训模拟交易时间,单位为秒 77 | :param trade_cmd_expire_seconds: 交易指令过期时间, 单位为秒 78 | :param cmd_cache: 是否读取存储历史执行过的指令,防止重启时重复执行已经交易过的指令 79 | :param slippage: 滑点,0.0 表示无滑点, 0.05 表示滑点为 5% 80 | """ 81 | super().follow( 82 | users=users, 83 | strategies=strategies, 84 | track_interval=track_interval, 85 | trade_cmd_expire_seconds=trade_cmd_expire_seconds, 86 | cmd_cache=cmd_cache, 87 | slippage=slippage, 88 | ) 89 | 90 | self._adjust_sell = adjust_sell 91 | 92 | self._users = self.warp_list(users) 93 | 94 | strategies = self.warp_list(strategies) 95 | total_assets = self.warp_list(total_assets) 96 | initial_assets = self.warp_list(initial_assets) 97 | 98 | if cmd_cache: 99 | self.load_expired_cmd_cache() 100 | 101 | self.start_trader_thread(self._users, trade_cmd_expire_seconds) 102 | 103 | for strategy_url, strategy_total_assets, strategy_initial_assets in zip( 104 | strategies, total_assets, initial_assets 105 | ): 106 | assets = self.calculate_assets( 107 | strategy_url, strategy_total_assets, strategy_initial_assets 108 | ) 109 | try: 110 | strategy_id = self.extract_strategy_id(strategy_url) 111 | strategy_name = self.extract_strategy_name(strategy_url) 112 | except: 113 | logger.error("抽取交易id和策略名失败, 无效模拟交易url: %s", strategy_url) 114 | raise 115 | strategy_worker = Thread( 116 | target=self.track_strategy_worker, 117 | args=[strategy_id, strategy_name], 118 | kwargs={"interval": track_interval, "assets": assets}, 119 | ) 120 | strategy_worker.start() 121 | logger.info("开始跟踪策略: %s", strategy_name) 122 | 123 | def calculate_assets(self, strategy_url, total_assets=None, initial_assets=None): 124 | # 都设置时优先选择 total_assets 125 | if total_assets is None and initial_assets is not None: 126 | net_value = self._get_portfolio_net_value(strategy_url) 127 | total_assets = initial_assets * net_value 128 | if not isinstance(total_assets, Number): 129 | raise TypeError("input assets type must be number(int, float)") 130 | if total_assets < 1e3: 131 | raise ValueError("雪球总资产不能小于1000元,当前预设值 {}".format(total_assets)) 132 | return total_assets 133 | 134 | @staticmethod 135 | def extract_strategy_id(strategy_url): 136 | return strategy_url 137 | 138 | def extract_strategy_name(self, strategy_url): 139 | base_url = "https://xueqiu.com/cubes/nav_daily/all.json?cube_symbol={}" 140 | url = base_url.format(strategy_url) 141 | rep = self.s.get(url) 142 | info_index = 0 143 | return rep.json()[info_index]["name"] 144 | 145 | def extract_transactions(self, history): 146 | if history["count"] <= 0: 147 | return [] 148 | rebalancing_index = 0 149 | raw_transactions = history["list"][rebalancing_index]["rebalancing_histories"] 150 | transactions = [] 151 | for transaction in raw_transactions: 152 | if transaction["price"] is None: 153 | logger.info("该笔交易无法获取价格,疑似未成交,跳过。交易详情: %s", transaction) 154 | continue 155 | transactions.append(transaction) 156 | 157 | return transactions 158 | 159 | def create_query_transaction_params(self, strategy): 160 | params = {"cube_symbol": strategy, "page": 1, "count": 1} 161 | return params 162 | 163 | # noinspection PyMethodOverriding 164 | def none_to_zero(self, data): 165 | if data is None: 166 | return 0 167 | return data 168 | 169 | # noinspection PyMethodOverriding 170 | def project_transactions(self, transactions, assets): 171 | for transaction in transactions: 172 | weight_diff = self.none_to_zero(transaction["weight"]) - self.none_to_zero( 173 | transaction["prev_weight"] 174 | ) 175 | 176 | initial_amount = abs(weight_diff) / 100 * assets / transaction["price"] 177 | 178 | transaction["datetime"] = datetime.fromtimestamp( 179 | transaction["created_at"] // 1000 180 | ) 181 | 182 | transaction["stock_code"] = transaction["stock_symbol"].lower() 183 | 184 | transaction["action"] = "buy" if weight_diff > 0 else "sell" 185 | 186 | transaction["amount"] = int(round(initial_amount, -2)) 187 | if transaction["action"] == "sell" and self._adjust_sell: 188 | transaction["amount"] = self._adjust_sell_amount( 189 | transaction["stock_code"], transaction["amount"] 190 | ) 191 | 192 | def _adjust_sell_amount(self, stock_code, amount): 193 | """ 194 | 根据实际持仓值计算雪球卖出股数 195 | 因为雪球的交易指令是基于持仓百分比,在取近似值的情况下可能出现不精确的问题。 196 | 导致如下情况的产生,计算出的指令为买入 1049 股,取近似值买入 1000 股。 197 | 而卖出的指令计算出为卖出 1051 股,取近似值卖出 1100 股,超过 1000 股的买入量, 198 | 导致卖出失败 199 | :param stock_code: 证券代码 200 | :type stock_code: str 201 | :param amount: 卖出股份数 202 | :type amount: int 203 | :return: 考虑实际持仓之后的卖出股份数 204 | :rtype: int 205 | """ 206 | stock_code = stock_code[-6:] 207 | user = self._users[0] 208 | position = user.position 209 | try: 210 | stock = next(s for s in position if s["证券代码"] == stock_code) 211 | except StopIteration: 212 | logger.info("根据持仓调整 %s 卖出额,发现未持有股票 %s, 不做任何调整", stock_code, stock_code) 213 | return amount 214 | 215 | available_amount = stock["可用余额"] 216 | if available_amount >= amount: 217 | return amount 218 | 219 | adjust_amount = available_amount // 100 * 100 220 | logger.info( 221 | "股票 %s 实际可用余额 %s, 指令卖出股数为 %s, 调整为 %s", 222 | stock_code, 223 | available_amount, 224 | amount, 225 | adjust_amount, 226 | ) 227 | return adjust_amount 228 | 229 | def _get_portfolio_info(self, portfolio_code): 230 | """ 231 | 获取组合信息 232 | """ 233 | url = self.PORTFOLIO_URL + portfolio_code 234 | portfolio_page = self.s.get(url) 235 | match_info = re.search(r"(?<=SNB.cubeInfo = ).*(?=;\n)", portfolio_page.text) 236 | if match_info is None: 237 | raise Exception("cant get portfolio info, portfolio url : {}".format(url)) 238 | try: 239 | portfolio_info = json.loads(match_info.group()) 240 | except Exception as e: 241 | raise Exception("get portfolio info error: {}".format(e)) 242 | return portfolio_info 243 | 244 | def _get_portfolio_net_value(self, portfolio_code): 245 | """ 246 | 获取组合信息 247 | """ 248 | portfolio_info = self._get_portfolio_info(portfolio_code) 249 | return portfolio_info["net_value"] 250 | -------------------------------------------------------------------------------- /easytrader/xqtrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import numbers 4 | import os 5 | import re 6 | import time 7 | import math 8 | 9 | import requests 10 | 11 | from easytrader import exceptions, webtrader 12 | from easytrader.log import logger 13 | from easytrader.utils.misc import parse_cookies_str 14 | 15 | 16 | class XueQiuTrader(webtrader.WebTrader): 17 | config_path = os.path.dirname(__file__) + "/config/xq.json" 18 | 19 | _HEADERS = { 20 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) " 21 | "AppleWebKit/537.36 (KHTML, like Gecko) " 22 | "Chrome/64.0.3282.167 Safari/537.36", 23 | "Host": "xueqiu.com", 24 | "Pragma": "no-cache", 25 | "Connection": "keep-alive", 26 | "Accept": "*/*", 27 | "Accept-Encoding": "gzip, deflate, br", 28 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 29 | "Cache-Control": "no-cache", 30 | "Referer": "https://xueqiu.com/P/ZH004612", 31 | "X-Requested-With": "XMLHttpRequest", 32 | } 33 | 34 | def __init__(self, **kwargs): 35 | super(XueQiuTrader, self).__init__() 36 | self.position_list = [] 37 | 38 | # 资金换算倍数 39 | self.multiple = ( 40 | kwargs["initial_assets"] if "initial_assets" in kwargs else 1000000 41 | ) 42 | if not isinstance(self.multiple, numbers.Number): 43 | raise TypeError("initial assets must be number(int, float)") 44 | if self.multiple < 1e3: 45 | raise ValueError("雪球初始资产不能小于1000元,当前预设值 {}".format(self.multiple)) 46 | 47 | self.s = requests.Session() 48 | self.s.verify = False 49 | self.s.headers.update(self._HEADERS) 50 | self.account_config = None 51 | 52 | def autologin(self, **kwargs): 53 | """ 54 | 使用 cookies 之后不需要自动登陆 55 | :return: 56 | """ 57 | self._set_cookies(self.account_config["cookies"]) 58 | 59 | def _set_cookies(self, cookies): 60 | """设置雪球 cookies,代码来自于 61 | https://github.com/shidenggui/easytrader/issues/269 62 | :param cookies: 雪球 cookies 63 | :type cookies: str 64 | """ 65 | cookie_dict = parse_cookies_str(cookies) 66 | self.s.cookies.update(cookie_dict) 67 | 68 | def _prepare_account(self, user="", password="", **kwargs): 69 | """ 70 | 转换参数到登录所需的字典格式 71 | :param cookies: 雪球登陆需要设置 cookies, 具体见 72 | https://smalltool.github.io/2016/08/02/cookie/ 73 | :param portfolio_code: 组合代码 74 | :param portfolio_market: 交易市场, 可选['cn', 'us', 'hk'] 默认 'cn' 75 | :return: 76 | """ 77 | if "portfolio_code" not in kwargs: 78 | raise TypeError("雪球登录需要设置 portfolio_code(组合代码) 参数") 79 | if "portfolio_market" not in kwargs: 80 | kwargs["portfolio_market"] = "cn" 81 | if "cookies" not in kwargs: 82 | raise TypeError( 83 | "雪球登陆需要设置 cookies, 具体见" 84 | "https://smalltool.github.io/2016/08/02/cookie/" 85 | ) 86 | self.account_config = { 87 | "cookies": kwargs["cookies"], 88 | "portfolio_code": kwargs["portfolio_code"], 89 | "portfolio_market": kwargs["portfolio_market"], 90 | } 91 | 92 | def _virtual_to_balance(self, virtual): 93 | """ 94 | 虚拟净值转化为资金 95 | :param virtual: 雪球组合净值 96 | :return: 换算的资金 97 | """ 98 | return virtual * self.multiple 99 | 100 | def _get_html(self, url): 101 | return self.s.get(url).text 102 | 103 | def _search_stock_info(self, code): 104 | """ 105 | 通过雪球的接口获取股票详细信息 106 | :param code: 股票代码 000001 107 | :return: 查询到的股票 {'stock_id': 1000279, 'code': 'SH600325', 108 | 'name': '华发股份', 'ind_color': '#d9633b', 'chg': -1.09, 109 | 'ind_id': 100014, 'percent': -9.31, 'current': 10.62, 110 | 'ind_name': '房地产'} 111 | ** flag : 未上市(0)、正常(1)、停牌(2)、涨跌停(3)、退市(4) 112 | """ 113 | data = { 114 | "code": str(code), 115 | "size": "300", 116 | "key": "47bce5c74f", 117 | "market": self.account_config["portfolio_market"], 118 | } 119 | r = self.s.get(self.config["search_stock_url"], params=data) 120 | stocks = json.loads(r.text) 121 | stocks = stocks["stocks"] 122 | stock = None 123 | if len(stocks) > 0: 124 | stock = stocks[0] 125 | return stock 126 | 127 | def _get_portfolio_info(self, portfolio_code): 128 | """ 129 | 获取组合信息 130 | :return: 字典 131 | """ 132 | data_rb = {'cube_symbol': portfolio_code} 133 | rb = self.s.get(self.config["portfolio_url_new"], params=data_rb) 134 | data_qt = {'code': portfolio_code} 135 | qt = self.s.get(self.config["portfolio_quote"], params=data_qt) 136 | try: 137 | rebalance_info = json.loads(rb.text) 138 | quote_info = json.loads(qt.text) 139 | net_value = quote_info[portfolio_code]['net_value'] 140 | portfolio_info = rebalance_info 141 | portfolio_info['net_value'] = net_value 142 | except Exception as e: 143 | raise Exception("get portfolio info error: {}".format(e)) 144 | return portfolio_info 145 | 146 | def get_balance(self): 147 | """ 148 | 获取账户资金状况 149 | :return: 150 | """ 151 | portfolio_code = self.account_config.get("portfolio_code", "ch") 152 | portfolio_info = self._get_portfolio_info(portfolio_code) 153 | asset_balance = self._virtual_to_balance( 154 | float(portfolio_info["net_value"]) 155 | ) # 总资产 156 | position = portfolio_info["last_rb"] # 仓位结构 157 | cash = asset_balance * float(position["cash"]) / 100 158 | market = asset_balance - cash 159 | return [ 160 | { 161 | "asset_balance": asset_balance, 162 | "current_balance": cash, 163 | "enable_balance": cash, 164 | "market_value": market, 165 | "money_type": u"人民币", 166 | "pre_interest": 0.25, 167 | } 168 | ] 169 | 170 | @property 171 | def cash_weight(self): 172 | portfolio_code = self.account_config.get("portfolio_code", "ch") 173 | portfolio_info = self._get_portfolio_info(portfolio_code) 174 | position = portfolio_info["last_rb"] 175 | return float(position["cash"]) 176 | 177 | def _get_position(self): 178 | """ 179 | 获取雪球持仓 180 | :return: 181 | """ 182 | portfolio_code = self.account_config["portfolio_code"] 183 | portfolio_info = self._get_portfolio_info(portfolio_code) 184 | position = portfolio_info["last_rb"] # 仓位结构 185 | stocks = position["holdings"] # 持仓股票 186 | return stocks 187 | 188 | @staticmethod 189 | def _time_strftime(time_stamp): 190 | try: 191 | local_time = time.localtime(time_stamp / 1000) 192 | return time.strftime("%Y-%m-%d %H:%M:%S", local_time) 193 | # pylint: disable=broad-except 194 | except Exception: 195 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) 196 | 197 | def get_position(self): 198 | """ 199 | 获取持仓 200 | :return: 201 | """ 202 | xq_positions = self._get_position() 203 | balance = self.get_balance()[0] 204 | position_list = [] 205 | for pos in xq_positions: 206 | volume = pos["weight"] * balance["asset_balance"] / 100 207 | position_list.append( 208 | { 209 | "cost_price": volume / 100, 210 | "current_amount": 100, 211 | "enable_amount": 100, 212 | "income_balance": 0, 213 | "keep_cost_price": volume / 100, 214 | "last_price": volume / 100, 215 | "market_value": volume, 216 | "position_str": "random", 217 | "stock_code": pos["stock_symbol"], 218 | "stock_name": pos["stock_name"], 219 | } 220 | ) 221 | return position_list 222 | 223 | def _get_xq_history(self): 224 | """ 225 | 获取雪球调仓历史 226 | :param instance: 227 | :param owner: 228 | :return: 229 | """ 230 | data = { 231 | "cube_symbol": str(self.account_config["portfolio_code"]), 232 | "count": 20, 233 | "page": 1, 234 | } 235 | resp = self.s.get(self.config["history_url"], params=data) 236 | res = json.loads(resp.text) 237 | return res["list"] 238 | 239 | @property 240 | def history(self): 241 | return self._get_xq_history() 242 | 243 | def get_entrust(self): 244 | """ 245 | 获取委托单(目前返回20次调仓的结果) 246 | 操作数量都按1手模拟换算的 247 | :return: 248 | """ 249 | xq_entrust_list = self._get_xq_history() 250 | entrust_list = [] 251 | replace_none = lambda s: s or 0 252 | for xq_entrusts in xq_entrust_list: 253 | status = xq_entrusts["status"] # 调仓状态 254 | if status == "pending": 255 | status = "已报" 256 | elif status in ["canceled", "failed"]: 257 | status = "废单" 258 | else: 259 | status = "已成" 260 | for entrust in xq_entrusts["rebalancing_histories"]: 261 | price = entrust["price"] 262 | entrust_list.append( 263 | { 264 | "entrust_no": entrust["id"], 265 | "entrust_bs": u"买入" 266 | if entrust["target_weight"] 267 | > replace_none(entrust["prev_weight"]) 268 | else u"卖出", 269 | "report_time": self._time_strftime( 270 | entrust["updated_at"] 271 | ), 272 | "entrust_status": status, 273 | "stock_code": entrust["stock_symbol"], 274 | "stock_name": entrust["stock_name"], 275 | "business_amount": 100, 276 | "business_price": price, 277 | "entrust_amount": 100, 278 | "entrust_price": price, 279 | } 280 | ) 281 | return entrust_list 282 | 283 | def cancel_entrust(self, entrust_no): 284 | """ 285 | 对未成交的调仓进行伪撤单 286 | :param entrust_no: 287 | :return: 288 | """ 289 | xq_entrust_list = self._get_xq_history() 290 | is_have = False 291 | for xq_entrusts in xq_entrust_list: 292 | status = xq_entrusts["status"] # 调仓状态 293 | for entrust in xq_entrusts["rebalancing_histories"]: 294 | if entrust["id"] == entrust_no and status == "pending": 295 | is_have = True 296 | buy_or_sell = ( 297 | "buy" 298 | if entrust["target_weight"] < entrust["weight"] 299 | else "sell" 300 | ) 301 | if ( 302 | entrust["target_weight"] == 0 303 | and entrust["weight"] == 0 304 | ): 305 | raise exceptions.TradeError(u"移除的股票操作无法撤销,建议重新买入") 306 | balance = self.get_balance()[0] 307 | volume = ( 308 | abs(entrust["target_weight"] - entrust["weight"]) 309 | * balance["asset_balance"] 310 | / 100 311 | ) 312 | r = self._trade( 313 | security=entrust["stock_symbol"], 314 | volume=volume, 315 | entrust_bs=buy_or_sell, 316 | ) 317 | if len(r) > 0 and "error_info" in r[0]: 318 | raise exceptions.TradeError( 319 | u"撤销失败!%s" % ("error_info" in r[0]) 320 | ) 321 | if not is_have: 322 | raise exceptions.TradeError(u"撤销对象已失效") 323 | return True 324 | 325 | def adjust_weight(self, stock_code, weight, fetch_position=True): 326 | """ 327 | 雪球组合调仓, weight 为调整后的仓位比例 328 | :param stock_code: str 股票代码 329 | :param weight: float 调整之后的持仓百分比, 0 - 100 之间的浮点数 330 | """ 331 | 332 | stock = self._search_stock_info(stock_code) 333 | if stock is None: 334 | raise exceptions.TradeError(u"没有查询要操作的股票信息") 335 | if stock["flag"] != 1: 336 | raise exceptions.TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") 337 | 338 | # 仓位比例向下取两位数 339 | weight = round(weight, 2) 340 | # 获取原有仓位信息 341 | if fetch_position: 342 | self.position_list = self._get_position() 343 | 344 | # 调整后的持仓 345 | for position in self.position_list: 346 | if position["stock_id"] == stock["stock_id"]: 347 | position["proactive"] = True 348 | position["weight"] = weight 349 | 350 | if weight != 0 and stock["stock_id"] not in [ 351 | k["stock_id"] for k in self.position_list 352 | ]: 353 | self.position_list.append( 354 | { 355 | "code": stock["code"], 356 | "name": stock["name"], 357 | "flag": stock["flag"], 358 | "current": stock["current"], 359 | "chg": stock["chg"], 360 | "percent": str(stock["percent"]), 361 | "stock_id": stock["stock_id"], 362 | "ind_id": stock["ind_id"], 363 | "ind_name": stock["ind_name"], 364 | "ind_color": stock["ind_color"], 365 | "textname": stock["name"], 366 | "segment_name": stock["ind_name"], 367 | "weight": weight, 368 | "url": "/S/" + stock["code"], 369 | "proactive": True, 370 | "price": str(stock["current"]), 371 | } 372 | ) 373 | 374 | remain_weight = 100 - sum(i.get("weight") for i in self.position_list) 375 | cash = round(remain_weight, 2) 376 | logger.info("调仓比例:%f, 剩余持仓 :%f", weight, remain_weight) 377 | data = { 378 | "cash": cash, 379 | "holdings": str(json.dumps(self.position_list)), 380 | "cube_symbol": str(self.account_config["portfolio_code"]), 381 | "segment": "true", 382 | "comment": "", 383 | } 384 | 385 | try: 386 | resp = self.s.post(self.config["rebalance_url"], data=data) 387 | # pylint: disable=broad-except 388 | except Exception as e: 389 | logger.warning("调仓失败: %s ", e) 390 | return None 391 | logger.info("调仓 %s: 持仓比例%d", stock["name"], weight) 392 | resp_json = json.loads(resp.text) 393 | if "error_description" in resp_json and resp.status_code != 200: 394 | logger.error("调仓错误: %s", resp_json["error_description"]) 395 | return [ 396 | { 397 | "error_no": resp_json["error_code"], 398 | "error_info": resp_json["error_description"], 399 | } 400 | ] 401 | logger.info("调仓成功 %s: 持仓比例%d", stock["name"], weight) 402 | return None 403 | 404 | def _trade(self, security, price=0, amount=0, volume=0, entrust_bs="buy"): 405 | """ 406 | 调仓 407 | :param security: 408 | :param price: 409 | :param amount: 410 | :param volume: 411 | :param entrust_bs: 412 | :return: 413 | """ 414 | stock = self._search_stock_info(security) 415 | balance = self.get_balance()[0] 416 | if stock is None: 417 | raise exceptions.TradeError(u"没有查询要操作的股票信息") 418 | if not volume: 419 | volume = int(float(price) * amount) # 可能要取整数 420 | if balance["current_balance"] < volume and entrust_bs == "buy": 421 | raise exceptions.TradeError(u"没有足够的现金进行操作") 422 | if stock["flag"] != 1: 423 | raise exceptions.TradeError(u"未上市、停牌、涨跌停、退市的股票无法操作。") 424 | if volume == 0: 425 | raise exceptions.TradeError(u"操作金额不能为零") 426 | 427 | # 计算调仓调仓份额 428 | weight = volume / balance["asset_balance"] * 100 429 | weight = round(weight, 2) 430 | 431 | # 获取原有仓位信息 432 | position_list = self._get_position() 433 | 434 | # 调整后的持仓 435 | is_have = False 436 | for position in position_list: 437 | if position["stock_id"] == stock["stock_id"]: 438 | is_have = True 439 | position["proactive"] = True 440 | old_weight = position["weight"] 441 | if entrust_bs == "buy": 442 | position["weight"] = weight + old_weight 443 | else: 444 | if weight > old_weight: 445 | raise exceptions.TradeError(u"操作数量大于实际可卖出数量") 446 | else: 447 | position["weight"] = old_weight - weight 448 | position["weight"] = round(position["weight"], 2) 449 | if not is_have: 450 | if entrust_bs == "buy": 451 | position_list.append( 452 | { 453 | "code": stock["code"], 454 | "name": stock["name"], 455 | "enName": stock["enName"], 456 | "hasexist": stock["hasexist"], 457 | "flag": stock["flag"], 458 | "type": stock["type"], 459 | "current": stock["current"], 460 | "chg": stock["chg"], 461 | "percent": str(stock["percent"]), 462 | "stock_id": stock["stock_id"], 463 | "ind_id": stock["ind_id"], 464 | "ind_name": stock["ind_name"], 465 | "ind_color": stock["ind_color"], 466 | "textname": stock["name"], 467 | "segment_name": stock["ind_name"], 468 | "weight": round(weight, 2), 469 | "url": "/S/" + stock["code"], 470 | "proactive": True, 471 | "price": str(stock["current"]), 472 | } 473 | ) 474 | else: 475 | raise exceptions.TradeError(u"没有持有要卖出的股票") 476 | 477 | if entrust_bs == "buy": 478 | cash = ( 479 | (balance["current_balance"] - volume) 480 | / balance["asset_balance"] 481 | * 100 482 | ) 483 | else: 484 | cash = ( 485 | (balance["current_balance"] + volume) 486 | / balance["asset_balance"] 487 | * 100 488 | ) 489 | cash = round(cash, 2) 490 | logger.info("weight:%f, cash:%f", weight, cash) 491 | 492 | data = { 493 | "cash": cash, 494 | "holdings": str(json.dumps(position_list)), 495 | "cube_symbol": str(self.account_config["portfolio_code"]), 496 | "segment": 1, 497 | "comment": "", 498 | } 499 | 500 | try: 501 | resp = self.s.post(self.config["rebalance_url"], data=data) 502 | # pylint: disable=broad-except 503 | except Exception as e: 504 | logger.warning("调仓失败: %s ", e) 505 | return None 506 | else: 507 | logger.info( 508 | "调仓 %s%s: %d", entrust_bs, stock["name"], resp.status_code 509 | ) 510 | resp_json = json.loads(resp.text) 511 | if "error_description" in resp_json and resp.status_code != 200: 512 | logger.error("调仓错误: %s", resp_json["error_description"]) 513 | return [ 514 | { 515 | "error_no": resp_json["error_code"], 516 | "error_info": resp_json["error_description"], 517 | } 518 | ] 519 | return [ 520 | { 521 | "entrust_no": resp_json["id"], 522 | "init_date": self._time_strftime(resp_json["created_at"]), 523 | "batch_no": "委托批号", 524 | "report_no": "申报号", 525 | "seat_no": "席位编号", 526 | "entrust_time": self._time_strftime( 527 | resp_json["updated_at"] 528 | ), 529 | "entrust_price": price, 530 | "entrust_amount": amount, 531 | "stock_code": security, 532 | "entrust_bs": "买入", 533 | "entrust_type": "雪球虚拟委托", 534 | "entrust_status": "-", 535 | } 536 | ] 537 | 538 | def buy(self, security, price=0, amount=0, volume=0, entrust_prop=0): 539 | """买入卖出股票 540 | :param security: 股票代码 541 | :param price: 买入价格 542 | :param amount: 买入股数 543 | :param volume: 买入总金额 由 volume / price 取整, 若指定 price 则此参数无效 544 | :param entrust_prop: 545 | """ 546 | return self._trade(security, price, amount, volume, "buy") 547 | 548 | def sell(self, security, price=0, amount=0, volume=0, entrust_prop=0): 549 | """卖出股票 550 | :param security: 股票代码 551 | :param price: 卖出价格 552 | :param amount: 卖出股数 553 | :param volume: 卖出总金额 由 volume / price 取整, 若指定 price 则此参数无效 554 | :param entrust_prop: 555 | """ 556 | return self._trade(security, price, amount, volume, "sell") 557 | 558 | 559 | def adjust_weights(self, weights, ignore_minor=0.0, fetch_position=True): 560 | """ 561 | 雪球组合调仓, weights 为调整后的仓位比例 562 | :param weights: dict[str, float] 股票代码 -> 调整之后的持仓百分比 563 | """ 564 | 565 | # 获取原有仓位信息 566 | if fetch_position: 567 | self.position_list = self._get_position() 568 | 569 | position_dict = {position["stock_id"]: position for position in position_list} 570 | new_position_list = [] 571 | 572 | for stock_code, weight in weights.items(): 573 | stock = self._search_stock_info(stock_code) 574 | if stock is None: 575 | raise exceptions.TradeError(u"没有查询要操作的股票信息") 576 | if stock["flag"] != 1: 577 | raise exceptions.TradeError(f"未上市、停牌、涨跌停、退市的股票无法操作: {stock['name']}") 578 | 579 | if stock["stock_id"] in position_dict: 580 | # 调仓 581 | position = position_dict[stock["stock_id"]] 582 | current_weight = position["weight"] 583 | if weight > 0 and abs(weight - current_weight) > ignore_minor: 584 | position["proactive"] = True 585 | position["weight"] = weight 586 | logger.info("调仓 %s %.2f -> %.2f", position['stock_name'], current_weight, weight) 587 | new_position_list.append(position) 588 | elif weight > 0: 589 | position["proactive"] = False 590 | new_position_list.append(position) 591 | elif weight == 0.0: 592 | logger.info("平仓 %s %.2f -> %.2f", position['stock_name'], current_weight, weight) 593 | else: 594 | # 开仓 595 | new_position_list.append( 596 | { 597 | "code": stock["code"], 598 | "name": stock["name"], 599 | "flag": stock["flag"], 600 | "current": stock["current"], 601 | "chg": stock["chg"], 602 | "percent": str(stock["percent"]), 603 | "stock_id": stock["stock_id"], 604 | "ind_id": stock["ind_id"], 605 | "ind_name": stock["ind_name"], 606 | "ind_color": stock["ind_color"], 607 | "textname": stock["name"], 608 | "segment_name": stock["ind_name"], 609 | "weight": weights[stock_code], 610 | "url": "/S/" + stock["code"], 611 | "proactive": True, 612 | "price": str(stock["current"]), 613 | } 614 | ) 615 | logger.info("开仓 %s 比例: %.2f", stock["name"], weight) 616 | 617 | remain_weight = 100 - sum(i.get("weight") for i in new_position_list) 618 | cash = round(remain_weight, 2) 619 | assert cash >= 0 620 | data = { 621 | "cash": cash, 622 | "holdings": str(json.dumps(new_position_list)), 623 | "cube_symbol": str(self.account_config["portfolio_code"]), 624 | "segment": "true", 625 | "comment": "", 626 | } 627 | try: 628 | resp = self.s.post(self.config["rebalance_url"], data=data) 629 | # pylint: disable=broad-except 630 | except Exception as e: 631 | logger.warning("调仓失败: %s ", e) 632 | return None 633 | logger.info("剩余仓位: %f", cash) 634 | resp_json = json.loads(resp.text) 635 | if "error_description" in resp_json and resp.status_code != 200: 636 | logger.error("调仓错误: %s", resp_json["error_description"]) 637 | return [ 638 | { 639 | "error_no": resp_json["error_code"], 640 | "error_info": resp_json["error_description"], 641 | } 642 | ] 643 | logger.info("调仓成功") 644 | return None -------------------------------------------------------------------------------- /easytrader/yh_clienttrader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import tempfile 4 | 5 | import pywinauto 6 | 7 | from easytrader import clienttrader, grid_strategies 8 | from easytrader.utils.captcha import recognize_verify_code 9 | 10 | 11 | class YHClientTrader(clienttrader.BaseLoginClientTrader): 12 | """ 13 | Changelog: 14 | 15 | 2018.07.01: 16 | 银河客户端 2018.5.11 更新后不再支持通过剪切板复制获取 Grid 内容, 17 | 改为使用保存为 Xls 再读取的方式获取 18 | """ 19 | 20 | grid_strategy = grid_strategies.Xls 21 | 22 | @property 23 | def broker_type(self): 24 | return "yh" 25 | 26 | def login(self, user, password, exe_path, comm_password=None, **kwargs): 27 | """ 28 | 登陆客户端 29 | :param user: 账号 30 | :param password: 明文密码 31 | :param exe_path: 客户端路径类似 'C:\\中国银河证券双子星3.2\\Binarystar.exe', 32 | 默认 'C:\\中国银河证券双子星3.2\\Binarystar.exe' 33 | :param comm_password: 通讯密码, 华泰需要,可不设 34 | :return: 35 | """ 36 | try: 37 | self._app = pywinauto.Application().connect( 38 | path=self._run_exe_path(exe_path), timeout=1 39 | ) 40 | # pylint: disable=broad-except 41 | except Exception: 42 | self._app = pywinauto.Application().start(exe_path) 43 | is_xiadan = True if "xiadan.exe" in exe_path else False 44 | # wait login window ready 45 | while True: 46 | try: 47 | self._app.top_window().Edit1.wait("ready") 48 | break 49 | except RuntimeError: 50 | pass 51 | 52 | self._app.top_window().Edit1.type_keys(user) 53 | self._app.top_window().Edit2.type_keys(password) 54 | while True: 55 | self._app.top_window().Edit3.type_keys( 56 | self._handle_verify_code(is_xiadan) 57 | ) 58 | if is_xiadan: 59 | self._app.top_window().child_window(control_id=1006, class_name="Button").click() 60 | else: 61 | self._app.top_window()["登录"].click() 62 | 63 | # detect login is success or not 64 | try: 65 | self._app.top_window().wait_not("exists visible", 10) 66 | break 67 | # pylint: disable=broad-except 68 | except Exception: 69 | if is_xiadan: 70 | self._app.top_window()["确定"].click() 71 | 72 | self._app = pywinauto.Application().connect( 73 | path=self._run_exe_path(exe_path), timeout=10 74 | ) 75 | self._close_prompt_windows() 76 | self._main = self._app.window(title="网上股票交易系统5.0") 77 | try: 78 | self._main.child_window( 79 | control_id=129, class_name="SysTreeView32" 80 | ).wait("ready", 2) 81 | # pylint: disable=broad-except 82 | except Exception: 83 | self.wait(2) 84 | self._switch_window_to_normal_mode() 85 | 86 | def _switch_window_to_normal_mode(self): 87 | self._app.top_window().child_window( 88 | control_id=32812, class_name="Button" 89 | ).click() 90 | 91 | def _handle_verify_code(self, is_xiadan): 92 | control = self._app.top_window().child_window( 93 | control_id=1499 if is_xiadan else 22202 94 | ) 95 | control.click() 96 | 97 | file_path = tempfile.mktemp() 98 | if is_xiadan: 99 | rect = control.element_info.rectangle 100 | rect.right = round( 101 | rect.right + (rect.right - rect.left) * 0.3 102 | ) # 扩展验证码控件截图范围为4个字符 103 | control.capture_as_image(rect).save(file_path, "jpeg") 104 | else: 105 | control.capture_as_image().save(file_path, "jpeg") 106 | verify_code = recognize_verify_code(file_path, "yh_client") 107 | return "".join(re.findall(r"\d+", verify_code)) 108 | 109 | @property 110 | def balance(self): 111 | self._switch_left_menus(self._config.BALANCE_MENU_PATH) 112 | return self._get_grid_data(self._config.BALANCE_GRID_CONTROL_ID) 113 | 114 | def auto_ipo(self): 115 | self._switch_left_menus(self._config.AUTO_IPO_MENU_PATH) 116 | stock_list = self._get_grid_data(self._config.COMMON_GRID_CONTROL_ID) 117 | if len(stock_list) == 0: 118 | return {"message": "今日无新股"} 119 | invalid_list_idx = [ 120 | i for i, v in enumerate(stock_list) if v["申购数量"] <= 0 121 | ] 122 | if len(stock_list) == len(invalid_list_idx): 123 | return {"message": "没有发现可以申购的新股"} 124 | self.wait(0.1) 125 | # for row in invalid_list_idx: 126 | # self._click_grid_by_row(row) 127 | self._click(self._config.AUTO_IPO_BUTTON_CONTROL_ID) 128 | self.wait(0.1) 129 | return self._handle_pop_dialogs() 130 | -------------------------------------------------------------------------------- /gj_client.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "国金用户名", 3 | "password": "国金明文密码" 4 | } -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: easytrader 2 | nav: 3 | - 简介: index.md 4 | - 安装: install.md 5 | - 使用: usage.md 6 | - miniqmt 量化接口: miniqmt.md 7 | - 雪球组合模拟交易: xueqiu.md 8 | - 远端服务模式: remote.md 9 | - 策略跟踪: follow.md 10 | theme: readthedocs 11 | markdown_extensions: 12 | - toc: 13 | permalink: true 14 | toc_depth: 3 15 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /readthedocs-requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shidenggui/easytrader/ff88802a9b450ca58ed4935521dca28aed7cd900/readthedocs-requirements.txt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i http://mirrors.aliyun.com/pypi/simple/ 2 | --trusted-host mirrors.aliyun.com 3 | beautifulsoup4==4.6.0 4 | bs4==0.0.1 5 | certifi==2018.4.16 6 | chardet==3.0.4 7 | click==6.7 8 | cssselect==1.0.3; python_version != '3.3.*' 9 | dill==0.2.8.2 10 | easyutils==0.1.7 11 | flask==1.0.2 12 | idna==2.7 13 | itsdangerous==0.24 14 | jinja2==2.10 15 | lxml==4.2.3 16 | markupsafe==1.0 17 | numpy==1.15.0; python_version >= '2.7' 18 | pandas==0.23.3 19 | pillow==5.2.0 20 | pyperclip==1.6.4 21 | pyquery==1.4.0; python_version != '3.0.*' 22 | pytesseract==0.2.4 23 | python-dateutil==2.7.3 24 | python-xlib==0.23 25 | pytz==2018.5 26 | pywinauto==0.6.6 27 | requests==2.19.1 28 | six==1.11.0 29 | urllib3==1.23; python_version != '3.1.*' 30 | werkzeug==0.14.1 31 | 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding:utf8 2 | from setuptools import setup 3 | 4 | 5 | setup( 6 | name="easytrader", 7 | version="0.23.7" 8 | description="A utility for China Stock Trade", 9 | long_description=open("README.md").read(), 10 | long_description_content_type="text/markdown", 11 | author="shidenggui", 12 | author_email="longlyshidenggui@gmail.com", 13 | license="BSD", 14 | url="https://github.com/shidenggui/easytrader", 15 | keywords="China stock trade", 16 | install_requires=[ 17 | "requests", 18 | "six", 19 | "easyutils", 20 | "flask", 21 | "pywinauto==0.6.6", 22 | "pillow", 23 | "pandas", 24 | ], 25 | extras_require={ 26 | "miniqmt": ["xtquant"], 27 | }, 28 | classifiers=[ 29 | "Development Status :: 4 - Beta", 30 | "Programming Language :: Python :: 3.5", 31 | "License :: OSI Approved :: BSD License", 32 | ], 33 | packages=["easytrader", "easytrader.config", "easytrader.utils", "easytrader.miniqmt"], 34 | package_data={ 35 | "": ["*.jar", "*.json"], 36 | "config": ["config/*.json"], 37 | "thirdlibrary": ["thirdlibrary/*.jar"], 38 | }, 39 | ) 40 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pytest 4 | pytest-cov 5 | 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding:utf8 2 | -------------------------------------------------------------------------------- /tests/test_easytrader.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import sys 4 | import time 5 | import unittest 6 | 7 | sys.path.append(".") 8 | 9 | TEST_CLIENTS = set(os.environ.get("EZ_TEST_CLIENTS", "").split(",")) 10 | 11 | IS_WIN_PLATFORM = sys.platform != "darwin" 12 | 13 | 14 | @unittest.skipUnless("yh" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip yh test") 15 | class TestYhClientTrader(unittest.TestCase): 16 | @classmethod 17 | def setUpClass(cls): 18 | import easytrader 19 | 20 | if "yh" not in TEST_CLIENTS: 21 | return 22 | 23 | # input your test account and password 24 | cls._ACCOUNT = os.environ.get("EZ_TEST_YH_ACCOUNT") or "your account" 25 | cls._PASSWORD = os.environ.get("EZ_TEST_YH_PASSWORD") or "your password" 26 | 27 | cls._user = easytrader.use("yh_client") 28 | cls._user.prepare(user=cls._ACCOUNT, password=cls._PASSWORD) 29 | 30 | def test_balance(self): 31 | time.sleep(3) 32 | result = self._user.balance 33 | 34 | def test_today_entrusts(self): 35 | result = self._user.today_entrusts 36 | 37 | def test_today_trades(self): 38 | result = self._user.today_trades 39 | 40 | def test_cancel_entrusts(self): 41 | result = self._user.cancel_entrusts 42 | 43 | def test_cancel_entrust(self): 44 | result = self._user.cancel_entrust("123456789") 45 | 46 | def test_invalid_buy(self): 47 | import easytrader 48 | 49 | with self.assertRaises(easytrader.exceptions.TradeError): 50 | result = self._user.buy("511990", 1, 1e10) 51 | 52 | def test_invalid_sell(self): 53 | import easytrader 54 | 55 | with self.assertRaises(easytrader.exceptions.TradeError): 56 | result = self._user.sell("162411", 200, 1e10) 57 | 58 | def test_auto_ipo(self): 59 | self._user.auto_ipo() 60 | 61 | 62 | @unittest.skipUnless("ht" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip ht test") 63 | class TestHTClientTrader(unittest.TestCase): 64 | @classmethod 65 | def setUpClass(cls): 66 | import easytrader 67 | 68 | if "ht" not in TEST_CLIENTS: 69 | return 70 | 71 | # input your test account and password 72 | cls._ACCOUNT = os.environ.get("EZ_TEST_HT_ACCOUNT") or "your account" 73 | cls._PASSWORD = os.environ.get("EZ_TEST_HT_PASSWORD") or "your password" 74 | cls._COMM_PASSWORD = ( 75 | os.environ.get("EZ_TEST_HT_COMM_PASSWORD") or "your comm password" 76 | ) 77 | 78 | cls._user = easytrader.use("ht_client") 79 | cls._user.prepare( 80 | user=cls._ACCOUNT, password=cls._PASSWORD, comm_password=cls._COMM_PASSWORD 81 | ) 82 | 83 | def test_balance(self): 84 | time.sleep(3) 85 | result = self._user.balance 86 | 87 | def test_today_entrusts(self): 88 | result = self._user.today_entrusts 89 | 90 | def test_today_trades(self): 91 | result = self._user.today_trades 92 | 93 | def test_cancel_entrusts(self): 94 | result = self._user.cancel_entrusts 95 | 96 | def test_cancel_entrust(self): 97 | result = self._user.cancel_entrust("123456789") 98 | 99 | def test_invalid_buy(self): 100 | import easytrader 101 | 102 | with self.assertRaises(easytrader.exceptions.TradeError): 103 | result = self._user.buy("511990", 1, 1e10) 104 | 105 | def test_invalid_sell(self): 106 | import easytrader 107 | 108 | with self.assertRaises(easytrader.exceptions.TradeError): 109 | result = self._user.sell("162411", 200, 1e10) 110 | 111 | def test_auto_ipo(self): 112 | self._user.auto_ipo() 113 | 114 | def test_invalid_repo(self): 115 | import easytrader 116 | 117 | with self.assertRaises(easytrader.exceptions.TradeError): 118 | result = self._user.repo("204001", 100, 1) 119 | 120 | def test_invalid_reverse_repo(self): 121 | import easytrader 122 | 123 | with self.assertRaises(easytrader.exceptions.TradeError): 124 | result = self._user.reverse_repo("204001", 1, 100) 125 | 126 | 127 | @unittest.skipUnless("htzq" in TEST_CLIENTS and IS_WIN_PLATFORM, "skip htzq test") 128 | class TestHTZQClientTrader(unittest.TestCase): 129 | @classmethod 130 | def setUpClass(cls): 131 | import easytrader 132 | 133 | if "htzq" not in TEST_CLIENTS: 134 | return 135 | 136 | # input your test account and password 137 | cls._ACCOUNT = os.environ.get("EZ_TEST_HTZQ_ACCOUNT") or "your account" 138 | cls._PASSWORD = os.environ.get("EZ_TEST_HTZQ_PASSWORD") or "your password" 139 | cls._COMM_PASSWORD = ( 140 | os.environ.get("EZ_TEST_HTZQ_COMM_PASSWORD") or "your comm password" 141 | ) 142 | 143 | cls._user = easytrader.use("htzq_client") 144 | 145 | cls._user.prepare( 146 | user=cls._ACCOUNT, password=cls._PASSWORD, comm_password=cls._COMM_PASSWORD 147 | ) 148 | 149 | def test_balance(self): 150 | time.sleep(3) 151 | result = self._user.balance 152 | 153 | def test_today_entrusts(self): 154 | result = self._user.today_entrusts 155 | 156 | def test_today_trades(self): 157 | result = self._user.today_trades 158 | 159 | def test_cancel_entrusts(self): 160 | result = self._user.cancel_entrusts 161 | 162 | def test_cancel_entrust(self): 163 | result = self._user.cancel_entrust("123456789") 164 | 165 | def test_invalid_buy(self): 166 | import easytrader 167 | 168 | with self.assertRaises(easytrader.exceptions.TradeError): 169 | result = self._user.buy("511990", 1, 1e10) 170 | 171 | def test_invalid_sell(self): 172 | import easytrader 173 | 174 | with self.assertRaises(easytrader.exceptions.TradeError): 175 | result = self._user.sell("162411", 200, 1e10) 176 | 177 | def test_auto_ipo(self): 178 | self._user.auto_ipo() 179 | 180 | 181 | if __name__ == "__main__": 182 | unittest.main(verbosity=2) 183 | -------------------------------------------------------------------------------- /tests/test_xq_follower.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import datetime 3 | import os 4 | import time 5 | import unittest 6 | from unittest import mock 7 | 8 | from easytrader.xq_follower import XueQiuFollower 9 | 10 | 11 | class TestXueQiuTrader(unittest.TestCase): 12 | def test_adjust_sell_amount_without_enable(self): 13 | follower = XueQiuFollower() 14 | 15 | mock_user = mock.MagicMock() 16 | follower._users = [mock_user] 17 | 18 | follower._adjust_sell = False 19 | amount = follower._adjust_sell_amount("169101", 1000) 20 | self.assertEqual(amount, amount) 21 | 22 | def test_adjust_sell_should_only_work_when_sell(self): 23 | follower = XueQiuFollower() 24 | follower._adjust_sell = True 25 | test_transaction = { 26 | "weight": 10, 27 | "prev_weight": 0, 28 | "price": 10, 29 | "stock_symbol": "162411", 30 | "created_at": int(time.time() * 1000), 31 | } 32 | test_assets = 1000 33 | 34 | mock_adjust_sell_amount = mock.MagicMock() 35 | follower._adjust_sell_amount = mock_adjust_sell_amount 36 | 37 | follower.project_transactions( 38 | transactions=[test_transaction], assets=test_assets 39 | ) 40 | mock_adjust_sell_amount.assert_not_called() 41 | 42 | mock_adjust_sell_amount.reset_mock() 43 | test_transaction["prev_weight"] = test_transaction["weight"] + 1 44 | follower.project_transactions( 45 | transactions=[test_transaction], assets=test_assets 46 | ) 47 | mock_adjust_sell_amount.assert_called() 48 | 49 | def test_adjust_sell_amount(self): 50 | follower = XueQiuFollower() 51 | 52 | mock_user = mock.MagicMock() 53 | follower._users = [mock_user] 54 | mock_user.position = TEST_POSITION 55 | 56 | follower._adjust_sell = True 57 | test_cases = [ 58 | ("169101", 600, 600), 59 | ("169101", 700, 600), 60 | ("000000", 100, 100), 61 | ("sh169101", 700, 600), 62 | ] 63 | for stock_code, sell_amount, excepted_amount in test_cases: 64 | amount = follower._adjust_sell_amount(stock_code, sell_amount) 65 | self.assertEqual(amount, excepted_amount) 66 | 67 | def test_slippage_with_default(self): 68 | follower = XueQiuFollower() 69 | mock_user = mock.MagicMock() 70 | 71 | # test default no slippage 72 | test_price = 1.0 73 | test_trade_cmd = { 74 | "strategy": "test_strategy", 75 | "strategy_name": "test_strategy", 76 | "action": "buy", 77 | "stock_code": "162411", 78 | "amount": 100, 79 | "price": 1.0, 80 | "datetime": datetime.datetime.now(), 81 | } 82 | follower._execute_trade_cmd( 83 | trade_cmd=test_trade_cmd, 84 | users=[mock_user], 85 | expire_seconds=10, 86 | entrust_prop="limit", 87 | send_interval=10, 88 | ) 89 | _, kwargs = getattr(mock_user, test_trade_cmd["action"]).call_args 90 | self.assertAlmostEqual(kwargs["price"], test_price) 91 | 92 | def test_slippage(self): 93 | follower = XueQiuFollower() 94 | mock_user = mock.MagicMock() 95 | 96 | test_price = 1.0 97 | follower.slippage = 0.05 98 | 99 | # test buy 100 | test_trade_cmd = { 101 | "strategy": "test_strategy", 102 | "strategy_name": "test_strategy", 103 | "action": "buy", 104 | "stock_code": "162411", 105 | "amount": 100, 106 | "price": 1.0, 107 | "datetime": datetime.datetime.now(), 108 | } 109 | follower._execute_trade_cmd( 110 | trade_cmd=test_trade_cmd, 111 | users=[mock_user], 112 | expire_seconds=10, 113 | entrust_prop="limit", 114 | send_interval=10, 115 | ) 116 | excepted_price = test_price * (1 + follower.slippage) 117 | _, kwargs = getattr(mock_user, test_trade_cmd["action"]).call_args 118 | self.assertAlmostEqual(kwargs["price"], excepted_price) 119 | 120 | # test sell 121 | test_trade_cmd["action"] = "sell" 122 | follower._execute_trade_cmd( 123 | trade_cmd=test_trade_cmd, 124 | users=[mock_user], 125 | expire_seconds=10, 126 | entrust_prop="limit", 127 | send_interval=10, 128 | ) 129 | excepted_price = test_price * (1 - follower.slippage) 130 | _, kwargs = getattr(mock_user, test_trade_cmd["action"]).call_args 131 | self.assertAlmostEqual(kwargs["price"], excepted_price) 132 | 133 | 134 | class TestXqFollower(unittest.TestCase): 135 | def setUp(self): 136 | self.follower = XueQiuFollower() 137 | cookies = os.getenv("EZ_TEST_XQ_COOKIES") 138 | if not cookies: 139 | return 140 | self.follower.login(cookies=cookies) 141 | 142 | def test_extract_transactions(self): 143 | result = self.follower.extract_transactions(TEST_XQ_PORTOFOLIO_HISTORY) 144 | self.assertTrue(len(result) == 1) 145 | 146 | 147 | TEST_POSITION = [ 148 | { 149 | "Unnamed: 14": "", 150 | "买入冻结": 0, 151 | "交易市场": "深A", 152 | "卖出冻结": 0, 153 | "参考市价": 1.464, 154 | "参考市值": 919.39, 155 | "参考成本价": 1.534, 156 | "参考盈亏": -43.77, 157 | "可用余额": 628, 158 | "当前持仓": 628, 159 | "盈亏比例(%)": -4.544, 160 | "股东代码": "0000000000", 161 | "股份余额": 628, 162 | "证券代码": "169101", 163 | } 164 | ] 165 | 166 | TEST_XQ_PORTOFOLIO_HISTORY = { 167 | "count": 1, 168 | "page": 1, 169 | "totalCount": 17, 170 | "list": [ 171 | { 172 | "id": 1, 173 | "status": "pending", 174 | "cube_id": 1, 175 | "prev_bebalancing_id": 1, 176 | "category": "user_rebalancing", 177 | "exe_strategy": "intraday_all", 178 | "created_at": 1, 179 | "updated_at": 1, 180 | "cash_value": 0.1, 181 | "cash": 100.0, 182 | "error_code": "1", 183 | "error_message": None, 184 | "error_status": None, 185 | "holdings": None, 186 | "rebalancing_histories": [ 187 | { 188 | "id": 1, 189 | "rebalancing_id": 1, 190 | "stock_id": 1023662, 191 | "stock_name": "华宝油气", 192 | "stock_symbol": "SZ162411", 193 | "volume": 0.0, 194 | "price": None, 195 | "net_value": 0.0, 196 | "weight": 0.0, 197 | "target_weight": 0.1, 198 | "prev_weight": None, 199 | "prev_target_weight": None, 200 | "prev_weight_adjusted": None, 201 | "prev_volume": None, 202 | "prev_price": None, 203 | "prev_net_value": None, 204 | "proactive": True, 205 | "created_at": 1554339333333, 206 | "updated_at": 1554339233333, 207 | "target_volume": 0.00068325, 208 | "prev_target_volume": None, 209 | }, 210 | { 211 | "id": 2, 212 | "rebalancing_id": 1, 213 | "stock_id": 1023662, 214 | "stock_name": "华宝油气", 215 | "stock_symbol": "SZ162411", 216 | "volume": 0.0, 217 | "price": 0.55, 218 | "net_value": 0.0, 219 | "weight": 0.0, 220 | "target_weight": 0.1, 221 | "prev_weight": None, 222 | "prev_target_weight": None, 223 | "prev_weight_adjusted": None, 224 | "prev_volume": None, 225 | "prev_price": None, 226 | "prev_net_value": None, 227 | "proactive": True, 228 | "created_at": 1554339333333, 229 | "updated_at": 1554339233333, 230 | "target_volume": 0.00068325, 231 | "prev_target_volume": None, 232 | }, 233 | ], 234 | "comment": "", 235 | "diff": 0.0, 236 | "new_buy_count": 0, 237 | } 238 | ], 239 | "maxPage": 17, 240 | } 241 | -------------------------------------------------------------------------------- /tests/test_xqtrader.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import unittest 3 | 4 | from easytrader.xqtrader import XueQiuTrader 5 | 6 | 7 | class TestXueQiuTrader(unittest.TestCase): 8 | def test_prepare_account(self): 9 | user = XueQiuTrader() 10 | params_without_cookies = dict( 11 | portfolio_code="ZH123456", portfolio_market="cn" 12 | ) 13 | with self.assertRaises(TypeError): 14 | user._prepare_account(**params_without_cookies) 15 | 16 | params_without_cookies.update(cookies="123") 17 | user._prepare_account(**params_without_cookies) 18 | self.assertEqual(params_without_cookies, user.account_config) 19 | -------------------------------------------------------------------------------- /xq.json: -------------------------------------------------------------------------------- 1 | { 2 | "cookies": "雪球 cookies,登陆后获取,获取方式见 https://smalltool.github.io/2016/08/02/cookie/", 3 | "portfolio_code": "组合代码(例:ZH818559)", 4 | "portfolio_market": "交易市场(例:us 或者 cn 或者 hk)" 5 | } 6 | -------------------------------------------------------------------------------- /yh_client.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": "银河用户名", 3 | "password": "银河明文密码" 4 | } --------------------------------------------------------------------------------