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.
@@ -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.
@@ -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,4 @@
1
+ from .cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,4 @@
1
+ from .base import Bridge
2
+ from .factory import create_bridge
3
+
4
+ __all__ = ["Bridge", "create_bridge"]
@@ -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,3 @@
1
+ from .client import QMTClient
2
+
3
+ __all__ = ["QMTClient"]
@@ -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)