├── 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 |
--------------------------------------------------------------------------------