@arong8888/tulip-aibot-mcp 0.1.0
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.
- package/README.md +61 -0
- package/bin/cli.js +16 -0
- package/package.json +28 -0
- package/python/__pycache__/agent_tools.cpython-312.pyc +0 -0
- package/python/__pycache__/quant_tools.cpython-312.pyc +0 -0
- package/python/__pycache__/reasoning_tools.cpython-312.pyc +0 -0
- package/python/agent_tools.py +230 -0
- package/python/quant_tools.py +2464 -0
- package/python/reasoning_tools.py +220 -0
- package/python/server.py +88 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @arong8888/tulip-aibot-mcp
|
|
2
|
+
|
|
3
|
+
郁金香AIBOT 的离线量化工具 MCP 服务器(Model Context Protocol)。
|
|
4
|
+
|
|
5
|
+
**57 个纯计算工具**,全部只用 Python 标准库:不连接 MT5、不连券商、不读行情、不下单——你把行情/回测数据喂进来,它负责算。
|
|
6
|
+
|
|
7
|
+
## 工具清单(五大类)
|
|
8
|
+
|
|
9
|
+
- **技术指标**:MA/RSI/MACD/布林带/ATR/ADX/随机指标/CCI/OBV/VWAP/威廉%R/ROC/唐奇安/肯特纳/盘整指数/均线交叉/枢轴点/斐波那契
|
|
10
|
+
- **风险与绩效**:夏普/索提诺/卡玛/Omega/溃疡指数/最大回撤及回撤事件表/VaR-CVaR/交易统计/PSR-DSR/Bootstrap 置信区间/蒙特卡洛破产概率
|
|
11
|
+
- **仓位与风控**:风险仓位计算/凯利公式/敞口限额检查/回撤响应阶梯
|
|
12
|
+
- **组合与筛选**:风险平价/最小方差配置/相关性矩阵聚类/多品种打分筛选/贪心组合搜索/分散化比率
|
|
13
|
+
- **策略挖掘**:信号质量评估/因子IC与ICIR/参数高原稳健性/Walk-Forward切分/分状态绩效/优化器防过拟合排名/均值回归半衰期/配对交易扫描/方差比检验/Hurst指数
|
|
14
|
+
- **代理工具**:安全计算器/文件搜索/备忘录;思维辅助:第一性原理/复利/贝叶斯/逆向思维
|
|
15
|
+
|
|
16
|
+
## 运行要求
|
|
17
|
+
|
|
18
|
+
- Node.js ≥ 16(仅作启动器)
|
|
19
|
+
- **Python 3.10+**(标准库即可,无任何 pip 依赖)
|
|
20
|
+
|
|
21
|
+
## 用法
|
|
22
|
+
|
|
23
|
+
### 直接运行
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @arong8888/tulip-aibot-mcp
|
|
27
|
+
# 若 python 不在 PATH:
|
|
28
|
+
PYTHON=/usr/local/bin/python3 npx @arong8888/tulip-aibot-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 接入 MCP 客户端(Claude Desktop / 其他)
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"tulip-aibot-mcp": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": ["-y", "@arong8888/tulip-aibot-mcp"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
协议:JSON-RPC 2.0 over stdio,支持 `initialize` / `tools/list` / `tools/call` / `ping`。
|
|
45
|
+
|
|
46
|
+
## 典型工作流
|
|
47
|
+
|
|
48
|
+
1. 用任意行情源(如 MT5 的 `get_rates`)取收盘价数组
|
|
49
|
+
2. 喂给本服务器的工具,例如:
|
|
50
|
+
- `rsi` / `macd` / `bollinger_bands` — 看指标
|
|
51
|
+
- `backtest_report` / `drawdown_episodes` — 评估回测
|
|
52
|
+
- `symbol_screener` — 多品种打分筛选
|
|
53
|
+
- `portfolio_allocation` (risk_parity) — 策略组合权重
|
|
54
|
+
- `param_robustness` — 检查参数是不是"刀锋参数"
|
|
55
|
+
- `sharpe_psr` (trials=N) — 校正多重试验的幸存者偏差
|
|
56
|
+
|
|
57
|
+
## 注意
|
|
58
|
+
|
|
59
|
+
- `note_taking` 工具会在安装目录旁写 `data/agent_notes.jsonl`
|
|
60
|
+
- 所有工具确定性计算:蒙特卡洛/自助法使用显式随机种子,同输入同输出
|
|
61
|
+
- 工具输出是**算术**,不是投资建议;`signal_quality`/`factor_ic` 的结论依赖你提供的前向收益无前视偏差
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const python = process.env.PYTHON || "python";
|
|
7
|
+
const server = path.join(__dirname, "..", "python", "server.py");
|
|
8
|
+
|
|
9
|
+
const child = spawn(python, [server], { stdio: "inherit" });
|
|
10
|
+
child.on("error", () => {
|
|
11
|
+
console.error("[tulip-aibot-mcp] 启动失败:需要 Python 3.10+。");
|
|
12
|
+
console.error("[tulip-aibot-mcp] 可用环境变量 PYTHON 指定解释器,例如:");
|
|
13
|
+
console.error('[tulip-aibot-mcp] set PYTHON=C:\\Python312\\python.exe && npx @arong8888/tulip-aibot-mcp');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
16
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arong8888/tulip-aibot-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "郁金香AIBOT 离线量化工具 MCP 服务器:57个量化交易工具(技术指标/风险绩效/仓位管理/组合优化/回测报告/策略挖掘),纯标准库、不连接交易账户",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"quant",
|
|
11
|
+
"trading",
|
|
12
|
+
"technical-analysis",
|
|
13
|
+
"risk-management",
|
|
14
|
+
"backtest",
|
|
15
|
+
"mt5"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"tulip-aibot-mcp": "bin/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"python/",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=16"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""AGENT 工具注册表:给 LLM 代理使用的安全离线工具。
|
|
2
|
+
|
|
3
|
+
纯标准库;不连接 MT5、不下单。通过 call_tool 统一分发,
|
|
4
|
+
list_tools 输出与 MCP tools/list 兼容的定义。
|
|
5
|
+
"""
|
|
6
|
+
import ast
|
|
7
|
+
import fnmatch
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
BASE_PATH = Path(__file__).resolve().parent
|
|
18
|
+
NOTES_FILE = BASE_PATH / "data" / "agent_notes.jsonl"
|
|
19
|
+
MAX_NOTE_CHARS = 2000
|
|
20
|
+
MAX_SEARCH_RESULTS = 200
|
|
21
|
+
MAX_FILE_BYTES = 2_000_000
|
|
22
|
+
MAX_EXPRESSION_CHARS = 200
|
|
23
|
+
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "logs"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolError(ValueError):
|
|
27
|
+
"""工具参数或执行失败;call_tool 转成 ok=False 的错误响应。"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_TOOLS: dict = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tool(name: str, description: str, properties: dict = None, required: list = None):
|
|
34
|
+
"""把函数注册为带 JSON Schema 的工具。"""
|
|
35
|
+
def decorator(func):
|
|
36
|
+
_TOOLS[name] = {
|
|
37
|
+
"name": name,
|
|
38
|
+
"description": description,
|
|
39
|
+
"inputSchema": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": properties or {},
|
|
42
|
+
"required": required or [],
|
|
43
|
+
},
|
|
44
|
+
"func": func,
|
|
45
|
+
}
|
|
46
|
+
return func
|
|
47
|
+
return decorator
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def has_tool(name: str) -> bool:
|
|
51
|
+
return name in _TOOLS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_tools() -> List[dict]:
|
|
55
|
+
return [{k: spec[k] for k in ("name", "description", "inputSchema")}
|
|
56
|
+
for spec in _TOOLS.values()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def call_tool(name: str, arguments: Optional[dict]) -> dict:
|
|
60
|
+
"""统一分发入口。永不抛异常:失败返回 {"ok": False, "error": ...}。"""
|
|
61
|
+
spec = _TOOLS.get(name)
|
|
62
|
+
if spec is None:
|
|
63
|
+
return {"ok": False, "error": f"未知工具: {name}"}
|
|
64
|
+
if arguments is None:
|
|
65
|
+
arguments = {}
|
|
66
|
+
if not isinstance(arguments, dict):
|
|
67
|
+
return {"ok": False, "error": "arguments 必须是对象"}
|
|
68
|
+
try:
|
|
69
|
+
return {"ok": True, "result": spec["func"](**arguments)}
|
|
70
|
+
except ToolError as exc:
|
|
71
|
+
return {"ok": False, "error": str(exc)}
|
|
72
|
+
except TypeError as exc:
|
|
73
|
+
return {"ok": False, "error": f"参数不匹配: {exc}"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------- calculator ----------
|
|
77
|
+
_ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow)
|
|
78
|
+
_ALLOWED_UNARY = (ast.USub, ast.UAdd)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _eval_node(node) -> float:
|
|
82
|
+
if isinstance(node, ast.Expression):
|
|
83
|
+
return _eval_node(node.body)
|
|
84
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) \
|
|
85
|
+
and not isinstance(node.value, bool):
|
|
86
|
+
return float(node.value)
|
|
87
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, _ALLOWED_BINOPS):
|
|
88
|
+
left, right = _eval_node(node.left), _eval_node(node.right)
|
|
89
|
+
if isinstance(node.op, ast.Pow) and abs(right) > 1000:
|
|
90
|
+
raise ToolError("幂指数过大")
|
|
91
|
+
try:
|
|
92
|
+
value = {
|
|
93
|
+
ast.Add: lambda: left + right,
|
|
94
|
+
ast.Sub: lambda: left - right,
|
|
95
|
+
ast.Mult: lambda: left * right,
|
|
96
|
+
ast.Div: lambda: left / right,
|
|
97
|
+
ast.FloorDiv: lambda: left // right,
|
|
98
|
+
ast.Mod: lambda: left % right,
|
|
99
|
+
ast.Pow: lambda: left ** right,
|
|
100
|
+
}[type(node.op)]()
|
|
101
|
+
except ZeroDivisionError:
|
|
102
|
+
raise ToolError("除数为零") from None
|
|
103
|
+
except OverflowError:
|
|
104
|
+
raise ToolError("计算结果溢出") from None
|
|
105
|
+
if not math.isfinite(value) or abs(value) > 1e100:
|
|
106
|
+
raise ToolError("结果超出安全范围")
|
|
107
|
+
return float(value)
|
|
108
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, _ALLOWED_UNARY):
|
|
109
|
+
value = _eval_node(node.operand)
|
|
110
|
+
return -value if isinstance(node.op, ast.USub) else value
|
|
111
|
+
raise ToolError("只允许数字与 + - * / // % ** 和括号")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@tool(
|
|
115
|
+
"calculator",
|
|
116
|
+
"安全计算四则与幂运算,例如 (2+3)*4 或 2**10。只支持数字和运算符,不支持函数与变量。",
|
|
117
|
+
properties={"expression": {"type": "string", "description": "算术表达式"}},
|
|
118
|
+
required=["expression"],
|
|
119
|
+
)
|
|
120
|
+
def tool_calculator(expression: str) -> dict:
|
|
121
|
+
if not isinstance(expression, str) or not expression.strip():
|
|
122
|
+
raise ToolError("表达式不能为空")
|
|
123
|
+
if len(expression) > MAX_EXPRESSION_CHARS:
|
|
124
|
+
raise ToolError(f"表达式过长(上限{MAX_EXPRESSION_CHARS}字符)")
|
|
125
|
+
try:
|
|
126
|
+
tree = ast.parse(expression, mode="eval")
|
|
127
|
+
except SyntaxError:
|
|
128
|
+
raise ToolError("表达式语法错误") from None
|
|
129
|
+
value = _eval_node(tree)
|
|
130
|
+
return {"expression": expression, "value": value}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------- file_search ----------
|
|
134
|
+
@tool(
|
|
135
|
+
"file_search",
|
|
136
|
+
"在项目内按正则搜索文本文件内容,返回文件路径、行号与行内容。仅限项目目录。",
|
|
137
|
+
properties={
|
|
138
|
+
"pattern": {"type": "string", "description": "正则表达式"},
|
|
139
|
+
"path": {"type": "string", "description": "项目内相对目录,默认项目根"},
|
|
140
|
+
"include": {"type": "string", "description": "文件名通配过滤,如 *.py"},
|
|
141
|
+
"max_results": {"type": "integer", "description": "最多返回条数,默认50,上限200"},
|
|
142
|
+
},
|
|
143
|
+
required=["pattern"],
|
|
144
|
+
)
|
|
145
|
+
def tool_file_search(pattern: str, path: str = ".", include: str = None,
|
|
146
|
+
max_results: int = 50) -> dict:
|
|
147
|
+
if not isinstance(pattern, str) or not pattern:
|
|
148
|
+
raise ToolError("pattern 不能为空")
|
|
149
|
+
if not isinstance(max_results, int) or max_results < 1:
|
|
150
|
+
raise ToolError("max_results 必须是正整数")
|
|
151
|
+
max_results = min(max_results, MAX_SEARCH_RESULTS)
|
|
152
|
+
try:
|
|
153
|
+
regex = re.compile(pattern)
|
|
154
|
+
except re.error:
|
|
155
|
+
raise ToolError("正则表达式无效") from None
|
|
156
|
+
root = (BASE_PATH / str(path)).resolve()
|
|
157
|
+
if root != BASE_PATH and BASE_PATH not in root.parents:
|
|
158
|
+
raise ToolError("path 必须位于项目目录内")
|
|
159
|
+
matches: List[dict] = []
|
|
160
|
+
scanned = 0
|
|
161
|
+
truncated = False
|
|
162
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
163
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
164
|
+
for filename in sorted(filenames):
|
|
165
|
+
if include and not fnmatch.fnmatch(filename, include):
|
|
166
|
+
continue
|
|
167
|
+
file_path = Path(dirpath) / filename
|
|
168
|
+
try:
|
|
169
|
+
if file_path.stat().st_size > MAX_FILE_BYTES:
|
|
170
|
+
continue
|
|
171
|
+
text = file_path.read_text(encoding="utf-8")
|
|
172
|
+
except (OSError, UnicodeDecodeError):
|
|
173
|
+
continue # 跳过二进制或不可读文件
|
|
174
|
+
scanned += 1
|
|
175
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
176
|
+
if regex.search(line):
|
|
177
|
+
if len(matches) >= max_results:
|
|
178
|
+
truncated = True
|
|
179
|
+
break
|
|
180
|
+
matches.append({
|
|
181
|
+
"path": str(file_path.relative_to(BASE_PATH)),
|
|
182
|
+
"line_number": line_number,
|
|
183
|
+
"line": line[:300],
|
|
184
|
+
})
|
|
185
|
+
if truncated:
|
|
186
|
+
break
|
|
187
|
+
if truncated:
|
|
188
|
+
break
|
|
189
|
+
return {"matches": matches, "scanned_files": scanned,
|
|
190
|
+
"total_returned": len(matches), "truncated": truncated}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------- note_taking ----------
|
|
194
|
+
@tool(
|
|
195
|
+
"note_taking",
|
|
196
|
+
"给代理的持久备忘录:add 追加一条笔记,list 按时间倒序读取最近的笔记。",
|
|
197
|
+
properties={
|
|
198
|
+
"action": {"type": "string", "enum": ["add", "list"]},
|
|
199
|
+
"text": {"type": "string", "description": "add 时必填,最长2000字符"},
|
|
200
|
+
"limit": {"type": "integer", "description": "list 返回条数,默认20,上限200"},
|
|
201
|
+
},
|
|
202
|
+
required=["action"],
|
|
203
|
+
)
|
|
204
|
+
def tool_note(action: str, text: str = "", limit: int = 20) -> dict:
|
|
205
|
+
if action == "add":
|
|
206
|
+
if not isinstance(text, str) or not text.strip():
|
|
207
|
+
raise ToolError("笔记内容不能为空")
|
|
208
|
+
if len(text) > MAX_NOTE_CHARS:
|
|
209
|
+
raise ToolError(f"笔记过长(上限{MAX_NOTE_CHARS}字符)")
|
|
210
|
+
NOTES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
entry = {
|
|
212
|
+
"id": str(time.time_ns()),
|
|
213
|
+
"text": text,
|
|
214
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
215
|
+
}
|
|
216
|
+
with open(NOTES_FILE, "a", encoding="utf-8") as handle:
|
|
217
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
218
|
+
return {"action": "add", "note": entry}
|
|
219
|
+
if action == "list":
|
|
220
|
+
if not NOTES_FILE.exists():
|
|
221
|
+
return {"action": "list", "notes": [], "count": 0}
|
|
222
|
+
try:
|
|
223
|
+
entries = [json.loads(line) for line in
|
|
224
|
+
NOTES_FILE.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
225
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
226
|
+
raise ToolError(f"笔记文件损坏: {exc}") from None
|
|
227
|
+
limit = min(int(limit), 200) if isinstance(limit, int) and limit > 0 else 20
|
|
228
|
+
return {"action": "list", "notes": list(reversed(entries[-limit:])),
|
|
229
|
+
"count": len(entries)}
|
|
230
|
+
raise ToolError("action 必须是 add 或 list")
|