qmtlink 0.1.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qmtlink-0.1.0a1/LICENSE +21 -0
- qmtlink-0.1.0a1/PKG-INFO +124 -0
- qmtlink-0.1.0a1/README.md +99 -0
- qmtlink-0.1.0a1/pyproject.toml +72 -0
- qmtlink-0.1.0a1/pyproject.toml.orig +58 -0
- qmtlink-0.1.0a1/src/qmtlink/__init__.py +13 -0
- qmtlink-0.1.0a1/src/qmtlink/__main__.py +4 -0
- qmtlink-0.1.0a1/src/qmtlink/bridge/__init__.py +4 -0
- qmtlink-0.1.0a1/src/qmtlink/bridge/base.py +19 -0
- qmtlink-0.1.0a1/src/qmtlink/bridge/factory.py +16 -0
- qmtlink-0.1.0a1/src/qmtlink/bridge/mock.py +58 -0
- qmtlink-0.1.0a1/src/qmtlink/bridge/xtquant.py +79 -0
- qmtlink-0.1.0a1/src/qmtlink/cli/__init__.py +1 -0
- qmtlink-0.1.0a1/src/qmtlink/cli/main.py +169 -0
- qmtlink-0.1.0a1/src/qmtlink/cli/output.py +23 -0
- qmtlink-0.1.0a1/src/qmtlink/client/__init__.py +3 -0
- qmtlink-0.1.0a1/src/qmtlink/client/client.py +84 -0
- qmtlink-0.1.0a1/src/qmtlink/config.py +49 -0
- qmtlink-0.1.0a1/src/qmtlink/errors.py +26 -0
- qmtlink-0.1.0a1/src/qmtlink/models/__init__.py +12 -0
- qmtlink-0.1.0a1/src/qmtlink/models/market.py +25 -0
- qmtlink-0.1.0a1/src/qmtlink/models/order.py +54 -0
- qmtlink-0.1.0a1/src/qmtlink/server/__init__.py +1 -0
- qmtlink-0.1.0a1/src/qmtlink/server/app.py +116 -0
- qmtlink-0.1.0a1/src/qmtlink/server/runner.py +14 -0
qmtlink-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QmtLink Contributors
|
|
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.
|
qmtlink-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qmtlink
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: An unofficial CLI, Python SDK, and HTTP bridge for miniQMT/xtquant
|
|
5
|
+
Keywords: miniqmt,qmt,xtquant,trading,cli
|
|
6
|
+
Author: QmtLink Contributors
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
15
|
+
Requires-Dist: pydantic>=2.13.4
|
|
16
|
+
Requires-Dist: typer>=0.27.1
|
|
17
|
+
Requires-Dist: fastapi>=0.141.1 ; extra == 'server'
|
|
18
|
+
Requires-Dist: uvicorn[standard]>=0.52.1 ; extra == 'server'
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Project-URL: Homepage, https://github.com/ilwk/qmtlink
|
|
21
|
+
Project-URL: Repository, https://github.com/ilwk/qmtlink
|
|
22
|
+
Project-URL: Issues, https://github.com/ilwk/qmtlink/issues
|
|
23
|
+
Provides-Extra: server
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# QmtLink
|
|
27
|
+
|
|
28
|
+
QmtLink 是一个非官方的 miniQMT/xtquant 跨平台中转项目,提供:
|
|
29
|
+
|
|
30
|
+
- Windows Bridge 一键启动命令
|
|
31
|
+
- 面向 AI 和自动化脚本的 JSON CLI
|
|
32
|
+
- 面向 Python 量化项目的 SDK
|
|
33
|
+
- HTTP API(实时 WebSocket 将在后续版本加入)
|
|
34
|
+
|
|
35
|
+
> 当前版本是开发预览版。Mock Bridge、HTTP API、CLI 和 SDK 可用;真实 miniQMT
|
|
36
|
+
> 交易尚未实现,请勿用于实盘。
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
客户端和 CLI:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add qmtlink
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Windows Bridge:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv add "qmtlink[server]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 快速体验
|
|
53
|
+
|
|
54
|
+
启动 Mock Bridge:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
qmt bridge run --mock
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
另一个终端执行:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
qmt health
|
|
64
|
+
qmt capabilities
|
|
65
|
+
qmt market quote --symbol 000001.SZ --symbol 600519.SH
|
|
66
|
+
qmt order preview --symbol 000001.SZ --side buy --quantity 100 --price 10.50
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
所有 CLI 命令默认输出 JSON。`qmtlink` 是 `qmt` 的等价备用命令。
|
|
70
|
+
|
|
71
|
+
## Python SDK
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from qmtlink import QMTClient
|
|
75
|
+
|
|
76
|
+
with QMTClient("http://127.0.0.1:8000") as client:
|
|
77
|
+
print(client.health())
|
|
78
|
+
print(client.get_quotes(["000001.SZ"]))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Bridge 诊断
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
qmt bridge doctor
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
真实模式会延迟导入 xtquant,基础客户端包在 Linux/macOS 上不会导入它:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
qmt bridge run
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 安全原则
|
|
94
|
+
|
|
95
|
+
- 真实交易默认关闭。
|
|
96
|
+
- 交易请求必须携带唯一 `client_order_id`。
|
|
97
|
+
- 下单请求不得在网络超时后盲目重试。
|
|
98
|
+
- API Key 通过 `QMTLINK_API_KEY` 提供,不建议放入命令参数。
|
|
99
|
+
|
|
100
|
+
## 环境变量
|
|
101
|
+
|
|
102
|
+
| 变量 | 默认值 | 用途 |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `QMTLINK_URL` | `http://127.0.0.1:8000` | CLI/SDK 服务地址 |
|
|
105
|
+
| `QMTLINK_API_KEY` | 空 | API Key |
|
|
106
|
+
| `QMTLINK_HOST` | `127.0.0.1` | Bridge 监听地址 |
|
|
107
|
+
| `QMTLINK_PORT` | `8000` | Bridge 监听端口 |
|
|
108
|
+
| `QMTLINK_MODE` | `real` | `real` 或 `mock` |
|
|
109
|
+
| `QMTLINK_ALLOW_LIVE_ORDERS` | `false` | 允许提交订单 |
|
|
110
|
+
|
|
111
|
+
## 开发
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv sync --extra server
|
|
115
|
+
uv run pytest
|
|
116
|
+
uv run ruff check .
|
|
117
|
+
uv build --no-sources
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
开发路线见 [ROADMAP.md](ROADMAP.md)。
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT。QmtLink 与 miniQMT、QMT、xtquant 及其权利方不存在官方隶属或背书关系。
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# QmtLink
|
|
2
|
+
|
|
3
|
+
QmtLink 是一个非官方的 miniQMT/xtquant 跨平台中转项目,提供:
|
|
4
|
+
|
|
5
|
+
- Windows Bridge 一键启动命令
|
|
6
|
+
- 面向 AI 和自动化脚本的 JSON CLI
|
|
7
|
+
- 面向 Python 量化项目的 SDK
|
|
8
|
+
- HTTP API(实时 WebSocket 将在后续版本加入)
|
|
9
|
+
|
|
10
|
+
> 当前版本是开发预览版。Mock Bridge、HTTP API、CLI 和 SDK 可用;真实 miniQMT
|
|
11
|
+
> 交易尚未实现,请勿用于实盘。
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
客户端和 CLI:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add qmtlink
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Windows Bridge:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv add "qmtlink[server]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 快速体验
|
|
28
|
+
|
|
29
|
+
启动 Mock Bridge:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
qmt bridge run --mock
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
另一个终端执行:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
qmt health
|
|
39
|
+
qmt capabilities
|
|
40
|
+
qmt market quote --symbol 000001.SZ --symbol 600519.SH
|
|
41
|
+
qmt order preview --symbol 000001.SZ --side buy --quantity 100 --price 10.50
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
所有 CLI 命令默认输出 JSON。`qmtlink` 是 `qmt` 的等价备用命令。
|
|
45
|
+
|
|
46
|
+
## Python SDK
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from qmtlink import QMTClient
|
|
50
|
+
|
|
51
|
+
with QMTClient("http://127.0.0.1:8000") as client:
|
|
52
|
+
print(client.health())
|
|
53
|
+
print(client.get_quotes(["000001.SZ"]))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Bridge 诊断
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
qmt bridge doctor
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
真实模式会延迟导入 xtquant,基础客户端包在 Linux/macOS 上不会导入它:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
qmt bridge run
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 安全原则
|
|
69
|
+
|
|
70
|
+
- 真实交易默认关闭。
|
|
71
|
+
- 交易请求必须携带唯一 `client_order_id`。
|
|
72
|
+
- 下单请求不得在网络超时后盲目重试。
|
|
73
|
+
- API Key 通过 `QMTLINK_API_KEY` 提供,不建议放入命令参数。
|
|
74
|
+
|
|
75
|
+
## 环境变量
|
|
76
|
+
|
|
77
|
+
| 变量 | 默认值 | 用途 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `QMTLINK_URL` | `http://127.0.0.1:8000` | CLI/SDK 服务地址 |
|
|
80
|
+
| `QMTLINK_API_KEY` | 空 | API Key |
|
|
81
|
+
| `QMTLINK_HOST` | `127.0.0.1` | Bridge 监听地址 |
|
|
82
|
+
| `QMTLINK_PORT` | `8000` | Bridge 监听端口 |
|
|
83
|
+
| `QMTLINK_MODE` | `real` | `real` 或 `mock` |
|
|
84
|
+
| `QMTLINK_ALLOW_LIVE_ORDERS` | `false` | 允许提交订单 |
|
|
85
|
+
|
|
86
|
+
## 开发
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv sync --extra server
|
|
90
|
+
uv run pytest
|
|
91
|
+
uv run ruff check .
|
|
92
|
+
uv build --no-sources
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
开发路线见 [ROADMAP.md](ROADMAP.md)。
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT。QmtLink 与 miniQMT、QMT、xtquant 及其权利方不存在官方隶属或背书关系。
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qmtlink"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "An unofficial CLI, Python SDK, and HTTP bridge for miniQMT/xtquant"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"miniqmt",
|
|
11
|
+
"qmt",
|
|
12
|
+
"xtquant",
|
|
13
|
+
"trading",
|
|
14
|
+
"cli",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.28.1",
|
|
25
|
+
"pydantic>=2.13.4",
|
|
26
|
+
"typer>=0.27.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "QmtLink Contributors"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
qmt = "qmtlink.cli.main:main"
|
|
34
|
+
qmtlink = "qmtlink.cli.main:main"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
server = [
|
|
38
|
+
"fastapi>=0.141.1",
|
|
39
|
+
"uvicorn[standard]>=0.52.1",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/ilwk/qmtlink"
|
|
44
|
+
Repository = "https://github.com/ilwk/qmtlink"
|
|
45
|
+
Issues = "https://github.com/ilwk/qmtlink/issues"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=9.1.1",
|
|
54
|
+
"pytest-asyncio>=1.4.0",
|
|
55
|
+
"ruff>=0.16.1",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = [
|
|
64
|
+
"E",
|
|
65
|
+
"F",
|
|
66
|
+
"I",
|
|
67
|
+
"UP",
|
|
68
|
+
"B",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qmtlink"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "An unofficial CLI, Python SDK, and HTTP bridge for miniQMT/xtquant"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "QmtLink Contributors" }]
|
|
10
|
+
keywords = ["miniqmt", "qmt", "xtquant", "trading", "cli"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"httpx>=0.28.1",
|
|
20
|
+
"pydantic>=2.13.4",
|
|
21
|
+
"typer>=0.27.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
qmt = "qmtlink.cli.main:main"
|
|
26
|
+
qmtlink = "qmtlink.cli.main:main"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
server = [
|
|
30
|
+
"fastapi>=0.141.1",
|
|
31
|
+
"uvicorn[standard]>=0.52.1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ilwk/qmtlink"
|
|
36
|
+
Repository = "https://github.com/ilwk/qmtlink"
|
|
37
|
+
Issues = "https://github.com/ilwk/qmtlink/issues"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=9.1.1",
|
|
46
|
+
"pytest-asyncio>=1.4.0",
|
|
47
|
+
"ruff>=0.16.1",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Public Python SDK for QmtLink."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .client import QMTClient
|
|
6
|
+
from .models import OrderRequest, OrderSide, OrderType
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("qmtlink")
|
|
10
|
+
except PackageNotFoundError: # pragma: no cover - source tree without installation
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["QMTClient", "OrderRequest", "OrderSide", "OrderType", "__version__"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from qmtlink.models import OrderPreview, OrderRequest, OrderResult, Quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Bridge(Protocol):
|
|
9
|
+
mode: str
|
|
10
|
+
|
|
11
|
+
def health(self) -> dict[str, object]: ...
|
|
12
|
+
|
|
13
|
+
def capabilities(self) -> dict[str, object]: ...
|
|
14
|
+
|
|
15
|
+
def get_quotes(self, symbols: list[str]) -> list[Quote]: ...
|
|
16
|
+
|
|
17
|
+
def preview_order(self, order: OrderRequest) -> OrderPreview: ...
|
|
18
|
+
|
|
19
|
+
def place_order(self, order: OrderRequest) -> OrderResult: ...
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from qmtlink.errors import QMTLinkError
|
|
4
|
+
|
|
5
|
+
from .base import Bridge
|
|
6
|
+
from .mock import MockBridge
|
|
7
|
+
from .xtquant import XtQuantBridge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_bridge(mode: str) -> Bridge:
|
|
11
|
+
normalized = mode.strip().lower()
|
|
12
|
+
if normalized == "mock":
|
|
13
|
+
return MockBridge()
|
|
14
|
+
if normalized == "real":
|
|
15
|
+
return XtQuantBridge()
|
|
16
|
+
raise QMTLinkError("INVALID_MODE", f"unsupported bridge mode: {mode}")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from qmtlink.models import OrderPreview, OrderRequest, OrderResult, Quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockBridge:
|
|
9
|
+
mode = "mock"
|
|
10
|
+
|
|
11
|
+
def health(self) -> dict[str, object]:
|
|
12
|
+
return {"mode": self.mode, "qmt_connected": False, "mock": True}
|
|
13
|
+
|
|
14
|
+
def capabilities(self) -> dict[str, object]:
|
|
15
|
+
return {
|
|
16
|
+
"mode": self.mode,
|
|
17
|
+
"market_data": True,
|
|
18
|
+
"realtime_stream": False,
|
|
19
|
+
"trading": True,
|
|
20
|
+
"real_trading": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def get_quotes(self, symbols: list[str]) -> list[Quote]:
|
|
24
|
+
now = int(time.time() * 1000)
|
|
25
|
+
return [
|
|
26
|
+
Quote(
|
|
27
|
+
symbol=symbol,
|
|
28
|
+
last_price=round(8 + (sum(map(ord, symbol)) % 5000) / 100, 2),
|
|
29
|
+
volume=0,
|
|
30
|
+
timestamp=now,
|
|
31
|
+
)
|
|
32
|
+
for symbol in symbols
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def preview_order(self, order: OrderRequest) -> OrderPreview:
|
|
36
|
+
estimated = None if order.price is None else round(order.price * order.quantity, 2)
|
|
37
|
+
return OrderPreview(
|
|
38
|
+
client_order_id=order.client_order_id,
|
|
39
|
+
symbol=order.symbol,
|
|
40
|
+
side=order.side,
|
|
41
|
+
quantity=order.quantity,
|
|
42
|
+
price=order.price,
|
|
43
|
+
order_type=order.order_type,
|
|
44
|
+
estimated_amount=estimated,
|
|
45
|
+
risk_checks={
|
|
46
|
+
"valid_quantity": True,
|
|
47
|
+
"valid_price": True,
|
|
48
|
+
"real_trading": False,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def place_order(self, order: OrderRequest) -> OrderResult:
|
|
53
|
+
return OrderResult(
|
|
54
|
+
client_order_id=order.client_order_id,
|
|
55
|
+
order_id=f"mock-{order.client_order_id}",
|
|
56
|
+
status="accepted",
|
|
57
|
+
submitted=True,
|
|
58
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from qmtlink.errors import QMTLinkError
|
|
6
|
+
from qmtlink.models import OrderPreview, OrderRequest, OrderResult, Quote
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class XtQuantBridge:
|
|
10
|
+
"""Thin, deliberately limited adapter for a locally installed xtquant."""
|
|
11
|
+
|
|
12
|
+
mode = "real"
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
try:
|
|
16
|
+
from xtquant import xtdata
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
raise QMTLinkError(
|
|
19
|
+
"XTQUANT_NOT_INSTALLED",
|
|
20
|
+
"xtquant is unavailable; run `qmt bridge doctor` on the miniQMT machine",
|
|
21
|
+
) from exc
|
|
22
|
+
self._xtdata = xtdata
|
|
23
|
+
|
|
24
|
+
def health(self) -> dict[str, object]:
|
|
25
|
+
return {"mode": self.mode, "qmt_connected": True, "mock": False}
|
|
26
|
+
|
|
27
|
+
def capabilities(self) -> dict[str, object]:
|
|
28
|
+
return {
|
|
29
|
+
"mode": self.mode,
|
|
30
|
+
"market_data": True,
|
|
31
|
+
"realtime_stream": False,
|
|
32
|
+
"trading": False,
|
|
33
|
+
"real_trading": False,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _number(data: dict[str, Any], *keys: str) -> float | None:
|
|
38
|
+
for key in keys:
|
|
39
|
+
value = data.get(key)
|
|
40
|
+
if value is not None:
|
|
41
|
+
return float(value)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def get_quotes(self, symbols: list[str]) -> list[Quote]:
|
|
45
|
+
raw = self._xtdata.get_full_tick(symbols)
|
|
46
|
+
quotes: list[Quote] = []
|
|
47
|
+
for symbol in symbols:
|
|
48
|
+
item = raw.get(symbol, {})
|
|
49
|
+
quotes.append(
|
|
50
|
+
Quote(
|
|
51
|
+
symbol=symbol,
|
|
52
|
+
last_price=self._number(item, "lastPrice", "last_price") or 0.0,
|
|
53
|
+
open=self._number(item, "open"),
|
|
54
|
+
high=self._number(item, "high"),
|
|
55
|
+
low=self._number(item, "low"),
|
|
56
|
+
volume=self._number(item, "volume"),
|
|
57
|
+
timestamp=item.get("time") or item.get("timestamp"),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return quotes
|
|
61
|
+
|
|
62
|
+
def preview_order(self, order: OrderRequest) -> OrderPreview:
|
|
63
|
+
estimated = None if order.price is None else round(order.price * order.quantity, 2)
|
|
64
|
+
return OrderPreview(
|
|
65
|
+
client_order_id=order.client_order_id,
|
|
66
|
+
symbol=order.symbol,
|
|
67
|
+
side=order.side,
|
|
68
|
+
quantity=order.quantity,
|
|
69
|
+
price=order.price,
|
|
70
|
+
order_type=order.order_type,
|
|
71
|
+
estimated_amount=estimated,
|
|
72
|
+
risk_checks={"trading_implemented": False},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def place_order(self, order: OrderRequest) -> OrderResult:
|
|
76
|
+
raise QMTLinkError(
|
|
77
|
+
"TRADING_NOT_IMPLEMENTED",
|
|
78
|
+
"real xtquant trading is not implemented in this preview release",
|
|
79
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from qmtlink.client import QMTClient
|
|
12
|
+
from qmtlink.config import ClientSettings, ServerSettings
|
|
13
|
+
from qmtlink.errors import QMTLinkError
|
|
14
|
+
from qmtlink.models import OrderRequest, OrderSide, OrderType
|
|
15
|
+
|
|
16
|
+
from .output import emit
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="CLI, SDK, and bridge for miniQMT/xtquant", no_args_is_help=True)
|
|
19
|
+
bridge_app = typer.Typer(help="Manage the local Windows Bridge", no_args_is_help=True)
|
|
20
|
+
market_app = typer.Typer(help="Query market data", no_args_is_help=True)
|
|
21
|
+
order_app = typer.Typer(help="Preview and submit orders", no_args_is_help=True)
|
|
22
|
+
app.add_typer(bridge_app, name="bridge")
|
|
23
|
+
app.add_typer(market_app, name="market")
|
|
24
|
+
app.add_typer(order_app, name="order")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _client() -> QMTClient:
|
|
28
|
+
settings = ClientSettings.from_env()
|
|
29
|
+
return QMTClient(settings.base_url, api_key=settings.api_key, timeout=settings.timeout)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _handle_error(exc: Exception) -> None:
|
|
33
|
+
if isinstance(exc, QMTLinkError):
|
|
34
|
+
emit(exc.as_dict(), ok=False)
|
|
35
|
+
raise typer.Exit(code=4 if exc.retryable else 5)
|
|
36
|
+
emit({"code": type(exc).__name__.upper(), "message": str(exc), "retryable": False}, ok=False)
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@bridge_app.command("doctor")
|
|
41
|
+
def bridge_doctor(pretty: bool = typer.Option(False, "--pretty")) -> None:
|
|
42
|
+
xtquant_available = importlib.util.find_spec("xtquant") is not None
|
|
43
|
+
emit(
|
|
44
|
+
{
|
|
45
|
+
"platform": platform.system().lower(),
|
|
46
|
+
"python": platform.python_version(),
|
|
47
|
+
"xtquant_importable": xtquant_available,
|
|
48
|
+
"api_key_configured": bool(os.getenv("QMTLINK_API_KEY")),
|
|
49
|
+
"ready_for_mock": True,
|
|
50
|
+
"ready_for_real": sys.platform == "win32" and xtquant_available,
|
|
51
|
+
},
|
|
52
|
+
pretty=pretty,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@bridge_app.command("run")
|
|
57
|
+
def bridge_run(
|
|
58
|
+
mock: bool = typer.Option(False, "--mock", help="Run without miniQMT"),
|
|
59
|
+
host: str | None = typer.Option(None, "--host"),
|
|
60
|
+
port: int | None = typer.Option(None, "--port", min=1, max=65535),
|
|
61
|
+
) -> None:
|
|
62
|
+
base = ServerSettings.from_env()
|
|
63
|
+
settings = ServerSettings(
|
|
64
|
+
host=host or base.host,
|
|
65
|
+
port=port or base.port,
|
|
66
|
+
mode="mock" if mock else base.mode,
|
|
67
|
+
api_key=base.api_key,
|
|
68
|
+
allow_live_orders=base.allow_live_orders,
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
from qmtlink.server.runner import run_server
|
|
72
|
+
|
|
73
|
+
run_server(settings)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
_handle_error(exc)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("health")
|
|
79
|
+
def health(pretty: bool = typer.Option(False, "--pretty")) -> None:
|
|
80
|
+
try:
|
|
81
|
+
with _client() as client:
|
|
82
|
+
emit(client.health(), pretty=pretty)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
_handle_error(exc)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("capabilities")
|
|
88
|
+
def capabilities(pretty: bool = typer.Option(False, "--pretty")) -> None:
|
|
89
|
+
try:
|
|
90
|
+
with _client() as client:
|
|
91
|
+
emit(client.capabilities(), pretty=pretty)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
_handle_error(exc)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@market_app.command("quote")
|
|
97
|
+
def market_quote(
|
|
98
|
+
symbols: Annotated[list[str], typer.Option("--symbol", help="Repeat for multiple symbols")],
|
|
99
|
+
pretty: bool = typer.Option(False, "--pretty"),
|
|
100
|
+
) -> None:
|
|
101
|
+
try:
|
|
102
|
+
with _client() as client:
|
|
103
|
+
emit(client.get_quotes(symbols), pretty=pretty)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
_handle_error(exc)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _order_request(
|
|
109
|
+
symbol: str,
|
|
110
|
+
side: OrderSide,
|
|
111
|
+
quantity: int,
|
|
112
|
+
price: float | None,
|
|
113
|
+
order_type: OrderType,
|
|
114
|
+
client_order_id: str | None,
|
|
115
|
+
live: bool,
|
|
116
|
+
) -> OrderRequest:
|
|
117
|
+
values: dict[str, object] = {
|
|
118
|
+
"symbol": symbol,
|
|
119
|
+
"side": side,
|
|
120
|
+
"quantity": quantity,
|
|
121
|
+
"price": price,
|
|
122
|
+
"order_type": order_type,
|
|
123
|
+
"live": live,
|
|
124
|
+
}
|
|
125
|
+
if client_order_id:
|
|
126
|
+
values["client_order_id"] = client_order_id
|
|
127
|
+
return OrderRequest.model_validate(values)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@order_app.command("preview")
|
|
131
|
+
def order_preview(
|
|
132
|
+
symbol: Annotated[str, typer.Option("--symbol")],
|
|
133
|
+
side: Annotated[OrderSide, typer.Option("--side")],
|
|
134
|
+
quantity: Annotated[int, typer.Option("--quantity", min=1)],
|
|
135
|
+
price: Annotated[float | None, typer.Option("--price", min=0)] = None,
|
|
136
|
+
order_type: Annotated[OrderType, typer.Option("--order-type")] = OrderType.LIMIT,
|
|
137
|
+
client_order_id: Annotated[str | None, typer.Option("--client-order-id")] = None,
|
|
138
|
+
pretty: Annotated[bool, typer.Option("--pretty")] = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
try:
|
|
141
|
+
request = _order_request(symbol, side, quantity, price, order_type, client_order_id, False)
|
|
142
|
+
with _client() as client:
|
|
143
|
+
emit(client.preview_order(request), pretty=pretty)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
_handle_error(exc)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@order_app.command("place")
|
|
149
|
+
def order_place(
|
|
150
|
+
symbol: Annotated[str, typer.Option("--symbol")],
|
|
151
|
+
side: Annotated[OrderSide, typer.Option("--side")],
|
|
152
|
+
quantity: Annotated[int, typer.Option("--quantity", min=1)],
|
|
153
|
+
price: Annotated[float | None, typer.Option("--price", min=0)] = None,
|
|
154
|
+
order_type: Annotated[OrderType, typer.Option("--order-type")] = OrderType.LIMIT,
|
|
155
|
+
client_order_id: Annotated[str | None, typer.Option("--client-order-id")] = None,
|
|
156
|
+
live: Annotated[bool, typer.Option("--live", help="Submit instead of preview")] = False,
|
|
157
|
+
pretty: Annotated[bool, typer.Option("--pretty")] = False,
|
|
158
|
+
) -> None:
|
|
159
|
+
try:
|
|
160
|
+
request = _order_request(symbol, side, quantity, price, order_type, client_order_id, live)
|
|
161
|
+
with _client() as client:
|
|
162
|
+
result = client.place_order(request) if live else client.preview_order(request)
|
|
163
|
+
emit(result, pretty=pretty)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
_handle_error(exc)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main() -> None:
|
|
169
|
+
app()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _jsonable(value: Any) -> Any:
|
|
11
|
+
if isinstance(value, BaseModel):
|
|
12
|
+
return value.model_dump(mode="json")
|
|
13
|
+
if isinstance(value, list):
|
|
14
|
+
return [_jsonable(item) for item in value]
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {key: _jsonable(item) for key, item in value.items()}
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def emit(data: Any, *, ok: bool = True, pretty: bool = False) -> None:
|
|
21
|
+
payload = {"ok": ok, "data": _jsonable(data)} if ok else {"ok": False, "error": data}
|
|
22
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2 if pretty else None)
|
|
23
|
+
sys.stdout.write("\n")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from qmtlink.config import ClientSettings
|
|
8
|
+
from qmtlink.errors import QMTLinkError
|
|
9
|
+
from qmtlink.models import OrderPreview, OrderRequest, OrderResult, Quote
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QMTClient:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
base_url: str | None = None,
|
|
16
|
+
*,
|
|
17
|
+
api_key: str | None = None,
|
|
18
|
+
timeout: float | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
settings = ClientSettings.from_env()
|
|
21
|
+
self._api_key = api_key if api_key is not None else settings.api_key
|
|
22
|
+
self._client = httpx.Client(
|
|
23
|
+
base_url=(base_url or settings.base_url).rstrip("/"),
|
|
24
|
+
timeout=timeout if timeout is not None else settings.timeout,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
28
|
+
headers = dict(kwargs.pop("headers", {}))
|
|
29
|
+
if self._api_key:
|
|
30
|
+
headers["X-API-Key"] = self._api_key
|
|
31
|
+
try:
|
|
32
|
+
response = self._client.request(method, path, headers=headers, **kwargs)
|
|
33
|
+
except httpx.TimeoutException as exc:
|
|
34
|
+
raise QMTLinkError("REQUEST_TIMEOUT", str(exc), retryable=True) from exc
|
|
35
|
+
except httpx.HTTPError as exc:
|
|
36
|
+
raise QMTLinkError("NETWORK_ERROR", str(exc), retryable=True) from exc
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
payload = response.json()
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise QMTLinkError(
|
|
42
|
+
"INVALID_RESPONSE", f"server returned HTTP {response.status_code} without JSON"
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
if response.is_error or not payload.get("ok", False):
|
|
46
|
+
error = payload.get("error") or {}
|
|
47
|
+
raise QMTLinkError(
|
|
48
|
+
error.get("code", "HTTP_ERROR"),
|
|
49
|
+
error.get("message", f"HTTP {response.status_code}"),
|
|
50
|
+
retryable=bool(error.get("retryable", False)),
|
|
51
|
+
status_code=response.status_code,
|
|
52
|
+
)
|
|
53
|
+
return payload
|
|
54
|
+
|
|
55
|
+
def health(self) -> dict[str, Any]:
|
|
56
|
+
return self._request("GET", "/api/v1/health")["data"]
|
|
57
|
+
|
|
58
|
+
def capabilities(self) -> dict[str, Any]:
|
|
59
|
+
return self._request("GET", "/api/v1/capabilities")["data"]
|
|
60
|
+
|
|
61
|
+
def get_quotes(self, symbols: list[str]) -> list[Quote]:
|
|
62
|
+
data = self._request("POST", "/api/v1/market/quotes", json={"symbols": symbols})["data"]
|
|
63
|
+
return [Quote.model_validate(item) for item in data]
|
|
64
|
+
|
|
65
|
+
def preview_order(self, order: OrderRequest) -> OrderPreview:
|
|
66
|
+
data = self._request(
|
|
67
|
+
"POST",
|
|
68
|
+
"/api/v1/orders/preview",
|
|
69
|
+
json=order.model_dump(mode="json"),
|
|
70
|
+
)["data"]
|
|
71
|
+
return OrderPreview.model_validate(data)
|
|
72
|
+
|
|
73
|
+
def place_order(self, order: OrderRequest) -> OrderResult:
|
|
74
|
+
data = self._request("POST", "/api/v1/orders", json=order.model_dump(mode="json"))["data"]
|
|
75
|
+
return OrderResult.model_validate(data)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._client.close()
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> QMTClient:
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *_args: object) -> None:
|
|
84
|
+
self.close()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Environment-backed settings shared by the CLI and server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
10
|
+
value = os.getenv(name)
|
|
11
|
+
if value is None:
|
|
12
|
+
return default
|
|
13
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ClientSettings:
|
|
18
|
+
base_url: str = "http://127.0.0.1:8000"
|
|
19
|
+
api_key: str | None = None
|
|
20
|
+
timeout: float = 30.0
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_env(cls) -> ClientSettings:
|
|
24
|
+
defaults = cls()
|
|
25
|
+
return cls(
|
|
26
|
+
base_url=os.getenv("QMTLINK_URL", defaults.base_url),
|
|
27
|
+
api_key=os.getenv("QMTLINK_API_KEY") or None,
|
|
28
|
+
timeout=float(os.getenv("QMTLINK_TIMEOUT", str(defaults.timeout))),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ServerSettings:
|
|
34
|
+
host: str = "127.0.0.1"
|
|
35
|
+
port: int = 8000
|
|
36
|
+
mode: str = "real"
|
|
37
|
+
api_key: str | None = None
|
|
38
|
+
allow_live_orders: bool = False
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_env(cls) -> ServerSettings:
|
|
42
|
+
defaults = cls()
|
|
43
|
+
return cls(
|
|
44
|
+
host=os.getenv("QMTLINK_HOST", defaults.host),
|
|
45
|
+
port=int(os.getenv("QMTLINK_PORT", str(defaults.port))),
|
|
46
|
+
mode=os.getenv("QMTLINK_MODE", defaults.mode),
|
|
47
|
+
api_key=os.getenv("QMTLINK_API_KEY") or None,
|
|
48
|
+
allow_live_orders=_env_bool("QMTLINK_ALLOW_LIVE_ORDERS"),
|
|
49
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""QmtLink exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QMTLinkError(RuntimeError):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
code: str,
|
|
10
|
+
message: str,
|
|
11
|
+
*,
|
|
12
|
+
retryable: bool = False,
|
|
13
|
+
status_code: int | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.retryable = retryable
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
|
|
21
|
+
def as_dict(self) -> dict[str, object]:
|
|
22
|
+
return {
|
|
23
|
+
"code": self.code,
|
|
24
|
+
"message": self.message,
|
|
25
|
+
"retryable": self.retryable,
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .market import Quote, QuoteRequest
|
|
2
|
+
from .order import OrderPreview, OrderRequest, OrderResult, OrderSide, OrderType
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"OrderPreview",
|
|
6
|
+
"OrderRequest",
|
|
7
|
+
"OrderResult",
|
|
8
|
+
"OrderSide",
|
|
9
|
+
"OrderType",
|
|
10
|
+
"Quote",
|
|
11
|
+
"QuoteRequest",
|
|
12
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QuoteRequest(BaseModel):
|
|
7
|
+
symbols: list[str] = Field(min_length=1, max_length=500)
|
|
8
|
+
|
|
9
|
+
@field_validator("symbols")
|
|
10
|
+
@classmethod
|
|
11
|
+
def normalize_symbols(cls, symbols: list[str]) -> list[str]:
|
|
12
|
+
normalized = [symbol.strip().upper() for symbol in symbols if symbol.strip()]
|
|
13
|
+
if not normalized:
|
|
14
|
+
raise ValueError("at least one symbol is required")
|
|
15
|
+
return list(dict.fromkeys(normalized))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Quote(BaseModel):
|
|
19
|
+
symbol: str
|
|
20
|
+
last_price: float
|
|
21
|
+
open: float | None = None
|
|
22
|
+
high: float | None = None
|
|
23
|
+
low: float | None = None
|
|
24
|
+
volume: float | None = None
|
|
25
|
+
timestamp: int | None = None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderSide(StrEnum):
|
|
10
|
+
BUY = "buy"
|
|
11
|
+
SELL = "sell"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrderType(StrEnum):
|
|
15
|
+
LIMIT = "limit"
|
|
16
|
+
MARKET = "market"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OrderRequest(BaseModel):
|
|
20
|
+
symbol: str
|
|
21
|
+
side: OrderSide
|
|
22
|
+
quantity: int = Field(gt=0)
|
|
23
|
+
price: float | None = Field(default=None, gt=0)
|
|
24
|
+
order_type: OrderType = OrderType.LIMIT
|
|
25
|
+
client_order_id: str = Field(default_factory=lambda: uuid4().hex, min_length=8, max_length=128)
|
|
26
|
+
live: bool = False
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def validate_order(self) -> OrderRequest:
|
|
30
|
+
self.symbol = self.symbol.strip().upper()
|
|
31
|
+
if not self.symbol:
|
|
32
|
+
raise ValueError("symbol is required")
|
|
33
|
+
if self.order_type == OrderType.LIMIT and self.price is None:
|
|
34
|
+
raise ValueError("price is required for a limit order")
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OrderPreview(BaseModel):
|
|
39
|
+
client_order_id: str
|
|
40
|
+
symbol: str
|
|
41
|
+
side: OrderSide
|
|
42
|
+
quantity: int
|
|
43
|
+
price: float | None
|
|
44
|
+
order_type: OrderType
|
|
45
|
+
estimated_amount: float | None
|
|
46
|
+
submitted: bool = False
|
|
47
|
+
risk_checks: dict[str, bool]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OrderResult(BaseModel):
|
|
51
|
+
client_order_id: str
|
|
52
|
+
order_id: str
|
|
53
|
+
status: str
|
|
54
|
+
submitted: bool
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional FastAPI server package."""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import time
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from qmtlink import __version__
|
|
11
|
+
from qmtlink.bridge import Bridge, create_bridge
|
|
12
|
+
from qmtlink.config import ServerSettings
|
|
13
|
+
from qmtlink.errors import QMTLinkError
|
|
14
|
+
from qmtlink.models import OrderRequest, QuoteRequest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _success(data: object, started: float) -> dict[str, object]:
|
|
18
|
+
return {
|
|
19
|
+
"ok": True,
|
|
20
|
+
"request_id": f"req_{uuid4().hex}",
|
|
21
|
+
"data": data,
|
|
22
|
+
"meta": {"elapsed_ms": round((time.perf_counter() - started) * 1000, 3)},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app(
|
|
27
|
+
bridge: Bridge | None = None,
|
|
28
|
+
settings: ServerSettings | None = None,
|
|
29
|
+
) -> FastAPI:
|
|
30
|
+
settings = settings or ServerSettings.from_env()
|
|
31
|
+
backend = bridge or create_bridge(settings.mode)
|
|
32
|
+
app = FastAPI(title="QmtLink", version=__version__)
|
|
33
|
+
app.state.bridge = backend
|
|
34
|
+
app.state.settings = settings
|
|
35
|
+
|
|
36
|
+
@app.exception_handler(QMTLinkError)
|
|
37
|
+
async def handle_qmtlink_error(_request: Request, exc: QMTLinkError) -> JSONResponse:
|
|
38
|
+
return JSONResponse(
|
|
39
|
+
status_code=exc.status_code or 500,
|
|
40
|
+
content={"ok": False, "error": exc.as_dict()},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def require_api_key(api_key: str | None) -> None:
|
|
44
|
+
expected = settings.api_key
|
|
45
|
+
if not expected:
|
|
46
|
+
raise HTTPException(
|
|
47
|
+
status_code=503,
|
|
48
|
+
detail={"code": "API_KEY_NOT_CONFIGURED", "message": "API key is required"},
|
|
49
|
+
)
|
|
50
|
+
if api_key is None or not hmac.compare_digest(api_key, expected):
|
|
51
|
+
raise HTTPException(
|
|
52
|
+
status_code=401,
|
|
53
|
+
detail={"code": "INVALID_API_KEY", "message": "invalid or missing API key"},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@app.exception_handler(HTTPException)
|
|
57
|
+
async def handle_http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
|
58
|
+
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
|
|
59
|
+
return JSONResponse(
|
|
60
|
+
status_code=exc.status_code,
|
|
61
|
+
content={
|
|
62
|
+
"ok": False,
|
|
63
|
+
"error": {
|
|
64
|
+
"code": detail.get("code", "HTTP_ERROR"),
|
|
65
|
+
"message": detail.get("message", str(exc.detail)),
|
|
66
|
+
"retryable": False,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@app.get("/api/v1/health")
|
|
72
|
+
async def health() -> dict[str, object]:
|
|
73
|
+
started = time.perf_counter()
|
|
74
|
+
data = {"version": __version__, **backend.health()}
|
|
75
|
+
return _success(data, started)
|
|
76
|
+
|
|
77
|
+
@app.get("/api/v1/capabilities")
|
|
78
|
+
async def capabilities() -> dict[str, object]:
|
|
79
|
+
started = time.perf_counter()
|
|
80
|
+
return _success(backend.capabilities(), started)
|
|
81
|
+
|
|
82
|
+
@app.post("/api/v1/market/quotes")
|
|
83
|
+
async def quotes(payload: QuoteRequest) -> dict[str, object]:
|
|
84
|
+
started = time.perf_counter()
|
|
85
|
+
data = [quote.model_dump(mode="json") for quote in backend.get_quotes(payload.symbols)]
|
|
86
|
+
return _success(data, started)
|
|
87
|
+
|
|
88
|
+
@app.post("/api/v1/orders/preview")
|
|
89
|
+
async def preview_order(
|
|
90
|
+
payload: OrderRequest,
|
|
91
|
+
x_api_key: str | None = Header(default=None),
|
|
92
|
+
) -> dict[str, object]:
|
|
93
|
+
started = time.perf_counter()
|
|
94
|
+
require_api_key(x_api_key)
|
|
95
|
+
return _success(backend.preview_order(payload).model_dump(mode="json"), started)
|
|
96
|
+
|
|
97
|
+
@app.post("/api/v1/orders")
|
|
98
|
+
async def place_order(
|
|
99
|
+
payload: OrderRequest,
|
|
100
|
+
x_api_key: str | None = Header(default=None),
|
|
101
|
+
) -> dict[str, object]:
|
|
102
|
+
started = time.perf_counter()
|
|
103
|
+
require_api_key(x_api_key)
|
|
104
|
+
if not payload.live:
|
|
105
|
+
raise HTTPException(
|
|
106
|
+
status_code=400,
|
|
107
|
+
detail={"code": "LIVE_FLAG_REQUIRED", "message": "set live=true to submit"},
|
|
108
|
+
)
|
|
109
|
+
if not settings.allow_live_orders:
|
|
110
|
+
raise HTTPException(
|
|
111
|
+
status_code=403,
|
|
112
|
+
detail={"code": "LIVE_ORDERS_DISABLED", "message": "live orders are disabled"},
|
|
113
|
+
)
|
|
114
|
+
return _success(backend.place_order(payload).model_dump(mode="json"), started)
|
|
115
|
+
|
|
116
|
+
return app
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from qmtlink.config import ServerSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_server(settings: ServerSettings) -> None:
|
|
7
|
+
try:
|
|
8
|
+
import uvicorn
|
|
9
|
+
except ImportError as exc: # pragma: no cover - depends on installation extras
|
|
10
|
+
raise RuntimeError('server dependencies are missing; install "qmtlink[server]"') from exc
|
|
11
|
+
|
|
12
|
+
from .app import create_app
|
|
13
|
+
|
|
14
|
+
uvicorn.run(create_app(settings=settings), host=settings.host, port=settings.port, workers=1)
|