bizonquant-mcp 0.1.0__py3-none-any.whl
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.
- bizonquant_mcp/__init__.py +19 -0
- bizonquant_mcp/__main__.py +5 -0
- bizonquant_mcp/cli.py +168 -0
- bizonquant_mcp/common.py +38 -0
- bizonquant_mcp/gateway.py +166 -0
- bizonquant_mcp/server.py +71 -0
- bizonquant_mcp/tools/__init__.py +3 -0
- bizonquant_mcp/tools/backtest.py +203 -0
- bizonquant_mcp/tools/market.py +210 -0
- bizonquant_mcp/tools/screening.py +181 -0
- bizonquant_mcp/version.py +3 -0
- bizonquant_mcp-0.1.0.dist-info/METADATA +84 -0
- bizonquant_mcp-0.1.0.dist-info/RECORD +15 -0
- bizonquant_mcp-0.1.0.dist-info/WHEEL +4 -0
- bizonquant_mcp-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""BizonQuant MCP server:把平台能力面(行情/多因子选股/回测)开放给外部 agent。
|
|
2
|
+
|
|
3
|
+
客户端形态(调研报告 C 方案):本包是 stdio MCP server,跑在用户本机,
|
|
4
|
+
所有工具都是对公网网关 REST 的薄封装——服务器零常驻进程。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from bizonquant_mcp.gateway import (
|
|
8
|
+
BizonQuantError,
|
|
9
|
+
CredentialError,
|
|
10
|
+
GatewayClient,
|
|
11
|
+
)
|
|
12
|
+
from bizonquant_mcp.version import __version__
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BizonQuantError",
|
|
16
|
+
"CredentialError",
|
|
17
|
+
"GatewayClient",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
bizonquant_mcp/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""CLI:init / doctor / revoke——从零到可用全程不开前端。
|
|
2
|
+
|
|
3
|
+
init 交互式录入 access_key/secret_key/base_url,写配置文件(0600),
|
|
4
|
+
并跑一遍 doctor 验证
|
|
5
|
+
doctor 探链路:health → 换发 token → whoami → 数据覆盖摘要
|
|
6
|
+
revoke 用同 key 再换发一次:互踢规则下同 key 旧会话全部失效
|
|
7
|
+
(key 本体的删除/停用需在桌面端或 API 管理)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from bizonquant_mcp import __version__
|
|
20
|
+
from bizonquant_mcp.gateway import DEFAULT_BASE_URL, BizonQuantError, GatewayClient
|
|
21
|
+
|
|
22
|
+
CONFIG_PATH = (
|
|
23
|
+
Path(os.environ.get("BIZONQUANT_CONFIG", ""))
|
|
24
|
+
or Path.home() / ".config" / "bizonquant-mcp" / "config.json"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_or_prompt(access: str | None, secret: str | None, base_url: str | None) -> tuple[str, str, str]:
|
|
29
|
+
env_access = os.environ.get("BIZONQUANT_ACCESS_KEY")
|
|
30
|
+
env_secret = os.environ.get("BIZONQUANT_SECRET_KEY")
|
|
31
|
+
access = access or env_access
|
|
32
|
+
secret = secret or env_secret
|
|
33
|
+
base_url = base_url or os.environ.get("BIZONQUANT_BASE_URL") or DEFAULT_BASE_URL
|
|
34
|
+
|
|
35
|
+
if not (access and secret) and CONFIG_PATH.exists():
|
|
36
|
+
cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
37
|
+
access = access or cfg.get("access_key")
|
|
38
|
+
secret = secret or cfg.get("secret_key")
|
|
39
|
+
base_url = base_url or cfg.get("base_url") or DEFAULT_BASE_URL
|
|
40
|
+
|
|
41
|
+
if not (access and secret):
|
|
42
|
+
if not sys.stdin.isatty():
|
|
43
|
+
raise BizonQuantError("非交互环境且未提供凭证(--access-key/--secret-key 或环境变量)")
|
|
44
|
+
print("BizonQuant MCP 凭证初始化")
|
|
45
|
+
print("(access_key/secret_key 在桌面端「API 密钥」页创建,permissions 勾选 mcp.read / mcp.write)")
|
|
46
|
+
access = access or input("access_key: ").strip()
|
|
47
|
+
secret = secret or input("secret_key: ").strip()
|
|
48
|
+
custom = input(f"base_url [{DEFAULT_BASE_URL}]: ").strip()
|
|
49
|
+
base_url = custom or base_url
|
|
50
|
+
return access, secret, base_url
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
54
|
+
access, secret, base_url = _load_or_prompt(args.access_key, args.secret_key, args.base_url)
|
|
55
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
CONFIG_PATH.write_text(
|
|
57
|
+
json.dumps(
|
|
58
|
+
{"access_key": access, "secret_key": secret, "base_url": base_url},
|
|
59
|
+
ensure_ascii=False,
|
|
60
|
+
indent=2,
|
|
61
|
+
),
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
CONFIG_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600:secret 落盘仅本人可读
|
|
65
|
+
print(f"配置已写入 {CONFIG_PATH}")
|
|
66
|
+
return cmd_doctor(args)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
70
|
+
access, secret, base_url = _load_or_prompt(args.access_key, args.secret_key, args.base_url)
|
|
71
|
+
gw = GatewayClient(access_key=access, secret_key=secret, base_url=base_url)
|
|
72
|
+
ok = True
|
|
73
|
+
|
|
74
|
+
async def _run() -> None:
|
|
75
|
+
nonlocal ok
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
resp = await gw._http.get("/health")
|
|
79
|
+
print(f"1) 网关可达:{base_url} → HTTP {resp.status_code}")
|
|
80
|
+
except Exception as exc: # noqa: BLE001 - doctor 要把任何异常转成人话
|
|
81
|
+
print(f"1) 网关不可达:{exc}")
|
|
82
|
+
ok = False
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
await gw._fetch_token()
|
|
86
|
+
print("2) agent-token 换发:成功(scope=agent)")
|
|
87
|
+
except Exception as exc: # noqa: BLE001
|
|
88
|
+
print(f"2) agent-token 换发失败:{exc}")
|
|
89
|
+
ok = False
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
me = await gw.get("/api/v1/users/me")
|
|
93
|
+
user = me
|
|
94
|
+
if isinstance(me, dict):
|
|
95
|
+
user = me.get("user") or me.get("data", {}).get("user") or me.get("data") or me
|
|
96
|
+
print(
|
|
97
|
+
f"3) whoami:{user.get('username', '?')}(tenant={user.get('tenant_id', '?')})"
|
|
98
|
+
if isinstance(user, dict)
|
|
99
|
+
else f"3) whoami:{me}"
|
|
100
|
+
)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
print(f"3) whoami 失败:{exc}")
|
|
103
|
+
ok = False
|
|
104
|
+
try:
|
|
105
|
+
cov = await gw.get("/api/v1/selection/multi-factor/metadata")
|
|
106
|
+
keys = sorted(cov.keys()) if isinstance(cov, dict) else type(cov).__name__
|
|
107
|
+
print(f"4) 数据面 metadata:{keys}")
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
print(f"4) 数据面不可用:{exc}")
|
|
110
|
+
|
|
111
|
+
import asyncio
|
|
112
|
+
|
|
113
|
+
asyncio.run(_run())
|
|
114
|
+
print("doctor: PASS" if ok else "doctor: FAIL")
|
|
115
|
+
return 0 if ok else 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_revoke(args: argparse.Namespace) -> int:
|
|
119
|
+
access, secret, base_url = _load_or_prompt(args.access_key, args.secret_key, args.base_url)
|
|
120
|
+
gw = GatewayClient(access_key=access, secret_key=secret, base_url=base_url)
|
|
121
|
+
|
|
122
|
+
async def _run() -> None:
|
|
123
|
+
# 同 key 再换发一次 → 互踢规则下该 key 的全部旧会话立即失效
|
|
124
|
+
await gw._fetch_token()
|
|
125
|
+
print("已换发新会话:同 key 的旧 agent token 全部 401。")
|
|
126
|
+
print("如需永久吊销,请在桌面端删除该 API Key。")
|
|
127
|
+
|
|
128
|
+
import asyncio
|
|
129
|
+
|
|
130
|
+
asyncio.run(_run())
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
if len(sys.argv) == 1:
|
|
136
|
+
# 无参数(console script 默认)= 启动 MCP stdio server
|
|
137
|
+
from bizonquant_mcp.server import main as server_main
|
|
138
|
+
|
|
139
|
+
server_main()
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
parser = argparse.ArgumentParser(prog="bizonquant-mcp", description=__doc__)
|
|
143
|
+
parser.add_argument("--version", action="version", version=f"bizonquant-mcp {__version__}")
|
|
144
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
145
|
+
|
|
146
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
147
|
+
p.add_argument("--access-key", help="覆盖配置/环境变量")
|
|
148
|
+
p.add_argument("--secret-key", help="覆盖配置/环境变量")
|
|
149
|
+
p.add_argument("--base-url", help="覆盖配置/环境变量")
|
|
150
|
+
|
|
151
|
+
p_init = sub.add_parser("init", help="录入凭证写配置文件并自检")
|
|
152
|
+
_common(p_init)
|
|
153
|
+
p_init.set_defaults(func=cmd_init)
|
|
154
|
+
|
|
155
|
+
p_doc = sub.add_parser("doctor", help="探链路:health/token/whoami/数据面")
|
|
156
|
+
_common(p_doc)
|
|
157
|
+
p_doc.set_defaults(func=cmd_doctor)
|
|
158
|
+
|
|
159
|
+
p_rev = sub.add_parser("revoke", help="踢掉同 key 全部旧会话")
|
|
160
|
+
_common(p_rev)
|
|
161
|
+
p_rev.set_defaults(func=cmd_revoke)
|
|
162
|
+
|
|
163
|
+
args = parser.parse_args()
|
|
164
|
+
try:
|
|
165
|
+
sys.exit(args.func(args))
|
|
166
|
+
except BizonQuantError as exc:
|
|
167
|
+
print(f"错误:{exc}", file=sys.stderr)
|
|
168
|
+
sys.exit(2)
|
bizonquant_mcp/common.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""工具公共件:响应解包与输出裁剪(移植 QuantBot TS 工具语义)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def unwrap(data: Any) -> Any:
|
|
10
|
+
"""网关两种响应包装:{code,message,data:{...}} 与顶层平铺——统一取载荷。"""
|
|
11
|
+
if isinstance(data, dict) and "data" in data and isinstance(data["data"], dict):
|
|
12
|
+
inner = data["data"]
|
|
13
|
+
# 防误伤:business payload 里也可能有 data 键(如 klines bars),
|
|
14
|
+
# 仅当外层像标准包装(有 code/message 或唯一 data 键)时才解包
|
|
15
|
+
if "code" in data or "message" in data or set(data) == {"data"}:
|
|
16
|
+
return inner
|
|
17
|
+
return data
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def trim_curve(points: list[Any], max_points: int = 120) -> list[Any]:
|
|
21
|
+
"""曲线抽样:≤max_points 原样返回,超过等距抽 max_points 点(保首尾)。"""
|
|
22
|
+
if len(points) <= max_points:
|
|
23
|
+
return points
|
|
24
|
+
step = (len(points) - 1) / (max_points - 1)
|
|
25
|
+
return [points[round(i * step)] for i in range(max_points)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sma(values: list[float], window: int) -> float | None:
|
|
29
|
+
"""简单均线;样本不足返回 None。"""
|
|
30
|
+
if len(values) < window:
|
|
31
|
+
return None
|
|
32
|
+
return sum(values[-window:]) / window
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def safe_round(value: float | None, digits: int = 4) -> float | None:
|
|
36
|
+
if value is None or (isinstance(value, float) and math.isnan(value)):
|
|
37
|
+
return None
|
|
38
|
+
return round(value, digits)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""网关 HTTP 客户端:token 换发缓存、401 纪律、错误映射。
|
|
2
|
+
|
|
3
|
+
客户端纪律(调研报告 §4 阶段 1):
|
|
4
|
+
- 默认拒绝 http:// 明文(只走 TLS 网关;明文中间盒改写 UA 的既有事故)
|
|
5
|
+
- token 只放 Authorization 头,不进 query
|
|
6
|
+
- 401 且 token 已尽力刷新过一次仍 401 → 停(防密码锁定类风暴;agent-token
|
|
7
|
+
无密码锁定,但 key 撤销后的连续换发同样是无意义风暴)
|
|
8
|
+
- UA 带包版本,便于服务端审计区分
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from bizonquant_mcp.version import __version__
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://bizon.naiyu.xyz:8443"
|
|
22
|
+
|
|
23
|
+
# access token 官方有效期 120 分钟;提前 5 分钟换发,避免边界 401
|
|
24
|
+
TOKEN_REFRESH_MARGIN_S = 300
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BizonQuantError(RuntimeError):
|
|
28
|
+
"""工具层统一错误:MCP 把 message 透传给 agent。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CredentialError(BizonQuantError):
|
|
32
|
+
"""key/secret 无效、账号被禁用或 key 被撤销——换发重试无意义。"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GatewayClient:
|
|
36
|
+
"""对公网网关的异步 REST 客户端,持 API Key 并缓存换发的 agent token。"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
access_key: str,
|
|
42
|
+
secret_key: str,
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
44
|
+
allow_insecure_http: bool = False,
|
|
45
|
+
timeout_s: float = 30.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._access_key = access_key.strip()
|
|
48
|
+
self._secret_key = secret_key.strip()
|
|
49
|
+
base_url = base_url.strip().rstrip("/")
|
|
50
|
+
if base_url.startswith("http://") and not allow_insecure_http:
|
|
51
|
+
raise BizonQuantError(
|
|
52
|
+
f"拒绝明文 http:// 网关地址({base_url}):中间盒可能改写请求。"
|
|
53
|
+
"只走 https://;确有内网明文需求请设 BIZONQUANT_ALLOW_INSECURE=1"
|
|
54
|
+
)
|
|
55
|
+
self._base_url = base_url
|
|
56
|
+
self._http = httpx.AsyncClient(
|
|
57
|
+
base_url=base_url,
|
|
58
|
+
timeout=timeout_s,
|
|
59
|
+
headers={"User-Agent": f"bizonquant-mcp/{__version__}"},
|
|
60
|
+
)
|
|
61
|
+
self._token: str | None = None
|
|
62
|
+
self._token_exp: float = 0.0
|
|
63
|
+
self._refresh_lock = asyncio.Lock()
|
|
64
|
+
|
|
65
|
+
async def aclose(self) -> None:
|
|
66
|
+
await self._http.aclose()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def base_url(self) -> str:
|
|
70
|
+
return self._base_url
|
|
71
|
+
|
|
72
|
+
# ── token 换发 ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async def _fetch_token(self) -> None:
|
|
75
|
+
resp = await self._http.post(
|
|
76
|
+
"/api/v1/auth/agent-token",
|
|
77
|
+
json={"access_key": self._access_key, "secret_key": self._secret_key},
|
|
78
|
+
)
|
|
79
|
+
if resp.status_code in (401, 403):
|
|
80
|
+
raise CredentialError(
|
|
81
|
+
f"API Key 无效或被撤销(HTTP {resp.status_code}):{resp.text[:200]}"
|
|
82
|
+
)
|
|
83
|
+
if resp.status_code == 429:
|
|
84
|
+
raise BizonQuantError(f"换发限流(HTTP 429):{resp.text[:200]}")
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
data = resp.json()
|
|
87
|
+
self._token = data["access_token"]
|
|
88
|
+
# JWT exp claim 是权威有效期;缺失时退回 2 小时(网关官方口径)
|
|
89
|
+
claims = _decode_jwt_claims(self._token)
|
|
90
|
+
exp = claims.get("exp")
|
|
91
|
+
self._token_exp = float(exp) if exp else time.time() + 7200
|
|
92
|
+
|
|
93
|
+
async def _ensure_token(self) -> str:
|
|
94
|
+
if self._token and time.time() < self._token_exp - TOKEN_REFRESH_MARGIN_S:
|
|
95
|
+
return self._token
|
|
96
|
+
async with self._refresh_lock:
|
|
97
|
+
# 双检:等锁期间别的协程可能已换好
|
|
98
|
+
if self._token and time.time() < self._token_exp - TOKEN_REFRESH_MARGIN_S:
|
|
99
|
+
return self._token
|
|
100
|
+
await self._fetch_token()
|
|
101
|
+
assert self._token is not None
|
|
102
|
+
return self._token
|
|
103
|
+
|
|
104
|
+
# ── 请求 ─────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async def request(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
path: str,
|
|
110
|
+
*,
|
|
111
|
+
params: dict[str, Any] | None = None,
|
|
112
|
+
json_body: dict[str, Any] | None = None,
|
|
113
|
+
) -> Any:
|
|
114
|
+
"""发起网关请求并返回 JSON 载荷;401 时换发重试一次。"""
|
|
115
|
+
token = await self._ensure_token()
|
|
116
|
+
resp = await self._http.request(
|
|
117
|
+
method,
|
|
118
|
+
path,
|
|
119
|
+
params=params,
|
|
120
|
+
json=json_body,
|
|
121
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
122
|
+
)
|
|
123
|
+
if resp.status_code == 401:
|
|
124
|
+
# token 过期或会话被撤(删 key/轮换):换发一次再试;
|
|
125
|
+
# 仍 401 说明 key 本身已失效,停手
|
|
126
|
+
await self._fetch_token()
|
|
127
|
+
resp = await self._http.request(
|
|
128
|
+
method,
|
|
129
|
+
path,
|
|
130
|
+
params=params,
|
|
131
|
+
json=json_body,
|
|
132
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
133
|
+
)
|
|
134
|
+
if resp.status_code == 401:
|
|
135
|
+
raise CredentialError("API Key 已失效(换发后仍 401),请检查 key 状态")
|
|
136
|
+
if resp.status_code == 403:
|
|
137
|
+
raise BizonQuantError(
|
|
138
|
+
f"网关拒绝该操作(403,agent token 能力面限制):{resp.text[:200]}"
|
|
139
|
+
)
|
|
140
|
+
if resp.status_code >= 400:
|
|
141
|
+
# 后端校验错误(422 等)的 detail 对 agent 修正参数至关重要,必须透传
|
|
142
|
+
raise BizonQuantError(
|
|
143
|
+
f"网关返回 HTTP {resp.status_code}:{resp.text[:500]}"
|
|
144
|
+
)
|
|
145
|
+
if not resp.content:
|
|
146
|
+
return None
|
|
147
|
+
return resp.json()
|
|
148
|
+
|
|
149
|
+
async def get(self, path: str, **kwargs: Any) -> Any:
|
|
150
|
+
return await self.request("GET", path, **kwargs)
|
|
151
|
+
|
|
152
|
+
async def post(self, path: str, *, json_body: dict[str, Any], **kwargs: Any) -> Any:
|
|
153
|
+
return await self.request("POST", path, json_body=json_body, **kwargs)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _decode_jwt_claims(token: str) -> dict[str, Any]:
|
|
157
|
+
"""解 JWT payload(不验签——TLS 信道内自己刚拿到的 token,无需验)。"""
|
|
158
|
+
import base64
|
|
159
|
+
import json
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
payload = token.split(".")[1]
|
|
163
|
+
payload += "=" * (-len(payload) % 4)
|
|
164
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
165
|
+
except Exception:
|
|
166
|
+
return {}
|
bizonquant_mcp/server.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""FastMCP 实例组装:stdio 入口 + 工具注册。
|
|
2
|
+
|
|
3
|
+
本 server 是纯能力面——会话管理归外部 agent 宿主(Claude Code / Cursor 等)。
|
|
4
|
+
凭证走环境变量:BIZONQUANT_ACCESS_KEY + BIZONQUANT_SECRET_KEY
|
|
5
|
+
(或 BIZONQUANT_API_KEY=access_key.secret_key)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
|
|
14
|
+
from bizonquant_mcp.gateway import DEFAULT_BASE_URL, BizonQuantError, GatewayClient
|
|
15
|
+
from bizonquant_mcp.version import __version__
|
|
16
|
+
from bizonquant_mcp.tools import backtest, market, screening
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def credentials_from_env() -> tuple[str, str, str]:
|
|
20
|
+
"""(access_key, secret_key, base_url);缺失即抛,让进程启动就失败。"""
|
|
21
|
+
access = os.environ.get("BIZONQUANT_ACCESS_KEY", "").strip()
|
|
22
|
+
secret = os.environ.get("BIZONQUANT_SECRET_KEY", "").strip()
|
|
23
|
+
if not access and not secret:
|
|
24
|
+
# 兼容单变量形式 access_key.secret_key(以首次出现的 . 为界)
|
|
25
|
+
combined = os.environ.get("BIZONQUANT_API_KEY", "").strip()
|
|
26
|
+
if combined and "." in combined:
|
|
27
|
+
access, secret = combined.split(".", 1)
|
|
28
|
+
if not access or not secret:
|
|
29
|
+
raise BizonQuantError(
|
|
30
|
+
"缺少凭证:请设置 BIZONQUANT_ACCESS_KEY 与 BIZONQUANT_SECRET_KEY"
|
|
31
|
+
"(或 BIZONQUANT_API_KEY=access_key.secret_key);"
|
|
32
|
+
"用 bizonquant-mcp init 生成配置后按提示写入 mcpServers env"
|
|
33
|
+
)
|
|
34
|
+
base_url = os.environ.get("BIZONQUANT_BASE_URL", DEFAULT_BASE_URL)
|
|
35
|
+
return access, secret, base_url
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_server() -> MCPServer:
|
|
39
|
+
access, secret, base_url = credentials_from_env()
|
|
40
|
+
gw = GatewayClient(
|
|
41
|
+
access_key=access,
|
|
42
|
+
secret_key=secret,
|
|
43
|
+
base_url=base_url,
|
|
44
|
+
allow_insecure_http=os.environ.get("BIZONQUANT_ALLOW_INSECURE") == "1",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
mcp: MCPServer = MCPServer(
|
|
48
|
+
"bizonquant",
|
|
49
|
+
instructions=(
|
|
50
|
+
"BizonQuant 量化平台能力面:股票搜索、K 线行情、多因子即时选股、"
|
|
51
|
+
"选股方案管理、多因子回测。回测为异步任务:submit_backtest 提交后用 "
|
|
52
|
+
"wait_backtest(有界等待,done:false 时续调)或 get_backtest_status 轮询。\n"
|
|
53
|
+
"纪律:工具未返回 = 没发生;backtest_id 只认工具返回值;"
|
|
54
|
+
"回测日期先查 get_data_coverage;选股条件里的因子名只认 list_factors。"
|
|
55
|
+
),
|
|
56
|
+
version=__version__,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
market.register(mcp, gw)
|
|
60
|
+
screening.register(mcp, gw)
|
|
61
|
+
backtest.register(mcp, gw)
|
|
62
|
+
return mcp
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
server = create_server()
|
|
67
|
+
server.run(transport="stdio")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""回测工具:submit_backtest / wait_backtest / get_backtest_status / get_backtest_result / list_backtests。
|
|
2
|
+
|
|
3
|
+
异步回测全景(调研 §2):submit 入 Celery 队列 → 结果落 PG → 进度走 Redis。
|
|
4
|
+
wait_backtest 单次 ≤50s 有界轮询(宿主工具超时通常 60s),done:false 续调。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bizonquant_mcp.common import trim_curve, unwrap
|
|
14
|
+
from bizonquant_mcp.gateway import GatewayClient
|
|
15
|
+
|
|
16
|
+
# 与宿主工具超时(60s)保持安全距离
|
|
17
|
+
WAIT_BUDGET_S = 50.0
|
|
18
|
+
POLL_INTERVAL_S = 3.0
|
|
19
|
+
# queued 超 30 分钟无进展提示队列疑似停摆(celery 宕机后 queued 行永不判死)
|
|
20
|
+
STALLED_QUEUED_S = 1800.0
|
|
21
|
+
|
|
22
|
+
_TERMINAL = {"completed", "success", "failed", "error", "cancelled", "aborted"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def submit_backtest(
|
|
26
|
+
gw: GatewayClient,
|
|
27
|
+
config: dict[str, Any],
|
|
28
|
+
start_date: str,
|
|
29
|
+
end_date: str,
|
|
30
|
+
engine_config: dict[str, Any] | None = None,
|
|
31
|
+
strategy_name: str = "多因子选股",
|
|
32
|
+
strategy_id: str | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""提交多因子正式回测(写操作:入队,不阻塞等待结果)。"""
|
|
35
|
+
body: dict[str, Any] = {
|
|
36
|
+
"config": config,
|
|
37
|
+
"date_range": {"start_date": start_date, "end_date": end_date},
|
|
38
|
+
"engine_config": engine_config or {},
|
|
39
|
+
"strategy_name": strategy_name,
|
|
40
|
+
}
|
|
41
|
+
if strategy_id:
|
|
42
|
+
body["strategy_id"] = strategy_id
|
|
43
|
+
data = await gw.post(
|
|
44
|
+
"/api/v1/selection/multi-factor/structured-backtest/submit",
|
|
45
|
+
json_body=body,
|
|
46
|
+
)
|
|
47
|
+
data = unwrap(data)
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return {"raw": data}
|
|
50
|
+
return {
|
|
51
|
+
"backtest_id": data.get("backtest_id") or data.get("id"),
|
|
52
|
+
"status": data.get("status", "queued"),
|
|
53
|
+
"message": data.get("message", "已提交,用 wait_backtest 或 get_backtest_status 跟进"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def get_backtest_status(gw: GatewayClient, backtest_id: str) -> dict[str, Any]:
|
|
58
|
+
"""单次查回测状态(阶段标签 + 排队位次)。"""
|
|
59
|
+
data = await gw.get(f"/api/v1/backtests/{backtest_id}")
|
|
60
|
+
data = unwrap(data)
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
return {"raw": data}
|
|
63
|
+
return {
|
|
64
|
+
"backtest_id": backtest_id,
|
|
65
|
+
"status": data.get("status"),
|
|
66
|
+
"stage": data.get("stage"),
|
|
67
|
+
"queue_position": data.get("queue_position"),
|
|
68
|
+
"progress": data.get("progress"),
|
|
69
|
+
"error": data.get("error"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def wait_backtest(
|
|
74
|
+
gw: GatewayClient, backtest_id: str, queued_since_s: float = 0.0
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
"""有界阻塞等待回测终态(单次 ≤50s)。done:false 时续调本工具。
|
|
77
|
+
|
|
78
|
+
queued_since_s 由调用方累计:同一回测多次续调时传入,用于发现
|
|
79
|
+
队列停摆(celery 宕机后 queued 行永不判死)。
|
|
80
|
+
"""
|
|
81
|
+
deadline = time.monotonic() + WAIT_BUDGET_S
|
|
82
|
+
status = "unknown"
|
|
83
|
+
last: dict[str, Any] = {}
|
|
84
|
+
while time.monotonic() < deadline:
|
|
85
|
+
last = await get_backtest_status(gw, backtest_id)
|
|
86
|
+
status = str(last.get("status") or "").lower()
|
|
87
|
+
if status in _TERMINAL:
|
|
88
|
+
return {**last, "done": True}
|
|
89
|
+
await asyncio.sleep(POLL_INTERVAL_S)
|
|
90
|
+
if status == "queued" and queued_since_s >= STALLED_QUEUED_S:
|
|
91
|
+
last["warning"] = (
|
|
92
|
+
f"已 queued 超 {int(queued_since_s / 60)} 分钟无进展:队列疑似停摆"
|
|
93
|
+
"(celery 宕机后排队任务不会自动判死),建议人工检查服务端"
|
|
94
|
+
)
|
|
95
|
+
return {**last, "done": False, "queued_since_s": queued_since_s + WAIT_BUDGET_S}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def get_backtest_result(
|
|
99
|
+
gw: GatewayClient, backtest_id: str, max_curve_points: int = 120
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""回测结果:指标全量,曲线抽样(≤max_curve_points 点),逐笔截尾。"""
|
|
102
|
+
data = await gw.get(f"/api/v1/backtests/{backtest_id}/result")
|
|
103
|
+
data = unwrap(data)
|
|
104
|
+
if not isinstance(data, dict):
|
|
105
|
+
return {"raw": data}
|
|
106
|
+
out: dict[str, Any] = {
|
|
107
|
+
"backtest_id": backtest_id,
|
|
108
|
+
"status": data.get("status"),
|
|
109
|
+
"metrics": data.get("metrics") or data.get("summary") or {},
|
|
110
|
+
}
|
|
111
|
+
for key in ("equity_curve", "nav_curve", "curve", "benchmark_curve"):
|
|
112
|
+
curve = data.get(key)
|
|
113
|
+
if isinstance(curve, list):
|
|
114
|
+
out[key] = trim_curve(curve, max_curve_points)
|
|
115
|
+
trades = data.get("trades") or data.get("records")
|
|
116
|
+
if isinstance(trades, list):
|
|
117
|
+
out["trades_head"] = trades[:50]
|
|
118
|
+
out["trades_count"] = len(trades)
|
|
119
|
+
for key in ("factor_report", "turnover", "logs", "positions", "error"):
|
|
120
|
+
if data.get(key) is not None:
|
|
121
|
+
out[key] = data[key]
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def list_backtests(
|
|
126
|
+
gw: GatewayClient,
|
|
127
|
+
engine_type: str = "structured_multi_factor",
|
|
128
|
+
strategy_type: str | None = None,
|
|
129
|
+
limit: int = 20,
|
|
130
|
+
) -> list[dict[str, Any]]:
|
|
131
|
+
"""回测历史(按提交时间倒序)。engine_type 必填:structured_multi_factor /
|
|
132
|
+
multi_factor_timing 等。"""
|
|
133
|
+
params: dict[str, Any] = {"engine_type": engine_type, "limit": limit}
|
|
134
|
+
if strategy_type:
|
|
135
|
+
params["strategy_type"] = strategy_type
|
|
136
|
+
data = await gw.get("/api/v1/backtests", params=params)
|
|
137
|
+
data = unwrap(data)
|
|
138
|
+
items = data if isinstance(data, list) else data.get("backtests", data.get("items", []))
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
"backtest_id": b.get("backtest_id") or b.get("id"),
|
|
142
|
+
"strategy_name": b.get("strategy_name"),
|
|
143
|
+
"strategy_type": b.get("strategy_type"),
|
|
144
|
+
"status": b.get("status"),
|
|
145
|
+
"date_range": b.get("date_range"),
|
|
146
|
+
"created_at": b.get("created_at"),
|
|
147
|
+
"metrics": b.get("metrics") or {},
|
|
148
|
+
}
|
|
149
|
+
for b in items or []
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def register(mcp: Any, gw: GatewayClient) -> None:
|
|
154
|
+
"""把本组工具注册进 MCPServer 实例。"""
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
async def submit_backtest_tool(
|
|
158
|
+
config: dict[str, Any],
|
|
159
|
+
start_date: str,
|
|
160
|
+
end_date: str,
|
|
161
|
+
engine_config: dict[str, Any] | None = None,
|
|
162
|
+
strategy_name: str = "多因子选股",
|
|
163
|
+
strategy_id: str | None = None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""提交多因子正式回测(异步任务,返回即入队)。
|
|
166
|
+
|
|
167
|
+
config 同 screen_stocks_tool;日期先过 get_data_coverage_tool 与
|
|
168
|
+
get_trading_day_info_tool(两端必须为交易日)。提交后用
|
|
169
|
+
wait_backtest_tool 等结果。backtest_id 只认本工具返回值。
|
|
170
|
+
"""
|
|
171
|
+
return await submit_backtest(
|
|
172
|
+
gw, config, start_date, end_date, engine_config, strategy_name, strategy_id
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@mcp.tool()
|
|
176
|
+
async def wait_backtest_tool(
|
|
177
|
+
backtest_id: str, queued_since_s: float = 0.0
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""有界阻塞等待回测终态(单次 ≤50s)。
|
|
180
|
+
|
|
181
|
+
done:false 表示未到终态,续调本工具并把返回的 queued_since_s 传回;
|
|
182
|
+
queued 超 30 分钟会提示队列疑似停摆。
|
|
183
|
+
"""
|
|
184
|
+
return await wait_backtest(gw, backtest_id, queued_since_s)
|
|
185
|
+
|
|
186
|
+
@mcp.tool()
|
|
187
|
+
async def get_backtest_status_tool(backtest_id: str) -> dict[str, Any]:
|
|
188
|
+
"""单次查回测状态(阶段标签 + 排队位次),不想阻塞等待时用。"""
|
|
189
|
+
return await get_backtest_status(gw, backtest_id)
|
|
190
|
+
|
|
191
|
+
@mcp.tool()
|
|
192
|
+
async def get_backtest_result_tool(
|
|
193
|
+
backtest_id: str, max_curve_points: int = 120
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""取回测结果:指标全量,资金曲线等距抽样(默认 120 点),逐笔截尾 50 笔。"""
|
|
196
|
+
return await get_backtest_result(gw, backtest_id, max_curve_points)
|
|
197
|
+
|
|
198
|
+
@mcp.tool()
|
|
199
|
+
async def list_backtests_tool(
|
|
200
|
+
engine_type: str = "structured_multi_factor", limit: int = 20
|
|
201
|
+
) -> list[dict[str, Any]]:
|
|
202
|
+
"""回测历史(倒序)。engine_type:structured_multi_factor / multi_factor_timing。"""
|
|
203
|
+
return await list_backtests(gw, engine_type, limit=limit)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""行情与基础工具:search_stocks / get_daily_klines / get_me / get_trading_day_info / get_data_coverage。
|
|
2
|
+
|
|
3
|
+
get_daily_klines 移植 QuantBot 的区间摘要语义:往前多拉 100 自然日热身 bar
|
|
4
|
+
算 MA20/MA60,热身段不进返回——「走势怎么样」「站没站上均线」直接读摘要。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bizonquant_mcp.common import safe_round, sma, trim_curve, unwrap
|
|
14
|
+
from bizonquant_mcp.gateway import GatewayClient
|
|
15
|
+
|
|
16
|
+
_WARMUP_DAYS = 100 # 与 QuantBot TS 对齐:60 根日线均线 ≈ 90 自然日 + 缓冲
|
|
17
|
+
_MAX_BARS_OUT = 250
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_me(gw: GatewayClient) -> dict[str, Any]:
|
|
21
|
+
data = await gw.get("/api/v1/users/me")
|
|
22
|
+
user = unwrap(data)
|
|
23
|
+
if isinstance(user, dict) and "user" in user:
|
|
24
|
+
user = user["user"]
|
|
25
|
+
return user if isinstance(user, dict) else {"raw": user}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def search_stocks(
|
|
29
|
+
gw: GatewayClient, query: str, limit: int = 20
|
|
30
|
+
) -> list[dict[str, Any]]:
|
|
31
|
+
data = await gw.get(
|
|
32
|
+
"/api/v1/stocks/search", params={"q": query, "limit": min(limit, 200)}
|
|
33
|
+
)
|
|
34
|
+
data = unwrap(data)
|
|
35
|
+
results = data.get("results", []) if isinstance(data, dict) else data
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
"symbol": r.get("symbol"),
|
|
39
|
+
"name": r.get("name"),
|
|
40
|
+
"market": r.get("market"),
|
|
41
|
+
"price": r.get("price") or r.get("closePrice"),
|
|
42
|
+
}
|
|
43
|
+
for r in (results or [])[:limit]
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_trading_day_info(
|
|
48
|
+
gw: GatewayClient, date: str | None = None, market: str = "SSE"
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
date = date or datetime.now().strftime("%Y-%m-%d")
|
|
51
|
+
params = {"market": market, "date": date}
|
|
52
|
+
is_trading = await gw.get(
|
|
53
|
+
"/api/v1/market-calendar/is-trading-day", params=params
|
|
54
|
+
)
|
|
55
|
+
next_day = await gw.get("/api/v1/market-calendar/next-trading-day", params=params)
|
|
56
|
+
prev_day = await gw.get("/api/v1/market-calendar/prev-trading-day", params=params)
|
|
57
|
+
return {
|
|
58
|
+
"date": date,
|
|
59
|
+
"market": market,
|
|
60
|
+
"is_trading_day": is_trading.get("is_trading_day")
|
|
61
|
+
if isinstance(is_trading, dict)
|
|
62
|
+
else None,
|
|
63
|
+
"next_trading_day": next_day.get("next_trading_day")
|
|
64
|
+
if isinstance(next_day, dict)
|
|
65
|
+
else None,
|
|
66
|
+
"prev_trading_day": prev_day.get("prev_trading_day")
|
|
67
|
+
if isinstance(prev_day, dict)
|
|
68
|
+
else None,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def get_data_coverage(gw: GatewayClient) -> dict[str, Any]:
|
|
73
|
+
"""数据覆盖区间/可用因子数/股票池——回测日期边界的唯一合法来源。"""
|
|
74
|
+
metadata = await gw.get("/api/v1/selection/multi-factor/metadata")
|
|
75
|
+
meta = unwrap(metadata)
|
|
76
|
+
if not isinstance(meta, dict):
|
|
77
|
+
return {"raw": meta}
|
|
78
|
+
out: dict[str, Any] = {}
|
|
79
|
+
cov = meta.get("data_coverage")
|
|
80
|
+
if isinstance(cov, dict):
|
|
81
|
+
out["data_coverage"] = cov
|
|
82
|
+
factors = meta.get("factors")
|
|
83
|
+
if isinstance(factors, list):
|
|
84
|
+
out["factor_count"] = len(factors)
|
|
85
|
+
if meta.get("universe_presets"):
|
|
86
|
+
out["universe_presets"] = meta["universe_presets"]
|
|
87
|
+
if not out:
|
|
88
|
+
out = {"keys": sorted(meta.keys())}
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def get_daily_klines(
|
|
93
|
+
gw: GatewayClient,
|
|
94
|
+
symbol: str,
|
|
95
|
+
start_date: str,
|
|
96
|
+
end_date: str | None = None,
|
|
97
|
+
interval: str = "1d",
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""K 线 + 区间摘要。热身段只用于均线,不出现在 bars 里。"""
|
|
100
|
+
end_date = end_date or datetime.now().strftime("%Y-%m-%d")
|
|
101
|
+
warm_start = (
|
|
102
|
+
datetime.strptime(start_date, "%Y-%m-%d") - timedelta(days=_WARMUP_DAYS)
|
|
103
|
+
).strftime("%Y-%m-%d")
|
|
104
|
+
|
|
105
|
+
data = await gw.get(
|
|
106
|
+
f"/api/v1/market/klines/{symbol}",
|
|
107
|
+
params={
|
|
108
|
+
"start": warm_start,
|
|
109
|
+
"end": end_date,
|
|
110
|
+
"interval": interval,
|
|
111
|
+
"source": "auto",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
data = unwrap(data)
|
|
115
|
+
bars: list[dict[str, Any]] = data.get("bars", []) if isinstance(data, dict) else []
|
|
116
|
+
# 均线必须用含热身的全量收盘价——先算再裁剪,顺序不能反
|
|
117
|
+
warm_closes = [float(b["close"]) for b in bars if b.get("close") is not None]
|
|
118
|
+
ma20_warm = sma(warm_closes, 20)
|
|
119
|
+
ma60_warm = sma(warm_closes, 60)
|
|
120
|
+
if interval == "1d":
|
|
121
|
+
bars = [b for b in bars if b.get("date") and b["date"] >= start_date]
|
|
122
|
+
closes = [float(b["close"]) for b in bars if b.get("close") is not None]
|
|
123
|
+
|
|
124
|
+
summary: dict[str, Any] = {"symbol": symbol, "interval": interval}
|
|
125
|
+
if closes:
|
|
126
|
+
last = closes[-1]
|
|
127
|
+
first = closes[0]
|
|
128
|
+
summary.update(
|
|
129
|
+
{
|
|
130
|
+
"bar_count": len(closes),
|
|
131
|
+
"first_date": bars[0].get("date"),
|
|
132
|
+
"last_date": bars[-1].get("date"),
|
|
133
|
+
"latest_close": safe_round(last),
|
|
134
|
+
"period_change_pct": safe_round((last / first - 1) * 100, 2),
|
|
135
|
+
"period_high": safe_round(max(float(b["high"]) for b in bars if b.get("high") is not None)),
|
|
136
|
+
"period_low": safe_round(min(float(b["low"]) for b in bars if b.get("low") is not None)),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
summary["ma20"] = safe_round(ma20_warm)
|
|
140
|
+
summary["ma60"] = safe_round(ma60_warm)
|
|
141
|
+
if ma60_warm is not None:
|
|
142
|
+
summary["above_ma60"] = last > ma60_warm
|
|
143
|
+
if ma20_warm is not None:
|
|
144
|
+
summary["above_ma20"] = last > ma20_warm
|
|
145
|
+
coverage = data.get("coverage") if isinstance(data, dict) else None
|
|
146
|
+
if isinstance(coverage, dict):
|
|
147
|
+
summary["coverage"] = {
|
|
148
|
+
k: coverage.get(k)
|
|
149
|
+
for k in ("local_min", "local_max", "actual_start", "actual_end", "missing_head", "missing_tail")
|
|
150
|
+
if k in coverage
|
|
151
|
+
}
|
|
152
|
+
out_bars = trim_curve(
|
|
153
|
+
[
|
|
154
|
+
{k: b.get(k) for k in ("date", "timestamp", "open", "high", "low", "close", "volume") if k in b}
|
|
155
|
+
for b in bars
|
|
156
|
+
],
|
|
157
|
+
_MAX_BARS_OUT,
|
|
158
|
+
)
|
|
159
|
+
return {"summary": summary, "bars": out_bars}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def register(mcp: Any, gw: GatewayClient) -> None:
|
|
163
|
+
"""把本组工具注册进 FastMCP 实例。"""
|
|
164
|
+
|
|
165
|
+
@mcp.tool()
|
|
166
|
+
async def get_me_tool() -> dict[str, Any]:
|
|
167
|
+
"""当前登录用户信息。同时是凭证探针:连不上/凭证无效会在这里暴露。"""
|
|
168
|
+
return await get_me(gw)
|
|
169
|
+
|
|
170
|
+
@mcp.tool()
|
|
171
|
+
async def search_stocks_tool(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
172
|
+
"""按代码/名称/拼音搜索股票,把模糊输入归一成规范代码(symbol)。
|
|
173
|
+
|
|
174
|
+
其它工具的 symbol 参数一律以此工具返回的规范代码为准。
|
|
175
|
+
"""
|
|
176
|
+
return await search_stocks(gw, query, limit)
|
|
177
|
+
|
|
178
|
+
@mcp.tool()
|
|
179
|
+
async def get_trading_day_info_tool(
|
|
180
|
+
date: str | None = None, market: str = "SSE"
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
"""判断某日是否交易日,并给出上一/下一交易日。
|
|
183
|
+
|
|
184
|
+
所有日期类参数(回测区间、as_of_date)都应先来这里锚定。
|
|
185
|
+
"""
|
|
186
|
+
return await get_trading_day_info(gw, date, market)
|
|
187
|
+
|
|
188
|
+
@mcp.tool()
|
|
189
|
+
async def get_data_coverage_tool() -> dict[str, Any]:
|
|
190
|
+
"""查数据覆盖区间、可用因子数与股票池预设。
|
|
191
|
+
|
|
192
|
+
回测日期与选股 as_of_date 的边界来源:日期超出覆盖区间必失败,
|
|
193
|
+
先查这里再提交。
|
|
194
|
+
"""
|
|
195
|
+
return await get_data_coverage(gw)
|
|
196
|
+
|
|
197
|
+
@mcp.tool()
|
|
198
|
+
async def get_daily_klines_tool(
|
|
199
|
+
symbol: str,
|
|
200
|
+
start_date: str,
|
|
201
|
+
end_date: str | None = None,
|
|
202
|
+
interval: str = "1d",
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
"""个股 K 线(默认日线),先读 summary 再看 bars。
|
|
205
|
+
|
|
206
|
+
summary 含区间涨跌幅/高低点/最新收盘/MA20/MA60/是否站上均线——
|
|
207
|
+
「走势怎么样」「站没站上 60 日线」直接读 summary,不必逐根分析 bars;
|
|
208
|
+
bars 超过 250 根时等距抽样。interval 支持 1d/60m/30m/15m/5m。
|
|
209
|
+
"""
|
|
210
|
+
return await get_daily_klines(gw, symbol, start_date, end_date, interval)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""选股与策略工具:list_factors / screen_stocks / list_presets / save_preset / list_my_strategies。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bizonquant_mcp.common import unwrap
|
|
8
|
+
from bizonquant_mcp.gateway import GatewayClient
|
|
9
|
+
|
|
10
|
+
# 本地取值语义覆盖:后端 usage 描述缺这些关键口径(与 QuantBot TS 对齐)
|
|
11
|
+
_FACTOR_USAGE_OVERRIDES: dict[str, str] = {
|
|
12
|
+
"moving_average_trend": (
|
|
13
|
+
"取值是 MA短/MA长 − 1(量级 ±0.1),不是 0/1 布尔。"
|
|
14
|
+
"「MA5 在 MA60 上方」写 ma_distance(window=60) gte 0,"
|
|
15
|
+
"不要写 moving_average_trend gte 1(等于要求 MA5 是 MA60 两倍,必然 0 只)"
|
|
16
|
+
),
|
|
17
|
+
"ma_distance": "现价距 N 日均线的比例;「站上 N 日线」= ma_distance(window=N) gte 0",
|
|
18
|
+
"price_momentum": "区间收益动量,量级 ±0.5;>0 为上涨动能",
|
|
19
|
+
"volume_momentum": "量能动量;>0 为放量",
|
|
20
|
+
"ma_trend": "均线斜率方向;>0 为多头排列",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def list_factors(gw: GatewayClient) -> dict[str, Any]:
|
|
25
|
+
"""因子目录:名称/方向/参数/取值语义。因子名的唯一合法来源。"""
|
|
26
|
+
data = await gw.get("/api/v1/selection/multi-factor/factors")
|
|
27
|
+
data = unwrap(data)
|
|
28
|
+
factors = data.get("factors", data) if isinstance(data, dict) else data
|
|
29
|
+
out: list[dict[str, Any]] = []
|
|
30
|
+
for f in factors or []:
|
|
31
|
+
if not isinstance(f, dict):
|
|
32
|
+
continue
|
|
33
|
+
name = f.get("name") or f.get("factor")
|
|
34
|
+
item = dict(f)
|
|
35
|
+
if name in _FACTOR_USAGE_OVERRIDES and not item.get("value_hint"):
|
|
36
|
+
item["value_hint"] = _FACTOR_USAGE_OVERRIDES[name]
|
|
37
|
+
out.append(item)
|
|
38
|
+
catalog = unwrap(await gw.get("/api/v1/selection/multi-factor/sectors"))
|
|
39
|
+
return {
|
|
40
|
+
"factors": out,
|
|
41
|
+
"sector_catalog": catalog if isinstance(catalog, dict) else None,
|
|
42
|
+
"value_hints": _FACTOR_USAGE_OVERRIDES,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def screen_stocks(
|
|
47
|
+
gw: GatewayClient,
|
|
48
|
+
config: dict[str, Any],
|
|
49
|
+
as_of_date: str | None = None,
|
|
50
|
+
limit: int = 20,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""即时截面选股。返回前 limit 只,附排序/过滤因子的当期值。"""
|
|
53
|
+
body: dict[str, Any] = {"config": config}
|
|
54
|
+
if as_of_date:
|
|
55
|
+
body["as_of_date"] = as_of_date
|
|
56
|
+
data = await gw.post(
|
|
57
|
+
"/api/v1/selection/multi-factor/screen", json_body=body
|
|
58
|
+
)
|
|
59
|
+
data = unwrap(data)
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
return {"raw": data}
|
|
62
|
+
stocks = data.get("stocks") or data.get("results") or []
|
|
63
|
+
# 剔除后端「结果共 N 条仅返回前 M 条」类冗余告警——passed_count 已表达
|
|
64
|
+
warnings = [
|
|
65
|
+
w
|
|
66
|
+
for w in (data.get("warnings") or [])
|
|
67
|
+
if not (isinstance(w, str) and "仅返回前" in w)
|
|
68
|
+
]
|
|
69
|
+
limit_ = max(1, min(limit, 100))
|
|
70
|
+
items = stocks[:limit_]
|
|
71
|
+
# 附带策略用到的因子值:排序因子 + 各类过滤条件的因子(对齐
|
|
72
|
+
# StructuredStrategyConfig:sorting.rules / technical / fundamental_filters / special_indicators)
|
|
73
|
+
wanted: list[str] = []
|
|
74
|
+
for rule in (config.get("sorting") or {}).get("rules") or []:
|
|
75
|
+
if isinstance(rule, dict) and rule.get("factor_name"):
|
|
76
|
+
wanted.append(rule["factor_name"])
|
|
77
|
+
for cond_key in ("technical", "fundamental_filters", "special_indicators", "fundamental"):
|
|
78
|
+
for cond in config.get(cond_key) or []:
|
|
79
|
+
if isinstance(cond, dict) and cond.get("factor_name"):
|
|
80
|
+
wanted.append(cond["factor_name"])
|
|
81
|
+
for s in items:
|
|
82
|
+
values = s.get("factor_values")
|
|
83
|
+
if not isinstance(values, dict):
|
|
84
|
+
values = s.get("factors") if isinstance(s.get("factors"), dict) else {}
|
|
85
|
+
s["factor_values"] = {f: values.get(f) for f in dict.fromkeys(wanted)}
|
|
86
|
+
return {
|
|
87
|
+
"passed_count": data.get("passed_count", data.get("total", len(stocks))),
|
|
88
|
+
"returned_count": len(items),
|
|
89
|
+
"as_of_date": data.get("as_of_date") or as_of_date,
|
|
90
|
+
"stocks": items,
|
|
91
|
+
"warnings": warnings,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def list_presets(gw: GatewayClient) -> list[dict[str, Any]]:
|
|
96
|
+
"""列出用户多因子选股方案(含 config/date_range,供提交回测取用)。"""
|
|
97
|
+
data = await gw.get("/api/v1/selection/multi-factor/presets")
|
|
98
|
+
data = unwrap(data)
|
|
99
|
+
return data if isinstance(data, list) else data.get("presets", [])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def save_preset(
|
|
103
|
+
gw: GatewayClient,
|
|
104
|
+
name: str,
|
|
105
|
+
config: dict[str, Any],
|
|
106
|
+
date_range: dict[str, str] | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""把多因子配置保存为选股方案(写操作:只写用户自己的方案列表)。"""
|
|
109
|
+
body: dict[str, Any] = {"name": name, "config": config}
|
|
110
|
+
if date_range:
|
|
111
|
+
body["date_range"] = date_range
|
|
112
|
+
data = await gw.post("/api/v1/selection/multi-factor/presets", json_body=body)
|
|
113
|
+
return unwrap(data)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def list_my_strategies(gw: GatewayClient) -> list[dict[str, Any]]:
|
|
117
|
+
"""用户全部策略(多因子/组合/轮动)及运行状态。"""
|
|
118
|
+
data = await gw.get("/api/v1/strategies")
|
|
119
|
+
data = unwrap(data)
|
|
120
|
+
items = data if isinstance(data, list) else data.get("strategies", data.get("items", []))
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
"strategy_id": s.get("strategy_id") or s.get("id"),
|
|
124
|
+
"name": s.get("name") or s.get("strategy_name"),
|
|
125
|
+
"type": s.get("type") or s.get("strategy_type"),
|
|
126
|
+
"status": s.get("status"),
|
|
127
|
+
"mode": s.get("mode"),
|
|
128
|
+
}
|
|
129
|
+
for s in items or []
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def register(mcp: Any, gw: GatewayClient) -> None:
|
|
134
|
+
"""把本组工具注册进 MCPServer 实例。"""
|
|
135
|
+
|
|
136
|
+
@mcp.tool()
|
|
137
|
+
async def list_factors_tool() -> dict[str, Any]:
|
|
138
|
+
"""查因子目录:因子名/方向/参数/取值语义。因子名的唯一合法来源。
|
|
139
|
+
|
|
140
|
+
value_hint 覆盖了高频踩坑口径:moving_average_trend 是 MA短/MA长−1
|
|
141
|
+
的比值(量级 ±0.1)不是 0/1 布尔;「站上 N 日线」= ma_distance
|
|
142
|
+
window=N gte 0。选股条件里的因子必须出自本目录。
|
|
143
|
+
"""
|
|
144
|
+
return await list_factors(gw)
|
|
145
|
+
|
|
146
|
+
@mcp.tool()
|
|
147
|
+
async def screen_stocks_tool(
|
|
148
|
+
config: dict[str, Any], as_of_date: str | None = None, limit: int = 20
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""即时截面选股(无副作用查询)。
|
|
151
|
+
|
|
152
|
+
config 为 StructuredStrategyConfig:universe_preset(hs300/all_a 等,
|
|
153
|
+
见 get_data_coverage_tool)、technical/fundamental_filters 条件
|
|
154
|
+
({factor_name, params, operator, value})、sorting.rules 排序
|
|
155
|
+
({factor_name, weight, direction: higher_better|lower_better})。
|
|
156
|
+
返回前 limit 只并附策略涉及的因子当期值(取不到为 null)。
|
|
157
|
+
as_of_date 用交易日(先问 get_trading_day_info_tool)。
|
|
158
|
+
"""
|
|
159
|
+
return await screen_stocks(gw, config, as_of_date, limit)
|
|
160
|
+
|
|
161
|
+
@mcp.tool()
|
|
162
|
+
async def list_presets_tool() -> list[dict[str, Any]]:
|
|
163
|
+
"""列出用户已保存的多因子选股方案(含 config 与默认回测区间)。"""
|
|
164
|
+
return await list_presets(gw)
|
|
165
|
+
|
|
166
|
+
@mcp.tool()
|
|
167
|
+
async def save_preset_tool(
|
|
168
|
+
name: str,
|
|
169
|
+
config: dict[str, Any],
|
|
170
|
+
date_range: dict[str, str] | None = None,
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""把多因子配置保存为选股方案(写操作,仅写用户自己的方案列表)。
|
|
173
|
+
|
|
174
|
+
date_range 形如 {"start_date": "2023-01-01", "end_date": "2026-08-28"}。
|
|
175
|
+
"""
|
|
176
|
+
return await save_preset(gw, name, config, date_range)
|
|
177
|
+
|
|
178
|
+
@mcp.tool()
|
|
179
|
+
async def list_my_strategies_tool() -> list[dict[str, Any]]:
|
|
180
|
+
"""列出用户全部策略(多因子/组合/轮动)及运行状态。"""
|
|
181
|
+
return await list_my_strategies(gw)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bizonquant-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BizonQuant MCP server —— 把量化能力面(行情/多因子选股/回测)开放给外部 agent
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: mcp>=1.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# bizonquant-mcp
|
|
12
|
+
|
|
13
|
+
BizonQuant 量化平台的 MCP server(客户端形态):跑在你本机,把平台能力面开放给外部 agent(Claude Code / Cursor / Codex / 自写 agent),**服务器零常驻进程、不开前端**。
|
|
14
|
+
|
|
15
|
+
## 能力(首期 15 工具)
|
|
16
|
+
|
|
17
|
+
| 组 | 工具 | 说明 |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| 锚点 | `get_me_tool` | 当前用户;凭证探针 |
|
|
20
|
+
| 锚点 | `get_trading_day_info_tool` | 交易日判断 + 上/下一交易日 |
|
|
21
|
+
| 锚点 | `get_data_coverage_tool` | 数据覆盖/因子数/股票池——回测日期边界来源 |
|
|
22
|
+
| 行情 | `search_stocks_tool` | 模糊名 → 规范代码 |
|
|
23
|
+
| 行情 | `get_daily_klines_tool` | K 线 + 区间摘要(涨跌/高低/MA20/MA60/站上均线) |
|
|
24
|
+
| 选股 | `list_factors_tool` | 因子目录 + 取值语义(防条件翻译错) |
|
|
25
|
+
| 选股 | `screen_stocks_tool` | 即时截面选股,附因子值 |
|
|
26
|
+
| 选股 | `list_presets_tool` / `save_preset_tool` | 方案列表 / 存方案 |
|
|
27
|
+
| 回测 | `submit_backtest_tool` | 提交多因子回测(异步) |
|
|
28
|
+
| 回测 | `wait_backtest_tool` | 有界等待(≤50s),done:false 续调 |
|
|
29
|
+
| 回测 | `get_backtest_status_tool` / `get_backtest_result_tool` / `list_backtests_tool` | 状态 / 结果(曲线抽样)/ 历史 |
|
|
30
|
+
| 总览 | `list_my_strategies_tool` | 全部策略与运行状态 |
|
|
31
|
+
|
|
32
|
+
## 安装与运行
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 从仓库根
|
|
36
|
+
cd mcp-server && uv sync
|
|
37
|
+
uv run bizonquant-mcp init # 录入 key 并自检(写入 ~/.config/bizonquant-mcp/config.json,0600)
|
|
38
|
+
uv run bizonquant-mcp doctor # 探链路:health / token / whoami / 数据面
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 接入 Claude Code(stdio)
|
|
42
|
+
|
|
43
|
+
```jsonc
|
|
44
|
+
// ~/.claude.json 的 mcpServers
|
|
45
|
+
"bizonquant": {
|
|
46
|
+
"command": "uv",
|
|
47
|
+
"args": ["--directory", "<仓库路径>/mcp-server", "run", "bizonquant-mcp"],
|
|
48
|
+
"env": {
|
|
49
|
+
"BIZONQUANT_ACCESS_KEY": "qm_live_xxx",
|
|
50
|
+
"BIZONQUANT_SECRET_KEY": "sk_yyy",
|
|
51
|
+
"BIZONQUANT_BASE_URL": "https://bizon.naiyu.xyz:8443"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
也支持单变量 `BIZONQUANT_API_KEY=access_key.secret_key`。
|
|
57
|
+
|
|
58
|
+
## 拿凭证(三选一)
|
|
59
|
+
|
|
60
|
+
1. **桌面端**:API 密钥页建 key,permissions 勾 `mcp.read`(只读)或 `mcp.*`(含 save_preset / submit_backtest 写点)
|
|
61
|
+
2. **过渡 curl**:登录(注意 device_type)→ `POST /api/v1/api-keys`(`{"name":"mcp","permissions":["mcp.*"]}`,secret 只显示一次)
|
|
62
|
+
3. 桌面端「Agent 接入」UI(待建,阶段 2)
|
|
63
|
+
|
|
64
|
+
## 客户端纪律(内置,无需配置)
|
|
65
|
+
|
|
66
|
+
- 默认拒绝 `http://` 明文网关(`BIZONQUANT_ALLOW_INSECURE=1` 可解锁内网场景)
|
|
67
|
+
- token 只走 Authorization 头;401 自动换发一次,仍 401 即停(key 撤销后不打风暴)
|
|
68
|
+
- UA 带 `bizonquant-mcp/{version}`,服务端可审计
|
|
69
|
+
|
|
70
|
+
## 安全模型
|
|
71
|
+
|
|
72
|
+
- 凭证是 API Key(access_key + bcrypt secret),不是账号密码——泄漏面仅限该 key 的能力面
|
|
73
|
+
- 服务端对 agent token 施加 **scope 硬闸**:GET 全放,非 GET 只放 5 条写白名单(选股/存方案/回测提交),`/admin` 与 `/api-keys` 一律 403
|
|
74
|
+
- 交易/资金类端点不在能力面内;删 key / 停用 / 轮换 secret 即时全链失效
|
|
75
|
+
- `revoke` 命令 = 同 key 再换发一次(互踢规则下旧会话全灭);永久吊销请在桌面端删 key
|
|
76
|
+
|
|
77
|
+
## 开发
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv run pytest -q # 全量测试(含与网关 scope 闸、TS 工具表的 (method,path) 对拍)
|
|
81
|
+
uv run bizonquant-mcp --version
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
对拍闸:`tests/test_conformance.py` 断言本包工具的 (method, path) ⊆ 网关 `AGENT_WRITE_ALLOWLIST` ∪ GET——网关收窄或工具新增写路径而网关未放行时,测试立即红。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
bizonquant_mcp/__init__.py,sha256=xOCKTPiNN9Q84zS8nbYioV7TyIuoHMNkG9sT9_6u9Fg,540
|
|
2
|
+
bizonquant_mcp/__main__.py,sha256=WXmGO-0o75IwDQE__pyuhpdRsbqZCxbruHhXhafmsuA,103
|
|
3
|
+
bizonquant_mcp/cli.py,sha256=WW6nr9sm6CF2q91NzOK4A_DrrRrNc026-H71kSn5_j8,6495
|
|
4
|
+
bizonquant_mcp/common.py,sha256=i5VNkvc8GPle5KUV2kvoMSmhUDTpAgFtXBeFmpaqRd8,1452
|
|
5
|
+
bizonquant_mcp/gateway.py,sha256=gEs62OM87OgUegA2x0Z3tF4TKhKXw5Pyc14FhhRpoS0,6433
|
|
6
|
+
bizonquant_mcp/server.py,sha256=u_L5Mbak8CN2doFdLRdbgTizGlXlcQC-0b8icML88kk,2691
|
|
7
|
+
bizonquant_mcp/version.py,sha256=8T_WMC6SJVyhhZrKnyrVbbmFgZhVCiXYwKJGCWPdiY0,100
|
|
8
|
+
bizonquant_mcp/tools/__init__.py,sha256=2hYOHsPsTnV1hB9-hZwLByH75u0KIZ5PCAvdK7MCI5o,104
|
|
9
|
+
bizonquant_mcp/tools/backtest.py,sha256=CwUtE568wP8PBhQ7TRLIutYg9z-rUII0PQoNS9Ec5I8,7938
|
|
10
|
+
bizonquant_mcp/tools/market.py,sha256=yLpmLwFwbCq_uDTkBi4iT4bBujqIYpp4bKHwKMt0rrw,7929
|
|
11
|
+
bizonquant_mcp/tools/screening.py,sha256=MKwJHdQmnwd7z4kod-Y97X4eds4y48nwi2hWT6iuevs,7672
|
|
12
|
+
bizonquant_mcp-0.1.0.dist-info/METADATA,sha256=YB7KcdvKuZk-DGEu9GnjLIN0H8dvPmHx-GBml_wOuhE,3862
|
|
13
|
+
bizonquant_mcp-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
bizonquant_mcp-0.1.0.dist-info/entry_points.txt,sha256=oO-xN72NP0V2BvU1w_DtmP2N0CKpVe5yZVehpkrhznY,59
|
|
15
|
+
bizonquant_mcp-0.1.0.dist-info/RECORD,,
|