feat(bridge): D-1 bridge MVP—FastAPI 4接口(xtquant封装+token鉴权+sh/sz代码转换)

- bridge.py: lifespan连miniQMT, health/order/account/positions, 连不上不崩
- xt_gateway.py: xtquant单例封装(延迟import), 照搬check_xtquant验证模式
- auth.py: X-Bridge-Token校验(hmac防时序攻击), 未配token返回503不裸奔
- requirements.txt(fastapi+uvicorn) + README.md(Windows部署步骤)

安全: 无硬编码secret, token/userdata/account均走环境变量(grep验证CLEAN)
This commit is contained in:
2026-07-10 23:48:59 +08:00
parent 6a40ae9336
commit eff9ed2ae9
5 changed files with 469 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
# sanguo QMT bridge (D-1 MVP)
Windows 端 FastAPI 服务,封装 xtquant,供 sanguo(NAS) 跨网调 miniQMT 下单/查询。
## 前提
1. **miniQMT 客户端已登录并保持运行**(极简模式即可)
2. **xtquant 可 import**miniQMT 安装目录自带,或 `pip install xtquant`
3. Python 3.10+
## 安装
```powershell
cd C:\sanguo_qmt_bridge # bridge 代码所在目录
pip install -r requirements.txt
```
> xtquant 来自 miniQMT 安装目录(`bin.x64\Lib\site-packages`),无需 pip install。
> 若 import 失败,确认 miniQMT 的 site-packages 在 PYTHONPATH,或 `pip install xtquant`。
## 配置(环境变量)
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `BRIDGE_TOKEN` | **必填**。共享密钥,sanguo 端同值。不设则受保护接口返回 503 | — |
| `MINIQMT_USERDATA` | miniQMT userdata_mini 路径 | `D:\国金QMT交易端模拟\userdata_mini` |
| `ACCOUNT_ID` | miniQMT 账户 ID | `66639661` |
| `BRIDGE_SESSION_ID` | xtquant session IDint | `20260710` |
**设置 BRIDGE_TOKEN**PowerShell):
```powershell
# 临时(当前终端)
$env:BRIDGE_TOKEN = "你的随机密钥"
# 永久(系统环境变量)
[Environment]::SetEnvironmentVariable("BRIDGE_TOKEN", "你的随机密钥", "User")
```
> 生成密钥:`python -c "import secrets; print(secrets.token_urlsafe(32))"`
## 启动
```powershell
cd C:\sanguo_qmt_bridge
uvicorn bridge:app --host 127.0.0.1 --port 8765
```
启动日志看到 `xtquant 连接成功` 即就绪。若 miniQMT 未连上,服务仍运行(`/health``disconnected`)。
## 接口
所有受保护接口需 header `X-Bridge-Token: <BRIDGE_TOKEN>``/health` 豁免。
### GET /health(无需鉴权)
```json
{"status": "ok", "miniqmt_connected": true}
```
### POST /order
请求:
```json
{
"code": "sh600000",
"action": "buy",
"price": 10.50,
"volume": 100,
"price_type": "limit",
"reason": "策略信号"
}
```
成功:
```json
{"ok": true, "order_id": 12345}
```
失败:
```json
{"ok": false, "error": "报单失败 order_id=-1"}
```
### GET /account
```json
{"ok": true, "cash": 100000.0, "frozen": 0.0, "market_value": 50000.0, "total": 150000.0}
```
### GET /positions
```json
{
"ok": true,
"positions": [
{"code": "sh600000", "volume": 200, "can_use": 200, "avg_price": 10.30}
]
}
```
## 代码格式
- 入参(/order):sanguo 格式 `sh600000` / `sz000001`
- 返回(/positions):sanguo 格式 `sh600000` / `sz000001`
- bridge 内部自动转 xtquant 格式 `600000.SH` / `000001.SZ`
## 验证
跑过 `check_xtquant.py` 确认 xtquant 可用后,启动 bridge 再验证:
```powershell
# 健康检查(无需 token
curl http://127.0.0.1:8765/health
# 查资金(需 token
curl -H "X-Bridge-Token: 你的密钥" http://127.0.0.1:8765/account
```
## 自启(可选,D-2
任务计划程序创建 `sanguo-bridge`onstart,命令:
```
uvicorn bridge:app --host 127.0.0.1 --port 8765
```
工作目录设为 bridge 代码目录。依赖 miniQMT 客户端已先启动。
+44
View File
@@ -0,0 +1,44 @@
"""Token 鉴权:每个请求带 X-Bridge-Token headerbridge 校验。
设计(spec §6):
- bridge 从环境变量 BRIDGE_TOKEN 读期望值,不进 git。
- 不符/缺失 -> 401。
- /health 豁免(该路由不挂 Depends(verify_token))。
- 使用 hmac.compare_digest 防时序攻击。
"""
import hmac
import logging
import os
from fastapi import HTTPException, Request
logger = logging.getLogger(__name__)
def get_expected_token() -> str | None:
"""从环境变量读 BRIDGE_TOKEN。"""
return os.environ.get("BRIDGE_TOKEN")
def verify_token(request: Request) -> None:
"""FastAPI Depends:校验 X-Bridge-Token header。
Raises:
HTTPException 503: BRIDGE_TOKEN 未配置(安全默认:拒绝所有受保护请求)。
HTTPException 401: token 缺失或不匹配。
"""
expected = get_expected_token()
if not expected:
# 未配置 token -> 拒绝所有受保护请求(不裸奔)
raise HTTPException(
status_code=503,
detail="BRIDGE_TOKEN 环境变量未配置,拒绝服务",
)
token = request.headers.get("X-Bridge-Token", "")
if not hmac.compare_digest(token, expected):
logger.warning(
"token 校验失败 source=%s",
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=401, detail="token 无效")
+115
View File
@@ -0,0 +1,115 @@
"""D-1 实盘 bridge MVPFastAPI 4 接口(health/order/account/positions)。
监听 127.0.0.1:8765,启动时连 miniQMT(连不上不崩溃,/health 报 disconnected)。
xtquant 调用照搬 check_xtquant.py 验证过的模式(封装在 xt_gateway.py)。
token 鉴权(auth.py),/health 豁免。
启动:uvicorn bridge:app --host 127.0.0.1 --port 8765
"""
import logging
from contextlib import asynccontextmanager
from typing import Literal
from fastapi import Depends, FastAPI
from pydantic import BaseModel, Field
from auth import verify_token
from xt_gateway import gateway
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
# ===== 请求模型 =====
class OrderRequest(BaseModel):
"""下单请求(sanguo 格式代码 sh600000/sz000001)。"""
code: str = Field(..., examples=["sh600000"])
action: Literal["buy", "sell"]
price: float = Field(..., gt=0, examples=[10.50])
volume: int = Field(..., gt=0, examples=[100])
price_type: Literal["limit", "market"] = "limit"
reason: str | None = None
# ===== 生命周期 =====
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""启动时连接 miniQMT;连不上不阻塞服务。"""
ok = gateway.connect()
if not ok:
logger.warning("miniQMT 连接失败,服务继续运行(/health 报 disconnected")
yield
# shutdown: 无资源需释放(xtquant.stop 由 miniQMT 管控)
app = FastAPI(title="sanguo QMT bridge", version="0.1.0", lifespan=lifespan)
# ===== 接口 =====
@app.get("/health")
async def health() -> dict:
"""健康检查(无需鉴权,供 frpc/Caddy 探活)。"""
return {"status": "ok", "miniqmt_connected": gateway.connected}
@app.post("/order", dependencies=[Depends(verify_token)])
async def place_order(req: OrderRequest) -> dict:
"""下单 -> {ok, order_id?} 或 {ok:false, error}。"""
if not gateway.connected:
return {"ok": False, "error": "miniQMT 未连接"}
logger.info(
"下单请求 code=%s action=%s price=%s volume=%s type=%s reason=%s",
req.code, req.action, req.price, req.volume, req.price_type, req.reason,
)
try:
order_id = gateway.place_order(
code=req.code,
action=req.action,
price=req.price,
volume=req.volume,
price_type=req.price_type,
)
except (ValueError, RuntimeError) as e:
return {"ok": False, "error": str(e)}
except Exception as e:
logger.error("下单异常: %s", e)
return {"ok": False, "error": f"下单异常: {e}"}
if order_id <= 0:
return {"ok": False, "error": f"报单失败 order_id={order_id}"}
logger.info("下单成功 order_id=%s code=%s action=%s", order_id, req.code, req.action)
return {"ok": True, "order_id": order_id}
@app.get("/account", dependencies=[Depends(verify_token)])
async def query_account() -> dict:
"""查资金 -> {ok, cash, frozen, market_value, total} 或 {ok:false, error}。"""
if not gateway.connected:
return {"ok": False, "error": "miniQMT 未连接"}
try:
data = gateway.query_account()
except Exception as e:
logger.error("查资金异常: %s", e)
return {"ok": False, "error": f"查询异常: {e}"}
return {"ok": True, **data}
@app.get("/positions", dependencies=[Depends(verify_token)])
async def query_positions() -> dict:
"""查持仓 -> {ok, positions:[...]} 或 {ok:false, error}。"""
if not gateway.connected:
return {"ok": False, "error": "miniQMT 未连接"}
try:
positions = gateway.query_positions()
except Exception as e:
logger.error("查持仓异常: %s", e)
return {"ok": False, "error": f"查询异常: {e}"}
return {"ok": True, "positions": positions}
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
+182
View File
@@ -0,0 +1,182 @@
"""xtquant 网关封装:单例 trader,连接 miniQMT 客户端。
照搬 check_xtquant.py 验证过的 xtquant 调用模式:
- XtQuantTrader(userdata, session_id) -> start() -> connect()
- StockAccount(account_id) -> subscribe(account)
- query_stock_asset / query_stock_positions / order_stock
启动时连接 miniQMT;连不上不崩溃,bridge /health 报 disconnected。
所有 xtquant import 延迟到 connect() 内部(Mac/NAS 无 xtquant 时模块仍可加载)。
"""
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
# ===== 配置(环境变量优先,fallback 到 check_xtquant.py 默认值)=====
MINIQMT_USERDATA = os.environ.get(
"MINIQMT_USERDATA",
r"D:\国金QMT交易端模拟\userdata_mini",
)
ACCOUNT_ID = os.environ.get("ACCOUNT_ID", "66639661")
SESSION_ID = int(os.environ.get("BRIDGE_SESSION_ID", "20260710"))
# ===== 代码格式转换 =====
def to_xtquant_code(code: str) -> str:
"""sanguo 格式(sh600000/sz000001) -> xtquant 格式(600000.SH/000001.SZ)。"""
code = code.strip().lower()
if "." in code:
return code.upper()
if code.startswith("sh"):
return f"{code[2:]}.SH"
if code.startswith("sz"):
return f"{code[2:]}.SZ"
raise ValueError(f"无法识别的股票代码格式: {code}")
def to_sanguo_code(code: str) -> str:
"""xtquant 格式(600000.SH/000001.SZ) -> sanguo 格式(sh600000/sz000001)。"""
code = code.strip()
if "." not in code:
raise ValueError(f"无法识别的 xtquant 代码格式: {code}")
symbol, market = code.split(".", 1)
return f"{market.lower()}{symbol}"
# ===== 网关 =====
class XtGateway:
"""xtquant 单例网关,封装 connect/query/place_order。
connected 属性供 bridge /health 查询;未连接时 query/place_order 抛 RuntimeError。
"""
def __init__(self) -> None:
self._xt: Any = None
self._account: Any = None
self._connected: bool = False
@property
def connected(self) -> bool:
"""miniQMT 是否已连接。"""
return self._connected
def connect(self) -> bool:
"""连接 miniQMT 客户端。失败记日志不崩溃,返回 False。"""
try:
from xtquant import xttrader
from xtquant.xttype import StockAccount
except ImportError as e:
logger.error("xtquant import 失败(检查 site-packages: %s", e)
self._connected = False
return False
try:
xt = xttrader.XtQuantTrader(MINIQMT_USERDATA, SESSION_ID)
xt.start()
ret = xt.connect()
if ret != 0:
logger.error(
"xtquant connect 返回 %sminiQMT 未登录或路径错误)", ret,
)
self._connected = False
return False
account = StockAccount(ACCOUNT_ID)
try:
xt.subscribe(account)
except Exception as e:
logger.warning("subscribe 异常(可忽略,继续): %s", e)
self._xt = xt
self._account = account
self._connected = True
logger.info("xtquant 连接成功 account=%s", ACCOUNT_ID)
return True
except Exception as e:
logger.error("xtquant 连接异常: %s", e)
self._connected = False
return False
def query_account(self) -> dict[str, float]:
"""查资金:{cash, frozen, market_value, total}。"""
if not self._connected:
raise RuntimeError("xtquant 未连接")
asset = self._xt.query_stock_asset(self._account)
if asset is None:
raise RuntimeError("query_stock_asset 返回空(账户ID/权限问题)")
return {
"cash": float(asset.cash),
"frozen": float(asset.frozen_cash),
"market_value": float(asset.market_value),
"total": float(asset.total_asset),
}
def query_positions(self) -> list[dict[str, Any]]:
"""查持仓:[{code, volume, can_use, avg_price}]code 已转 sanguo 格式。"""
if not self._connected:
raise RuntimeError("xtquant 未连接")
positions = self._xt.query_stock_positions(self._account) or []
return [
{
"code": to_sanguo_code(p.stock_code),
"volume": int(p.volume),
"can_use": int(p.can_use_volume),
"avg_price": float(p.avg_price),
}
for p in positions
]
def place_order(
self,
code: str,
action: str,
price: float,
volume: int,
price_type: str = "limit",
) -> int:
"""下单,返回 order_id>0 = 报单成功)。
Args:
code: sanguo 格式(sh600000/sz000001),内部转 xtquant 格式。
action: "buy" / "sell"
price: 委托价格(市价单忽略)。
volume: 委托数量(股)。
price_type: "limit"(限价 FIX_PRICE) / "market"(市价最新 LATEST_PRICE)。
Raises:
ValueError: action/price_type/code 不合法。
RuntimeError: xtquant 未连接。
"""
if not self._connected:
raise RuntimeError("xtquant 未连接")
from xtquant import xtconstant
order_type = (
xtconstant.STOCK_BUY if action == "buy" else xtconstant.STOCK_SELL
)
xt_price_type = (
xtconstant.FIX_PRICE
if price_type == "limit"
else xtconstant.LATEST_PRICE
)
xt_code = to_xtquant_code(code)
order_id = self._xt.order_stock(
self._account,
xt_code,
order_type,
volume,
xt_price_type,
price,
"sanguo_bridge",
"",
)
return int(order_id)
# 模块级单例(bridge.py 启动时调 connect
gateway = XtGateway()