├── vnpy_optionmaster ├── pricing │ ├── __init__.py │ ├── black_76_cython.cp313-win_amd64.pyd │ ├── binomial_tree_cython.cp313-win_amd64.pyd │ ├── black_scholes_cython.cp313-win_amd64.pyd │ ├── cython_model │ │ ├── black_76_cython │ │ │ ├── setup.py │ │ │ └── black_76_cython.pyx │ │ ├── black_scholes_cython │ │ │ ├── setup.py │ │ │ └── black_scholes_cython.pyx │ │ └── binomial_tree_cython │ │ │ ├── setup.py │ │ │ └── binomial_tree_cython.pyx │ ├── black_scholes.py │ ├── black_76.py │ └── binomial_tree.py ├── ui │ ├── __init__.py │ ├── option.ico │ ├── chart.py │ ├── monitor.py │ └── widget.py ├── time.py ├── __init__.py ├── algo.py ├── base.py └── engine.py ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── workflows │ └── pythonapp.yml ├── script ├── run.py ├── test_model.py └── test_ag_option.py ├── README.md ├── CHANGELOG.md ├── LICENSE ├── .gitignore └── pyproject.toml /vnpy_optionmaster/pricing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vnpy_optionmaster/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from .widget import OptionManager 2 | 3 | 4 | __all__ = ["OptionManager"] 5 | -------------------------------------------------------------------------------- /vnpy_optionmaster/ui/option.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vnpy/vnpy_optionmaster/HEAD/vnpy_optionmaster/ui/option.ico -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 建议每次发起的PR内容尽可能精简,复杂的修改请拆分为多次PR,便于管理合并。 2 | 3 | ## 改进内容 4 | 5 | 1. 6 | 2. 7 | 3. 8 | 9 | ## 相关的Issue号(如有) 10 | 11 | Close # -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/black_76_cython.cp313-win_amd64.pyd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vnpy/vnpy_optionmaster/HEAD/vnpy_optionmaster/pricing/black_76_cython.cp313-win_amd64.pyd -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/binomial_tree_cython.cp313-win_amd64.pyd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vnpy/vnpy_optionmaster/HEAD/vnpy_optionmaster/pricing/binomial_tree_cython.cp313-win_amd64.pyd -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/black_scholes_cython.cp313-win_amd64.pyd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vnpy/vnpy_optionmaster/HEAD/vnpy_optionmaster/pricing/black_scholes_cython.cp313-win_amd64.pyd -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/cython_model/black_76_cython/setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from Cython.Build import cythonize 3 | 4 | setup( 5 | name='black_76_cython', 6 | ext_modules=cythonize("black_76_cython.pyx"), 7 | ) 8 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # 获取帮助 2 | 3 | 在开发和使用VeighNa项目的过程中遇到问题时,获取帮助的渠道包括: 4 | 5 | * Github Issues:[Issues页面](https://github.com/vnpy/vnpy/issues) 6 | * 官方QQ群: 262656087 7 | * 项目论坛:[VeighNa量化社区](http://www.vnpy.com/forum) 8 | * 项目邮箱: vn.py@foxmail.com 9 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/cython_model/black_scholes_cython/setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from Cython.Build import cythonize 3 | 4 | setup( 5 | name='black_scholes_cython', 6 | ext_modules=cythonize("black_scholes_cython.pyx"), 7 | ) 8 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/cython_model/binomial_tree_cython/setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from Cython.Build import cythonize 3 | import numpy 4 | 5 | setup( 6 | name='binomial_tree_cython', 7 | ext_modules=cythonize("binomial_tree_cython.pyx"), 8 | include_dirs=[numpy.get_include()] 9 | ) 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # 行为准则 2 | 3 | 这是一份VeighNa项目社区的行为准则,也是项目作者自己在刚入行量化金融行业时对于理想中的社区的期望: 4 | 5 | * 为交易员而生:作为一款从金融机构量化业务中诞生的交易系统开发框架,设计上都优先满足机构专业交易员的使用习惯,而不是其他用户(散户、爱好者、技术人员等) 6 | 7 | * 对新用户友好,保持耐心:大部分人在接触新东西的时候都是磕磕碰碰、有很多的问题,请记住此时别人对你伸出的援助之手,并把它传递给未来需要的人 8 | 9 | * 尊重他人,慎重言行:礼貌文明的交流方式除了能得到别人同样的回应,更能减少不必要的摩擦,保证高效的交流 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 环境 2 | 3 | * 操作系统: 如Windows 11或者Ubuntu 22.04 4 | * Python版本: 如VeighNa Studio-4.0.0 5 | * VeighNa版本: 如v4.0.0发行版或者dev branch 20250320(下载日期) 6 | 7 | ## Issue类型 8 | 三选一:Bug/Enhancement/Question 9 | 10 | ## 预期程序行为 11 | 12 | 13 | ## 实际程序行为 14 | 15 | 16 | ## 重现步骤 17 | 18 | 针对Bug类型Issue,请提供具体重现步骤以及报错截图 19 | 20 | -------------------------------------------------------------------------------- /script/run.py: -------------------------------------------------------------------------------- 1 | from vnpy.event import EventEngine 2 | from vnpy.trader.engine import MainEngine 3 | from vnpy.trader.ui import MainWindow, create_qapp 4 | 5 | from vnpy_ctp import CtpGateway 6 | from vnpy_optionmaster import OptionMasterApp 7 | 8 | 9 | def main() -> None: 10 | """Start Trader""" 11 | qapp = create_qapp() 12 | 13 | event_engine = EventEngine() 14 | main_engine = MainEngine(event_engine) 15 | 16 | main_engine.add_gateway(CtpGateway) 17 | main_engine.add_app(OptionMasterApp) 18 | 19 | main_window = MainWindow(main_engine, event_engine) 20 | main_window.showMaximized() 21 | 22 | qapp.exec() 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VeighNa框架的期权波动率交易模块 2 | 3 |

4 | 5 |

6 | 7 |

8 | 9 | 10 | 11 | 12 |

13 | 14 | ## 说明 15 | 16 | 针对期权波动率交易类策略设计的应用模块,支持波动率曲面实时跟踪、期权组合持仓希腊值风控、Delta自动对冲算法、电子眼波动率执行算法、情景分析压力测试等功能。 17 | 18 | ## 安装 19 | 20 | 安装环境推荐基于4.0.0版本以上的【[**VeighNa Studio**](https://www.vnpy.com)】。 21 | 22 | 直接使用pip命令: 23 | 24 | ``` 25 | pip install vnpy_optionmaster 26 | ``` 27 | 28 | 29 | 或者下载源代码后,解压后在cmd中运行: 30 | 31 | ``` 32 | pip install . 33 | ``` 34 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: windows-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.13 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.13' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install ta-lib==0.6.4 --index=https://pypi.vnpy.com 20 | pip install vnpy ruff mypy uv 21 | - name: Lint with ruff 22 | run: | 23 | # Run ruff linter based on pyproject.toml configuration 24 | ruff check . 25 | - name: Type check with mypy 26 | run: | 27 | # Run mypy type checking based on pyproject.toml configuration 28 | mypy vnpy_optionmaster 29 | - name: Build packages with uv 30 | run: | 31 | # Build source distribution and wheel distribution 32 | uv build 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.3.0版本 2 | 3 | 1. 优化隐含波动率计算函数对于深度虚值期权的计算 4 | 5 | # 1.2.2版本 6 | 7 | 1. 优化定价模型中期权最小价值边界判断的逻辑 8 | 2. 修复black-76模型中theta计算公式的问题 9 | 10 | # 1.2.1版本 11 | 12 | 1. 修复关闭窗口时的异常报错 13 | 14 | # 1.2.0版本 15 | 16 | 1. vnpy框架4.0版本升级适配 17 | 18 | # 1.1.1版本 19 | 20 | 1. 修复模型测试脚本中对于vega计算的错误传参 21 | 22 | # 1.1.0版本 23 | 24 | 1. 移除不必要的价格缓存代码 25 | 26 | # 1.0.9版本 27 | 28 | 1. 调整适配PySide6新版本 29 | 30 | # 1.0.8版本 31 | 32 | 1. 增加对IB的股票期权品种支持 33 | 2. Python和Cython定价模型改为计算理论希腊值 34 | 3. 调整对象希腊值为理论模式 35 | 4. 调整中值隐波动的计算方法 36 | 37 | # 1.0.7版本 38 | 39 | 1. 修复Greeks监控表行数设置不足的bug 40 | 2. 改为使用OmsEngine提供的OffsetConverter组件 41 | 3. 修复OptionMaster管理界面配置商品期货报错的问题 42 | 43 | # 1.0.6版本 44 | 1. 增加对更多期权品种的支持 45 | 2. 增加期权产品对应标的合约的匹配函数,不再限制产品范围 46 | 47 | # 1.0.5版本 48 | 49 | 1. 适配新版本exchange_calendars的函数调用方式 50 | 2. 限制exchange_calendars依赖版本不低于4.1.1 51 | 52 | # 1.0.4版本 53 | 54 | 1. 移除反向合约支持 55 | 56 | # 1.0.3版本 57 | 58 | 1. 完善变量和函数的类型声明 59 | 2. 修复希腊值风险监控模块的数据刷新问题 60 | 3. UI相关代码替换PySide6风格API调用 61 | 62 | 63 | # 1.0.2版本 64 | 65 | 1. 将模块的图标文件信息,改为完整路径字符串 66 | -------------------------------------------------------------------------------- /vnpy_optionmaster/time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import exchange_calendars 3 | 4 | 5 | ANNUAL_DAYS = 240 6 | 7 | # Get public holidays data from Shanghai Stock Exchange 8 | cn_calendar: exchange_calendars.ExchangeCalendar = exchange_calendars.get_calendar('XSHG') 9 | holidays: list = [x.to_pydatetime() for x in cn_calendar.precomputed_holidays()] 10 | 11 | # Filter future public holidays 12 | start: datetime = datetime.today() 13 | PUBLIC_HOLIDAYS = [x for x in holidays if x >= start] 14 | 15 | 16 | def calculate_days_to_expiry(option_expiry: datetime) -> int: 17 | """""" 18 | current_dt: datetime = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 19 | days: int = 1 20 | 21 | while current_dt < option_expiry: 22 | current_dt += timedelta(days=1) 23 | 24 | # Ignore weekends 25 | if current_dt.weekday() in [5, 6]: 26 | continue 27 | 28 | # Ignore public holidays 29 | if current_dt in PUBLIC_HOLIDAYS: 30 | continue 31 | 32 | days += 1 33 | 34 | return days 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present, Xiaoyou Chen 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. -------------------------------------------------------------------------------- /script/test_model.py: -------------------------------------------------------------------------------- 1 | from vnpy_optionmaster.pricing import ( 2 | binomial_tree as binomial_tree_python, 3 | black_76 as black_76_python, 4 | black_scholes as black_scholes_python, 5 | ) 6 | 7 | from vnpy_optionmaster.pricing import ( 8 | binomial_tree_cython, 9 | black_76_cython, 10 | black_scholes_cython, 11 | ) 12 | 13 | 14 | s = 100 15 | k = 100 16 | r = 0.03 17 | t = 0.1 18 | v = 0.2 19 | cp = 1 20 | 21 | 22 | for py_model, cy_model in [ 23 | (binomial_tree_python, binomial_tree_cython), 24 | (black_76_python, black_76_cython), 25 | (black_scholes_python, black_scholes_cython) 26 | ]: 27 | print("-" * 30) 28 | 29 | for model in [py_model, cy_model]: 30 | print(" ") 31 | print(model.__name__) 32 | print("price", model.calculate_price(s, k, r, t, v, cp)) 33 | print("delta", model.calculate_delta(s, k, r, t, v, cp)) 34 | print("gamma", model.calculate_gamma(s, k, r, t, v, cp)) 35 | print("theta", model.calculate_theta(s, k, r, t, v, cp)) 36 | 37 | if "tree" in model.__name__: 38 | print("vega", model.calculate_vega(s, k, r, t, v, cp)) 39 | else: 40 | print("vega", model.calculate_vega(s, k, r, t, v)) 41 | 42 | print("greeks", model.calculate_greeks(s, k, r, t, v, cp)) 43 | -------------------------------------------------------------------------------- /vnpy_optionmaster/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2015-present, Xiaoyou Chen 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 | 23 | 24 | from pathlib import Path 25 | from vnpy.trader.app import BaseApp 26 | from .engine import OptionEngine, APP_NAME 27 | 28 | 29 | __all__ = [ 30 | "APP_NAME", 31 | "OptionEngine", 32 | "OptionData", 33 | "OptionMasterApp", 34 | ] 35 | 36 | 37 | __version__ = "1.3.0" 38 | 39 | 40 | class OptionMasterApp(BaseApp): 41 | """""" 42 | app_name: str = APP_NAME 43 | app_module: str = __module__ 44 | app_path: Path = Path(__file__).parent 45 | display_name: str = "期权交易" 46 | engine_class: type[OptionEngine] = OptionEngine 47 | widget_name: str = "OptionManager" 48 | icon_name: str = str(app_path.joinpath("ui", "option.ico")) 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[co] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .vscode/ -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "vnpy_optionmaster" 3 | dynamic = ["version"] 4 | description = "Option pricing and trading application for VeighNa quant trading framework." 5 | readme = "README.md" 6 | license = {text = "MIT"} 7 | authors = [{name = "Xiaoyou Chen", email = "xiaoyou.chen@mail.vnpy.com"}] 8 | classifiers = [ 9 | "Development Status :: 5 - Production/Stable", 10 | "License :: OSI Approved :: MIT License", 11 | "Operating System :: OS Independent", 12 | "Programming Language :: Python :: 3", 13 | "Programming Language :: Python :: 3.10", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | "Programming Language :: Python :: 3.13", 17 | "Topic :: Office/Business :: Financial :: Investment", 18 | "Programming Language :: Python :: Implementation :: CPython", 19 | "Natural Language :: Chinese (Simplified)", 20 | "Typing :: Typed", 21 | ] 22 | requires-python = ">=3.10" 23 | dependencies = [ 24 | "vnpy>=4.0.0", 25 | "scipy>=1.15.2", 26 | "matplotlib>=3.10.1", 27 | "exchange_calendars>=4.10" 28 | ] 29 | keywords = ["quant", "quantitative", "investment", "trading", "algotrading", "options"] 30 | 31 | [project.urls] 32 | "Homepage" = "https://www.vnpy.com" 33 | "Documentation" = "https://www.vnpy.com/docs" 34 | "Changes" = "https://github.com/vnpy/vnpy_optionmaster/blob/master/CHANGELOG.md" 35 | "Source" = "https://github.com/vnpy/vnpy_optionmaster/" 36 | "Forum" = "https://www.vnpy.com/forum" 37 | 38 | [build-system] 39 | requires = ["hatchling>=1.27.0"] 40 | build-backend = "hatchling.build" 41 | 42 | [tool.hatch.version] 43 | path = "vnpy_optionmaster/__init__.py" 44 | pattern = "__version__ = ['\"](?P[^'\"]+)['\"]" 45 | 46 | [tool.hatch.build.targets.wheel] 47 | packages = ["vnpy_optionmaster"] 48 | include-package-data = true 49 | exclude = [ 50 | "vnpy_optionmaster/pricing/cython_model", 51 | "vnpy_optionmaster/pricing/cython_model/**/*" 52 | ] 53 | 54 | [tool.hatch.build] 55 | packages = ["vnpy_optionmaster"] 56 | artifacts = ["vnpy_optionmaster/pricing/*.cp*-*.pyd"] 57 | 58 | [tool.hatch.build.targets.sdist] 59 | include = ["vnpy_optionmaster*"] 60 | 61 | [tool.ruff] 62 | target-version = "py310" 63 | output-format = "full" 64 | 65 | [tool.ruff.lint] 66 | select = [ 67 | "B", # flake8-bugbear 68 | "E", # pycodestyle error 69 | "F", # pyflakes 70 | "UP", # pyupgrade 71 | "W", # pycodestyle warning 72 | ] 73 | ignore = ["E501"] 74 | 75 | [tool.mypy] 76 | python_version = "3.10" 77 | warn_return_any = true 78 | warn_unused_configs = true 79 | disallow_untyped_defs = true 80 | disallow_incomplete_defs = true 81 | check_untyped_defs = true 82 | disallow_untyped_decorators = true 83 | no_implicit_optional = true 84 | strict_optional = true 85 | warn_redundant_casts = true 86 | warn_unused_ignores = true 87 | warn_no_return = true 88 | ignore_missing_imports = true 89 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/black_scholes.py: -------------------------------------------------------------------------------- 1 | from scipy import stats 2 | from math import log, pow, sqrt, exp 3 | 4 | cdf = stats.norm.cdf 5 | pdf = stats.norm.pdf 6 | 7 | 8 | def calculate_d1( 9 | s: float, 10 | k: float, 11 | r: float, 12 | t: float, 13 | v: float 14 | ) -> float: 15 | """Calculate option D1 value""" 16 | d1: float = (log(s / k) + (r + 0.5 * pow(v, 2)) * t) / (v * sqrt(t)) 17 | return d1 18 | 19 | 20 | def calculate_price( 21 | s: float, 22 | k: float, 23 | r: float, 24 | t: float, 25 | v: float, 26 | cp: int, 27 | d1: float = 0.0 28 | ) -> float: 29 | """Calculate option price""" 30 | # Return option space value if volatility not positive 31 | if v <= 0: 32 | return max(0, cp * (s - k * exp(-r * t))) 33 | 34 | if not d1: 35 | d1 = calculate_d1(s, k, r, t, v) 36 | d2: float = d1 - v * sqrt(t) 37 | 38 | price: float = cp * (s * cdf(cp * d1) - k * cdf(cp * d2) * exp(-r * t)) 39 | return price 40 | 41 | 42 | def calculate_delta( 43 | s: float, 44 | k: float, 45 | r: float, 46 | t: float, 47 | v: float, 48 | cp: int, 49 | d1: float = 0.0 50 | ) -> float: 51 | """Calculate option delta""" 52 | if v <= 0: 53 | return 0 54 | 55 | if not d1: 56 | d1 = calculate_d1(s, k, r, t, v) 57 | 58 | delta: float = cp * cdf(cp * d1) 59 | return delta 60 | 61 | 62 | def calculate_gamma( 63 | s: float, 64 | k: float, 65 | r: float, 66 | t: float, 67 | v: float, 68 | d1: float = 0.0 69 | ) -> float: 70 | """Calculate option gamma""" 71 | if v <= 0: 72 | return 0 73 | 74 | if not d1: 75 | d1 = calculate_d1(s, k, r, t, v) 76 | 77 | gamma: float = pdf(d1) / (s * v * sqrt(t)) 78 | return gamma 79 | 80 | 81 | def calculate_theta( 82 | s: float, 83 | k: float, 84 | r: float, 85 | t: float, 86 | v: float, 87 | cp: int, 88 | d1: float = 0.0, 89 | annual_days: int = 240 90 | ) -> float: 91 | """Calculate option theta""" 92 | if v <= 0: 93 | return 0 94 | 95 | if not d1: 96 | d1 = calculate_d1(s, k, r, t, v) 97 | d2: float = d1 - v * sqrt(t) 98 | 99 | theta: float = -s * pdf(d1) * v / (2 * sqrt(t)) \ 100 | - cp * r * k * exp(-r * t) * cdf(cp * d2) 101 | return theta 102 | 103 | 104 | def calculate_vega( 105 | s: float, 106 | k: float, 107 | r: float, 108 | t: float, 109 | v: float, 110 | d1: float = 0.0 111 | ) -> float: 112 | """Calculate option vega""" 113 | if v <= 0: 114 | return 0 115 | 116 | if not d1: 117 | d1 = calculate_d1(s, k, r, t, v) 118 | 119 | vega: float = s * pdf(d1) * sqrt(t) 120 | return vega 121 | 122 | 123 | def calculate_greeks( 124 | s: float, 125 | k: float, 126 | r: float, 127 | t: float, 128 | v: float, 129 | cp: int 130 | ) -> tuple[float, float, float, float, float]: 131 | """Calculate option price and greeks""" 132 | d1: float = calculate_d1(s, k, r, t, v) 133 | price: float = calculate_price(s, k, r, t, v, cp, d1) 134 | delta: float = calculate_delta(s, k, r, t, v, cp, d1) 135 | gamma: float = calculate_gamma(s, k, r, t, v, d1) 136 | theta: float = calculate_theta(s, k, r, t, v, cp, d1) 137 | vega: float = calculate_vega(s, k, r, t, v, d1) 138 | return price, delta, gamma, theta, vega 139 | 140 | 141 | def calculate_impv( 142 | price: float, 143 | s: float, 144 | k: float, 145 | r: float, 146 | t: float, 147 | cp: int 148 | ) -> float: 149 | """Calculate option implied volatility""" 150 | # Check option price must be positive 151 | if price <= 0: 152 | return 0 153 | 154 | # Check if option price meets minimum value (exercise value) 155 | meet: bool = price > cp * (s - k * exp(-r * t)) 156 | 157 | # If minimum value not met, return 0 158 | if not meet: 159 | return 0 160 | 161 | # Calculate implied volatility with Newton's method 162 | # Smart initial guess based on moneyness 163 | if cp == 1: 164 | moneyness: float = s / k 165 | else: 166 | moneyness = k / s 167 | 168 | v_base: float = (price / s) / sqrt(t) * 2.5 169 | 170 | # Adjust based on moneyness 171 | if moneyness < 0.9: 172 | adjustment: float = (1 + (1 - moneyness) * 20) 173 | elif moneyness > 1.15: 174 | adjustment = max(0.6, 1 - (moneyness - 1) * 0.2) 175 | else: 176 | adjustment = 1.0 177 | 178 | v: float = v_base * adjustment 179 | v = min(max(v, 0.2), 5.0) 180 | 181 | for _i in range(100): 182 | # Calculate option price and vega with current guess 183 | p: float = calculate_price(s, k, r, t, v, cp) 184 | vega: float = calculate_vega(s, k, r, t, v) 185 | 186 | # Break loop if vega too close to 0 187 | if not vega or abs(vega) < 1e-10: 188 | break 189 | 190 | # Calculate error value 191 | dx: float = (price - p) / vega 192 | 193 | # Check if error value meets requirement 194 | if abs(dx) < 0.00001: 195 | break 196 | 197 | # Limit step size 198 | dx = max(-0.5, min(0.5, dx)) 199 | 200 | # Calculate guessed implied volatility of next round 201 | v_new: float = v + dx 202 | if v_new <= 0: 203 | v_new = v * 0.5 204 | v = min(v_new, 10.0) 205 | 206 | # Final check 207 | if v <= 0: 208 | return 0 209 | 210 | # Round to 4 decimal places 211 | v = round(v, 4) 212 | 213 | return v 214 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/black_76.py: -------------------------------------------------------------------------------- 1 | from scipy import stats 2 | from math import log, pow, sqrt, exp 3 | 4 | cdf = stats.norm.cdf 5 | pdf = stats.norm.pdf 6 | 7 | 8 | def calculate_d1( 9 | s: float, 10 | k: float, 11 | r: float, 12 | t: float, 13 | v: float 14 | ) -> float: 15 | """Calculate option D1 value""" 16 | d1: float = (log(s / k) + (0.5 * pow(v, 2)) * t) / (v * sqrt(t)) 17 | return d1 18 | 19 | 20 | def calculate_price( 21 | s: float, 22 | k: float, 23 | r: float, 24 | t: float, 25 | v: float, 26 | cp: int, 27 | d1: float = 0.0 28 | ) -> float: 29 | """Calculate option price""" 30 | # Return option space value if volatility not positive 31 | if v <= 0: 32 | return max(0, cp * (s - k)) * exp(-r * t) 33 | 34 | if not d1: 35 | d1 = calculate_d1(s, k, r, t, v) 36 | d2: float = d1 - v * sqrt(t) 37 | 38 | price: float = cp * (s * cdf(cp * d1) - k * cdf(cp * d2)) * exp(-r * t) 39 | return price 40 | 41 | 42 | def calculate_delta( 43 | s: float, 44 | k: float, 45 | r: float, 46 | t: float, 47 | v: float, 48 | cp: int, 49 | d1: float = 0.0 50 | ) -> float: 51 | """Calculate option delta""" 52 | if v <= 0: 53 | return 0 54 | 55 | if not d1: 56 | d1 = calculate_d1(s, k, r, t, v) 57 | 58 | delta: float = cp * exp(-r * t) * cdf(cp * d1) 59 | return delta 60 | 61 | 62 | def calculate_gamma( 63 | s: float, 64 | k: float, 65 | r: float, 66 | t: float, 67 | v: float, 68 | d1: float = 0.0 69 | ) -> float: 70 | """Calculate option gamma""" 71 | if v <= 0: 72 | return 0 73 | 74 | if not d1: 75 | d1 = calculate_d1(s, k, r, t, v) 76 | 77 | gamma: float = exp(-r * t) * pdf(d1) / (s * v * sqrt(t)) 78 | return gamma 79 | 80 | 81 | def calculate_theta( 82 | s: float, 83 | k: float, 84 | r: float, 85 | t: float, 86 | v: float, 87 | cp: int, 88 | d1: float = 0.0 89 | ) -> float: 90 | """Calculate option theta""" 91 | if v <= 0: 92 | return 0 93 | 94 | if not d1: 95 | d1 = calculate_d1(s, k, r, t, v) 96 | d2: float = d1 - v * sqrt(t) 97 | 98 | theta: float = -s * exp(-r * t) * pdf(d1) * v / (2 * sqrt(t)) \ 99 | - cp * r * s * exp(-r * t) * cdf(cp * d1) \ 100 | + cp * r * k * exp(-r * t) * cdf(cp * d2) 101 | return theta 102 | 103 | 104 | def calculate_vega( 105 | s: float, 106 | k: float, 107 | r: float, 108 | t: float, 109 | v: float, 110 | d1: float = 0.0 111 | ) -> float: 112 | """Calculate option vega""" 113 | if v <= 0: 114 | return 0 115 | 116 | if not d1: 117 | d1 = calculate_d1(s, k, r, t, v) 118 | 119 | vega: float = s * exp(-r * t) * pdf(d1) * sqrt(t) 120 | return vega 121 | 122 | 123 | def calculate_greeks( 124 | s: float, 125 | k: float, 126 | r: float, 127 | t: float, 128 | v: float, 129 | cp: int 130 | ) -> tuple[float, float, float, float, float]: 131 | """Calculate option price and greeks""" 132 | d1: float = calculate_d1(s, k, r, t, v) 133 | price: float = calculate_price(s, k, r, t, v, cp, d1) 134 | delta: float = calculate_delta(s, k, r, t, v, cp, d1) 135 | gamma: float = calculate_gamma(s, k, r, t, v, d1) 136 | theta: float = calculate_theta(s, k, r, t, v, cp, d1) 137 | vega: float = calculate_vega(s, k, r, t, v, d1) 138 | return price, delta, gamma, theta, vega 139 | 140 | 141 | def calculate_impv( 142 | price: float, 143 | s: float, 144 | k: float, 145 | r: float, 146 | t: float, 147 | cp: int 148 | ) -> float: 149 | """Calculate option implied volatility""" 150 | # Check option price must be positive 151 | if price <= 0: 152 | return 0 153 | 154 | # Check if option price meets minimum value (exercise value) 155 | meet: bool = price > cp * (s - k) * exp(-r * t) 156 | 157 | # If minimum value not met, return 0 158 | if not meet: 159 | return 0 160 | 161 | # Calculate implied volatility with Newton's method 162 | # Smart initial guess based on moneyness 163 | if cp == 1: 164 | moneyness: float = s / k 165 | else: 166 | moneyness = k / s 167 | 168 | v_base: float = (price / s) / sqrt(t) * 2.5 169 | 170 | # Adjust based on moneyness 171 | if moneyness < 0.9: 172 | adjustment: float = (1 + (1 - moneyness) * 20) 173 | elif moneyness > 1.15: 174 | adjustment = max(0.6, 1 - (moneyness - 1) * 0.2) 175 | else: 176 | adjustment = 1.0 177 | 178 | v: float = v_base * adjustment 179 | v = min(max(v, 0.2), 5.0) 180 | 181 | for _i in range(100): 182 | # Calculate option price and vega with current guess 183 | p: float = calculate_price(s, k, r, t, v, cp) 184 | vega: float = calculate_vega(s, k, r, t, v) 185 | 186 | # Break loop if vega too close to 0 187 | if not vega or abs(vega) < 1e-10: 188 | break 189 | 190 | # Calculate error value 191 | dx: float = (price - p) / vega 192 | 193 | # Check if error value meets requirement 194 | if abs(dx) < 0.00001: 195 | break 196 | 197 | # Limit step size 198 | dx = max(-0.5, min(0.5, dx)) 199 | 200 | # Calculate guessed implied volatility of next round 201 | v_new: float = v + dx 202 | if v_new <= 0: 203 | v_new = v * 0.5 204 | v = min(v_new, 10.0) 205 | 206 | # Final check 207 | if v <= 0: 208 | return 0 209 | 210 | # Round to 4 decimal places 211 | v = round(v, 4) 212 | 213 | return v 214 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/cython_model/black_scholes_cython/black_scholes_cython.pyx: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | cdef extern from "math.h" nogil: 4 | double exp(double) 5 | double sqrt(double) 6 | double pow(double, double) 7 | double log(double) 8 | double erf(double) 9 | double fabs(double) 10 | double fmax(double, double) 11 | 12 | 13 | cdef double cdf(double x): 14 | return 0.5 * (1 + erf(x / sqrt(2.0))) 15 | 16 | 17 | cdef double pdf(double x): 18 | # 1 / sqrt(2 * 3.1416) = 0.3989422804014327 19 | return exp(- pow(x, 2) * 0.5) * 0.3989422804014327 20 | 21 | 22 | cdef double calculate_d1(double s, double k, double r, double t, double v): 23 | """Calculate option D1 value""" 24 | return (log(s / k) + (r + 0.5 * pow(v, 2)) * t) / (v * sqrt(t)) 25 | 26 | 27 | def calculate_price( 28 | double s, 29 | double k, 30 | double r, 31 | double t, 32 | double v, 33 | int cp, 34 | double d1 = 0.0 35 | ) -> float: 36 | """Calculate option price""" 37 | cdef double d2, price 38 | 39 | # Return option space value if volatility not positive 40 | if v <= 0: 41 | return max(0, cp * (s - k * exp(-r * t))) 42 | 43 | if not d1: 44 | d1 = calculate_d1(s, k, r, t, v) 45 | d2 = d1 - v * sqrt(t) 46 | 47 | price = cp * (s * cdf(cp * d1) - k * cdf(cp * d2) * exp(-r * t)) 48 | return price 49 | 50 | 51 | def calculate_delta( 52 | double s, 53 | double k, 54 | double r, 55 | double t, 56 | double v, 57 | int cp, 58 | double d1 = 0.0 59 | ) -> float: 60 | """Calculate option delta""" 61 | cdef _delta, delta 62 | 63 | if v <= 0: 64 | return 0 65 | 66 | if not d1: 67 | d1 = calculate_d1(s, k, r, t, v) 68 | 69 | delta: float = cp * cdf(cp * d1) 70 | return delta 71 | 72 | 73 | def calculate_gamma( 74 | double s, 75 | double k, 76 | double r, 77 | double t, 78 | double v, 79 | double d1 = 0.0 80 | ) -> float: 81 | """Calculate option gamma""" 82 | cdef _gamma, gamma 83 | 84 | if v <= 0 or s <= 0 or t<= 0: 85 | return 0 86 | 87 | if not d1: 88 | d1 = calculate_d1(s, k, r, t, v) 89 | 90 | gamma = pdf(d1) / (s * v * sqrt(t)) 91 | return gamma 92 | 93 | 94 | def calculate_theta( 95 | double s, 96 | double k, 97 | double r, 98 | double t, 99 | double v, 100 | int cp, 101 | double d1 = 0.0, 102 | int annual_days = 240 103 | ) -> float: 104 | """Calculate option theta""" 105 | cdef double d2, _theta, theta 106 | 107 | if v <= 0: 108 | return 0 109 | 110 | if not d1: 111 | d1 = calculate_d1(s, k, r, t, v) 112 | d2: float = d1 - v * sqrt(t) 113 | 114 | theta = -s * pdf(d1) * v / (2 * sqrt(t)) \ 115 | - cp * r * k * exp(-r * t) * cdf(cp * d2) 116 | return theta 117 | 118 | 119 | def calculate_vega( 120 | double s, 121 | double k, 122 | double r, 123 | double t, 124 | double v, 125 | double d1 = 0.0 126 | ) -> float: 127 | """Calculate option vega""" 128 | cdef double vega 129 | 130 | if v <= 0: 131 | return 0 132 | 133 | if not d1: 134 | d1 = calculate_d1(s, k, r, t, v) 135 | 136 | vega: float = s * pdf(d1) * sqrt(t) 137 | return vega 138 | 139 | 140 | def calculate_greeks( 141 | double s, 142 | double k, 143 | double r, 144 | double t, 145 | double v, 146 | int cp 147 | ) -> Tuple[float, float, float, float, float]: 148 | """Calculate option price and greeks""" 149 | cdef double d1, price, delta, gamma, theta, vega 150 | 151 | d1 = calculate_d1(s, k, r, t, v) 152 | 153 | price = calculate_price(s, k, r, t, v, cp, d1) 154 | delta = calculate_delta(s, k, r, t, v, cp, d1) 155 | gamma = calculate_gamma(s, k, r, t, v, d1) 156 | theta = calculate_theta(s, k, r, t, v, cp, d1) 157 | vega = calculate_vega(s, k, r, t, v, d1) 158 | 159 | return price, delta, gamma, theta, vega 160 | 161 | 162 | def calculate_impv( 163 | double price, 164 | double s, 165 | double k, 166 | double r, 167 | double t, 168 | int cp 169 | ) -> float: 170 | """Calculate option implied volatility""" 171 | cdef bint meet 172 | cdef double v, p, vega, dx, moneyness, v_base, adjustment, v_new 173 | 174 | # Check option price must be positive 175 | if price <= 0: 176 | return 0 177 | 178 | # Check if option price meets minimum value (exercise value) 179 | meet = price > cp * (s - k * exp(-r * t)) 180 | 181 | # If minimum value not met, return 0 182 | if not meet: 183 | return 0 184 | 185 | # Calculate implied volatility with Newton's method 186 | # Smart initial guess based on moneyness 187 | if cp == 1: 188 | moneyness = s / k 189 | else: 190 | moneyness = k / s 191 | 192 | v_base = (price / s) / sqrt(t) * 2.5 193 | 194 | # Adjust based on moneyness 195 | if moneyness < 0.9: 196 | adjustment = 1 + (1 - moneyness) * 20 197 | elif moneyness > 1.15: 198 | adjustment = fmax(0.6, 1 - (moneyness - 1) * 0.2) 199 | else: 200 | adjustment = 1.0 201 | 202 | v = v_base * adjustment 203 | v = min(max(v, 0.2), 5.0) 204 | 205 | for i in range(100): 206 | # Calculate option price and vega with current guess 207 | p = calculate_price(s, k, r, t, v, cp) 208 | vega = calculate_vega(s, k, r, t, v) 209 | 210 | # Break loop if vega too close to 0 211 | if not vega or fabs(vega) < 1e-10: 212 | break 213 | 214 | # Calculate error value 215 | dx = (price - p) / vega 216 | 217 | # Check if error value meets requirement 218 | if fabs(dx) < 0.00001: 219 | break 220 | 221 | # Limit step size 222 | dx = fmax(-0.5, min(0.5, dx)) 223 | 224 | # Calculate guessed implied volatility of next round 225 | v_new = v + dx 226 | if v_new <= 0: 227 | v_new = v * 0.5 228 | v = min(v_new, 10.0) 229 | 230 | # Final check 231 | if v <= 0: 232 | return 0 233 | 234 | # Round to 4 decimal places 235 | v = round(v, 4) 236 | 237 | return v 238 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/cython_model/black_76_cython/black_76_cython.pyx: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | cdef extern from "math.h" nogil: 4 | double exp(double) 5 | double sqrt(double) 6 | double pow(double, double) 7 | double log(double) 8 | double erf(double) 9 | double fabs(double) 10 | double fmax(double, double) 11 | 12 | 13 | cdef double cdf(double x): 14 | return 0.5 * (1 + erf(x / sqrt(2.0))) 15 | 16 | 17 | cdef double pdf(double x): 18 | # 1 / sqrt(2 * 3.1416) = 0.3989422804014327 19 | return exp(- pow(x, 2) * 0.5) * 0.3989422804014327 20 | 21 | 22 | cdef double calculate_d1(double s, double k, double r, double t, double v): 23 | """Calculate option D1 value""" 24 | return (log(s / k) + (0.5 * pow(v, 2)) * t) / (v * sqrt(t)) 25 | 26 | 27 | def calculate_price( 28 | double s, 29 | double k, 30 | double r, 31 | double t, 32 | double v, 33 | int cp, 34 | double d1 = 0.0 35 | ) -> float: 36 | """Calculate option price""" 37 | cdef double d2, price 38 | 39 | # Return option space value if volatility not positive 40 | if v <= 0: 41 | return max(0, cp * (s - k)) * exp(-r * t) 42 | 43 | if not d1: 44 | d1 = calculate_d1(s, k, r, t, v) 45 | d2 = d1 - v * sqrt(t) 46 | 47 | price = cp * (s * cdf(cp * d1) - k * cdf(cp * d2)) * exp(-r * t) 48 | return price 49 | 50 | 51 | def calculate_delta( 52 | double s, 53 | double k, 54 | double r, 55 | double t, 56 | double v, 57 | int cp, 58 | double d1 = 0.0 59 | ) -> float: 60 | """Calculate option delta""" 61 | cdef _delta, delta 62 | 63 | if v <= 0: 64 | return 0 65 | 66 | if not d1: 67 | d1 = calculate_d1(s, k, r, t, v) 68 | 69 | delta: float = cp * exp(-r * t) * cdf(cp * d1) 70 | return delta 71 | 72 | 73 | def calculate_gamma( 74 | double s, 75 | double k, 76 | double r, 77 | double t, 78 | double v, 79 | double d1 = 0.0 80 | ) -> float: 81 | """Calculate option gamma""" 82 | cdef _gamma, gamma 83 | 84 | if v <= 0 or s <= 0 or t<= 0: 85 | return 0 86 | 87 | if not d1: 88 | d1 = calculate_d1(s, k, r, t, v) 89 | 90 | gamma = exp(-r * t) * pdf(d1) / (s * v * sqrt(t)) 91 | return gamma 92 | 93 | 94 | def calculate_theta( 95 | double s, 96 | double k, 97 | double r, 98 | double t, 99 | double v, 100 | int cp, 101 | double d1 = 0.0 102 | ) -> float: 103 | """Calculate option theta""" 104 | cdef double d2, _theta, theta 105 | 106 | if v <= 0: 107 | return 0 108 | 109 | if not d1: 110 | d1 = calculate_d1(s, k, r, t, v) 111 | d2: float = d1 - v * sqrt(t) 112 | 113 | theta = -s * exp(-r * t) * pdf(d1) * v / (2 * sqrt(t)) \ 114 | - cp * r * s * exp(-r * t) * cdf(cp * d1) \ 115 | + cp * r * k * exp(-r * t) * cdf(cp * d2) 116 | return theta 117 | 118 | 119 | def calculate_vega( 120 | double s, 121 | double k, 122 | double r, 123 | double t, 124 | double v, 125 | double d1 = 0.0 126 | ) -> float: 127 | """Calculate option vega""" 128 | cdef double vega 129 | 130 | if v <= 0: 131 | return 0 132 | 133 | if not d1: 134 | d1 = calculate_d1(s, k, r, t, v) 135 | 136 | vega: float = s * exp(-r * t) * pdf(d1) * sqrt(t) 137 | 138 | return vega 139 | 140 | 141 | def calculate_greeks( 142 | double s, 143 | double k, 144 | double r, 145 | double t, 146 | double v, 147 | int cp 148 | ) -> Tuple[float, float, float, float, float]: 149 | """Calculate option price and greeks""" 150 | cdef double d1, price, delta, gamma, theta, vega 151 | 152 | d1 = calculate_d1(s, k, r, t, v) 153 | 154 | price = calculate_price(s, k, r, t, v, cp, d1) 155 | delta = calculate_delta(s, k, r, t, v, cp, d1) 156 | gamma = calculate_gamma(s, k, r, t, v, d1) 157 | theta = calculate_theta(s, k, r, t, v, cp, d1) 158 | vega = calculate_vega(s, k, r, t, v, d1) 159 | 160 | return price, delta, gamma, theta, vega 161 | 162 | 163 | def calculate_impv( 164 | double price, 165 | double s, 166 | double k, 167 | double r, 168 | double t, 169 | int cp 170 | ) -> float: 171 | """Calculate option implied volatility""" 172 | cdef bint meet 173 | cdef double v, p, vega, dx, moneyness, v_base, adjustment, v_new 174 | 175 | # Check option price must be positive 176 | if price <= 0: 177 | return 0 178 | 179 | # Check if option price meets minimum value (exercise value) 180 | meet = price > cp * (s - k) * exp(-r * t) 181 | 182 | # If minimum value not met, return 0 183 | if not meet: 184 | return 0 185 | 186 | # Calculate implied volatility with Newton's method 187 | # Smart initial guess based on moneyness 188 | if cp == 1: 189 | moneyness = s / k 190 | else: 191 | moneyness = k / s 192 | 193 | v_base = (price / s) / sqrt(t) * 2.5 194 | 195 | # Adjust based on moneyness 196 | if moneyness < 0.9: 197 | adjustment = 1 + (1 - moneyness) * 20 198 | elif moneyness > 1.15: 199 | adjustment = fmax(0.6, 1 - (moneyness - 1) * 0.2) 200 | else: 201 | adjustment = 1.0 202 | 203 | v = v_base * adjustment 204 | v = min(max(v, 0.2), 5.0) 205 | 206 | for i in range(100): 207 | # Calculate option price and vega with current guess 208 | p = calculate_price(s, k, r, t, v, cp) 209 | vega = calculate_vega(s, k, r, t, v) 210 | 211 | # Break loop if vega too close to 0 212 | if not vega or fabs(vega) < 1e-10: 213 | break 214 | 215 | # Calculate error value 216 | dx = (price - p) / vega 217 | 218 | # Check if error value meets requirement 219 | if fabs(dx) < 0.00001: 220 | break 221 | 222 | # Limit step size 223 | dx = fmax(-0.5, min(0.5, dx)) 224 | 225 | # Calculate guessed implied volatility of next round 226 | v_new = v + dx 227 | if v_new <= 0: 228 | v_new = v * 0.5 229 | v = min(v_new, 10.0) 230 | 231 | # Final check 232 | if v <= 0: 233 | return 0 234 | 235 | # Round to 4 decimal places 236 | v = round(v, 4) 237 | 238 | return v 239 | -------------------------------------------------------------------------------- /script/test_ag_option.py: -------------------------------------------------------------------------------- 1 | """隐含波动率计算测试脚本""" 2 | import sys 3 | import io 4 | from collections.abc import Callable 5 | 6 | from vnpy_optionmaster.pricing import ( 7 | binomial_tree, black_76, black_scholes, 8 | binomial_tree_cython, 9 | black_76_cython, 10 | black_scholes_cython 11 | ) 12 | 13 | 14 | # 测试数据 15 | TEST_DATA: list[dict] = [ 16 | # 深度虚值期权 17 | {"symbol": "ag2601P14700", "price": 22.0, "underlying": 16143.0, "strike": 14700, "r": 0.02, "t": 0.0167, "cp": -1, "category": "深度虚值"}, 18 | {"symbol": "ag2601P14800", "price": 27.0, "underlying": 16143.0, "strike": 14800, "r": 0.02, "t": 0.0167, "cp": -1, "category": "深度虚值"}, 19 | {"symbol": "ag2601P14900", "price": 32.5, "underlying": 16143.0, "strike": 14900, "r": 0.02, "t": 0.0167, "cp": -1, "category": "深度虚值"}, 20 | {"symbol": "ag2601P15000", "price": 41.0, "underlying": 16143.0, "strike": 15000, "r": 0.02, "t": 0.0167, "cp": -1, "category": "深度虚值"}, 21 | # 平值期权 22 | {"symbol": "ag2601P16100", "price": 350.0, "underlying": 16143.0, "strike": 16100, "r": 0.02, "t": 0.0167, "cp": -1, "category": "平值"}, 23 | {"symbol": "ag2601C16100", "price": 340.0, "underlying": 16143.0, "strike": 16100, "r": 0.02, "t": 0.0167, "cp": 1, "category": "平值"}, 24 | # 深度实值期权 25 | {"symbol": "ag2601C14000", "price": 2200.0, "underlying": 16143.0, "strike": 14000, "r": 0.02, "t": 0.0167, "cp": 1, "category": "深度实值"}, 26 | {"symbol": "ag2601C13000", "price": 3200.0, "underlying": 16143.0, "strike": 13000, "r": 0.02, "t": 0.0167, "cp": 1, "category": "深度实值"}, 27 | {"symbol": "ag2601P19000", "price": 2920.0, "underlying": 16143.0, "strike": 19000, "r": 0.02, "t": 0.0167, "cp": -1, "category": "深度实值"}, 28 | ] 29 | 30 | 31 | def verify_impv(model_name: str, calc_price_func: Callable, calc_impv_func: Callable, use_n: bool = False) -> dict: 32 | """验证隐含波动率计算结果""" 33 | results = {"success": 0, "total": len(TEST_DATA), "details": []} 34 | 35 | for data in TEST_DATA: 36 | # 计算隐含波动率 37 | if use_n: 38 | impv = calc_impv_func( 39 | price=data["price"], 40 | f=data["underlying"], 41 | k=data["strike"], 42 | r=data["r"], 43 | t=data["t"], 44 | cp=data["cp"] 45 | ) 46 | else: 47 | impv = calc_impv_func( 48 | price=data["price"], 49 | s=data["underlying"], 50 | k=data["strike"], 51 | r=data["r"], 52 | t=data["t"], 53 | cp=data["cp"] 54 | ) 55 | 56 | # 反向验证 57 | if impv > 0: 58 | if use_n: 59 | calc_price = calc_price_func( 60 | f=data["underlying"], 61 | k=data["strike"], 62 | r=data["r"], 63 | t=data["t"], 64 | v=impv, 65 | cp=data["cp"] 66 | ) 67 | else: 68 | calc_price = calc_price_func( 69 | s=data["underlying"], 70 | k=data["strike"], 71 | r=data["r"], 72 | t=data["t"], 73 | v=impv, 74 | cp=data["cp"] 75 | ) 76 | error = abs(calc_price - data["price"]) 77 | success = error < 2.0 78 | else: 79 | error = data["price"] 80 | success = False 81 | 82 | if success: 83 | results["success"] += 1 # type: ignore 84 | 85 | results["details"].append({ # type: ignore 86 | "symbol": data["symbol"], 87 | "category": data["category"], 88 | "impv": impv, 89 | "error": error, 90 | "success": success 91 | }) 92 | 93 | return results 94 | 95 | 96 | def print_results(model_name: str, results: dict) -> None: 97 | """打印测试结果""" 98 | print(f"\n{'='*70}") 99 | print(f"{model_name} 测试结果") 100 | print(f"{'='*70}") 101 | print(f"{'合约':15s} {'类别':8s} {'隐含波动率':>12s} {'误差':>10s} {'状态':>6s}") 102 | print("-" * 70) 103 | 104 | for d in results["details"]: 105 | status = "✓" if d["success"] else "✗" 106 | print(f"{d['symbol']:15s} {d['category']:8s} {d['impv']:12.4f} {d['error']:10.2f} {status:>6s}") 107 | 108 | rate = results["success"] / results["total"] * 100 109 | print("-" * 70) 110 | print(f"成功率: {results['success']}/{results['total']} ({rate:.1f}%)") 111 | 112 | 113 | def main() -> None: 114 | """主函数""" 115 | sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') 116 | 117 | print("=" * 70) 118 | print("期权定价模型隐含波动率计算测试") 119 | print("=" * 70) 120 | 121 | # 测试二叉树模型 122 | bt_results = verify_impv( 123 | "Binomial Tree", 124 | binomial_tree.calculate_price, 125 | binomial_tree.calculate_impv, 126 | use_n=True 127 | ) 128 | print_results("Binomial Tree (二叉树)", bt_results) 129 | 130 | # 测试 Black-76 模型 131 | b76_results = verify_impv( 132 | "Black-76", 133 | black_76.calculate_price, 134 | black_76.calculate_impv, 135 | use_n=False 136 | ) 137 | print_results("Black-76", b76_results) 138 | 139 | # 测试 Black-Scholes 模型 140 | bs_results = verify_impv( 141 | "Black-Scholes", 142 | black_scholes.calculate_price, 143 | black_scholes.calculate_impv, 144 | use_n=False 145 | ) 146 | print_results("Black-Scholes", bs_results) 147 | 148 | # 测试 Cython 版本 149 | print(f"\n{'='*70}") 150 | print("Cython 版本测试") 151 | print(f"{'='*70}") 152 | 153 | # 测试二叉树模型 (Cython) 154 | bt_cython_results = verify_impv( 155 | "Binomial Tree Cython", 156 | binomial_tree_cython.calculate_price, 157 | binomial_tree_cython.calculate_impv, 158 | use_n=True 159 | ) 160 | print_results("Binomial Tree Cython (二叉树)", bt_cython_results) 161 | 162 | # 测试 Black-76 模型 (Cython) 163 | b76_cython_results = verify_impv( 164 | "Black-76 Cython", 165 | black_76_cython.calculate_price, 166 | black_76_cython.calculate_impv, 167 | use_n=False 168 | ) 169 | print_results("Black-76 Cython", b76_cython_results) 170 | 171 | # 测试 Black-Scholes 模型 (Cython) 172 | bs_cython_results = verify_impv( 173 | "Black-Scholes Cython", 174 | black_scholes_cython.calculate_price, 175 | black_scholes_cython.calculate_impv, 176 | use_n=False 177 | ) 178 | print_results("Black-Scholes Cython", bs_cython_results) 179 | 180 | # 汇总 181 | print(f"\n{'='*70}") 182 | print("汇总统计") 183 | print(f"{'='*70}") 184 | print(f"{'模型':25s} {'成功率':>15s}") 185 | print("-" * 45) 186 | for name, results in [ 187 | ("Binomial Tree", bt_results), 188 | ("Black-76", b76_results), 189 | ("Black-Scholes", bs_results), 190 | ("Binomial Tree Cython", bt_cython_results), 191 | ("Black-76 Cython", b76_cython_results), 192 | ("Black-Scholes Cython", bs_cython_results), 193 | ]: 194 | rate = results["success"] / results["total"] * 100 195 | print(f"{name:25s} {results['success']}/{results['total']} ({rate:5.1f}%)") 196 | 197 | 198 | if __name__ == "__main__": 199 | main() 200 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/binomial_tree.py: -------------------------------------------------------------------------------- 1 | from numpy import zeros, ndarray 2 | from math import exp, sqrt 3 | 4 | 5 | DEFAULT_STEP = 15 6 | 7 | 8 | def generate_tree( 9 | f: float, 10 | k: float, 11 | r: float, 12 | t: float, 13 | v: float, 14 | cp: int, 15 | n: int 16 | ) -> tuple[ndarray, ndarray]: 17 | """Generate binomial tree for pricing American option.""" 18 | dt: float = t / n 19 | u: float = exp(v * sqrt(dt)) 20 | d: float = 1 / u 21 | a: float = 1 22 | tree_size: int = n + 1 23 | 24 | underlying_tree: ndarray = zeros((tree_size, tree_size)) 25 | option_tree: ndarray = zeros((tree_size, tree_size)) 26 | 27 | # Calculate risk neutral probability 28 | p: float = (a - d) / (u - d) 29 | p1: float = p / a 30 | p2: float = (1 - p) / a 31 | discount: float = exp(-r * dt) 32 | 33 | # Calculate underlying price tree 34 | underlying_tree[0, 0] = f 35 | 36 | for i in range(1, n + 1): 37 | underlying_tree[0, i] = underlying_tree[0, i - 1] * u 38 | for j in range(1, n + 1): 39 | underlying_tree[j, i] = underlying_tree[j - 1, i - 1] * d 40 | 41 | # Calculate option price tree 42 | for j in range(n + 1): 43 | option_tree[j, n] = max(0, cp * (underlying_tree[j, n] - k)) 44 | 45 | for i in range(n - 1, -1, -1): 46 | for j in range(i + 1): 47 | option_tree[j, i] = max( 48 | (p1 * option_tree[j, i + 1] + p2 * option_tree[j + 1, i + 1]) * discount, 49 | cp * (underlying_tree[j, i] - k), 50 | 0 51 | ) 52 | 53 | # Return both trees 54 | return option_tree, underlying_tree 55 | 56 | 57 | def calculate_price( 58 | f: float, 59 | k: float, 60 | r: float, 61 | t: float, 62 | v: float, 63 | cp: int, 64 | n: int = DEFAULT_STEP 65 | ) -> float: 66 | """Calculate option price""" 67 | option_tree, _ = generate_tree(f, k, r, t, v, cp, n) 68 | return option_tree[0, 0] # type: ignore 69 | 70 | 71 | def calculate_delta( 72 | f: float, 73 | k: float, 74 | r: float, 75 | t: float, 76 | v: float, 77 | cp: int, 78 | n: int = DEFAULT_STEP 79 | ) -> float: 80 | """Calculate option delta""" 81 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 82 | 83 | option_price_change: float = option_tree[0, 1] - option_tree[1, 1] 84 | underlying_price_change: float = underlying_tree[0, 1] - underlying_tree[1, 1] 85 | 86 | delta: float = option_price_change / underlying_price_change 87 | return delta 88 | 89 | 90 | def calculate_gamma( 91 | f: float, 92 | k: float, 93 | r: float, 94 | t: float, 95 | v: float, 96 | cp: int, 97 | n: int = DEFAULT_STEP 98 | ) -> float: 99 | """Calculate option gamma""" 100 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 101 | 102 | gamma_delta_1: float = (option_tree[0, 2] - option_tree[1, 2]) / \ 103 | (underlying_tree[0, 2] - underlying_tree[1, 2]) 104 | gamma_delta_2: float = (option_tree[1, 2] - option_tree[2, 2]) / \ 105 | (underlying_tree[1, 2] - underlying_tree[2, 2]) 106 | 107 | gamma: float = (gamma_delta_1 - gamma_delta_2) / \ 108 | (0.5 * (underlying_tree[0, 2] - underlying_tree[2, 2])) 109 | return gamma 110 | 111 | 112 | def calculate_theta( 113 | f: float, 114 | k: float, 115 | r: float, 116 | t: float, 117 | v: float, 118 | cp: int, 119 | n: int = DEFAULT_STEP 120 | ) -> float: 121 | """Calcualte option theta""" 122 | option_tree, _ = generate_tree(f, k, r, t, v, cp, n) 123 | 124 | dt: float = t / n 125 | 126 | theta: float = (option_tree[1, 2] - option_tree[0, 0]) / (2 * dt) 127 | return theta 128 | 129 | 130 | def calculate_vega( 131 | f: float, 132 | k: float, 133 | r: float, 134 | t: float, 135 | v: float, 136 | cp: int, 137 | n: int = DEFAULT_STEP 138 | ) -> float: 139 | """Calculate option vega""" 140 | price_1: float = calculate_price(f, k, r, t, v, cp, n) 141 | price_2: float = calculate_price(f, k, r, t, v * 1.001, cp, n) 142 | vega: float = (price_2 - price_1) / (v * 0.001) 143 | return vega 144 | 145 | 146 | def calculate_greeks( 147 | f: float, 148 | k: float, 149 | r: float, 150 | t: float, 151 | v: float, 152 | cp: int, 153 | n: int = DEFAULT_STEP 154 | ) -> tuple[float, float, float, float, float]: 155 | """Calculate option price and greeks""" 156 | dt: float = t / n 157 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 158 | option_tree_vega, _ = generate_tree(f, k, r, t, v * 1.001, cp, n) 159 | 160 | # Price 161 | price: float = option_tree[0, 0] 162 | 163 | # Delta 164 | option_price_change: float = option_tree[0, 1] - option_tree[1, 1] 165 | underlying_price_change: float = underlying_tree[0, 1] - underlying_tree[1, 1] 166 | delta: float = option_price_change / underlying_price_change 167 | 168 | # Gamma 169 | gamma_delta_1: float = (option_tree[0, 2] - option_tree[1, 2]) / \ 170 | (underlying_tree[0, 2] - underlying_tree[1, 2]) 171 | gamma_delta_2: float = (option_tree[1, 2] - option_tree[2, 2]) / \ 172 | (underlying_tree[1, 2] - underlying_tree[2, 2]) 173 | gamma: float = (gamma_delta_1 - gamma_delta_2) / \ 174 | (0.5 * (underlying_tree[0, 2] - underlying_tree[2, 2])) 175 | 176 | # Theta 177 | theta: float = (option_tree[1, 2] - option_tree[0, 0]) / (2 * dt) 178 | 179 | # Vega 180 | vega: float = (option_tree_vega[0, 0] - option_tree[0, 0]) / (0.001 * v) 181 | 182 | return price, delta, gamma, theta, vega 183 | 184 | 185 | def calculate_impv( 186 | price: float, 187 | f: float, 188 | k: float, 189 | r: float, 190 | t: float, 191 | cp: int, 192 | n: int = DEFAULT_STEP 193 | ) -> float: 194 | """Calculate option implied volatility""" 195 | # Check option price must be positive 196 | if price <= 0: 197 | return 0 198 | 199 | # Check if option price meets minimum value (exercise value) 200 | meet: bool = False 201 | 202 | if cp == 1 and price > (f - k): 203 | meet = True 204 | elif cp == -1 and price > (k - f): 205 | meet = True 206 | 207 | # If minimum value not met, return 0 208 | if not meet: 209 | return 0 210 | 211 | # Calculate implied volatility with Newton's method 212 | # Smart initial guess based on moneyness 213 | if cp == 1: 214 | moneyness: float = f / k 215 | else: 216 | moneyness = k / f 217 | 218 | v_base: float = (price / f) / sqrt(t) * 2.5 219 | 220 | # Adjust based on moneyness 221 | if moneyness < 0.9: 222 | adjustment: float = (1 + (1 - moneyness) * 20) 223 | elif moneyness > 1.15: 224 | adjustment = max(0.6, 1 - (moneyness - 1) * 0.2) 225 | else: 226 | adjustment = 1.0 227 | 228 | v: float = v_base * adjustment 229 | v = min(max(v, 0.2), 5.0) 230 | 231 | for _i in range(100): 232 | # Calculate option price and vega with current guess 233 | p: float = calculate_price(f, k, r, t, v, cp, n) 234 | vega: float = calculate_vega(f, k, r, t, v, cp, n) 235 | 236 | # Break loop if vega too close to 0 237 | if not vega or abs(vega) < 1e-10: 238 | break 239 | 240 | # Calculate error value 241 | dx: float = (price - p) / vega 242 | 243 | # Check if error value meets requirement 244 | if abs(dx) < 0.00001: 245 | break 246 | 247 | # Limit step size 248 | dx = max(-0.5, min(0.5, dx)) 249 | 250 | # Calculate guessed implied volatility of next round 251 | v_new: float = v + dx 252 | if v_new <= 0: 253 | v_new = v * 0.5 254 | v = min(v_new, 10.0) 255 | 256 | # Final check 257 | if v <= 0: 258 | return 0 259 | 260 | # Round to 4 decimal places 261 | v = round(v, 4) 262 | 263 | return v 264 | -------------------------------------------------------------------------------- /vnpy_optionmaster/pricing/cython_model/binomial_tree_cython/binomial_tree_cython.pyx: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | import numpy as np 3 | 4 | cimport numpy as np 5 | cimport cython 6 | 7 | cdef extern from "math.h" nogil: 8 | double exp(double) 9 | double sqrt(double) 10 | double pow(double, double) 11 | double fmax(double, double) 12 | double fabs(double) 13 | 14 | 15 | DEFAULT_STEP = 15 16 | 17 | 18 | cdef tuple generate_tree( 19 | double f, 20 | double k, 21 | double r, 22 | double t, 23 | double v, 24 | int cp, 25 | int n 26 | ): 27 | """Generate binomial tree for pricing American option.""" 28 | cdef double dt = t / n 29 | cdef double u = exp(v * sqrt(dt)) 30 | cdef double d = 1 / u 31 | cdef double a = 1 32 | 33 | cdef int tree_size = n + 1 34 | cdef np.ndarray[np.double_t, ndim = 2] underlying_tree = np.zeros((tree_size, tree_size)) 35 | cdef np.ndarray[np.double_t, ndim = 2] option_tree = np.zeros((tree_size, tree_size)) 36 | 37 | cdef int i, j 38 | 39 | # Calculate risk neutral probability 40 | cdef double p = (a - d) / (u - d) 41 | cdef double p1 = p / a 42 | cdef double p2 = (1 - p) / a 43 | cdef double discount = exp(-r * dt) 44 | 45 | # Calculate underlying price tree 46 | underlying_tree[0, 0] = f 47 | 48 | for i in range(1, n + 1): 49 | underlying_tree[0, i] = underlying_tree[0, i - 1] * u 50 | for j in range(1, n + 1): 51 | underlying_tree[j, i] = underlying_tree[j - 1, i - 1] * d 52 | 53 | # Calculate option price tree 54 | for j in range(n + 1): 55 | option_tree[j, n] = max(0, cp * (underlying_tree[j, n] - k)) 56 | 57 | for i in range(n - 1, -1, -1): 58 | for j in range(i + 1): 59 | option_tree[j, i] = max( 60 | (p1 * option_tree[j, i + 1] + p2 * option_tree[j + 1, i + 1]) * discount, 61 | cp * (underlying_tree[j, i] - k), 62 | 0 63 | ) 64 | 65 | # Return both trees 66 | return option_tree, underlying_tree 67 | 68 | 69 | def calculate_price( 70 | double f, 71 | double k, 72 | double r, 73 | double t, 74 | double v, 75 | int cp, 76 | int n = DEFAULT_STEP 77 | ) -> float: 78 | """Calculate option price""" 79 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 80 | return option_tree[0, 0] 81 | 82 | 83 | def calculate_delta( 84 | double f, 85 | double k, 86 | double r, 87 | double t, 88 | double v, 89 | int cp, 90 | int n = DEFAULT_STEP 91 | ) -> float: 92 | """Calculate option delta""" 93 | cdef double option_price_change, underlying_price_change 94 | cdef _delta, delta 95 | 96 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 97 | 98 | option_price_change = option_tree[0, 1] - option_tree[1, 1] 99 | underlying_price_change = underlying_tree[0, 1] - underlying_tree[1, 1] 100 | 101 | delta = option_price_change / underlying_price_change 102 | return delta 103 | 104 | def calculate_gamma( 105 | double f, 106 | double k, 107 | double r, 108 | double t, 109 | double v, 110 | int cp, 111 | int n = DEFAULT_STEP 112 | ) -> float: 113 | """Calculate option gamma""" 114 | cdef double gamma_delta_1, gamma_delta_2 115 | cdef double _gamma, gamma 116 | 117 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 118 | 119 | gamma_delta_1 = (option_tree[0, 2] - option_tree[1, 2]) / \ 120 | (underlying_tree[0, 2] - underlying_tree[1, 2]) 121 | gamma_delta_2 = (option_tree[1, 2] - option_tree[2, 2]) / \ 122 | (underlying_tree[1, 2] - underlying_tree[2, 2]) 123 | 124 | gamma = (gamma_delta_1 - gamma_delta_2) / \ 125 | (0.5 * (underlying_tree[0, 2] - underlying_tree[2, 2])) 126 | return gamma 127 | 128 | 129 | def calculate_theta( 130 | double f, 131 | double k, 132 | double r, 133 | double t, 134 | double v, 135 | int cp, 136 | int n = DEFAULT_STEP 137 | ) -> float: 138 | """Calcualte option theta""" 139 | cdef double dt, theta 140 | 141 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 142 | 143 | dt = t / n 144 | 145 | theta = (option_tree[1, 2] - option_tree[0, 0]) / (2 * dt) 146 | return theta 147 | 148 | 149 | def calculate_vega( 150 | double f, 151 | double k, 152 | double r, 153 | double t, 154 | double v, 155 | int cp, 156 | int n = DEFAULT_STEP 157 | ) -> float: 158 | """Calculate option vega""" 159 | cdef double price_1 = calculate_price(f, k, r, t, v, cp, n) 160 | cdef double price_2 = calculate_price(f, k, r, t, v * 1.001, cp, n) 161 | cdef double vega = (price_2 - price_1) / (v * 0.001) 162 | return vega 163 | 164 | 165 | def calculate_greeks( 166 | double f, 167 | double k, 168 | double r, 169 | double t, 170 | double v, 171 | int cp, 172 | int n = DEFAULT_STEP 173 | ) -> Tuple[float, float, float, float, float]: 174 | """Calculate option price and greeks""" 175 | cdef double dt = t / n 176 | cdef double price, delta, gamma, vega, theta 177 | cdef double _delta, _gamma 178 | cdef double option_price_change, underlying_price_change 179 | cdef double gamma_delta_1, gamma_delta_2 180 | 181 | option_tree, underlying_tree = generate_tree(f, k, r, t, v, cp, n) 182 | option_tree_vega, underlying_tree_vega = generate_tree(f, k, r, t, v * 1.001, cp, n) 183 | 184 | # Price 185 | price = option_tree[0, 0] 186 | 187 | # Delta 188 | option_price_change = option_tree[0, 1] - option_tree[1, 1] 189 | underlying_price_change = underlying_tree[0, 1] - underlying_tree[1, 1] 190 | 191 | delta = option_price_change / underlying_price_change 192 | 193 | # Gamma 194 | gamma_delta_1 = (option_tree[0, 2] - option_tree[1, 2]) / \ 195 | (underlying_tree[0, 2] - underlying_tree[1, 2]) 196 | gamma_delta_2 = (option_tree[1, 2] - option_tree[2, 2]) / \ 197 | (underlying_tree[1, 2] - underlying_tree[2, 2]) 198 | 199 | gamma = (gamma_delta_1 - gamma_delta_2) / \ 200 | (0.5 * (underlying_tree[0, 2] - underlying_tree[2, 2])) 201 | 202 | # Theta 203 | theta = (option_tree[1, 2] - option_tree[0, 0]) / (2 * dt) 204 | 205 | # Vega 206 | vega = (option_tree_vega[0, 0] - option_tree[0, 0]) / (0.001 * v) 207 | 208 | return price, delta, gamma, theta, vega 209 | 210 | 211 | def calculate_impv( 212 | double price, 213 | double f, 214 | double k, 215 | double r, 216 | double t, 217 | int cp, 218 | int n = DEFAULT_STEP 219 | ) -> float: 220 | """Calculate option implied volatility""" 221 | cdef double p, v, dx, vega, moneyness, v_base, adjustment, v_new 222 | cdef bint meet 223 | 224 | # Check option price must be positive 225 | if price <= 0: 226 | return 0 227 | 228 | # Check if option price meets minimum value (exercise value) 229 | meet = False 230 | 231 | if cp == 1 and price > (f - k): 232 | meet = True 233 | elif cp == -1 and price > (k - f): 234 | meet = True 235 | 236 | # If minimum value not met, return 0 237 | if not meet: 238 | return 0 239 | 240 | # Calculate implied volatility with Newton's method 241 | # Smart initial guess based on moneyness 242 | if cp == 1: 243 | moneyness = f / k 244 | else: 245 | moneyness = k / f 246 | 247 | v_base = (price / f) / sqrt(t) * 2.5 248 | 249 | # Adjust based on moneyness 250 | if moneyness < 0.9: 251 | adjustment = 1 + (1 - moneyness) * 20 252 | elif moneyness > 1.15: 253 | adjustment = fmax(0.6, 1 - (moneyness - 1) * 0.2) 254 | else: 255 | adjustment = 1.0 256 | 257 | v = v_base * adjustment 258 | v = min(max(v, 0.2), 5.0) 259 | 260 | for i in range(100): 261 | # Calculate option price and vega with current guess 262 | p = calculate_price(f, k, r, t, v, cp, n) 263 | vega = calculate_vega(f, k, r, t, v, cp, n) 264 | 265 | # Break loop if vega too close to 0 266 | if not vega or fabs(vega) < 1e-10: 267 | break 268 | 269 | # Calculate error value 270 | dx = (price - p) / vega 271 | 272 | # Check if error value meets requirement 273 | if fabs(dx) < 0.00001: 274 | break 275 | 276 | # Limit step size 277 | dx = fmax(-0.5, min(0.5, dx)) 278 | 279 | # Calculate guessed implied volatility of next round 280 | v_new = v + dx 281 | if v_new <= 0: 282 | v_new = v * 0.5 283 | v = min(v_new, 10.0) 284 | 285 | # Final check 286 | if v <= 0: 287 | return 0 288 | 289 | # Round to 4 decimal places 290 | v = round(v, 4) 291 | 292 | return v 293 | -------------------------------------------------------------------------------- /vnpy_optionmaster/algo.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from vnpy.trader.object import TickData, OrderData, TradeData 4 | from vnpy.trader.constant import Direction, Offset 5 | from vnpy.trader.utility import round_to 6 | 7 | from .base import OptionData, UnderlyingData 8 | 9 | if TYPE_CHECKING: 10 | from .engine import OptionAlgoEngine 11 | 12 | 13 | class ElectronicEyeAlgo: 14 | 15 | def __init__( 16 | self, 17 | algo_engine: "OptionAlgoEngine", 18 | option: OptionData 19 | ) -> None: 20 | """""" 21 | self.algo_engine: OptionAlgoEngine = algo_engine 22 | self.option: OptionData = option 23 | self.underlying: UnderlyingData = option.underlying 24 | self.pricetick: float = option.pricetick 25 | self.vt_symbol: str = option.vt_symbol 26 | 27 | # Parameters 28 | self.pricing_active: bool = False 29 | self.trading_active: bool = False 30 | 31 | self.price_spread: float = 0.0 32 | self.volatility_spread: float = 0.0 33 | 34 | self.long_allowed: bool = False 35 | self.short_allowed: bool = False 36 | 37 | self.max_pos: int = 0 38 | self.target_pos: int = 0 39 | self.max_order_size: int = 0 40 | 41 | # Variables 42 | self.long_active_orderids: set[str] = set() 43 | self.short_active_orderids: set[str] = set() 44 | 45 | self.algo_spread: float = 0.0 46 | self.ref_price: float = 0.0 47 | self.algo_bid_price: float = 0.0 48 | self.algo_ask_price: float = 0.0 49 | self.pricing_impv: float = 0.0 50 | 51 | def start_pricing(self, params: dict) -> bool: 52 | """""" 53 | if self.pricing_active: 54 | return False 55 | 56 | self.price_spread = params["price_spread"] 57 | self.volatility_spread = params["volatility_spread"] 58 | 59 | self.pricing_active = True 60 | self.put_status_event() 61 | self.calculate_price() 62 | self.write_log("启动定价") 63 | 64 | return True 65 | 66 | def stop_pricing(self) -> bool: 67 | """""" 68 | if not self.pricing_active: 69 | return False 70 | 71 | if self.trading_active: 72 | return False 73 | 74 | self.pricing_active = False 75 | 76 | # Clear parameters 77 | self.algo_spread = 0.0 78 | self.ref_price = 0.0 79 | self.algo_bid_price = 0.0 80 | self.algo_ask_price = 0.0 81 | self.pricing_impv = 0.0 82 | 83 | self.put_status_event() 84 | self.put_pricing_event() 85 | self.write_log("停止定价") 86 | 87 | return True 88 | 89 | def start_trading(self, params: dict) -> bool: 90 | """""" 91 | if self.trading_active: 92 | return False 93 | 94 | if not self.pricing_active: 95 | self.write_log("请先启动定价") 96 | return False 97 | 98 | self.long_allowed = params["long_allowed"] 99 | self.short_allowed = params["short_allowed"] 100 | self.max_pos = params["max_pos"] 101 | self.target_pos = params["target_pos"] 102 | self.max_order_size = params["max_order_size"] 103 | 104 | if not self.max_order_size: 105 | self.write_log("请先设置最大委托数量") 106 | return False 107 | 108 | self.trading_active = True 109 | 110 | self.put_trading_event() 111 | self.put_status_event() 112 | self.write_log("启动交易") 113 | 114 | return True 115 | 116 | def stop_trading(self) -> bool: 117 | """""" 118 | if not self.trading_active: 119 | return False 120 | 121 | self.trading_active = False 122 | 123 | self.cancel_long() 124 | self.cancel_short() 125 | 126 | self.put_status_event() 127 | self.put_trading_event() 128 | self.write_log("停止交易") 129 | 130 | return True 131 | 132 | def on_underlying_tick(self, tick: TickData) -> None: 133 | """""" 134 | if self.pricing_active: 135 | self.calculate_price() 136 | 137 | if self.trading_active: 138 | self.do_trading() 139 | 140 | def on_option_tick(self, tick: TickData) -> None: 141 | """""" 142 | if self.trading_active: 143 | self.do_trading() 144 | 145 | def on_order(self, order: OrderData) -> None: 146 | """""" 147 | if not order.is_active(): 148 | if order.vt_orderid in self.long_active_orderids: 149 | self.long_active_orderids.remove(order.vt_orderid) 150 | elif order.vt_orderid in self.short_active_orderids: 151 | self.short_active_orderids.remove(order.vt_orderid) 152 | 153 | def on_trade(self, trade: TradeData) -> None: 154 | """""" 155 | msg: str = ( 156 | f"委托成交,{trade.direction} {trade.offset} {trade.volume}@{trade.price}," 157 | f"委托号[{trade.vt_orderid},成交号[{trade.vt_tradeid}]" 158 | ) 159 | self.write_log(msg) 160 | 161 | def on_timer(self) -> None: 162 | """""" 163 | if self.long_active_orderids: 164 | self.cancel_long() 165 | 166 | if self.short_active_orderids: 167 | self.cancel_short() 168 | 169 | def send_order( 170 | self, 171 | direction: Direction, 172 | offset: Offset, 173 | price: float, 174 | volume: int 175 | ) -> str: 176 | """""" 177 | vt_orderid: str = self.algo_engine.send_order( 178 | self, 179 | self.vt_symbol, 180 | direction, 181 | offset, 182 | price, 183 | volume 184 | ) 185 | 186 | self.write_log(f"发出委托,{direction} {offset} {volume}@{price} [{vt_orderid}]") 187 | 188 | return vt_orderid 189 | 190 | def buy(self, price: float, volume: int) -> None: 191 | """""" 192 | vt_orderid: str = self.send_order(Direction.LONG, Offset.OPEN, price, volume) 193 | self.long_active_orderids.add(vt_orderid) 194 | 195 | def sell(self, price: float, volume: int) -> None: 196 | """""" 197 | vt_orderid: str = self.send_order(Direction.SHORT, Offset.CLOSE, price, volume) 198 | self.short_active_orderids.add(vt_orderid) 199 | 200 | def short(self, price: float, volume: int) -> None: 201 | """""" 202 | vt_orderid: str = self.send_order(Direction.SHORT, Offset.OPEN, price, volume) 203 | self.short_active_orderids.add(vt_orderid) 204 | 205 | def cover(self, price: float, volume: int) -> None: 206 | """""" 207 | vt_orderid: str = self.send_order(Direction.LONG, Offset.CLOSE, price, volume) 208 | self.long_active_orderids.add(vt_orderid) 209 | 210 | def send_long(self, price: float, volume: int) -> None: 211 | """""" 212 | option: OptionData = self.option 213 | 214 | if not option.short_pos: 215 | self.buy(price, volume) 216 | elif option.short_pos >= volume: 217 | self.cover(price, volume) 218 | else: 219 | self.cover(price, option.short_pos) 220 | self.buy(price, volume - option.short_pos) 221 | 222 | def send_short(self, price: float, volume: int) -> None: 223 | """""" 224 | option: OptionData = self.option 225 | 226 | if not option.long_pos: 227 | self.short(price, volume) 228 | elif option.long_pos >= volume: 229 | self.sell(price, volume) 230 | else: 231 | self.sell(price, option.long_pos) 232 | self.short(price, volume - option.long_pos) 233 | 234 | def cancel_order(self, vt_orderid: str) -> None: 235 | """""" 236 | self.write_log(f"委托撤单:[{vt_orderid}]") 237 | self.algo_engine.cancel_order(vt_orderid) 238 | 239 | def cancel_long(self) -> None: 240 | """""" 241 | for vt_orderid in self.long_active_orderids: 242 | self.cancel_order(vt_orderid) 243 | 244 | def cancel_short(self) -> None: 245 | """""" 246 | for vt_orderid in self.short_active_orderids: 247 | self.cancel_order(vt_orderid) 248 | 249 | def check_long_finished(self) -> bool: 250 | """""" 251 | if not self.long_active_orderids: 252 | return True 253 | 254 | return False 255 | 256 | def check_short_finished(self) -> bool: 257 | """""" 258 | if not self.short_active_orderids: 259 | return True 260 | 261 | return False 262 | 263 | def calculate_price(self) -> None: 264 | """""" 265 | option: OptionData = self.option 266 | 267 | # Get ref price 268 | self.pricing_impv = option.pricing_impv 269 | ref_price: float = option.calculate_ref_price() 270 | self.ref_price = round_to(ref_price, self.pricetick) 271 | 272 | # Calculate spread 273 | algo_spread: float = max( 274 | self.price_spread, 275 | self.volatility_spread * option.theo_vega / option.size 276 | ) 277 | half_spread: float = algo_spread / 2 278 | 279 | # Calculate bid/ask 280 | self.algo_bid_price = round_to(ref_price - half_spread, self.pricetick) 281 | self.algo_ask_price = round_to(ref_price + half_spread, self.pricetick) 282 | self.algo_spread = round_to(algo_spread, self.pricetick) 283 | 284 | self.put_pricing_event() 285 | 286 | def do_trading(self) -> None: 287 | """""" 288 | if self.long_allowed and self.check_long_finished(): 289 | self.snipe_long() 290 | 291 | if self.short_allowed and self.check_short_finished(): 292 | self.snipe_short() 293 | 294 | def snipe_long(self) -> None: 295 | """""" 296 | option: OptionData = self.option 297 | tick: TickData | None = option.tick 298 | if not tick: 299 | return 300 | 301 | # Calculate volume left to trade 302 | pos_up_limit: int = self.target_pos + self.max_pos 303 | volume_left: int = pos_up_limit - option.net_pos 304 | 305 | # Check price 306 | if volume_left > 0 and tick.ask_price_1 <= self.algo_bid_price: 307 | volume = min( 308 | volume_left, 309 | tick.ask_volume_1, 310 | self.max_order_size 311 | ) 312 | 313 | self.send_long(self.algo_bid_price, volume) # type: ignore 314 | 315 | def snipe_short(self) -> None: 316 | """""" 317 | option: OptionData = self.option 318 | tick: TickData | None = option.tick 319 | if not tick: 320 | return 321 | 322 | # Calculate volume left to trade 323 | pos_down_limit: int = self.target_pos - self.max_pos 324 | volume_left: int = option.net_pos - pos_down_limit 325 | 326 | # Check price 327 | if volume_left > 0 and tick.bid_price_1 >= self.algo_ask_price: 328 | volume = min( 329 | volume_left, 330 | tick.bid_volume_1, 331 | self.max_order_size 332 | ) 333 | 334 | self.send_short(self.algo_ask_price, volume) # type: ignore 335 | 336 | def put_pricing_event(self) -> None: 337 | """""" 338 | self.algo_engine.put_algo_pricing_event(self) 339 | 340 | def put_trading_event(self) -> None: 341 | """""" 342 | self.algo_engine.put_algo_trading_event(self) 343 | 344 | def put_status_event(self) -> None: 345 | """""" 346 | self.algo_engine.put_algo_status_event(self) 347 | 348 | def write_log(self, msg: str) -> None: 349 | """""" 350 | self.algo_engine.write_algo_log(self, msg) 351 | -------------------------------------------------------------------------------- /vnpy_optionmaster/ui/chart.py: -------------------------------------------------------------------------------- 1 | import pyqtgraph as pg 2 | from typing import cast 3 | 4 | from vnpy.trader.ui import QtWidgets, QtCore, QtGui 5 | from vnpy.trader.event import EVENT_TIMER 6 | 7 | from ..base import PortfolioData, OptionData 8 | from ..engine import OptionEngine, Event, EventEngine 9 | from ..time import ANNUAL_DAYS 10 | 11 | import numpy as np 12 | import matplotlib 13 | matplotlib.use('Qt5Agg') # noqa 14 | import matplotlib.pyplot as plt # noqa 15 | from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas # noqa 16 | from matplotlib.figure import Figure # noqa 17 | from mpl_toolkits.mplot3d import Axes3D # noqa 18 | from pylab import mpl # noqa 19 | 20 | plt.style.use("dark_background") 21 | mpl.rcParams['font.sans-serif'] = ['Microsoft YaHei'] # set font for Chinese 22 | mpl.rcParams['axes.unicode_minus'] = False 23 | 24 | 25 | class OptionVolatilityChart(QtWidgets.QWidget): 26 | 27 | signal_timer: QtCore.Signal = QtCore.Signal(Event) 28 | 29 | def __init__(self, option_engine: OptionEngine, portfolio_name: str) -> None: 30 | """""" 31 | super().__init__() 32 | 33 | self.option_engine: OptionEngine = option_engine 34 | self.event_engine: EventEngine = option_engine.event_engine 35 | self.portfolio_name: str = portfolio_name 36 | 37 | self.timer_count: int = 0 38 | self.timer_trigger: int = 3 39 | 40 | self.chain_checks: dict[str, QtWidgets.QCheckBox] = {} 41 | self.put_curves: dict[str, pg.PlotCurveItem] = {} 42 | self.call_curves: dict[str, pg.PlotCurveItem] = {} 43 | self.pricing_curves: dict[str, pg.PlotCurveItem] = {} 44 | 45 | self.colors: list = [ 46 | (255, 0, 0), 47 | (255, 255, 0), 48 | (0, 255, 0), 49 | (0, 0, 255), 50 | (0, 128, 0), 51 | (19, 234, 201), 52 | (195, 46, 212), 53 | (250, 194, 5), 54 | (0, 114, 189), 55 | ] 56 | 57 | self.init_ui() 58 | self.register_event() 59 | 60 | def init_ui(self) -> None: 61 | """""" 62 | self.setWindowTitle("波动率曲线") 63 | 64 | # Create checkbox for each chain 65 | hbox: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout() 66 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 67 | 68 | chain_symbols: list = list(portfolio.chains.keys()) 69 | chain_symbols.sort() 70 | 71 | hbox.addStretch() 72 | 73 | for chain_symbol in chain_symbols: 74 | chain_check: QtWidgets.QCheckBox = QtWidgets.QCheckBox() 75 | chain_check.setText(chain_symbol.split(".")[0]) 76 | chain_check.setChecked(True) 77 | chain_check.stateChanged.connect(self.update_curve_visible) 78 | 79 | hbox.addWidget(chain_check) 80 | self.chain_checks[chain_symbol] = chain_check 81 | 82 | hbox.addStretch() 83 | 84 | # Create graphics window 85 | pg.setConfigOptions(antialias=True) 86 | 87 | graphics_window: pg.GraphicsLayoutWidget = pg.GraphicsLayoutWidget() 88 | self.impv_chart = graphics_window.addPlot(title="隐含波动率曲线") 89 | self.impv_chart.showGrid(x=True, y=True) 90 | self.impv_chart.setLabel("left", "波动率") 91 | self.impv_chart.setLabel("bottom", "行权价") 92 | self.impv_chart.addLegend() 93 | self.impv_chart.setMenuEnabled(False) 94 | self.impv_chart.setMouseEnabled(False, False) 95 | 96 | for chain_symbol in chain_symbols: 97 | self.add_impv_curve(chain_symbol) 98 | 99 | # Set Layout 100 | vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout() 101 | vbox.addLayout(hbox) 102 | vbox.addWidget(graphics_window) 103 | self.setLayout(vbox) 104 | 105 | def register_event(self) -> None: 106 | """""" 107 | self.signal_timer.connect(self.process_timer_event) 108 | 109 | self.event_engine.register(EVENT_TIMER, self.signal_timer.emit) 110 | 111 | def process_timer_event(self, event: Event) -> None: 112 | """""" 113 | self.timer_count += 1 114 | if self.timer_count < self.timer_trigger: 115 | return 116 | self.timer_trigger = 0 117 | 118 | self.update_curve_data() 119 | 120 | def add_impv_curve(self, chain_symbol: str) -> None: 121 | """""" 122 | symbol_size: int = 14 123 | symbol: str = chain_symbol.split(".")[0] 124 | color: tuple = self.colors.pop(0) 125 | pen: QtGui.QPen = pg.mkPen(color, width=2) 126 | 127 | self.call_curves[chain_symbol] = self.impv_chart.plot( 128 | symbolSize=symbol_size, 129 | symbol="t1", 130 | name=symbol + " 看涨", 131 | pen=pen, 132 | symbolBrush=color 133 | ) 134 | self.put_curves[chain_symbol] = self.impv_chart.plot( 135 | symbolSize=symbol_size, 136 | symbol="t", 137 | name=symbol + " 看跌", 138 | pen=pen, 139 | symbolBrush=color 140 | ) 141 | self.pricing_curves[chain_symbol] = self.impv_chart.plot( 142 | symbolSize=symbol_size, 143 | symbol="o", 144 | name=symbol + " 定价", 145 | pen=pen, 146 | symbolBrush=color 147 | ) 148 | 149 | def update_curve_data(self) -> None: 150 | """""" 151 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 152 | 153 | for chain in portfolio.chains.values(): 154 | call_impv: list = [] 155 | put_impv: list = [] 156 | pricing_impv: list = [] 157 | strike_prices: list = [] 158 | 159 | for index in chain.indexes: 160 | call: OptionData = chain.calls[index] 161 | call_impv.append(call.mid_impv * 100) 162 | pricing_impv.append(call.pricing_impv * 100) 163 | strike_prices.append(call.strike_price) 164 | 165 | put: OptionData = chain.puts[index] 166 | put_impv.append(put.mid_impv * 100) 167 | 168 | self.call_curves[chain.chain_symbol].setData( 169 | y=call_impv, 170 | x=strike_prices 171 | ) 172 | self.put_curves[chain.chain_symbol].setData( 173 | y=put_impv, 174 | x=strike_prices 175 | ) 176 | self.pricing_curves[chain.chain_symbol].setData( 177 | y=pricing_impv, 178 | x=strike_prices 179 | ) 180 | 181 | def update_curve_visible(self) -> None: 182 | """""" 183 | self.impv_chart.clear() 184 | 185 | for chain_symbol, checkbox in self.chain_checks.items(): 186 | if checkbox.isChecked(): 187 | call_curve: pg.PlotCurveItem = self.call_curves[chain_symbol] 188 | put_curve: pg.PlotCurveItem = self.put_curves[chain_symbol] 189 | pricing_curve: pg.PlotCurveItem = self.pricing_curves[chain_symbol] 190 | 191 | self.impv_chart.addItem(call_curve) 192 | self.impv_chart.addItem(put_curve) 193 | self.impv_chart.addItem(pricing_curve) 194 | 195 | 196 | class ScenarioAnalysisChart(QtWidgets.QWidget): 197 | """""" 198 | 199 | def __init__(self, option_engine: OptionEngine, portfolio_name: str) -> None: 200 | """""" 201 | super().__init__() 202 | 203 | self.option_engine: OptionEngine = option_engine 204 | self.portfolio_name: str = portfolio_name 205 | 206 | self.init_ui() 207 | 208 | def init_ui(self) -> None: 209 | """""" 210 | self.setWindowTitle("情景分析") 211 | 212 | # Create widgets 213 | self.price_change_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 214 | self.price_change_spin.setSuffix("%") 215 | self.price_change_spin.setMinimum(2) 216 | self.price_change_spin.setValue(10) 217 | 218 | self.impv_change_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 219 | self.impv_change_spin.setSuffix("%") 220 | self.impv_change_spin.setMinimum(2) 221 | self.impv_change_spin.setValue(10) 222 | 223 | self.time_change_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 224 | self.time_change_spin.setSuffix("日") 225 | self.time_change_spin.setMinimum(0) 226 | self.time_change_spin.setValue(1) 227 | 228 | self.target_combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 229 | self.target_combo.addItems([ 230 | "盈亏", 231 | "Delta", 232 | "Gamma", 233 | "Theta", 234 | "Vega" 235 | ]) 236 | 237 | button: QtWidgets.QPushButton = QtWidgets.QPushButton("执行分析") 238 | button.clicked.connect(self.run_analysis) 239 | 240 | # Create charts 241 | fig: Figure = Figure() 242 | canvas: FigureCanvas = FigureCanvas(fig) 243 | 244 | ax = fig.add_subplot(projection="3d") 245 | self.ax = cast(Axes3D, ax) 246 | self.ax.set_xlabel("价格涨跌 %") 247 | self.ax.set_ylabel("波动率涨跌 %") 248 | self.ax.set_zlabel("盈亏") 249 | 250 | # Set layout 251 | hbox1: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout() 252 | hbox1.addWidget(QtWidgets.QLabel("目标数据")) 253 | hbox1.addWidget(self.target_combo) 254 | hbox1.addWidget(QtWidgets.QLabel("时间衰减")) 255 | hbox1.addWidget(self.time_change_spin) 256 | hbox1.addStretch() 257 | 258 | hbox2: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout() 259 | hbox2.addWidget(QtWidgets.QLabel("价格变动")) 260 | hbox2.addWidget(self.price_change_spin) 261 | hbox2.addWidget(QtWidgets.QLabel("波动率变动")) 262 | hbox2.addWidget(self.impv_change_spin) 263 | hbox2.addStretch() 264 | hbox2.addWidget(button) 265 | 266 | vbox: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout() 267 | vbox.addLayout(hbox1) 268 | vbox.addLayout(hbox2) 269 | vbox.addWidget(canvas) 270 | 271 | self.setLayout(vbox) 272 | 273 | def run_analysis(self) -> None: 274 | """""" 275 | # Generate range 276 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 277 | 278 | price_change_range = self.price_change_spin.value() 279 | price_changes = np.arange(-price_change_range, price_change_range + 1) / 100 280 | 281 | impv_change_range = self.impv_change_spin.value() 282 | impv_changes = np.arange(-impv_change_range, impv_change_range + 1) / 100 283 | 284 | time_change = self.time_change_spin.value() / ANNUAL_DAYS 285 | target_name = self.target_combo.currentText() 286 | 287 | # Check underlying price exists 288 | for underlying in portfolio.underlyings.values(): 289 | if not underlying.mid_price: 290 | QtWidgets.QMessageBox.warning( 291 | self, 292 | "无法执行情景分析", 293 | f"标的物{underlying.symbol}当前中间价为{underlying.mid_price}", 294 | QtWidgets.QMessageBox.StandardButton.Ok 295 | ) 296 | return 297 | 298 | # Run analysis calculation 299 | pnls: list = [] 300 | deltas: list = [] 301 | gammas: list = [] 302 | thetas: list = [] 303 | vegas: list = [] 304 | 305 | for impv_change in impv_changes: 306 | pnl_buf: list = [] 307 | delta_buf: list = [] 308 | gamma_buf: list = [] 309 | theta_buf: list = [] 310 | vega_buf: list = [] 311 | 312 | for price_change in price_changes: 313 | portfolio_pnl: float = 0 314 | portfolio_delta: float = 0.0 315 | portfolio_gamma: float = 0 316 | portfolio_theta: float = 0 317 | portfolio_vega: float = 0 318 | 319 | # Calculate underlying pnl 320 | for underlying in portfolio.underlyings.values(): 321 | if not underlying.net_pos: 322 | continue 323 | 324 | value = underlying.mid_price * underlying.net_pos * underlying.size 325 | portfolio_pnl += value * price_change 326 | portfolio_delta += value / 100 327 | 328 | # Calculate option pnl 329 | for option in portfolio.options.values(): 330 | if not option.net_pos: 331 | continue 332 | 333 | new_underlying_price = option.underlying.mid_price * (1 + price_change) 334 | new_time_to_expiry = max(option.time_to_expiry - time_change, 0) 335 | new_mid_impv = option.mid_impv * (1 + impv_change) 336 | 337 | new_price, delta, gamma, theta, vega = option.calculate_greeks( 338 | new_underlying_price, 339 | option.strike_price, 340 | option.interest_rate, 341 | new_time_to_expiry, 342 | new_mid_impv, 343 | option.option_type 344 | ) 345 | 346 | # 添加对option.tick为None的检查 347 | if option.tick is None: 348 | diff = 0 349 | else: 350 | diff = new_price - option.tick.last_price 351 | multiplier = option.net_pos * option.size 352 | 353 | portfolio_pnl += diff * multiplier 354 | portfolio_delta += delta * multiplier 355 | portfolio_gamma += gamma * multiplier 356 | portfolio_theta += theta * multiplier 357 | portfolio_vega += vega * multiplier 358 | 359 | pnl_buf.append(portfolio_pnl) 360 | delta_buf.append(portfolio_delta) 361 | gamma_buf.append(portfolio_gamma) 362 | theta_buf.append(portfolio_theta) 363 | vega_buf.append(portfolio_vega) 364 | 365 | pnls.append(pnl_buf) 366 | deltas.append(delta_buf) 367 | gammas.append(gamma_buf) 368 | thetas.append(theta_buf) 369 | vegas.append(vega_buf) 370 | 371 | # Plot chart 372 | if target_name == "盈亏": 373 | target_data: list = pnls 374 | elif target_name == "Delta": 375 | target_data = deltas 376 | elif target_name == "Gamma": 377 | target_data = gammas 378 | elif target_name == "Theta": 379 | target_data = thetas 380 | else: 381 | target_data = vegas 382 | 383 | self.update_chart(price_changes * 100, impv_changes * 100, target_data, target_name) 384 | 385 | def update_chart( 386 | self, 387 | price_changes: np.ndarray, 388 | impv_changes: np.ndarray, 389 | target_data: list[list[float]], 390 | target_name: str 391 | ) -> None: 392 | """""" 393 | self.ax.clear() 394 | 395 | price_changes, impv_changes = np.meshgrid(price_changes, impv_changes) 396 | 397 | self.ax.set_zlabel(target_name) 398 | self.ax.plot_surface( 399 | X=price_changes, 400 | Y=impv_changes, 401 | Z=np.array(target_data), 402 | rstride=1, 403 | cstride=1, 404 | cmap='coolwarm' 405 | ) 406 | -------------------------------------------------------------------------------- /vnpy_optionmaster/ui/monitor.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from collections import defaultdict 3 | from typing import cast 4 | 5 | from vnpy.event import Event, EventEngine 6 | from vnpy.trader.ui import QtWidgets, QtCore, QtGui 7 | from vnpy.trader.ui.widget import COLOR_BID, COLOR_ASK, COLOR_BLACK 8 | from vnpy.trader.event import ( 9 | EVENT_TICK, EVENT_TRADE, EVENT_POSITION, EVENT_TIMER 10 | ) 11 | from vnpy.trader.object import TickData, TradeData, PositionData 12 | from vnpy.trader.utility import round_to 13 | 14 | from ..engine import OptionEngine 15 | from ..base import UnderlyingData, OptionData, ChainData, PortfolioData, InstrumentData 16 | 17 | 18 | COLOR_WHITE = QtGui.QColor("white") 19 | COLOR_POS = QtGui.QColor("yellow") 20 | COLOR_GREEKS = QtGui.QColor("cyan") 21 | 22 | 23 | class MonitorCell(QtWidgets.QTableWidgetItem): 24 | """""" 25 | 26 | def __init__(self, text: str = "", vt_symbol: str = "") -> None: 27 | """""" 28 | super().__init__(text) 29 | 30 | self.vt_symbol: str = vt_symbol 31 | 32 | self.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) 33 | 34 | 35 | class IndexCell(MonitorCell): 36 | """""" 37 | 38 | def __init__(self, text: str = "", vt_symbol: str = "") -> None: 39 | """""" 40 | super().__init__(text, vt_symbol) 41 | 42 | self.setForeground(COLOR_BLACK) 43 | self.setBackground(COLOR_WHITE) 44 | 45 | 46 | class BidCell(MonitorCell): 47 | """""" 48 | 49 | def __init__(self, text: str = "", vt_symbol: str = "") -> None: 50 | """""" 51 | super().__init__(text, vt_symbol) 52 | 53 | self.setForeground(COLOR_BID) 54 | 55 | 56 | class AskCell(MonitorCell): 57 | """""" 58 | 59 | def __init__(self, text: str = "", vt_symbol: str = "") -> None: 60 | """""" 61 | super().__init__(text, vt_symbol) 62 | 63 | self.setForeground(COLOR_ASK) 64 | 65 | 66 | class PosCell(MonitorCell): 67 | """""" 68 | 69 | def __init__(self, text: str = "", vt_symbol: str = "") -> None: 70 | """""" 71 | super().__init__(text, vt_symbol) 72 | 73 | self.setForeground(COLOR_POS) 74 | 75 | 76 | class GreeksCell(MonitorCell): 77 | """""" 78 | 79 | def __init__(self, text: str = "", vt_symbol: str = "") -> None: 80 | """""" 81 | super().__init__(text, vt_symbol) 82 | 83 | self.setForeground(COLOR_GREEKS) 84 | 85 | 86 | class MonitorTable(QtWidgets.QTableWidget): 87 | """""" 88 | 89 | def __init__(self) -> None: 90 | """""" 91 | super().__init__() 92 | 93 | self.init_menu() 94 | 95 | def init_menu(self) -> None: 96 | """ 97 | Create right click menu. 98 | """ 99 | self.menu: QtWidgets.QMenu = QtWidgets.QMenu(self) 100 | 101 | resize_action = QtGui.QAction("调整列宽", self) 102 | resize_action.triggered.connect(self.resizeColumnsToContents) 103 | self.menu.addAction(resize_action) 104 | 105 | def contextMenuEvent(self, event: QtGui.QContextMenuEvent) -> None: 106 | """ 107 | Show menu with right click. 108 | """ 109 | self.menu.popup(QtGui.QCursor.pos()) 110 | 111 | 112 | class OptionMarketMonitor(MonitorTable): 113 | """""" 114 | signal_tick: QtCore.Signal = QtCore.Signal(Event) 115 | signal_trade: QtCore.Signal = QtCore.Signal(Event) 116 | signal_position: QtCore.Signal = QtCore.Signal(Event) 117 | 118 | headers: list[dict] = [ 119 | {"name": "symbol", "display": "代码", "cell": MonitorCell}, 120 | {"name": "theo_vega", "display": "Vega", "cell": GreeksCell}, 121 | {"name": "theo_theta", "display": "Theta", "cell": GreeksCell}, 122 | {"name": "theo_gamma", "display": "Gamma", "cell": GreeksCell}, 123 | {"name": "theo_delta", "display": "Delta", "cell": GreeksCell}, 124 | {"name": "open_interest", "display": "持仓量", "cell": MonitorCell}, 125 | {"name": "volume", "display": "成交量", "cell": MonitorCell}, 126 | {"name": "bid_impv", "display": "买隐波", "cell": BidCell}, 127 | {"name": "bid_volume", "display": "买量", "cell": BidCell}, 128 | {"name": "bid_price", "display": "买价", "cell": BidCell}, 129 | {"name": "ask_price", "display": "卖价", "cell": AskCell}, 130 | {"name": "ask_volume", "display": "卖量", "cell": AskCell}, 131 | {"name": "ask_impv", "display": "卖隐波", "cell": AskCell}, 132 | {"name": "net_pos", "display": "净持仓", "cell": PosCell}, 133 | ] 134 | 135 | def __init__(self, option_engine: OptionEngine, portfolio_name: str) -> None: 136 | """""" 137 | super().__init__() 138 | 139 | self.option_engine: OptionEngine = option_engine 140 | self.event_engine: EventEngine = option_engine.event_engine 141 | self.portfolio_name: str = portfolio_name 142 | 143 | self.cells: dict[str, dict] = {} 144 | self.option_symbols: set[str] = set() 145 | self.underlying_option_map: dict[str, list] = defaultdict(list) 146 | 147 | self.init_ui() 148 | self.register_event() 149 | 150 | def init_ui(self) -> None: 151 | """""" 152 | self.setWindowTitle("T型报价") 153 | self.verticalHeader().setVisible(False) 154 | self.setEditTriggers(self.EditTrigger.NoEditTriggers) 155 | 156 | # Store option and underlying symbols 157 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 158 | 159 | for option in portfolio.options.values(): 160 | self.option_symbols.add(option.vt_symbol) 161 | self.underlying_option_map[option.underlying.vt_symbol].append(option.vt_symbol) 162 | 163 | # Get greeks decimals precision 164 | self.greeks_precision: str = f"{portfolio.precision}f" 165 | 166 | # Set table row and column numbers 167 | row_count: int = 0 168 | for chain in portfolio.chains.values(): 169 | row_count += (1 + len(chain.indexes)) 170 | self.setRowCount(row_count) 171 | 172 | column_count: int = len(self.headers) * 2 + 1 173 | self.setColumnCount(column_count) 174 | 175 | call_labels: list = [d["display"] for d in self.headers] 176 | put_labels: list = copy(call_labels) 177 | put_labels.reverse() 178 | labels: list = call_labels + ["行权价"] + put_labels 179 | self.setHorizontalHeaderLabels(labels) 180 | 181 | # Init cells 182 | strike_column: int = len(self.headers) 183 | current_row: int = 0 184 | 185 | chain_symbols: list = list(portfolio.chains.keys()) 186 | chain_symbols.sort() 187 | 188 | for chain_symbol in chain_symbols: 189 | chain = portfolio.get_chain(chain_symbol) 190 | 191 | self.setItem( 192 | current_row, 193 | strike_column, 194 | IndexCell(chain.chain_symbol.split(".")[0]) 195 | ) 196 | 197 | for index in chain.indexes: 198 | call: OptionData = chain.calls[index] 199 | put: OptionData = chain.puts[index] 200 | 201 | current_row += 1 202 | 203 | # Call cells 204 | call_cells: dict = {} 205 | 206 | for column, d in enumerate(self.headers): 207 | value = getattr(call, d["name"], "") 208 | cell = d["cell"]( 209 | text=str(value), 210 | vt_symbol=call.vt_symbol 211 | ) 212 | self.setItem(current_row, column, cell) 213 | call_cells[d["name"]] = cell 214 | 215 | self.cells[call.vt_symbol] = call_cells 216 | 217 | # Put cells 218 | put_cells: dict = {} 219 | put_headers: list = copy(self.headers) 220 | put_headers.reverse() 221 | 222 | for column, d in enumerate(put_headers): 223 | column += (strike_column + 1) 224 | value = getattr(put, d["name"], "") 225 | cell = d["cell"]( 226 | text=str(value), 227 | vt_symbol=put.vt_symbol 228 | ) 229 | self.setItem(current_row, column, cell) 230 | put_cells[d["name"]] = cell 231 | 232 | self.cells[put.vt_symbol] = put_cells 233 | 234 | # Strike cell 235 | index_cell: IndexCell = IndexCell(str(call.chain_index)) 236 | self.setItem(current_row, strike_column, index_cell) 237 | 238 | # Move to next row 239 | current_row += 1 240 | 241 | def register_event(self) -> None: 242 | """""" 243 | self.signal_tick.connect(self.process_tick_event) 244 | self.signal_trade.connect(self.process_trade_event) 245 | self.signal_position.connect(self.process_position_event) 246 | 247 | self.event_engine.register(EVENT_TICK, self.signal_tick.emit) 248 | self.event_engine.register(EVENT_TRADE, self.signal_trade.emit) 249 | self.event_engine.register(EVENT_POSITION, self.signal_position.emit) 250 | 251 | def process_tick_event(self, event: Event) -> None: 252 | """""" 253 | tick: TickData = event.data 254 | 255 | if tick.vt_symbol in self.option_symbols: 256 | self.update_price(tick.vt_symbol) 257 | self.update_impv(tick.vt_symbol) 258 | self.update_greeks(tick.vt_symbol) 259 | elif tick.vt_symbol in self.underlying_option_map: 260 | option_symbols: list = self.underlying_option_map[tick.vt_symbol] 261 | 262 | for vt_symbol in option_symbols: 263 | self.update_impv(vt_symbol) 264 | self.update_greeks(vt_symbol) 265 | 266 | def process_trade_event(self, event: Event) -> None: 267 | """""" 268 | trade: TradeData = event.data 269 | self.update_pos(trade.vt_symbol) 270 | 271 | def process_position_event(self, event: Event) -> None: 272 | """""" 273 | position: PositionData = event.data 274 | self.update_pos(position.vt_symbol) 275 | 276 | def update_pos(self, vt_symbol: str) -> None: 277 | """""" 278 | option_cells: dict | None = self.cells.get(vt_symbol, None) 279 | if not option_cells: 280 | return 281 | 282 | option: OptionData = cast(OptionData, self.option_engine.get_instrument(vt_symbol)) 283 | option_cells["net_pos"].setText(str(option.net_pos)) 284 | 285 | def update_price(self, vt_symbol: str) -> None: 286 | """""" 287 | option_cells: dict | None = self.cells.get(vt_symbol, None) 288 | if not option_cells: 289 | return 290 | 291 | option: OptionData = cast(OptionData, self.option_engine.get_instrument(vt_symbol)) 292 | tick: TickData | None = option.tick 293 | if not tick: 294 | return 295 | 296 | option_cells["bid_price"].setText(f'{tick.bid_price_1:0.4f}') 297 | option_cells["bid_volume"].setText(str(tick.bid_volume_1)) 298 | option_cells["ask_price"].setText(f'{tick.ask_price_1:0.4f}') 299 | option_cells["ask_volume"].setText(str(tick.ask_volume_1)) 300 | option_cells["volume"].setText(str(tick.volume)) 301 | option_cells["open_interest"].setText(str(tick.open_interest)) 302 | 303 | def update_impv(self, vt_symbol: str) -> None: 304 | """""" 305 | option_cells: dict | None = self.cells.get(vt_symbol, None) 306 | if not option_cells: 307 | return 308 | 309 | option: OptionData = cast(OptionData, self.option_engine.get_instrument(vt_symbol)) 310 | option_cells["bid_impv"].setText(f"{option.bid_impv * 100:.2f}") 311 | option_cells["ask_impv"].setText(f"{option.ask_impv * 100:.2f}") 312 | 313 | def update_greeks(self, vt_symbol: str) -> None: 314 | """""" 315 | option_cells: dict | None = self.cells.get(vt_symbol, None) 316 | if not option_cells: 317 | return 318 | 319 | option: OptionData = cast(OptionData, self.option_engine.get_instrument(vt_symbol)) 320 | 321 | option_cells["theo_delta"].setText(f"{option.theo_delta:.{self.greeks_precision}}") 322 | option_cells["theo_gamma"].setText(f"{option.theo_gamma:.{self.greeks_precision}}") 323 | option_cells["theo_theta"].setText(f"{option.theo_theta:.{self.greeks_precision}}") 324 | option_cells["theo_vega"].setText(f"{option.theo_vega:.{self.greeks_precision}}") 325 | 326 | 327 | class OptionGreeksMonitor(MonitorTable): 328 | """""" 329 | signal_tick: QtCore.Signal = QtCore.Signal(Event) 330 | signal_trade: QtCore.Signal = QtCore.Signal(Event) 331 | signal_position: QtCore.Signal = QtCore.Signal(Event) 332 | 333 | headers: list[dict] = [ 334 | {"name": "long_pos", "display": "多仓", "cell": PosCell}, 335 | {"name": "short_pos", "display": "空仓", "cell": PosCell}, 336 | {"name": "net_pos", "display": "净仓", "cell": PosCell}, 337 | {"name": "pos_delta", "display": "Delta", "cell": GreeksCell}, 338 | {"name": "pos_gamma", "display": "Gamma", "cell": GreeksCell}, 339 | {"name": "pos_theta", "display": "Theta", "cell": GreeksCell}, 340 | {"name": "pos_vega", "display": "Vega", "cell": GreeksCell} 341 | ] 342 | 343 | ROW_DATA = OptionData | UnderlyingData | ChainData | PortfolioData 344 | 345 | def __init__(self, option_engine: OptionEngine, portfolio_name: str) -> None: 346 | """""" 347 | super().__init__() 348 | 349 | self.option_engine: OptionEngine = option_engine 350 | self.event_engine: EventEngine = option_engine.event_engine 351 | self.portfolio_name: str = portfolio_name 352 | 353 | self.cells: dict[tuple, dict] = {} 354 | self.option_symbols: set[str] = set() 355 | self.underlying_option_map: dict[str, list] = defaultdict(list) 356 | 357 | self.init_ui() 358 | self.register_event() 359 | 360 | def init_ui(self) -> None: 361 | """""" 362 | self.setWindowTitle("希腊值风险") 363 | self.verticalHeader().setVisible(False) 364 | self.setEditTriggers(self.EditTrigger.NoEditTriggers) 365 | 366 | # Store option and underlying symbols 367 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 368 | 369 | for option in portfolio.options.values(): 370 | self.option_symbols.add(option.vt_symbol) 371 | self.underlying_option_map[option.underlying.vt_symbol].append(option.vt_symbol) 372 | 373 | # Get greeks decimals precision 374 | self.greeks_precision: str = f"{portfolio.precision}f" 375 | 376 | # Set table row and column numbers 377 | row_count: int = 2 378 | 379 | row_count += (len(portfolio.underlyings) + 1) 380 | 381 | row_count += (len(portfolio.chains) + 1) 382 | 383 | for chain in portfolio.chains.values(): 384 | row_count += len(chain.options) 385 | 386 | self.setRowCount(row_count) 387 | 388 | column_count: int = len(self.headers) + 2 389 | self.setColumnCount(column_count) 390 | 391 | labels: list = ["类别", "代码"] + [d["display"] for d in self.headers] 392 | self.setHorizontalHeaderLabels(labels) 393 | 394 | # Init cells 395 | row_settings: list = [] 396 | row_settings.append((self.portfolio_name, "组合")) 397 | row_settings.append(None) 398 | 399 | underlying_symbols: list = list(portfolio.underlyings.keys()) 400 | underlying_symbols.sort() 401 | for underlying_symbol in underlying_symbols: 402 | row_settings.append((underlying_symbol, "标的")) 403 | row_settings.append(None) 404 | 405 | chain_symbols: list = list(portfolio.chains.keys()) 406 | chain_symbols.sort() 407 | for chain_symbol in chain_symbols: 408 | row_settings.append((chain_symbol, "期权链")) 409 | row_settings.append(None) 410 | 411 | option_symbols: list = list(portfolio.options.keys()) 412 | option_symbols.sort() 413 | for option_symbol in option_symbols: 414 | row_settings.append((option_symbol, "期权")) 415 | 416 | for row, row_key in enumerate(row_settings): 417 | if not row_key: 418 | continue 419 | row_name, type_name = row_key 420 | 421 | type_cell: MonitorCell = MonitorCell(type_name) 422 | self.setItem(row, 0, type_cell) 423 | 424 | name = row_name.split(".")[0] 425 | name_cell: MonitorCell = MonitorCell(name) 426 | self.setItem(row, 1, name_cell) 427 | 428 | row_cells: dict = {} 429 | for column, d in enumerate(self.headers): 430 | cell = d["cell"]() 431 | self.setItem(row, column + 2, cell) 432 | row_cells[d["name"]] = cell 433 | self.cells[row_key] = row_cells 434 | 435 | if row_name != self.portfolio_name: 436 | self.hideRow(row) 437 | 438 | self.resizeColumnToContents(0) 439 | 440 | def register_event(self) -> None: 441 | """""" 442 | self.signal_tick.connect(self.process_tick_event) 443 | self.signal_trade.connect(self.process_trade_event) 444 | self.signal_position.connect(self.process_position_event) 445 | 446 | self.event_engine.register(EVENT_TICK, self.signal_tick.emit) 447 | self.event_engine.register(EVENT_TRADE, self.signal_trade.emit) 448 | self.event_engine.register(EVENT_POSITION, self.signal_position.emit) 449 | 450 | def process_tick_event(self, event: Event) -> None: 451 | """""" 452 | tick: TickData = event.data 453 | 454 | if tick.vt_symbol not in self.underlying_option_map: 455 | return 456 | 457 | self.update_underlying_tick(tick.vt_symbol) 458 | 459 | def process_trade_event(self, event: Event) -> None: 460 | """""" 461 | trade: TradeData = event.data 462 | if trade.vt_symbol not in self.option_symbols: 463 | return 464 | 465 | self.update_pos(trade.vt_symbol) 466 | 467 | def process_position_event(self, event: Event) -> None: 468 | """""" 469 | position: PositionData = event.data 470 | if position.vt_symbol not in self.option_symbols: 471 | return 472 | 473 | self.update_pos(position.vt_symbol) 474 | 475 | def update_underlying_tick(self, vt_symbol: str) -> None: 476 | """""" 477 | underlying: UnderlyingData = cast(UnderlyingData, self.option_engine.get_instrument(vt_symbol)) 478 | self.update_row(vt_symbol, "标的", underlying) 479 | 480 | for chain in underlying.chains.values(): 481 | self.update_row(chain.chain_symbol, "期权链", chain) 482 | 483 | for option in chain.options.values(): 484 | self.update_row(option.vt_symbol, "期权", option) 485 | 486 | portfolio: PortfolioData = underlying.portfolio 487 | self.update_row(portfolio.name, "组合", portfolio) 488 | 489 | def update_pos(self, vt_symbol: str) -> None: 490 | """""" 491 | instrument: InstrumentData = cast(InstrumentData, self.option_engine.get_instrument(vt_symbol)) 492 | if isinstance(instrument, OptionData): 493 | self.update_row(vt_symbol, "期权", instrument) 494 | else: 495 | underlying: UnderlyingData = cast(UnderlyingData, instrument) 496 | self.update_row(vt_symbol, "标的", underlying) 497 | 498 | # For option, greeks of chain also needs to be updated. 499 | if isinstance(instrument, OptionData): 500 | chain: ChainData = instrument.chain 501 | self.update_row(chain.chain_symbol, "期权链", chain) 502 | 503 | portfolio: PortfolioData = instrument.portfolio 504 | self.update_row(portfolio.name, "组合", portfolio) 505 | 506 | def update_row(self, row_name: str, type_name: str, row_data: ROW_DATA) -> None: 507 | """""" 508 | row_key: tuple = (row_name, type_name) 509 | row_cells: dict = self.cells[row_key] 510 | row: int = self.row(row_cells["long_pos"]) 511 | 512 | # Hide rows with no existing position 513 | if not row_data.long_pos and not row_data.short_pos: 514 | if row_name != self.portfolio_name: 515 | self.hideRow(row) 516 | return 517 | 518 | self.showRow(row) 519 | 520 | row_cells["long_pos"].setText(f"{row_data.long_pos}") 521 | row_cells["short_pos"].setText(f"{row_data.short_pos}") 522 | row_cells["net_pos"].setText(f"{row_data.net_pos}") 523 | row_cells["pos_delta"].setText(f"{row_data.pos_delta:.{self.greeks_precision}}") 524 | 525 | if not isinstance(row_data, UnderlyingData): 526 | row_cells["pos_gamma"].setText(f"{row_data.pos_gamma:.{self.greeks_precision}}") 527 | row_cells["pos_theta"].setText(f"{row_data.pos_theta:.{self.greeks_precision}}") 528 | row_cells["pos_vega"].setText(f"{row_data.pos_vega:.{self.greeks_precision}}") 529 | 530 | 531 | class OptionChainMonitor(MonitorTable): 532 | """""" 533 | signal_timer: QtCore.Signal = QtCore.Signal(Event) 534 | 535 | def __init__(self, option_engine: OptionEngine, portfolio_name: str): 536 | """""" 537 | super().__init__() 538 | 539 | self.option_engine: OptionEngine = option_engine 540 | self.event_engine: EventEngine = option_engine.event_engine 541 | self.portfolio_name: str = portfolio_name 542 | 543 | self.cells: dict[str, dict] = {} 544 | 545 | self.init_ui() 546 | self.register_event() 547 | 548 | def init_ui(self) -> None: 549 | """""" 550 | self.setWindowTitle("期权链跟踪") 551 | self.verticalHeader().setVisible(False) 552 | self.setEditTriggers(self.EditTrigger.NoEditTriggers) 553 | 554 | # Store option and underlying symbols 555 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 556 | 557 | # Set table row and column numbers 558 | self.setRowCount(len(portfolio.chains)) 559 | 560 | labels: list = ["期权链", "剩余交易日", "标的物", "升贴水"] 561 | self.setColumnCount(len(labels)) 562 | self.setHorizontalHeaderLabels(labels) 563 | 564 | # Init cells 565 | chain_symbols: list = list(portfolio.chains.keys()) 566 | chain_symbols.sort() 567 | 568 | for row, chain_symbol in enumerate(chain_symbols): 569 | chain: ChainData = portfolio.chains[chain_symbol] 570 | adjustment_cell: MonitorCell = MonitorCell() 571 | underlying_cell: MonitorCell = MonitorCell() 572 | 573 | self.setItem(row, 0, MonitorCell(chain.chain_symbol.split(".")[0])) 574 | self.setItem(row, 1, MonitorCell(str(chain.days_to_expiry))) 575 | self.setItem(row, 2, underlying_cell) 576 | self.setItem(row, 3, adjustment_cell) 577 | 578 | self.cells[chain.chain_symbol] = { 579 | "underlying": underlying_cell, 580 | "adjustment": adjustment_cell 581 | } 582 | 583 | # Additional table adjustment 584 | horizontal_header: QtWidgets.QHeaderView = self.horizontalHeader() 585 | horizontal_header.setSectionResizeMode(horizontal_header.ResizeMode.Stretch) 586 | 587 | def register_event(self) -> None: 588 | """""" 589 | self.signal_timer.connect(self.process_timer_event) 590 | 591 | self.event_engine.register(EVENT_TIMER, self.signal_timer.emit) 592 | 593 | def process_timer_event(self, event: Event) -> None: 594 | """""" 595 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 596 | 597 | for chain in portfolio.chains.values(): 598 | underlying: UnderlyingData = chain.underlying 599 | 600 | underlying_symbol: str = underlying.vt_symbol.split(".")[0] 601 | 602 | if chain.underlying_adjustment == float("inf"): 603 | continue 604 | 605 | if underlying.pricetick: 606 | adjustment = round_to(chain.underlying_adjustment, underlying.pricetick) 607 | else: 608 | adjustment = 0 609 | 610 | chain_cells: dict = self.cells[chain.chain_symbol] 611 | chain_cells["underlying"].setText(underlying_symbol) 612 | chain_cells["adjustment"].setText(str(adjustment)) 613 | -------------------------------------------------------------------------------- /vnpy_optionmaster/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from collections.abc import Callable 3 | from types import ModuleType 4 | from functools import lru_cache 5 | 6 | from vnpy.event import EventEngine 7 | from vnpy.event.engine import Event 8 | from vnpy.trader.event import EVENT_TICK 9 | from vnpy.trader.object import ContractData, TickData, TradeData 10 | from vnpy.trader.constant import Exchange, OptionType, Direction, Offset 11 | from vnpy.trader.converter import PositionHolding 12 | from vnpy.trader.utility import extract_vt_symbol 13 | 14 | from .time import calculate_days_to_expiry, ANNUAL_DAYS 15 | 16 | 17 | APP_NAME = "OptionMaster" 18 | 19 | EVENT_OPTION_NEW_PORTFOLIO = "eOptionNewPortfolio" 20 | EVENT_OPTION_ALGO_PRICING = "eOptionAlgoPricing" 21 | EVENT_OPTION_ALGO_TRADING = "eOptionAlgoTrading" 22 | EVENT_OPTION_ALGO_STATUS = "eOptionAlgoStatus" 23 | EVENT_OPTION_ALGO_LOG = "eOptionAlgoLog" 24 | EVENT_OPTION_RISK_NOTICE = "eOptionRiskNotice" 25 | 26 | 27 | class InstrumentData: 28 | """""" 29 | 30 | def __init__(self, contract: ContractData) -> None: 31 | """""" 32 | self.symbol: str = contract.symbol 33 | self.exchange: Exchange = contract.exchange 34 | self.vt_symbol: str = contract.vt_symbol 35 | 36 | self.pricetick: float = contract.pricetick 37 | self.min_volume: float = contract.min_volume 38 | self.size: float = contract.size 39 | 40 | self.long_pos: int = 0 41 | self.short_pos: int = 0 42 | self.net_pos: int = 0 43 | self.mid_price: float = 0 44 | 45 | self.tick: TickData | None = None 46 | self.portfolio: PortfolioData 47 | 48 | def calculate_net_pos(self) -> None: 49 | """""" 50 | self.net_pos = self.long_pos - self.short_pos 51 | 52 | def update_tick(self, tick: TickData) -> None: 53 | """""" 54 | self.tick = tick 55 | self.mid_price = (tick.bid_price_1 + tick.ask_price_1) / 2 56 | 57 | def update_trade(self, trade: TradeData) -> None: 58 | """""" 59 | if trade.direction == Direction.LONG: 60 | if trade.offset == Offset.OPEN: 61 | self.long_pos += trade.volume # type: ignore 62 | else: 63 | self.short_pos -= trade.volume # type: ignore 64 | else: 65 | if trade.offset == Offset.OPEN: 66 | self.short_pos += trade.volume # type: ignore 67 | else: 68 | self.long_pos -= trade.volume # type: ignore 69 | self.calculate_net_pos() 70 | 71 | def update_holding(self, holding: PositionHolding) -> None: 72 | """""" 73 | self.long_pos = holding.long_pos # type: ignore 74 | self.short_pos = holding.short_pos # type: ignore 75 | self.calculate_net_pos() 76 | 77 | def set_portfolio(self, portfolio: "PortfolioData") -> None: 78 | """""" 79 | self.portfolio = portfolio 80 | 81 | 82 | class OptionData(InstrumentData): 83 | """""" 84 | 85 | def __init__(self, contract: ContractData) -> None: 86 | """""" 87 | super().__init__(contract) 88 | 89 | # Option contract features 90 | self.strike_price: float = contract.option_strike # type: ignore 91 | self.chain_index: str = contract.option_index # type: ignore 92 | 93 | self.option_type: int = 0 94 | if contract.option_type == OptionType.CALL: 95 | self.option_type = 1 96 | else: 97 | self.option_type = -1 98 | 99 | self.option_expiry: datetime = contract.option_expiry # type: ignore 100 | self.days_to_expiry: int = calculate_days_to_expiry(contract.option_expiry) # type: ignore 101 | self.time_to_expiry: float = self.days_to_expiry / ANNUAL_DAYS 102 | 103 | self.interest_rate: float = 0 104 | 105 | # Option portfolio related 106 | self.underlying: UnderlyingData 107 | self.chain: ChainData 108 | self.underlying_adjustment: float = 0 109 | 110 | # Pricing model 111 | self.calculate_price: Callable 112 | self.calculate_greeks: Callable 113 | self.calculate_impv: Callable 114 | 115 | # Implied volatility 116 | self.bid_impv: float = 0 117 | self.ask_impv: float = 0 118 | self.mid_impv: float = 0 119 | self.pricing_impv: float = 0 120 | 121 | # Greeks related 122 | self.theo_delta: float = 0 123 | self.theo_gamma: float = 0 124 | self.theo_theta: float = 0 125 | self.theo_vega: float = 0 126 | 127 | self.pos_value: float = 0 128 | self.pos_delta: float = 0 129 | self.pos_gamma: float = 0 130 | self.pos_theta: float = 0 131 | self.pos_vega: float = 0 132 | 133 | def calculate_option_impv(self) -> None: 134 | """""" 135 | if not self.tick or not self.underlying: 136 | return 137 | 138 | underlying_price: float = self.underlying.mid_price 139 | if not underlying_price: 140 | return 141 | underlying_price += self.underlying_adjustment 142 | 143 | ask_price: float = self.tick.ask_price_1 144 | bid_price: float = self.tick.bid_price_1 145 | 146 | if ask_price and bid_price: 147 | mid_price: float = (ask_price + bid_price) / 2 148 | elif ask_price: 149 | mid_price = ask_price 150 | elif bid_price: 151 | mid_price = ask_price 152 | else: 153 | mid_price = 0 154 | 155 | self.ask_impv = self.calculate_impv( 156 | ask_price, 157 | underlying_price, 158 | self.strike_price, 159 | self.interest_rate, 160 | self.time_to_expiry, 161 | self.option_type 162 | ) 163 | 164 | self.bid_impv = self.calculate_impv( 165 | bid_price, 166 | underlying_price, 167 | self.strike_price, 168 | self.interest_rate, 169 | self.time_to_expiry, 170 | self.option_type 171 | ) 172 | 173 | self.mid_impv = self.calculate_impv( 174 | mid_price, 175 | underlying_price, 176 | self.strike_price, 177 | self.interest_rate, 178 | self.time_to_expiry, 179 | self.option_type 180 | ) 181 | 182 | def calculate_theo_greeks(self) -> None: 183 | """""" 184 | if not self.underlying: 185 | return 186 | 187 | underlying_price: float = self.underlying.mid_price 188 | if not underlying_price or not self.mid_impv: 189 | return 190 | underlying_price += self.underlying_adjustment 191 | 192 | _, delta, gamma, theta, vega = self.calculate_greeks( 193 | underlying_price, 194 | self.strike_price, 195 | self.interest_rate, 196 | self.time_to_expiry, 197 | self.mid_impv, 198 | self.option_type 199 | ) 200 | 201 | self.theo_delta = delta * self.size 202 | self.theo_gamma = gamma * self.size 203 | self.theo_theta = theta * self.size / 240 204 | self.theo_vega = vega * self.size / 100 205 | 206 | def calculate_pos_greeks(self) -> None: 207 | """""" 208 | if self.tick: 209 | self.pos_value = self.tick.last_price * self.size * self.net_pos 210 | 211 | self.pos_delta = self.theo_delta * self.net_pos 212 | self.pos_gamma = self.theo_gamma * self.net_pos 213 | self.pos_theta = self.theo_theta * self.net_pos 214 | self.pos_vega = self.theo_vega * self.net_pos 215 | 216 | def calculate_ref_price(self) -> float: 217 | """""" 218 | underlying_price: float = self.underlying.mid_price 219 | underlying_price += self.underlying_adjustment 220 | 221 | ref_price: float = self.calculate_price( 222 | underlying_price, 223 | self.strike_price, 224 | self.interest_rate, 225 | self.time_to_expiry, 226 | self.pricing_impv, 227 | self.option_type 228 | ) 229 | 230 | return ref_price 231 | 232 | def update_tick(self, tick: TickData) -> None: 233 | """""" 234 | super().update_tick(tick) 235 | 236 | self.calculate_option_impv() 237 | 238 | def update_trade(self, trade: TradeData) -> None: 239 | """""" 240 | super().update_trade(trade) 241 | self.calculate_pos_greeks() 242 | 243 | def update_underlying_tick(self, underlying_adjustment: float) -> None: 244 | """""" 245 | self.underlying_adjustment = underlying_adjustment 246 | 247 | self.calculate_option_impv() 248 | self.calculate_theo_greeks() 249 | self.calculate_pos_greeks() 250 | 251 | def set_chain(self, chain: "ChainData") -> None: 252 | """""" 253 | self.chain = chain 254 | 255 | def set_underlying(self, underlying: "UnderlyingData") -> None: 256 | """""" 257 | self.underlying = underlying 258 | 259 | def set_interest_rate(self, interest_rate: float) -> None: 260 | """""" 261 | self.interest_rate = interest_rate 262 | 263 | def set_pricing_model(self, pricing_model: ModuleType) -> None: 264 | """""" 265 | self.calculate_greeks = pricing_model.calculate_greeks 266 | self.calculate_impv = pricing_model.calculate_impv 267 | self.calculate_price = pricing_model.calculate_price 268 | 269 | 270 | class UnderlyingData(InstrumentData): 271 | """""" 272 | 273 | def __init__(self, contract: ContractData) -> None: 274 | """""" 275 | super().__init__(contract) 276 | 277 | self.theo_delta: float = self.size # 标的物理论Delta固定为1 278 | self.pos_delta: float = 0 279 | self.chains: dict[str, ChainData] = {} 280 | 281 | def add_chain(self, chain: "ChainData") -> None: 282 | """""" 283 | self.chains[chain.chain_symbol] = chain 284 | 285 | def update_tick(self, tick: TickData) -> None: 286 | """""" 287 | super().update_tick(tick) 288 | 289 | for chain in self.chains.values(): 290 | chain.update_underlying_tick() 291 | 292 | self.calculate_pos_greeks() 293 | 294 | def update_trade(self, trade: TradeData) -> None: 295 | """""" 296 | super().update_trade(trade) 297 | 298 | self.calculate_pos_greeks() 299 | 300 | def calculate_pos_greeks(self) -> None: 301 | """""" 302 | self.pos_delta = self.theo_delta * self.net_pos 303 | 304 | 305 | class ChainData: 306 | """""" 307 | 308 | def __init__(self, chain_symbol: str, event_engine: EventEngine) -> None: 309 | """""" 310 | self.chain_symbol: str = chain_symbol 311 | self.event_engine: EventEngine = event_engine 312 | 313 | self.long_pos: int = 0 314 | self.short_pos: int = 0 315 | self.net_pos: int = 0 316 | 317 | self.pos_value: float = 0 318 | self.pos_delta: float = 0 319 | self.pos_gamma: float = 0 320 | self.pos_theta: float = 0 321 | self.pos_vega: float = 0 322 | 323 | self.underlying: UnderlyingData 324 | 325 | self.options: dict[str, OptionData] = {} 326 | self.calls: dict[str, OptionData] = {} 327 | self.puts: dict[str, OptionData] = {} 328 | 329 | self.portfolio: PortfolioData 330 | 331 | self.indexes: list[str] = [] 332 | self.atm_price: float = 0 333 | self.atm_index: str = "" 334 | self.underlying_adjustment: float = 0 335 | self.days_to_expiry: int = 0 336 | 337 | self.use_synthetic: bool = False 338 | 339 | def add_option(self, option: OptionData) -> None: 340 | """""" 341 | self.options[option.vt_symbol] = option 342 | 343 | if option.option_type > 0: 344 | self.calls[option.chain_index] = option 345 | else: 346 | self.puts[option.chain_index] = option 347 | 348 | option.set_chain(self) 349 | 350 | if option.chain_index not in self.indexes: 351 | self.indexes.append(option.chain_index) 352 | 353 | # Sort index by number if possible, otherwise by string 354 | try: 355 | float(option.chain_index) 356 | self.indexes.sort(key=float) 357 | except ValueError: 358 | self.indexes.sort() 359 | 360 | self.days_to_expiry = option.days_to_expiry 361 | 362 | def calculate_pos_greeks(self) -> None: 363 | """""" 364 | # Clear data 365 | self.long_pos = 0 366 | self.short_pos = 0 367 | self.net_pos = 0 368 | self.pos_value = 0 369 | self.pos_delta = 0 370 | self.pos_gamma = 0 371 | self.pos_theta = 0 372 | self.pos_vega = 0 373 | 374 | # Sum all value 375 | for option in self.options.values(): 376 | if option.net_pos: 377 | self.long_pos += option.long_pos 378 | self.short_pos += option.short_pos 379 | self.pos_value += option.pos_value 380 | self.pos_delta += option.pos_delta 381 | self.pos_gamma += option.pos_gamma 382 | self.pos_theta += option.pos_theta 383 | self.pos_vega += option.pos_vega 384 | 385 | self.net_pos = self.long_pos - self.short_pos 386 | 387 | def update_tick(self, tick: TickData) -> None: 388 | """""" 389 | option: OptionData = self.options[tick.vt_symbol] 390 | option.update_tick(tick) 391 | 392 | if self.use_synthetic: 393 | if not self.atm_index: 394 | self.calculate_atm_price() 395 | 396 | if option.chain_index == self.atm_index: 397 | self.update_synthetic_price() 398 | 399 | def update_underlying_tick(self) -> None: 400 | """""" 401 | if not self.use_synthetic: 402 | self.calculate_underlying_adjustment() 403 | 404 | for option in self.options.values(): 405 | option.update_underlying_tick(self.underlying_adjustment) 406 | 407 | self.calculate_pos_greeks() 408 | 409 | def update_trade(self, trade: TradeData) -> None: 410 | """""" 411 | option: OptionData = self.options[trade.vt_symbol] 412 | 413 | # Deduct old option pos greeks 414 | self.long_pos -= option.long_pos 415 | self.short_pos -= option.short_pos 416 | self.pos_value -= option.pos_value 417 | self.pos_delta -= option.pos_delta 418 | self.pos_gamma -= option.pos_gamma 419 | self.pos_theta -= option.pos_theta 420 | self.pos_vega -= option.pos_vega 421 | 422 | # Calculate new option pos greeks 423 | option.update_trade(trade) 424 | 425 | # Add new option pos greeks 426 | self.long_pos += option.long_pos 427 | self.short_pos += option.short_pos 428 | self.pos_value += option.pos_value 429 | self.pos_delta += option.pos_delta 430 | self.pos_gamma += option.pos_gamma 431 | self.pos_theta += option.pos_theta 432 | self.pos_vega += option.pos_vega 433 | 434 | self.net_pos = self.long_pos - self.short_pos 435 | 436 | def set_underlying(self, underlying: "UnderlyingData") -> None: 437 | """""" 438 | underlying.add_chain(self) 439 | self.underlying = underlying 440 | 441 | for option in self.options.values(): 442 | option.set_underlying(underlying) 443 | 444 | if underlying.exchange == Exchange.LOCAL: 445 | self.use_synthetic = True 446 | 447 | def set_interest_rate(self, interest_rate: float) -> None: 448 | """""" 449 | for option in self.options.values(): 450 | option.set_interest_rate(interest_rate) 451 | 452 | def set_pricing_model(self, pricing_model: ModuleType) -> None: 453 | """""" 454 | for option in self.options.values(): 455 | option.set_pricing_model(pricing_model) 456 | 457 | def set_portfolio(self, portfolio: "PortfolioData") -> None: 458 | """""" 459 | for option in self.options.values(): 460 | option.set_portfolio(portfolio) 461 | 462 | def calculate_atm_price(self) -> None: 463 | """""" 464 | min_diff: float = 0 465 | atm_price: float = 0 466 | atm_index: str = "" 467 | 468 | for index, call in self.calls.items(): 469 | put: OptionData = self.puts[index] 470 | 471 | call_tick: TickData | None = call.tick 472 | if not call_tick or not call_tick.bid_price_1 or not call_tick.ask_price_1: 473 | continue 474 | 475 | put_tick: TickData | None = put.tick 476 | if not put_tick or not put_tick.bid_price_1 or not put_tick.ask_price_1: 477 | continue 478 | 479 | call_mid_price: float = (call_tick.ask_price_1 + call_tick.bid_price_1) / 2 480 | put_mid_price: float = (put_tick.ask_price_1 + put_tick.bid_price_1) / 2 481 | 482 | diff: float = abs(call_mid_price - put_mid_price) 483 | 484 | if not min_diff or diff < min_diff: 485 | min_diff = diff 486 | atm_price = call.strike_price 487 | atm_index = call.chain_index 488 | 489 | self.atm_price = atm_price 490 | self.atm_index = atm_index 491 | 492 | def calculate_underlying_adjustment(self) -> None: 493 | """""" 494 | if not self.atm_price: 495 | return 496 | 497 | atm_call: OptionData = self.calls[self.atm_index] 498 | atm_put: OptionData = self.puts[self.atm_index] 499 | 500 | call_price: float = atm_call.mid_price 501 | put_price: float = atm_put.mid_price 502 | 503 | synthetic_price: float = call_price - put_price + self.atm_price 504 | self.underlying_adjustment = synthetic_price - self.underlying.mid_price 505 | 506 | def update_synthetic_price(self) -> None: 507 | """""" 508 | call: OptionData = self.calls[self.atm_index] 509 | put: OptionData = self.puts[self.atm_index] 510 | 511 | self.underlying.mid_price = call.mid_price - put.mid_price + self.atm_price 512 | self.update_underlying_tick() 513 | 514 | # 推送合成期货的行情 515 | symbol, exchange = extract_vt_symbol(self.underlying.vt_symbol) 516 | 517 | tick: TickData = TickData( 518 | symbol=symbol, 519 | exchange=exchange, 520 | datetime=datetime.now(), 521 | last_price=self.underlying.mid_price, 522 | gateway_name=APP_NAME 523 | ) 524 | event: Event = Event(EVENT_TICK + tick.vt_symbol, tick) 525 | self.event_engine.put(event) 526 | 527 | 528 | class PortfolioData: 529 | 530 | def __init__(self, name: str, event_engine: EventEngine) -> None: 531 | """""" 532 | self.name: str = name 533 | self.event_engine: EventEngine = event_engine 534 | 535 | self.long_pos: int = 0 536 | self.short_pos: int = 0 537 | self.net_pos: int = 0 538 | 539 | self.pos_delta: float = 0 540 | self.pos_gamma: float = 0 541 | self.pos_theta: float = 0 542 | self.pos_vega: float = 0 543 | 544 | # All instrument 545 | self._options: dict[str, OptionData] = {} 546 | self._chains: dict[str, ChainData] = {} 547 | 548 | # Active instrument 549 | self.options: dict[str, OptionData] = {} 550 | self.chains: dict[str, ChainData] = {} 551 | self.underlyings: dict[str, UnderlyingData] = {} 552 | 553 | # Greeks decimals precision 554 | self.precision: int = 0 555 | 556 | def calculate_pos_greeks(self) -> None: 557 | """""" 558 | self.long_pos = 0 559 | self.short_pos = 0 560 | self.net_pos = 0 561 | 562 | self.pos_value = 0.0 563 | self.pos_delta = 0 564 | self.pos_gamma = 0 565 | self.pos_theta = 0 566 | self.pos_vega = 0 567 | 568 | for underlying in self.underlyings.values(): 569 | self.pos_delta += underlying.pos_delta 570 | 571 | for chain in self.chains.values(): 572 | self.long_pos += chain.long_pos 573 | self.short_pos += chain.short_pos 574 | self.pos_value += chain.pos_value 575 | self.pos_delta += chain.pos_delta 576 | self.pos_gamma += chain.pos_gamma 577 | self.pos_theta += chain.pos_theta 578 | self.pos_vega += chain.pos_vega 579 | 580 | self.net_pos = self.long_pos - self.short_pos 581 | 582 | def update_tick(self, tick: TickData) -> None: 583 | """""" 584 | if tick.vt_symbol in self.options: 585 | option: OptionData = self.options[tick.vt_symbol] 586 | chain: ChainData = option.chain 587 | chain.update_tick(tick) 588 | self.calculate_pos_greeks() 589 | elif tick.vt_symbol in self.underlyings: 590 | underlying: UnderlyingData = self.underlyings[tick.vt_symbol] 591 | underlying.update_tick(tick) 592 | self.calculate_pos_greeks() 593 | 594 | def update_trade(self, trade: TradeData) -> None: 595 | """""" 596 | if trade.vt_symbol in self.options: 597 | option: OptionData = self.options[trade.vt_symbol] 598 | chain: ChainData = option.chain 599 | chain.update_trade(trade) 600 | self.calculate_pos_greeks() 601 | elif trade.vt_symbol in self.underlyings: 602 | underlying: UnderlyingData = self.underlyings[trade.vt_symbol] 603 | underlying.update_trade(trade) 604 | self.calculate_pos_greeks() 605 | 606 | def set_interest_rate(self, interest_rate: float) -> None: 607 | """""" 608 | for chain in self.chains.values(): 609 | chain.set_interest_rate(interest_rate) 610 | 611 | def set_pricing_model(self, pricing_model: ModuleType) -> None: 612 | """""" 613 | for chain in self.chains.values(): 614 | chain.set_pricing_model(pricing_model) 615 | 616 | def set_precision(self, precision: int) -> None: 617 | """""" 618 | self.precision = precision 619 | 620 | def set_chain_underlying(self, chain_symbol: str, contract: ContractData) -> None: 621 | """""" 622 | underlying: UnderlyingData | None = self.underlyings.get(contract.vt_symbol, None) 623 | if not underlying: 624 | underlying = UnderlyingData(contract) 625 | underlying.set_portfolio(self) 626 | self.underlyings[contract.vt_symbol] = underlying 627 | 628 | chain: ChainData = self.get_chain(chain_symbol) 629 | chain.set_underlying(underlying) 630 | 631 | # Add to active dict 632 | self.chains[chain_symbol] = chain 633 | 634 | for option in chain.options.values(): 635 | self.options[option.vt_symbol] = option 636 | 637 | def get_chain(self, chain_symbol: str) -> ChainData: 638 | """""" 639 | chain: ChainData | None = self._chains.get(chain_symbol, None) 640 | 641 | if not chain: 642 | chain = ChainData(chain_symbol, self.event_engine) 643 | chain.set_portfolio(self) 644 | self._chains[chain_symbol] = chain 645 | 646 | return chain 647 | 648 | def add_option(self, contract: ContractData) -> None: 649 | """""" 650 | option: OptionData = OptionData(contract) 651 | option.set_portfolio(self) 652 | self._options[contract.vt_symbol] = option 653 | 654 | exchange_name: str = contract.exchange.value 655 | chain_symbol: str = f"{contract.option_underlying}.{exchange_name}" 656 | 657 | chain: ChainData = self.get_chain(chain_symbol) 658 | chain.add_option(option) 659 | 660 | def calculate_atm_price(self) -> None: 661 | """""" 662 | for chain in self.chains.values(): 663 | chain.calculate_atm_price() 664 | 665 | 666 | @lru_cache(maxsize=100) 667 | def get_underlying_prefix(portfolio_name: str) -> str: 668 | """ 669 | 基于期权产品名称获取对应标的代码 670 | 671 | 已知规则: 672 | "510050_O.SSE": "510050" 673 | "159919_O.SZSE": "159919" 674 | 675 | "IO.CFFEX": "IF", 676 | "HO.CFFEX": "IH", 677 | "MO.CFFEX": "IM", 678 | 679 | "i_o.DCE": "i", 680 | "cu_o.SHFE": "cu", 681 | "sc_o.INE": "sc", 682 | "SR.CZCE": "SR", 683 | """ 684 | # 上交所 685 | if portfolio_name.endswith("SSE"): 686 | return portfolio_name.replace("_O.SSE", "") 687 | # 深交所 688 | elif portfolio_name.endswith("SZSE"): 689 | return portfolio_name.replace("_O.SZSE", "") 690 | # 港交所 691 | elif portfolio_name.endswith("SEHK"): 692 | return portfolio_name.replace("_O.SEHK", "") 693 | # 美股 694 | elif portfolio_name.endswith("SMART"): 695 | return portfolio_name.replace("_O.SMART", "") 696 | # 中金所(特殊规则) 697 | elif portfolio_name.endswith("CFFEX"): 698 | d: dict = { 699 | "IO.CFFEX": "IF", 700 | "HO.CFFEX": "IH", 701 | "MO.CFFEX": "IM", 702 | } 703 | prefix: str = d.get(portfolio_name, "") 704 | return prefix 705 | # 上期所 706 | elif portfolio_name.endswith("SHFE"): 707 | return portfolio_name.replace("_o.SHFE", "") 708 | # 能交所 709 | elif portfolio_name.endswith("INE"): 710 | return portfolio_name.replace("_o.INE", "") 711 | # 大商所 712 | elif portfolio_name.endswith("DCE"): 713 | return portfolio_name.replace("_o.DCE", "") 714 | # 郑商所 715 | elif portfolio_name.endswith("CZCE"): 716 | return portfolio_name.replace(".CZCE", "") 717 | # 其他 718 | else: 719 | return "" 720 | -------------------------------------------------------------------------------- /vnpy_optionmaster/engine.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from collections import defaultdict 3 | from typing import cast 4 | 5 | from vnpy.trader.object import ( 6 | LogData, ContractData, TickData, 7 | OrderData, TradeData, PositionData, 8 | SubscribeRequest, OrderRequest, CancelRequest 9 | ) 10 | from vnpy.event import Event, EventEngine 11 | from vnpy.trader.engine import BaseEngine, MainEngine 12 | from vnpy.trader.event import ( 13 | EVENT_TRADE, EVENT_TICK, EVENT_CONTRACT, 14 | EVENT_TIMER, EVENT_ORDER 15 | ) 16 | from vnpy.trader.constant import ( 17 | Product, Offset, Direction, OrderType, Exchange, Status 18 | ) 19 | from vnpy.trader.converter import OffsetConverter, PositionHolding 20 | from vnpy.trader.utility import extract_vt_symbol, round_to, save_json, load_json 21 | 22 | from .base import ( 23 | APP_NAME, 24 | EVENT_OPTION_NEW_PORTFOLIO, 25 | EVENT_OPTION_ALGO_PRICING, 26 | EVENT_OPTION_ALGO_TRADING, 27 | EVENT_OPTION_ALGO_STATUS, 28 | EVENT_OPTION_ALGO_LOG, 29 | EVENT_OPTION_RISK_NOTICE, 30 | InstrumentData, PortfolioData, OptionData, UnderlyingData, 31 | get_underlying_prefix 32 | ) 33 | try: 34 | from .pricing import black_76_cython as black_76 # type: ignore 35 | from .pricing import binomial_tree_cython as binomial_tree # type: ignore 36 | from .pricing import black_scholes_cython as black_scholes # type: ignore 37 | except ImportError: 38 | from .pricing import ( 39 | black_76, binomial_tree, black_scholes 40 | ) 41 | print("Faile to import cython option pricing model, please rebuild with cython in cmd.") 42 | from .algo import ElectronicEyeAlgo 43 | 44 | 45 | PRICING_MODELS: dict = { 46 | "Black-76 欧式期货期权": black_76, 47 | "Black-Scholes 欧式股票期权": black_scholes, 48 | "二叉树 美式期货期权": binomial_tree 49 | } 50 | 51 | 52 | class OptionEngine(BaseEngine): 53 | """""" 54 | 55 | setting_filename: str = "option_master_setting.json" 56 | data_filename: str = "option_master_data.json" 57 | 58 | def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None: 59 | """""" 60 | super().__init__(main_engine, event_engine, APP_NAME) 61 | 62 | self.portfolios: dict[str, PortfolioData] = {} 63 | self.instruments: dict[str, InstrumentData] = {} 64 | self.active_portfolios: dict[str, PortfolioData] = {} 65 | 66 | self.timer_count: int = 0 67 | self.timer_trigger: int = 60 68 | 69 | self.hedge_engine: OptionHedgeEngine = OptionHedgeEngine(self) 70 | self.algo_engine: OptionAlgoEngine = OptionAlgoEngine(self) 71 | self.risk_engine: OptionRiskEngine = OptionRiskEngine(self) 72 | 73 | self.setting: dict = {} 74 | 75 | self.load_setting() 76 | self.register_event() 77 | 78 | def close(self) -> None: 79 | """""" 80 | self.save_setting() 81 | self.save_data() 82 | 83 | def load_setting(self) -> None: 84 | """""" 85 | self.setting = load_json(self.setting_filename) 86 | 87 | def save_setting(self) -> None: 88 | """ 89 | Save underlying adjustment. 90 | """ 91 | save_json(self.setting_filename, self.setting) 92 | 93 | def load_data(self) -> None: 94 | """""" 95 | data: dict = load_json(self.data_filename) 96 | 97 | for portfolio in self.active_portfolios.values(): 98 | portfolio_name: str = portfolio.name 99 | 100 | # Load underlying adjustment from setting 101 | chain_adjustments: dict = data.get("chain_adjustments", {}) 102 | chain_adjustment_data: dict = chain_adjustments.get(portfolio_name, {}) 103 | 104 | if chain_adjustment_data: 105 | for chain in portfolio.chains.values(): 106 | if not chain.use_synthetic: 107 | chain.underlying_adjustment = chain_adjustment_data.get( 108 | chain.chain_symbol, 0 109 | ) 110 | 111 | # Load pricing impv from setting 112 | pricing_impvs: dict = data.get("pricing_impvs", {}) 113 | pricing_impv_data: dict = pricing_impvs.get(portfolio_name, {}) 114 | 115 | if pricing_impv_data: 116 | for chain in portfolio.chains.values(): 117 | for index in chain.indexes: 118 | key: str = f"{chain.chain_symbol}_{index}" 119 | pricing_impv = pricing_impv_data.get(key, 0) 120 | 121 | if pricing_impv: 122 | call: OptionData = chain.calls[index] 123 | call.pricing_impv = pricing_impv 124 | 125 | put: OptionData = chain.puts[index] 126 | put.pricing_impv = pricing_impv 127 | 128 | def save_data(self) -> None: 129 | """""" 130 | chain_adjustments: dict = {} 131 | pricing_impvs: dict = {} 132 | 133 | for portfolio in self.active_portfolios.values(): 134 | chain_adjustment_data: dict = {} 135 | pricing_impv_data: dict = {} 136 | for chain in portfolio.chains.values(): 137 | chain_adjustment_data[chain.chain_symbol] = chain.underlying_adjustment 138 | 139 | for call in chain.calls.values(): 140 | key: str = f"{chain.chain_symbol}_{call.chain_index}" 141 | pricing_impv_data[key] = call.pricing_impv 142 | 143 | chain_adjustments[portfolio.name] = chain_adjustment_data 144 | pricing_impvs[portfolio.name] = pricing_impv_data 145 | 146 | data: dict = { 147 | "chain_adjustments": chain_adjustments, 148 | "pricing_impvs": pricing_impvs 149 | } 150 | 151 | save_json(self.data_filename, data) 152 | 153 | def register_event(self) -> None: 154 | """""" 155 | self.event_engine.register(EVENT_TICK, self.process_tick_event) 156 | self.event_engine.register(EVENT_CONTRACT, self.process_contract_event) 157 | self.event_engine.register(EVENT_TRADE, self.process_trade_event) 158 | self.event_engine.register(EVENT_TIMER, self.process_timer_event) 159 | 160 | def process_tick_event(self, event: Event) -> None: 161 | """""" 162 | tick: TickData = event.data 163 | 164 | instrument: InstrumentData | None = self.instruments.get(tick.vt_symbol, None) 165 | if not instrument: 166 | return 167 | 168 | portfolio: PortfolioData = instrument.portfolio 169 | if not portfolio: 170 | return 171 | 172 | portfolio.update_tick(tick) 173 | 174 | def process_trade_event(self, event: Event) -> None: 175 | """""" 176 | trade: TradeData = event.data 177 | 178 | instrument: InstrumentData | None = self.instruments.get(trade.vt_symbol, None) 179 | if not instrument: 180 | return 181 | 182 | portfolio: PortfolioData = instrument.portfolio 183 | if not portfolio: 184 | return 185 | 186 | portfolio.update_trade(trade) 187 | 188 | def process_contract_event(self, event: Event) -> None: 189 | """""" 190 | contract: ContractData = event.data 191 | 192 | if contract.product == Product.OPTION: 193 | exchange_name: str = contract.exchange.value 194 | portfolio_name: str = f"{contract.option_portfolio}.{exchange_name}" 195 | 196 | portfolio: PortfolioData = self.get_portfolio(portfolio_name) 197 | portfolio.add_option(contract) 198 | 199 | def process_timer_event(self, event: Event) -> None: 200 | """""" 201 | self.timer_count += 1 202 | if self.timer_count < self.timer_trigger: 203 | return 204 | self.timer_count = 0 205 | 206 | for portfolio in self.active_portfolios.values(): 207 | portfolio.calculate_atm_price() 208 | 209 | def get_portfolio(self, portfolio_name: str) -> PortfolioData: 210 | """""" 211 | portfolio: PortfolioData | None = self.portfolios.get(portfolio_name, None) 212 | if not portfolio: 213 | portfolio = PortfolioData(portfolio_name, self.event_engine) 214 | self.portfolios[portfolio_name] = portfolio 215 | 216 | event: Event = Event(EVENT_OPTION_NEW_PORTFOLIO, portfolio_name) 217 | self.event_engine.put(event) 218 | 219 | return portfolio 220 | 221 | def subscribe_data(self, vt_symbol: str) -> None: 222 | """""" 223 | contract: ContractData | None = self.main_engine.get_contract(vt_symbol) 224 | if not contract: 225 | return 226 | 227 | req: SubscribeRequest = SubscribeRequest(contract.symbol, contract.exchange) 228 | self.main_engine.subscribe(req, contract.gateway_name) 229 | 230 | def update_portfolio_setting( 231 | self, 232 | portfolio_name: str, 233 | model_name: str, 234 | interest_rate: float, 235 | chain_underlying_map: dict[str, str], 236 | precision: int = 0 237 | ) -> None: 238 | """""" 239 | portfolio: PortfolioData = self.get_portfolio(portfolio_name) 240 | 241 | for chain_symbol, underlying_symbol in chain_underlying_map.items(): 242 | if "LOCAL" in underlying_symbol: 243 | symbol, exchange = extract_vt_symbol(underlying_symbol) 244 | contract: ContractData | None = ContractData( 245 | symbol=symbol, 246 | exchange=exchange, 247 | name="", 248 | product=Product.INDEX, 249 | size=0, 250 | pricetick=0, 251 | gateway_name=APP_NAME 252 | ) 253 | else: 254 | contract = self.main_engine.get_contract(underlying_symbol) 255 | 256 | if contract: 257 | portfolio.set_chain_underlying(chain_symbol, contract) 258 | 259 | portfolio.set_interest_rate(interest_rate) 260 | 261 | pricing_model = PRICING_MODELS[model_name] 262 | portfolio.set_pricing_model(pricing_model) 263 | portfolio.set_precision(precision) 264 | 265 | portfolio_settings: dict = self.setting.setdefault("portfolio_settings", {}) 266 | portfolio_settings[portfolio_name] = { 267 | "model_name": model_name, 268 | "interest_rate": interest_rate, 269 | "chain_underlying_map": chain_underlying_map, 270 | "precision": precision 271 | } 272 | self.save_setting() 273 | 274 | def get_portfolio_setting(self, portfolio_name: str) -> dict: 275 | """""" 276 | portfolio_settings: dict = self.setting.setdefault("portfolio_settings", {}) 277 | portfolio_setting: dict = portfolio_settings.get(portfolio_name, {}) 278 | return portfolio_setting 279 | 280 | def init_portfolio(self, portfolio_name: str) -> bool: 281 | """""" 282 | # Add to active dict 283 | if portfolio_name in self.active_portfolios: 284 | return False 285 | portfolio: PortfolioData = self.get_portfolio(portfolio_name) 286 | self.active_portfolios[portfolio_name] = portfolio 287 | 288 | # Subscribe market data 289 | for underlying in portfolio.underlyings.values(): 290 | if underlying.exchange == Exchange.LOCAL: 291 | continue 292 | 293 | self.instruments[underlying.vt_symbol] = underlying 294 | self.subscribe_data(underlying.vt_symbol) 295 | 296 | for option in portfolio.options.values(): 297 | # Ignore options with no underlying set 298 | if not option.underlying: 299 | continue 300 | 301 | self.instruments[option.vt_symbol] = option 302 | self.subscribe_data(option.vt_symbol) 303 | 304 | # Update position volume 305 | for instrument in self.instruments.values(): 306 | contract: ContractData = self.main_engine.get_contract(instrument.vt_symbol) # type: ignore 307 | converter: OffsetConverter = self.main_engine.get_converter(contract.gateway_name) # type: ignore 308 | holding: PositionHolding = converter.get_position_holding(instrument.vt_symbol) # type: ignore 309 | 310 | if holding: 311 | instrument.update_holding(holding) 312 | 313 | portfolio.calculate_pos_greeks() 314 | 315 | # Load chain adjustment and pricing impv data 316 | self.load_data() 317 | 318 | return True 319 | 320 | def get_portfolio_names(self) -> list[str]: 321 | """""" 322 | return list(self.portfolios.keys()) 323 | 324 | def get_underlying_symbols(self, portfolio_name: str) -> list[str]: 325 | """""" 326 | underlying_prefix: str = get_underlying_prefix(portfolio_name) 327 | underlying_symbols: list = [] 328 | 329 | contracts: list[ContractData] = self.main_engine.get_all_contracts() 330 | for contract in contracts: 331 | if contract.product == Product.OPTION: 332 | continue 333 | 334 | if ( 335 | underlying_prefix 336 | and contract.symbol.startswith(underlying_prefix) 337 | ): 338 | underlying_symbols.append(contract.vt_symbol) 339 | 340 | underlying_symbols.sort() 341 | 342 | return underlying_symbols 343 | 344 | def get_instrument(self, vt_symbol: str) -> InstrumentData | OptionData | UnderlyingData | None: 345 | """""" 346 | instrument: InstrumentData | OptionData | UnderlyingData | None = self.instruments.get(vt_symbol) 347 | return instrument 348 | 349 | def set_timer_trigger(self, timer_trigger: int) -> None: 350 | """""" 351 | self.timer_trigger = timer_trigger 352 | 353 | 354 | class OptionHedgeEngine: 355 | """""" 356 | 357 | def __init__(self, option_engine: OptionEngine) -> None: 358 | """""" 359 | self.option_engine: OptionEngine = option_engine 360 | self.main_engine: MainEngine = option_engine.main_engine 361 | self.event_engine: EventEngine = option_engine.event_engine 362 | 363 | # Hedging parameters 364 | self.portfolio_name: str = "" 365 | self.vt_symbol: str = "" 366 | self.timer_trigger: int = 5 367 | self.delta_target: int = 0 368 | self.delta_range: int = 0 369 | self.hedge_payup: int = 1 370 | 371 | self.active: bool = False 372 | self.active_orderids: set[str] = set() 373 | self.timer_count: int = 0 374 | 375 | self.register_event() 376 | 377 | def register_event(self) -> None: 378 | """""" 379 | self.event_engine.register(EVENT_ORDER, self.process_order_event) 380 | self.event_engine.register(EVENT_TIMER, self.process_timer_event) 381 | 382 | def process_order_event(self, event: Event) -> None: 383 | """""" 384 | order: OrderData = event.data 385 | 386 | if order.vt_orderid not in self.active_orderids: 387 | return 388 | 389 | if not order.is_active(): 390 | self.active_orderids.remove(order.vt_orderid) 391 | 392 | def process_timer_event(self, event: Event) -> None: 393 | """""" 394 | if not self.active: 395 | return 396 | 397 | self.timer_count += 1 398 | if self.timer_count < self.timer_trigger: 399 | return 400 | self.timer_count = 0 401 | 402 | self.run() 403 | 404 | def start( 405 | self, 406 | portfolio_name: str, 407 | vt_symbol: str, 408 | timer_trigger: int, 409 | delta_target: int, 410 | delta_range: int, 411 | hedge_payup: int 412 | ) -> None: 413 | """""" 414 | if self.active: 415 | return 416 | 417 | self.portfolio_name = portfolio_name 418 | self.vt_symbol = vt_symbol 419 | self.timer_trigger = timer_trigger 420 | self.delta_target = delta_target 421 | self.delta_range = delta_range 422 | self.hedge_payup = hedge_payup 423 | 424 | self.active = True 425 | 426 | def stop(self) -> None: 427 | """""" 428 | if not self.active: 429 | return 430 | 431 | self.active = False 432 | self.timer_count = 0 433 | 434 | def run(self) -> None: 435 | """""" 436 | if not self.check_order_finished(): 437 | self.cancel_all() 438 | return 439 | 440 | delta_max = self.delta_target + self.delta_range 441 | delta_min = self.delta_target - self.delta_range 442 | 443 | # Do nothing if portfolio delta is in the allowed range 444 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 445 | if delta_min <= portfolio.pos_delta <= delta_max: 446 | return 447 | 448 | # Calculate volume of contract to hedge 449 | delta_to_hedge = self.delta_target - portfolio.pos_delta 450 | instrument: UnderlyingData = cast(UnderlyingData, self.option_engine.get_instrument(self.vt_symbol)) 451 | 452 | hedge_volume = delta_to_hedge / instrument.theo_delta 453 | 454 | # Send hedge orders 455 | tick: TickData | None = self.main_engine.get_tick(self.vt_symbol) 456 | if not tick: 457 | return 458 | 459 | contract: ContractData = cast(ContractData, self.main_engine.get_contract(self.vt_symbol)) 460 | 461 | converter: OffsetConverter | None = self.main_engine.get_converter(contract.gateway_name) 462 | holding: PositionHolding | None = None 463 | if converter: 464 | holding = converter.get_position_holding(self.vt_symbol) 465 | 466 | # Check if hedge volume meets contract minimum trading volume 467 | if abs(hedge_volume) < contract.min_volume: 468 | return 469 | 470 | if hedge_volume > 0: 471 | price: float = tick.ask_price_1 + contract.pricetick * self.hedge_payup 472 | direction: Direction = Direction.LONG 473 | 474 | if holding: 475 | available = holding.short_pos - holding.short_pos_frozen 476 | else: 477 | available = 0 478 | else: 479 | price = tick.bid_price_1 - contract.pricetick * self.hedge_payup 480 | direction = Direction.SHORT 481 | 482 | if holding: 483 | available = holding.long_pos - holding.long_pos_frozen 484 | else: 485 | available = 0 486 | 487 | order_volume = abs(hedge_volume) 488 | 489 | req: OrderRequest = OrderRequest( 490 | symbol=contract.symbol, 491 | exchange=contract.exchange, 492 | direction=direction, 493 | type=OrderType.LIMIT, 494 | volume=order_volume, 495 | price=round_to(price, contract.pricetick), 496 | reference=f"{APP_NAME}_DeltaHedging" 497 | ) 498 | 499 | # Close positon if opposite available is enough 500 | if available > order_volume: 501 | req.offset = Offset.CLOSE 502 | vt_orderid: str = self.main_engine.send_order(req, contract.gateway_name) 503 | self.active_orderids.add(vt_orderid) 504 | # Open position if no oppsite available 505 | elif not available: 506 | req.offset = Offset.OPEN 507 | vt_orderid = self.main_engine.send_order(req, contract.gateway_name) 508 | self.active_orderids.add(vt_orderid) 509 | # Else close all opposite available and open left volume 510 | else: 511 | close_req: OrderRequest = copy(req) 512 | close_req.offset = Offset.CLOSE 513 | close_req.volume = available 514 | close_orderid = self.main_engine.send_order(close_req, contract.gateway_name) 515 | self.active_orderids.add(close_orderid) 516 | 517 | open_req: OrderRequest = copy(req) 518 | open_req.offset = Offset.OPEN 519 | open_req.volume = order_volume - available 520 | open_orderid: str = self.main_engine.send_order(open_req, contract.gateway_name) 521 | self.active_orderids.add(open_orderid) 522 | 523 | def check_order_finished(self) -> bool: 524 | """""" 525 | if self.active_orderids: 526 | return False 527 | else: 528 | return True 529 | 530 | def cancel_all(self) -> None: 531 | """""" 532 | for vt_orderid in self.active_orderids: 533 | order: OrderData | None = self.main_engine.get_order(vt_orderid) 534 | if order: 535 | req: CancelRequest = order.create_cancel_request() 536 | self.main_engine.cancel_order(req, order.gateway_name) 537 | 538 | 539 | class OptionAlgoEngine: 540 | 541 | def __init__(self, option_engine: OptionEngine) -> None: 542 | """""" 543 | self.option_engine: OptionEngine = option_engine 544 | self.main_engine: MainEngine = option_engine.main_engine 545 | self.event_engine: EventEngine = option_engine.event_engine 546 | 547 | self.algos: dict[str, ElectronicEyeAlgo] = {} 548 | self.active_algos: dict[str, ElectronicEyeAlgo] = {} 549 | 550 | self.underlying_algo_map: dict[str, list[ElectronicEyeAlgo]] = defaultdict(list) 551 | self.order_algo_map: dict[str, ElectronicEyeAlgo] = {} 552 | 553 | self.register_event() 554 | 555 | def init_engine(self, portfolio_name: str) -> None: 556 | """""" 557 | if self.algos: 558 | return 559 | 560 | portfolio: PortfolioData = self.option_engine.get_portfolio(portfolio_name) 561 | 562 | for option in portfolio.options.values(): 563 | algo: ElectronicEyeAlgo = ElectronicEyeAlgo(self, option) 564 | self.algos[option.vt_symbol] = algo 565 | 566 | def register_event(self) -> None: 567 | """""" 568 | self.event_engine.register(EVENT_ORDER, self.process_order_event) 569 | self.event_engine.register(EVENT_TRADE, self.process_trade_event) 570 | self.event_engine.register(EVENT_TIMER, self.process_timer_event) 571 | 572 | def process_underlying_tick_event(self, event: Event) -> None: 573 | """""" 574 | tick: TickData = event.data 575 | 576 | for algo in self.underlying_algo_map[tick.vt_symbol]: 577 | algo.on_underlying_tick(tick) 578 | 579 | def process_option_tick_event(self, event: Event) -> None: 580 | """""" 581 | tick: TickData = event.data 582 | 583 | algo: ElectronicEyeAlgo = self.algos[tick.vt_symbol] 584 | algo.on_option_tick(tick) 585 | 586 | def process_order_event(self, event: Event) -> None: 587 | """""" 588 | order: OrderData = event.data 589 | algo: ElectronicEyeAlgo | None = self.order_algo_map.get(order.vt_orderid, None) 590 | 591 | if algo: 592 | algo.on_order(order) 593 | 594 | def process_trade_event(self, event: Event) -> None: 595 | """""" 596 | trade: TradeData = event.data 597 | algo: ElectronicEyeAlgo | None = self.order_algo_map.get(trade.vt_orderid, None) 598 | 599 | if algo: 600 | algo.on_trade(trade) 601 | 602 | def process_timer_event(self, event: Event) -> None: 603 | """""" 604 | for algo in self.active_algos.values(): 605 | algo.on_timer() 606 | 607 | def start_algo_pricing(self, vt_symbol: str, params: dict) -> None: 608 | """""" 609 | algo: ElectronicEyeAlgo = self.algos[vt_symbol] 610 | 611 | result: bool = algo.start_pricing(params) 612 | if not result: 613 | return 614 | 615 | self.underlying_algo_map[algo.underlying.vt_symbol].append(algo) 616 | self.active_algos[vt_symbol] = algo 617 | 618 | self.event_engine.register( 619 | EVENT_TICK + algo.option.vt_symbol, 620 | self.process_option_tick_event 621 | ) 622 | self.event_engine.register( 623 | EVENT_TICK + algo.underlying.vt_symbol, 624 | self.process_underlying_tick_event 625 | ) 626 | 627 | def stop_algo_pricing(self, vt_symbol: str) -> None: 628 | """""" 629 | algo: ElectronicEyeAlgo = self.algos[vt_symbol] 630 | 631 | result: bool = algo.stop_pricing() 632 | if not result: 633 | return 634 | 635 | self.event_engine.unregister( 636 | EVENT_TICK + vt_symbol, 637 | self.process_option_tick_event 638 | ) 639 | 640 | buf: list = self.underlying_algo_map[algo.underlying.vt_symbol] 641 | buf.remove(algo) 642 | 643 | self.active_algos.pop(vt_symbol) 644 | 645 | if not buf: 646 | self.event_engine.unregister( 647 | EVENT_TICK + algo.underlying.vt_symbol, 648 | self.process_underlying_tick_event 649 | ) 650 | 651 | def start_algo_trading(self, vt_symbol: str, params: dict) -> None: 652 | """""" 653 | algo: ElectronicEyeAlgo = self.algos[vt_symbol] 654 | algo.start_trading(params) 655 | 656 | def stop_algo_trading(self, vt_symbol: str) -> None: 657 | """""" 658 | algo: ElectronicEyeAlgo = self.algos[vt_symbol] 659 | algo.stop_trading() 660 | 661 | def send_order( 662 | self, 663 | algo: ElectronicEyeAlgo, 664 | vt_symbol: str, 665 | direction: Direction, 666 | offset: Offset, 667 | price: float, 668 | volume: int 669 | ) -> str: 670 | """""" 671 | contract: ContractData | None = self.main_engine.get_contract(vt_symbol) 672 | if not contract: 673 | return "" 674 | 675 | req: OrderRequest = OrderRequest( 676 | contract.symbol, 677 | contract.exchange, 678 | direction, 679 | OrderType.LIMIT, 680 | volume, 681 | price, 682 | offset, 683 | reference=f"{APP_NAME}_ElectronicEye" 684 | ) 685 | 686 | vt_orderid: str = self.main_engine.send_order(req, contract.gateway_name) 687 | self.order_algo_map[vt_orderid] = algo 688 | 689 | return vt_orderid 690 | 691 | def cancel_order(self, vt_orderid: str) -> None: 692 | """""" 693 | order: OrderData | None = self.main_engine.get_order(vt_orderid) 694 | if order: 695 | req: CancelRequest = order.create_cancel_request() 696 | self.main_engine.cancel_order(req, order.gateway_name) 697 | 698 | def write_algo_log(self, algo: ElectronicEyeAlgo, msg: str) -> None: 699 | """""" 700 | msg = f"[{algo.vt_symbol}] {msg}" 701 | log: LogData = LogData(APP_NAME, msg) 702 | event: Event = Event(EVENT_OPTION_ALGO_LOG, log) 703 | self.event_engine.put(event) 704 | 705 | def put_algo_pricing_event(self, algo: ElectronicEyeAlgo) -> None: 706 | """""" 707 | event: Event = Event(EVENT_OPTION_ALGO_PRICING, algo) 708 | self.event_engine.put(event) 709 | 710 | def put_algo_trading_event(self, algo: ElectronicEyeAlgo) -> None: 711 | """""" 712 | event: Event = Event(EVENT_OPTION_ALGO_TRADING, algo) 713 | self.event_engine.put(event) 714 | 715 | def put_algo_status_event(self, algo: ElectronicEyeAlgo) -> None: 716 | """""" 717 | event: Event = Event(EVENT_OPTION_ALGO_STATUS, algo) 718 | self.event_engine.put(event) 719 | 720 | 721 | class OptionRiskEngine: 722 | """期权风控引擎""" 723 | 724 | def __init__(self, option_engine: OptionEngine) -> None: 725 | """""" 726 | self.option_engine: OptionEngine = option_engine 727 | self.event_engine: EventEngine = option_engine.event_engine 728 | 729 | self.instruments: dict[str, InstrumentData] = option_engine.instruments 730 | 731 | # 成交持仓比风控 732 | self.trade_volume: int = 0 733 | self.net_pos: int = 0 734 | 735 | # 委托撤单比风控 736 | self.all_orderids: set[str] = set() 737 | self.cancel_orderids: set[str] = set() 738 | 739 | # 定时运行参数 740 | self.timer_count: int = 0 741 | self.timer_trigger: int = 10 742 | 743 | self.register_event() 744 | 745 | def register_event(self) -> None: 746 | """""" 747 | self.event_engine.register(EVENT_ORDER, self.process_order_event) 748 | self.event_engine.register(EVENT_TRADE, self.process_trade_event) 749 | self.event_engine.register(EVENT_TIMER, self.process_timer_event) 750 | 751 | def process_order_event(self, event: Event) -> None: 752 | """""" 753 | order: OrderData = event.data 754 | self.all_orderids.add(order.vt_orderid) 755 | 756 | if order.status == Status.CANCELLED: 757 | self.cancel_orderids.add(order.vt_orderid) 758 | 759 | def process_trade_event(self, event: Event) -> None: 760 | """""" 761 | trade: TradeData = event.data 762 | 763 | self.trade_volume += trade.volume # type: ignore 764 | 765 | def process_timer_event(self, event: Event) -> None: 766 | """""" 767 | self.timer_count += 1 768 | if self.timer_count < self.timer_trigger: 769 | return 770 | self.timer_count = 0 771 | 772 | self.net_pos = 0 773 | for instrument in self.instruments.values(): 774 | self.net_pos += instrument.net_pos 775 | 776 | self.put_event() 777 | 778 | def put_event(self) -> None: 779 | """推送事件""" 780 | order_count: int = len(self.all_orderids) 781 | cancel_count: int = len(self.cancel_orderids) 782 | 783 | if self.net_pos: 784 | trade_position_ratio: float = self.trade_volume / abs(self.net_pos) 785 | else: 786 | trade_position_ratio = 9999 787 | 788 | if order_count: 789 | cancel_order_ratio: float = cancel_count / order_count 790 | else: 791 | cancel_order_ratio = 0 792 | 793 | data: dict = { 794 | "trade_volume": self.trade_volume, 795 | "net_pos": self.net_pos, 796 | "order_count": len(self.all_orderids), 797 | "cancel_count": len(self.cancel_orderids), 798 | "trade_position_ratio": trade_position_ratio, 799 | "cancel_order_ratio": cancel_order_ratio 800 | } 801 | self.event_engine.put(Event(EVENT_OPTION_RISK_NOTICE, data)) 802 | -------------------------------------------------------------------------------- /vnpy_optionmaster/ui/widget.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import cast 3 | 4 | from typing import cast 5 | 6 | from vnpy.event import EventEngine, Event 7 | from vnpy.trader.engine import MainEngine 8 | from vnpy.trader.ui import QtWidgets, QtCore, QtGui 9 | from vnpy.trader.constant import Direction, Offset, OrderType 10 | from vnpy.trader.object import OrderRequest, CancelRequest, ContractData, TickData 11 | from vnpy.trader.event import EVENT_TICK 12 | from vnpy.trader.utility import get_digits 13 | 14 | from ..base import APP_NAME, EVENT_OPTION_NEW_PORTFOLIO, EVENT_OPTION_RISK_NOTICE, PortfolioData, UnderlyingData 15 | from ..engine import OptionEngine, OptionHedgeEngine, PRICING_MODELS 16 | from .monitor import ( 17 | OptionMarketMonitor, OptionGreeksMonitor, OptionChainMonitor, 18 | MonitorCell 19 | ) 20 | from .chart import OptionVolatilityChart, ScenarioAnalysisChart 21 | from .manager import ElectronicEyeManager, PricingVolatilityManager 22 | 23 | 24 | class OptionManager(QtWidgets.QWidget): 25 | """""" 26 | signal_new_portfolio: QtCore.Signal = QtCore.Signal(Event) 27 | 28 | def __init__(self, main_engine: MainEngine, event_engine: EventEngine) -> None: 29 | """""" 30 | super().__init__() 31 | 32 | self.main_engine: MainEngine = main_engine 33 | self.event_engine: EventEngine = event_engine 34 | self.option_engine: OptionEngine = cast(OptionEngine, main_engine.get_engine(APP_NAME)) 35 | 36 | self.portfolio_name: str = "" 37 | 38 | self.market_monitor: OptionMarketMonitor 39 | self.greeks_monitor: OptionGreeksMonitor 40 | self.volatility_chart: OptionVolatilityChart 41 | self.chain_monitor: OptionChainMonitor 42 | self.manual_trader: OptionManualTrader 43 | self.hedge_widget: OptionHedgeWidget 44 | self.scenario_chart: ScenarioAnalysisChart 45 | self.eye_manager: ElectronicEyeManager 46 | self.pricing_manager: PricingVolatilityManager 47 | 48 | self.init_ui() 49 | self.register_event() 50 | 51 | def init_ui(self) -> None: 52 | """""" 53 | self.setWindowTitle("OptionMaster") 54 | 55 | self.portfolio_combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 56 | self.portfolio_combo.setFixedWidth(150) 57 | self.update_portfolio_combo() 58 | 59 | self.portfolio_button = QtWidgets.QPushButton("配置") 60 | self.portfolio_button.clicked.connect(self.open_portfolio_dialog) 61 | 62 | self.market_button = QtWidgets.QPushButton("T型报价") 63 | self.greeks_button = QtWidgets.QPushButton("持仓希腊值") 64 | self.chain_button = QtWidgets.QPushButton("升贴水监控") 65 | self.manual_button = QtWidgets.QPushButton("快速交易") 66 | self.volatility_button = QtWidgets.QPushButton("波动率曲线") 67 | self.hedge_button = QtWidgets.QPushButton("Delta对冲") 68 | self.scenario_button = QtWidgets.QPushButton("情景分析") 69 | self.eye_button = QtWidgets.QPushButton("电子眼") 70 | self.pricing_button = QtWidgets.QPushButton("波动率管理") 71 | self.risk_button = QtWidgets.QPushButton("风险监控") 72 | 73 | for button in [ 74 | self.market_button, 75 | self.greeks_button, 76 | self.chain_button, 77 | self.manual_button, 78 | self.volatility_button, 79 | self.hedge_button, 80 | self.scenario_button, 81 | self.eye_button, 82 | self.pricing_button, 83 | self.risk_button 84 | ]: 85 | button.setEnabled(False) 86 | 87 | hbox: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout() 88 | hbox.addWidget(QtWidgets.QLabel("期权产品")) 89 | hbox.addWidget(self.portfolio_combo) 90 | hbox.addWidget(self.portfolio_button) 91 | hbox.addWidget(self.market_button) 92 | hbox.addWidget(self.greeks_button) 93 | hbox.addWidget(self.manual_button) 94 | hbox.addWidget(self.chain_button) 95 | hbox.addWidget(self.volatility_button) 96 | hbox.addWidget(self.hedge_button) 97 | hbox.addWidget(self.scenario_button) 98 | hbox.addWidget(self.pricing_button) 99 | hbox.addWidget(self.eye_button) 100 | hbox.addWidget(self.risk_button) 101 | 102 | self.setLayout(hbox) 103 | 104 | def register_event(self) -> None: 105 | """""" 106 | self.signal_new_portfolio.connect(self.process_new_portfolio_event) 107 | 108 | self.event_engine.register(EVENT_OPTION_NEW_PORTFOLIO, self.signal_new_portfolio.emit) 109 | 110 | def process_new_portfolio_event(self, event: Event) -> None: 111 | """""" 112 | self.update_portfolio_combo() 113 | 114 | def update_portfolio_combo(self) -> None: 115 | """""" 116 | if not self.portfolio_combo.isEnabled(): 117 | return 118 | 119 | self.portfolio_combo.clear() 120 | portfolio_names: list = self.option_engine.get_portfolio_names() 121 | self.portfolio_combo.addItems(portfolio_names) 122 | 123 | def open_portfolio_dialog(self) -> None: 124 | """""" 125 | portfolio_name: str = self.portfolio_combo.currentText() 126 | if not portfolio_name: 127 | return 128 | 129 | self.portfolio_name = portfolio_name 130 | 131 | dialog: PortfolioDialog = PortfolioDialog(self.option_engine, portfolio_name) 132 | result: int = dialog.exec_() 133 | 134 | if result == dialog.DialogCode.Accepted: 135 | self.portfolio_combo.setEnabled(False) 136 | self.portfolio_button.setEnabled(False) 137 | 138 | self.init_widgets() 139 | 140 | def init_widgets(self) -> None: 141 | """""" 142 | self.market_monitor = OptionMarketMonitor(self.option_engine, self.portfolio_name) 143 | self.greeks_monitor = OptionGreeksMonitor(self.option_engine, self.portfolio_name) 144 | self.volatility_chart = OptionVolatilityChart(self.option_engine, self.portfolio_name) 145 | self.chain_monitor = OptionChainMonitor(self.option_engine, self.portfolio_name) 146 | self.manual_trader = OptionManualTrader(self.option_engine, self.portfolio_name) 147 | self.hedge_widget = OptionHedgeWidget(self.option_engine, self.portfolio_name) 148 | self.scenario_chart = ScenarioAnalysisChart(self.option_engine, self.portfolio_name) 149 | self.eye_manager = ElectronicEyeManager(self.option_engine, self.portfolio_name) 150 | self.pricing_manager = PricingVolatilityManager(self.option_engine, self.portfolio_name) 151 | self.risk_widget = OptionRiskWidget(self.option_engine) 152 | 153 | self.market_monitor.itemDoubleClicked.connect(self.manual_trader.update_symbol) 154 | 155 | self.market_button.clicked.connect(self.market_monitor.show) 156 | self.greeks_button.clicked.connect(self.greeks_monitor.show) 157 | self.manual_button.clicked.connect(self.manual_trader.show) 158 | self.chain_button.clicked.connect(self.chain_monitor.show) 159 | self.volatility_button.clicked.connect(self.volatility_chart.show) 160 | self.scenario_button.clicked.connect(self.scenario_chart.show) 161 | self.hedge_button.clicked.connect(self.hedge_widget.show) 162 | self.eye_button.clicked.connect(self.eye_manager.show) 163 | self.pricing_button.clicked.connect(self.pricing_manager.show) 164 | self.risk_button.clicked.connect(self.risk_widget.show) 165 | 166 | for button in [ 167 | self.market_button, 168 | self.greeks_button, 169 | self.chain_button, 170 | self.manual_button, 171 | self.volatility_button, 172 | self.scenario_button, 173 | self.hedge_button, 174 | self.eye_button, 175 | self.pricing_button, 176 | self.risk_button 177 | ]: 178 | button.setEnabled(True) 179 | 180 | def closeEvent(self, event: QtGui.QCloseEvent) -> None: 181 | """""" 182 | if hasattr(self, "market_monitor"): 183 | self.market_monitor.close() 184 | self.greeks_monitor.close() 185 | self.volatility_chart.close() 186 | self.chain_monitor.close() 187 | self.manual_trader.close() 188 | self.hedge_widget.close() 189 | self.scenario_chart.close() 190 | self.eye_manager.close() 191 | self.pricing_manager.close() 192 | self.risk_widget.close() 193 | 194 | event.accept() 195 | 196 | 197 | class PortfolioDialog(QtWidgets.QDialog): 198 | """""" 199 | 200 | def __init__(self, option_engine: OptionEngine, portfolio_name: str): 201 | """""" 202 | super().__init__() 203 | 204 | self.option_engine: OptionEngine = option_engine 205 | self.portfolio_name: str = portfolio_name 206 | 207 | self.init_ui() 208 | 209 | def init_ui(self) -> None: 210 | """""" 211 | self.setWindowTitle(f"{self.portfolio_name}组合配置") 212 | 213 | portfolio_setting: dict = self.option_engine.get_portfolio_setting( 214 | self.portfolio_name 215 | ) 216 | 217 | form: QtWidgets.QFormLayout = QtWidgets.QFormLayout() 218 | 219 | # Model Combo 220 | self.model_name_combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 221 | self.model_name_combo.addItems(list(PRICING_MODELS.keys())) 222 | 223 | model_name: str = portfolio_setting.get("model_name", "") 224 | if model_name: 225 | self.model_name_combo.setCurrentIndex( 226 | self.model_name_combo.findText(model_name) 227 | ) 228 | 229 | form.addRow("定价模型", self.model_name_combo) 230 | 231 | # Interest rate spin 232 | self.interest_rate_spin: QtWidgets.QDoubleSpinBox = QtWidgets.QDoubleSpinBox() 233 | self.interest_rate_spin.setMinimum(0) 234 | self.interest_rate_spin.setMaximum(20) 235 | self.interest_rate_spin.setDecimals(1) 236 | self.interest_rate_spin.setSuffix("%") 237 | 238 | interest_rate: float = portfolio_setting.get("interest_rate", 0.02) 239 | self.interest_rate_spin.setValue(interest_rate * 100) 240 | 241 | form.addRow("年化利率", self.interest_rate_spin) 242 | 243 | # Greeks decimals precision 244 | self.precision_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 245 | self.precision_spin.setMinimum(0) 246 | self.precision_spin.setMaximum(10) 247 | 248 | precision: int = portfolio_setting.get("precision", 0) 249 | self.precision_spin.setValue(precision) 250 | 251 | form.addRow("Greeks小数位", self.precision_spin) 252 | 253 | # Underlying for each chain 254 | self.combos: dict[str, QtWidgets.QComboBox] = {} 255 | 256 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 257 | underlying_symbols: list = self.option_engine.get_underlying_symbols( 258 | self.portfolio_name 259 | ) 260 | 261 | chain_symbols: list = list(portfolio._chains.keys()) 262 | chain_symbols.sort() 263 | 264 | chain_underlying_map: dict = portfolio_setting.get("chain_underlying_map", {}) 265 | 266 | for chain_symbol in chain_symbols: 267 | combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 268 | combo.addItem("") 269 | combo.addItems(underlying_symbols) 270 | 271 | symbol, _ = chain_symbol.split(".") 272 | synthetic_symbol = f"{symbol}.LOCAL" 273 | combo.addItem(synthetic_symbol) 274 | 275 | underlying_symbol: str = chain_underlying_map.get(chain_symbol, "") 276 | if underlying_symbol: 277 | combo.setCurrentIndex(combo.findText(underlying_symbol)) 278 | 279 | form.addRow(chain_symbol, combo) 280 | self.combos[chain_symbol] = combo 281 | 282 | # Set layout 283 | button: QtWidgets.QPushButton = QtWidgets.QPushButton("确定") 284 | button.clicked.connect(self.update_portfolio_setting) 285 | form.addRow(button) 286 | 287 | self.setLayout(form) 288 | 289 | def update_portfolio_setting(self) -> None: 290 | """""" 291 | model_name: str = self.model_name_combo.currentText() 292 | interest_rate: float = self.interest_rate_spin.value() / 100 293 | 294 | precision: int = self.precision_spin.value() 295 | 296 | chain_underlying_map: dict = {} 297 | for chain_symbol, combo in self.combos.items(): 298 | underlying_symbol: str = combo.currentText() 299 | 300 | if underlying_symbol: 301 | chain_underlying_map[chain_symbol] = underlying_symbol 302 | 303 | self.option_engine.update_portfolio_setting( 304 | self.portfolio_name, 305 | model_name, 306 | interest_rate, 307 | chain_underlying_map, 308 | precision 309 | ) 310 | 311 | result: bool = self.option_engine.init_portfolio(self.portfolio_name) 312 | 313 | if result: 314 | self.accept() 315 | else: 316 | self.close() 317 | 318 | 319 | class OptionManualTrader(QtWidgets.QWidget): 320 | """""" 321 | signal_tick: QtCore.Signal = QtCore.Signal(TickData) 322 | 323 | def __init__(self, option_engine: OptionEngine, portfolio_name: str) -> None: 324 | """""" 325 | super().__init__() 326 | 327 | self.option_engine: OptionEngine = option_engine 328 | self.main_engine: MainEngine = option_engine.main_engine 329 | self.event_engine: EventEngine = option_engine.event_engine 330 | 331 | self.contracts: dict[str, ContractData] = {} 332 | self.vt_symbol: str = "" 333 | self.price_digits: int = 0 334 | 335 | self.init_ui() 336 | self.init_contracts() 337 | self.connect_signal() 338 | 339 | def init_ui(self) -> None: 340 | """""" 341 | self.setWindowTitle("期权交易") 342 | 343 | # Trading Area 344 | self.symbol_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit() 345 | self.symbol_line.returnPressed.connect(self._update_symbol) 346 | 347 | float_validator: QtGui.QDoubleValidator = QtGui.QDoubleValidator() 348 | float_validator.setBottom(0) 349 | 350 | self.price_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit() 351 | self.price_line.setValidator(float_validator) 352 | 353 | int_validator: QtGui.QIntValidator = QtGui.QIntValidator() 354 | int_validator.setBottom(0) 355 | 356 | self.volume_line: QtWidgets.QLineEdit = QtWidgets.QLineEdit() 357 | self.volume_line.setValidator(int_validator) 358 | 359 | self.direction_combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 360 | self.direction_combo.addItems([ 361 | Direction.LONG.value, 362 | Direction.SHORT.value 363 | ]) 364 | 365 | self.offset_combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 366 | self.offset_combo.addItems([ 367 | Offset.OPEN.value, 368 | Offset.CLOSE.value 369 | ]) 370 | 371 | order_button: QtWidgets.QPushButton = QtWidgets.QPushButton("委托") 372 | order_button.clicked.connect(self.send_order) 373 | 374 | cancel_button: QtWidgets.QPushButton = QtWidgets.QPushButton("全撤") 375 | cancel_button.clicked.connect(self.cancel_all) 376 | 377 | form1: QtWidgets.QFormLayout = QtWidgets.QFormLayout() 378 | form1.addRow("代码", self.symbol_line) 379 | form1.addRow("方向", self.direction_combo) 380 | form1.addRow("开平", self.offset_combo) 381 | form1.addRow("价格", self.price_line) 382 | form1.addRow("数量", self.volume_line) 383 | form1.addRow(order_button) 384 | form1.addRow(cancel_button) 385 | 386 | # Depth Area 387 | bid_color: str = "rgb(255,174,201)" 388 | ask_color: str = "rgb(160,255,160)" 389 | 390 | self.bp1_label: QtWidgets.QLabel = self.create_label(bid_color) 391 | self.bp2_label: QtWidgets.QLabel = self.create_label(bid_color) 392 | self.bp3_label: QtWidgets.QLabel = self.create_label(bid_color) 393 | self.bp4_label: QtWidgets.QLabel = self.create_label(bid_color) 394 | self.bp5_label: QtWidgets.QLabel = self.create_label(bid_color) 395 | 396 | self.bv1_label: QtWidgets.QLabel = self.create_label( 397 | bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 398 | self.bv2_label: QtWidgets.QLabel = self.create_label( 399 | bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 400 | self.bv3_label: QtWidgets.QLabel = self.create_label( 401 | bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 402 | self.bv4_label: QtWidgets.QLabel = self.create_label( 403 | bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 404 | self.bv5_label: QtWidgets.QLabel = self.create_label( 405 | bid_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 406 | 407 | self.ap1_label: QtWidgets.QLabel = self.create_label(ask_color) 408 | self.ap2_label: QtWidgets.QLabel = self.create_label(ask_color) 409 | self.ap3_label: QtWidgets.QLabel = self.create_label(ask_color) 410 | self.ap4_label: QtWidgets.QLabel = self.create_label(ask_color) 411 | self.ap5_label: QtWidgets.QLabel = self.create_label(ask_color) 412 | 413 | self.av1_label: QtWidgets.QLabel = self.create_label( 414 | ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 415 | self.av2_label: QtWidgets.QLabel = self.create_label( 416 | ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 417 | self.av3_label: QtWidgets.QLabel = self.create_label( 418 | ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 419 | self.av4_label: QtWidgets.QLabel = self.create_label( 420 | ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 421 | self.av5_label: QtWidgets.QLabel = self.create_label( 422 | ask_color, alignment=QtCore.Qt.AlignmentFlag.AlignRight) 423 | 424 | self.lp_label: QtWidgets.QLabel = self.create_label() 425 | self.return_label: QtWidgets.QLabel = self.create_label(alignment=QtCore.Qt.AlignmentFlag.AlignRight) 426 | 427 | min_width: int = 70 428 | self.lp_label.setMinimumWidth(min_width) 429 | self.return_label.setMinimumWidth(min_width) 430 | 431 | form2: QtWidgets.QFormLayout = QtWidgets.QFormLayout() 432 | form2.addRow(self.ap5_label, self.av5_label) 433 | form2.addRow(self.ap4_label, self.av4_label) 434 | form2.addRow(self.ap3_label, self.av3_label) 435 | form2.addRow(self.ap2_label, self.av2_label) 436 | form2.addRow(self.ap1_label, self.av1_label) 437 | form2.addRow(self.lp_label, self.return_label) 438 | form2.addRow(self.bp1_label, self.bv1_label) 439 | form2.addRow(self.bp2_label, self.bv2_label) 440 | form2.addRow(self.bp3_label, self.bv3_label) 441 | form2.addRow(self.bp4_label, self.bv4_label) 442 | form2.addRow(self.bp5_label, self.bv5_label) 443 | 444 | # Set layout 445 | hbox: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout() 446 | hbox.addLayout(form1) 447 | hbox.addLayout(form2) 448 | self.setLayout(hbox) 449 | 450 | def init_contracts(self) -> None: 451 | """""" 452 | contracts: list[ContractData] = self.main_engine.get_all_contracts() 453 | for contract in contracts: 454 | self.contracts[contract.symbol] = contract 455 | 456 | def connect_signal(self) -> None: 457 | """""" 458 | self.signal_tick.connect(self.update_tick) 459 | 460 | def send_order(self) -> None: 461 | """""" 462 | symbol: str = self.symbol_line.text() 463 | contract: ContractData | None = self.contracts.get(symbol, None) 464 | if not contract: 465 | return 466 | 467 | price_text: str = self.price_line.text() 468 | volume_text: str = self.volume_line.text() 469 | 470 | if not price_text or not volume_text: 471 | return 472 | 473 | price: float = float(price_text) 474 | volume: int = int(volume_text) 475 | direction: Direction = Direction(self.direction_combo.currentText()) 476 | offset: Offset = Offset(self.offset_combo.currentText()) 477 | 478 | req: OrderRequest = OrderRequest( 479 | symbol=contract.symbol, 480 | exchange=contract.exchange, 481 | direction=direction, 482 | type=OrderType.LIMIT, 483 | offset=offset, 484 | volume=volume, 485 | price=price 486 | ) 487 | self.main_engine.send_order(req, contract.gateway_name) 488 | 489 | def cancel_all(self) -> None: 490 | """""" 491 | for order in self.main_engine.get_all_active_orders(): 492 | req: CancelRequest = order.create_cancel_request() 493 | self.main_engine.cancel_order(req, order.gateway_name) 494 | 495 | def update_symbol(self, cell: MonitorCell) -> None: 496 | """""" 497 | if not cell.vt_symbol: 498 | return 499 | 500 | symbol: str = cell.vt_symbol.split(".")[0] 501 | self.symbol_line.setText(symbol) 502 | self._update_symbol() 503 | 504 | def _update_symbol(self) -> None: 505 | """""" 506 | symbol: str = self.symbol_line.text() 507 | contract: ContractData | None = self.contracts.get(symbol, None) 508 | 509 | if contract and contract.vt_symbol == self.vt_symbol: 510 | return 511 | 512 | if self.vt_symbol: 513 | self.event_engine.unregister(EVENT_TICK + self.vt_symbol, self.process_tick_event) 514 | self.clear_data() 515 | self.vt_symbol = "" 516 | 517 | if not contract: 518 | return 519 | 520 | vt_symbol: str = contract.vt_symbol 521 | self.vt_symbol = vt_symbol 522 | self.price_digits = get_digits(contract.pricetick) 523 | 524 | tick: TickData | None = self.main_engine.get_tick(vt_symbol) 525 | if tick: 526 | self.update_tick(tick) 527 | 528 | self.event_engine.register(EVENT_TICK + vt_symbol, self.process_tick_event) 529 | 530 | def create_label( 531 | self, 532 | color: str = "", 533 | alignment: QtCore.Qt.AlignmentFlag = QtCore.Qt.AlignmentFlag.AlignLeft 534 | ) -> QtWidgets.QLabel: 535 | """ 536 | Create label with certain font color. 537 | """ 538 | label: QtWidgets.QLabel = QtWidgets.QLabel("-") 539 | if color: 540 | label.setStyleSheet(f"color:{color}") 541 | label.setAlignment(alignment) 542 | return label 543 | 544 | def process_tick_event(self, event: Event) -> None: 545 | """""" 546 | tick: TickData = event.data 547 | if tick.vt_symbol != self.vt_symbol: 548 | return 549 | self.signal_tick.emit(tick) 550 | 551 | def update_tick(self, tick: TickData) -> None: 552 | """""" 553 | price_digits: int = self.price_digits 554 | 555 | self.lp_label.setText(f"{tick.last_price:.{price_digits}f}") 556 | self.bp1_label.setText(f"{tick.bid_price_1:.{price_digits}f}") 557 | self.bv1_label.setText(str(tick.bid_volume_1)) 558 | self.ap1_label.setText(f"{tick.ask_price_1:.{price_digits}f}") 559 | self.av1_label.setText(str(tick.ask_volume_1)) 560 | 561 | if tick.pre_close: 562 | r: float = (tick.last_price / tick.pre_close - 1) * 100 563 | self.return_label.setText(f"{r:.2f}%") 564 | 565 | if tick.bid_price_2: 566 | self.bp2_label.setText(f"{tick.bid_price_2:.{price_digits}f}") 567 | self.bv2_label.setText(str(tick.bid_volume_2)) 568 | self.ap2_label.setText(f"{tick.ask_price_2:.{price_digits}f}") 569 | self.av2_label.setText(str(tick.ask_volume_2)) 570 | 571 | self.bp3_label.setText(f"{tick.bid_price_3:.{price_digits}f}") 572 | self.bv3_label.setText(str(tick.bid_volume_3)) 573 | self.ap3_label.setText(f"{tick.ask_price_3:.{price_digits}f}") 574 | self.av3_label.setText(str(tick.ask_volume_3)) 575 | 576 | self.bp4_label.setText(f"{tick.bid_price_4:.{price_digits}f}") 577 | self.bv4_label.setText(str(tick.bid_volume_4)) 578 | self.ap4_label.setText(f"{tick.ask_price_4:.{price_digits}f}") 579 | self.av4_label.setText(str(tick.ask_volume_4)) 580 | 581 | self.bp5_label.setText(f"{tick.bid_price_5:.{price_digits}f}") 582 | self.bv5_label.setText(str(tick.bid_volume_5)) 583 | self.ap5_label.setText(f"{tick.ask_price_5:.{price_digits}f}") 584 | self.av5_label.setText(str(tick.ask_volume_5)) 585 | 586 | def clear_data(self) -> None: 587 | """""" 588 | self.lp_label.setText("-") 589 | self.return_label.setText("-") 590 | self.bp1_label.setText("-") 591 | self.bv1_label.setText("-") 592 | self.ap1_label.setText("-") 593 | self.av1_label.setText("-") 594 | 595 | self.bp2_label.setText("-") 596 | self.bv2_label.setText("-") 597 | self.ap2_label.setText("-") 598 | self.av2_label.setText("-") 599 | 600 | self.bp3_label.setText("-") 601 | self.bv3_label.setText("-") 602 | self.ap3_label.setText("-") 603 | self.av3_label.setText("-") 604 | 605 | self.bp4_label.setText("-") 606 | self.bv4_label.setText("-") 607 | self.ap4_label.setText("-") 608 | self.av4_label.setText("-") 609 | 610 | self.bp5_label.setText("-") 611 | self.bv5_label.setText("-") 612 | self.ap5_label.setText("-") 613 | self.av5_label.setText("-") 614 | 615 | 616 | class OptionHedgeWidget(QtWidgets.QWidget): 617 | """""" 618 | 619 | def __init__(self, option_engine: OptionEngine, portfolio_name: str) -> None: 620 | """""" 621 | super().__init__() 622 | 623 | self.option_engine: OptionEngine = option_engine 624 | self.portfolio_name: str = portfolio_name 625 | self.hedge_engine: OptionHedgeEngine = option_engine.hedge_engine 626 | 627 | self.symbol_map: dict[str, str] = {} 628 | 629 | self.init_ui() 630 | 631 | def init_ui(self) -> None: 632 | """""" 633 | self.setWindowTitle("Delta对冲") 634 | 635 | portfolio: PortfolioData = self.option_engine.get_portfolio(self.portfolio_name) 636 | underlying_symbols: list = [vs for vs in portfolio.underlyings.keys() if "LOCAL" not in vs] 637 | underlying_symbols.sort() 638 | 639 | self.symbol_combo: QtWidgets.QComboBox = QtWidgets.QComboBox() 640 | self.symbol_combo.addItems(underlying_symbols) 641 | 642 | self.trigger_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 643 | self.trigger_spin.setSuffix("秒") 644 | self.trigger_spin.setMinimum(1) 645 | self.trigger_spin.setValue(5) 646 | 647 | self.target_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 648 | self.target_spin.setMaximum(99999999) 649 | self.target_spin.setMinimum(-99999999) 650 | self.target_spin.setValue(0) 651 | 652 | self.range_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 653 | self.range_spin.setMinimum(0) 654 | self.range_spin.setMaximum(9999999) 655 | self.range_spin.setValue(12000) 656 | 657 | self.payup_spin: QtWidgets.QSpinBox = QtWidgets.QSpinBox() 658 | self.payup_spin.setMinimum(0) 659 | self.payup_spin.setValue(3) 660 | 661 | self.start_button: QtWidgets.QPushButton = QtWidgets.QPushButton("启动") 662 | self.start_button.clicked.connect(self.start) 663 | 664 | self.stop_button: QtWidgets.QPushButton = QtWidgets.QPushButton("停止") 665 | self.stop_button.clicked.connect(self.stop) 666 | self.stop_button.setEnabled(False) 667 | 668 | form: QtWidgets.QFormLayout = QtWidgets.QFormLayout() 669 | form.addRow("对冲合约", self.symbol_combo) 670 | form.addRow("执行频率", self.trigger_spin) 671 | form.addRow("Delta目标", self.target_spin) 672 | form.addRow("对冲阈值", self.range_spin) 673 | form.addRow("委托超价", self.payup_spin) 674 | form.addRow(self.start_button) 675 | form.addRow(self.stop_button) 676 | 677 | self.setLayout(form) 678 | 679 | def start(self) -> None: 680 | """""" 681 | vt_symbol: str = self.symbol_combo.currentText() 682 | timer_trigger: int = self.trigger_spin.value() 683 | delta_target: int = self.target_spin.value() 684 | delta_range: int = self.range_spin.value() 685 | hedge_payup: int = self.payup_spin.value() 686 | 687 | # Check delta of underlying 688 | underlying: UnderlyingData = cast(UnderlyingData, self.option_engine.get_instrument(vt_symbol)) 689 | min_range: int = int(underlying.theo_delta * 0.6) 690 | if delta_range < min_range: 691 | msg: str = f"Delta对冲阈值({delta_range})低于对冲合约"\ 692 | f"Delta值的60%({min_range}),可能导致来回频繁对冲!" 693 | 694 | QtWidgets.QMessageBox.warning( 695 | self, 696 | "无法启动自动对冲", 697 | msg, 698 | QtWidgets.QMessageBox.StandardButton.Ok 699 | ) 700 | return 701 | 702 | self.hedge_engine.start( 703 | self.portfolio_name, 704 | vt_symbol, 705 | timer_trigger, 706 | delta_target, 707 | delta_range, 708 | hedge_payup 709 | ) 710 | 711 | self.update_widget_status(False) 712 | 713 | def stop(self) -> None: 714 | """""" 715 | self.hedge_engine.stop() 716 | 717 | self.update_widget_status(True) 718 | 719 | def update_widget_status(self, status: bool) -> None: 720 | """""" 721 | self.start_button.setEnabled(status) 722 | self.symbol_combo.setEnabled(status) 723 | self.target_spin.setEnabled(status) 724 | self.range_spin.setEnabled(status) 725 | self.payup_spin.setEnabled(status) 726 | self.trigger_spin.setEnabled(status) 727 | self.stop_button.setEnabled(not status) 728 | 729 | 730 | class OptionRiskWidget(QtWidgets.QWidget): 731 | """期权风险监控组件""" 732 | 733 | signal: QtCore.Signal = QtCore.Signal(Event) 734 | 735 | def __init__(self, option_engine: OptionEngine) -> None: 736 | """""" 737 | super().__init__() 738 | 739 | self.event_engine: EventEngine = option_engine.event_engine 740 | 741 | self.cancel_order_limit: float = 0.9 742 | self.trade_position_limit: float = 99999 743 | 744 | self.tray_icon: QtWidgets.QSystemTrayIcon | None = None 745 | 746 | self.init_ui() 747 | self.register_event() 748 | 749 | def init_ui(self) -> None: 750 | """""" 751 | self.setWindowTitle("风险监控") 752 | self.resize(400, 200) 753 | 754 | self.trade_volume_label: QtWidgets.QLabel = QtWidgets.QLabel("0") 755 | self.net_pos_label: QtWidgets.QLabel = QtWidgets.QLabel("0") 756 | self.order_count_label: QtWidgets.QLabel = QtWidgets.QLabel("0") 757 | self.cancel_count_label: QtWidgets.QLabel = QtWidgets.QLabel("0") 758 | self.trade_position_ratio_label: QtWidgets.QLabel = QtWidgets.QLabel("0") 759 | self.cancel_order_ratio_label: QtWidgets.QLabel = QtWidgets.QLabel("0") 760 | 761 | self.trade_position_limit_spin: QtWidgets.QDoubleSpinBox = QtWidgets.QDoubleSpinBox() 762 | self.trade_position_limit_spin.setDecimals(1) 763 | self.trade_position_limit_spin.setRange(0, 100000) 764 | self.trade_position_limit_spin.setValue(self.trade_position_limit) 765 | self.trade_position_limit_spin.valueChanged.connect(self.set_trade_position_limit) 766 | 767 | self.cancel_order_ratio_spin: QtWidgets.QDoubleSpinBox = QtWidgets.QDoubleSpinBox() 768 | self.cancel_order_ratio_spin.setDecimals(1) 769 | self.cancel_order_ratio_spin.setRange(0, 1) 770 | self.cancel_order_ratio_spin.setValue(self.cancel_order_limit) 771 | self.cancel_order_ratio_spin.valueChanged.connect(self.set_cancel_order_limit) 772 | 773 | form: QtWidgets.QFormLayout = QtWidgets.QFormLayout() 774 | form.addRow("成交持仓限制", self.trade_position_limit_spin) 775 | form.addRow("撤单委托限制", self.cancel_order_ratio_spin) 776 | form.addRow(QtWidgets.QLabel(" ")) 777 | form.addRow("总成交量", self.trade_volume_label) 778 | form.addRow("净持仓量", self.net_pos_label) 779 | form.addRow("成交持仓比", self.trade_position_ratio_label) 780 | form.addRow("委托笔数", self.order_count_label) 781 | form.addRow("撤单笔数", self.cancel_count_label) 782 | form.addRow("撤单委托比", self.cancel_order_ratio_label) 783 | 784 | self.setLayout(form) 785 | 786 | icon_path: Path = Path(__file__).parent.joinpath("option.ico") 787 | icon: QtGui.QIcon = QtGui.QIcon(str(icon_path)) 788 | self.tray_icon = QtWidgets.QSystemTrayIcon() 789 | self.tray_icon.setIcon(icon) 790 | self.tray_icon.setVisible(True) 791 | 792 | def register_event(self) -> None: 793 | """""" 794 | self.signal.connect(self.process_event) 795 | self.event_engine.register(EVENT_OPTION_RISK_NOTICE, self.signal.emit) 796 | 797 | def process_event(self, event: Event) -> None: 798 | """""" 799 | data = event.data 800 | self.trade_volume_label.setText(str(data["trade_volume"])) 801 | self.net_pos_label.setText(str(data["net_pos"])) 802 | self.order_count_label.setText(str(data["order_count"])) 803 | self.cancel_count_label.setText(str(data["cancel_count"])) 804 | self.trade_position_ratio_label.setText(f"{data['trade_position_ratio']:.2f}") 805 | self.cancel_order_ratio_label.setText(f"{data['cancel_order_ratio']:.2f}") 806 | 807 | texts: list = [] 808 | if data["trade_position_ratio"] >= self.trade_position_limit: 809 | ratio = data["trade_position_ratio"] 810 | texts.append(f"当前交易持仓比{ratio}超过限制{self.trade_position_limit}!") 811 | 812 | if data["cancel_order_ratio"] >= self.cancel_order_limit: 813 | ratio = data["cancel_order_ratio"] 814 | texts.append(f"当前撤单委托比{ratio}超过限制{self.cancel_order_limit}!") 815 | 816 | if texts: 817 | msg: str = "\n\n".join(texts) 818 | self.show_warning(msg) 819 | 820 | def set_cancel_order_limit(self, limit: float) -> None: 821 | """设置撤单委托比限制""" 822 | self.cancel_order_limit = limit 823 | 824 | def set_trade_position_limit(self, limit: float) -> None: 825 | """设置成交持仓比限制""" 826 | self.trade_position_limit = limit 827 | 828 | def show_warning(self, msg: str) -> None: 829 | """显示提示信息""" 830 | if self.tray_icon: 831 | self.tray_icon.showMessage("风险提示", msg) 832 | --------------------------------------------------------------------------------