@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.
@@ -0,0 +1,2464 @@
1
+ """量化交易工具注册表:技术指标、风险绩效、仓位管理与统计工具。
2
+
3
+ 纯标准库、纯离线计算;不连接 MT5、不下单、不读文件。
4
+ 通过 call_tool 统一分发,list_tools 输出与 MCP tools/list 兼容的定义。
5
+ 所有输入显式校验(有限数字、长度上限),所有输出保证为有限数值。
6
+ """
7
+ import math
8
+ import random
9
+ from typing import List, Optional
10
+
11
+ MAX_SERIES_LEN = 100_000
12
+ MAX_CANDLES = 50_000
13
+
14
+
15
+ class ToolError(ValueError):
16
+ """工具参数或执行失败;call_tool 转成 ok=False 的错误响应。"""
17
+
18
+
19
+ _TOOLS: dict = {}
20
+
21
+
22
+ def tool(name: str, description: str, properties: dict = None, required: list = None):
23
+ """把函数注册为带 JSON Schema 的工具。"""
24
+ def decorator(func):
25
+ _TOOLS[name] = {
26
+ "name": name,
27
+ "description": description,
28
+ "inputSchema": {
29
+ "type": "object",
30
+ "properties": properties or {},
31
+ "required": required or [],
32
+ },
33
+ "func": func,
34
+ }
35
+ return func
36
+ return decorator
37
+
38
+
39
+ def has_tool(name: str) -> bool:
40
+ return name in _TOOLS
41
+
42
+
43
+ def list_tools() -> List[dict]:
44
+ return [{k: spec[k] for k in ("name", "description", "inputSchema")}
45
+ for spec in _TOOLS.values()]
46
+
47
+
48
+ def call_tool(name: str, arguments: Optional[dict]) -> dict:
49
+ """统一分发入口。永不抛异常:失败返回 {"ok": False, "error": ...}。"""
50
+ spec = _TOOLS.get(name)
51
+ if spec is None:
52
+ return {"ok": False, "error": f"未知工具: {name}"}
53
+ if arguments is None:
54
+ arguments = {}
55
+ if not isinstance(arguments, dict):
56
+ return {"ok": False, "error": "arguments 必须是对象"}
57
+ try:
58
+ return {"ok": True, "result": spec["func"](**arguments)}
59
+ except ToolError as exc:
60
+ return {"ok": False, "error": str(exc)}
61
+ except TypeError as exc:
62
+ return {"ok": False, "error": f"参数不匹配: {exc}"}
63
+
64
+
65
+ # ---------- 输入校验与数值辅助 ----------
66
+
67
+ def _finite(value) -> bool:
68
+ return isinstance(value, (int, float)) and not isinstance(value, bool) \
69
+ and math.isfinite(value)
70
+
71
+
72
+ def _number(value, label, low=None, high=None) -> float:
73
+ if not _finite(value):
74
+ raise ToolError(f"{label}: 必须是有限数字")
75
+ v = float(value)
76
+ if low is not None and v < low:
77
+ raise ToolError(f"{label}: 不能小于 {low}")
78
+ if high is not None and v > high:
79
+ raise ToolError(f"{label}: 不能大于 {high}")
80
+ return v
81
+
82
+
83
+ def _int(value, label, low=1, high=1_000_000) -> int:
84
+ if isinstance(value, bool) or not isinstance(value, int):
85
+ raise ToolError(f"{label}: 必须是整数")
86
+ if not low <= value <= high:
87
+ raise ToolError(f"{label}: 必须在 [{low}, {high}] 内")
88
+ return value
89
+
90
+
91
+ def _series(value, label, min_len=2, max_len=MAX_SERIES_LEN) -> List[float]:
92
+ if not isinstance(value, list) or not min_len <= len(value) <= max_len:
93
+ raise ToolError(f"{label}: 需要长度 {min_len}..{max_len} 的数组")
94
+ out = []
95
+ for i, v in enumerate(value):
96
+ if not _finite(v):
97
+ raise ToolError(f"{label}[{i}]: 必须是有限数字")
98
+ out.append(float(v))
99
+ return out
100
+
101
+
102
+ def _ohlc(high, low, close, min_len=2, max_len=MAX_CANDLES):
103
+ high = _series(high, "high", min_len, max_len)
104
+ low = _series(low, "low", min_len, max_len)
105
+ close = _series(close, "close", min_len, max_len)
106
+ if not (len(high) == len(low) == len(close)):
107
+ raise ToolError("high/low/close 长度必须一致")
108
+ for i, (h, l, c) in enumerate(zip(high, low, close)):
109
+ if h < max(l, c) or l > min(h, c):
110
+ raise ToolError(f"第{i}根K线不满足 low <= high/close <= high")
111
+ return high, low, close
112
+
113
+
114
+ def _r(value, nd=6) -> float:
115
+ """输出收敛:只允许有限数值,统一保留 nd 位小数。"""
116
+ v = float(value)
117
+ if not math.isfinite(v):
118
+ raise ToolError("计算结果出现非有限数值")
119
+ return round(v, nd)
120
+
121
+
122
+ def _period(value, label, max_period=2000) -> int:
123
+ return _int(value, label, 1, max_period)
124
+
125
+
126
+ # ---------- 均值类基础算法 ----------
127
+
128
+ def _sma_series(values: List[float], period: int) -> List[float]:
129
+ out, s = [], 0.0
130
+ for i, v in enumerate(values):
131
+ s += v
132
+ if i >= period:
133
+ s -= values[i - period]
134
+ if i >= period - 1:
135
+ out.append(s / period)
136
+ return out
137
+
138
+
139
+ def _ema_series(values: List[float], period: int) -> List[float]:
140
+ """EMA 以首段 SMA 为种子,前 period-1 位为 None(预热期)。"""
141
+ out = [None] * (period - 1)
142
+ alpha = 2.0 / (period + 1)
143
+ e = sum(values[:period]) / period
144
+ out.append(e)
145
+ for v in values[period:]:
146
+ e = alpha * v + (1 - alpha) * e
147
+ out.append(e)
148
+ return out
149
+
150
+
151
+ def _wilder_series(values: List[float], period: int) -> List[float]:
152
+ """Wilder 平滑(ATR/ADX/RSI 同族):首值为均值,之后 prev*(n-1)+v)/n。"""
153
+ if len(values) < period:
154
+ raise ToolError(f"数据不足:平滑需要至少{period}个点")
155
+ out = [sum(values[:period]) / period]
156
+ for v in values[period:]:
157
+ out.append((out[-1] * (period - 1) + v) / period)
158
+ return out
159
+
160
+
161
+ def _std(values: List[float], ddof: int = 0) -> float:
162
+ n = len(values)
163
+ if n - ddof <= 0:
164
+ raise ToolError("样本量不足以计算标准差")
165
+ mean = sum(values) / n
166
+ var = sum((v - mean) ** 2 for v in values) / (n - ddof)
167
+ return math.sqrt(var)
168
+
169
+
170
+ def _percentile(sorted_values: List[float], p: float) -> float:
171
+ """线性插值百分位,p∈[0,100],输入必须已排序。"""
172
+ if not sorted_values:
173
+ raise ToolError("空序列")
174
+ if p <= 0:
175
+ return sorted_values[0]
176
+ if p >= 100:
177
+ return sorted_values[-1]
178
+ idx = p / 100 * (len(sorted_values) - 1)
179
+ lo = int(math.floor(idx))
180
+ hi = int(math.ceil(idx))
181
+ frac = idx - lo
182
+ return sorted_values[lo] * (1 - frac) + sorted_values[hi] * frac
183
+
184
+
185
+ # ---------- 技术指标:单价格序列 ----------
186
+
187
+ @tool(
188
+ "ma",
189
+ "移动平均线:method=sma|ema|wma,返回完整序列与最新值。EMA 以首段 SMA 为种子。",
190
+ properties={
191
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序)"},
192
+ "period": {"type": "integer", "description": "周期,默认20"},
193
+ "method": {"type": "string", "enum": ["sma", "ema", "wma"], "description": "均线类型,默认sma"},
194
+ },
195
+ required=["closes"],
196
+ )
197
+ def tool_ma(closes, period: int = 20, method: str = "sma") -> dict:
198
+ closes = _series(closes, "closes", 2)
199
+ period = _period(period, "period")
200
+ if period > len(closes):
201
+ raise ToolError(f"period({period})不能大于序列长度({len(closes)})")
202
+ if method == "sma":
203
+ values = _sma_series(closes, period)
204
+ elif method == "ema":
205
+ values = [v for v in _ema_series(closes, period) if v is not None]
206
+ elif method == "wma":
207
+ weights = list(range(1, period + 1))
208
+ denom = sum(weights)
209
+ values = [sum(closes[i - period + 1:i + 1][j] * weights[j] for j in range(period)) / denom
210
+ for i in range(period - 1, len(closes))]
211
+ else:
212
+ raise ToolError("method 必须是 sma/ema/wma")
213
+ return {"method": method, "period": period, "warmup": period - 1,
214
+ "latest": _r(values[-1]), "values": [_r(v) for v in values]}
215
+
216
+
217
+ @tool(
218
+ "rsi",
219
+ "RSI 相对强弱指标(Wilder 平滑)。返回最新值与完整序列;70/30 为常用超买超卖阈值。",
220
+ properties={
221
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序)"},
222
+ "period": {"type": "integer", "description": "周期,默认14"},
223
+ },
224
+ required=["closes"],
225
+ )
226
+ def tool_rsi(closes, period: int = 14) -> dict:
227
+ closes = _series(closes, "closes", 2)
228
+ period = _period(period, "period")
229
+ if len(closes) <= period:
230
+ raise ToolError(f"RSI 需要至少{period + 1}个收盘价")
231
+ gains, losses = [], []
232
+ for i in range(1, len(closes)):
233
+ change = closes[i] - closes[i - 1]
234
+ gains.append(max(change, 0.0))
235
+ losses.append(max(-change, 0.0))
236
+ avg_gain = sum(gains[:period]) / period
237
+ avg_loss = sum(losses[:period]) / period
238
+
239
+ def rsi_of(g, l):
240
+ return 100.0 if l == 0 else 100.0 - 100.0 / (1.0 + g / l)
241
+
242
+ values = [rsi_of(avg_gain, avg_loss)] # 种子RSI:第period个变化处
243
+ for i in range(period, len(gains)):
244
+ avg_gain = (avg_gain * (period - 1) + gains[i]) / period
245
+ avg_loss = (avg_loss * (period - 1) + losses[i]) / period
246
+ values.append(rsi_of(avg_gain, avg_loss))
247
+ return {"period": period, "latest": _r(values[-1]),
248
+ "values": [_r(v) for v in values]}
249
+
250
+
251
+ @tool(
252
+ "macd",
253
+ "MACD 指标:fast/slow EMA 差值 + 信号线 + 柱状图。返回最新值与序列。",
254
+ properties={
255
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序)"},
256
+ "fast": {"type": "integer", "description": "快线周期,默认12"},
257
+ "slow": {"type": "integer", "description": "慢线周期,默认26"},
258
+ "signal": {"type": "integer", "description": "信号线周期,默认9"},
259
+ },
260
+ required=["closes"],
261
+ )
262
+ def tool_macd(closes, fast: int = 12, slow: int = 26, signal: int = 9) -> dict:
263
+ closes = _series(closes, "closes", 2)
264
+ fast = _period(fast, "fast")
265
+ slow = _period(slow, "slow")
266
+ signal = _period(signal, "signal")
267
+ if fast >= slow:
268
+ raise ToolError("fast 必须小于 slow")
269
+ if len(closes) < slow + signal:
270
+ raise ToolError(f"MACD 需要至少{slow + signal}个收盘价")
271
+ ema_fast = _ema_series(closes, fast)
272
+ ema_slow = _ema_series(closes, slow)
273
+ macd_line = [f - s for f, s in zip(ema_fast, ema_slow) if s is not None and f is not None]
274
+ sig_full = _ema_series(macd_line, signal)
275
+ sig = [v for v in sig_full if v is not None]
276
+ hist = [m - s for m, s in zip(macd_line[signal - 1:], sig)]
277
+ return {
278
+ "fast": fast, "slow": slow, "signal": signal,
279
+ "latest": {"macd": _r(macd_line[-1]), "signal": _r(sig[-1]), "hist": _r(hist[-1])},
280
+ "macd_values": [_r(v) for v in macd_line],
281
+ }
282
+
283
+
284
+ @tool(
285
+ "bollinger_bands",
286
+ "布林带:SMA ± k倍标准差(总体标准差)。返回最新上/中/下轨、%B 与带宽。",
287
+ properties={
288
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序)"},
289
+ "period": {"type": "integer", "description": "周期,默认20"},
290
+ "deviation": {"type": "number", "description": "标准差倍数,默认2"},
291
+ },
292
+ required=["closes"],
293
+ )
294
+ def tool_bollinger(closes, period: int = 20, deviation: float = 2.0) -> dict:
295
+ closes = _series(closes, "closes", 2)
296
+ period = _period(period, "period")
297
+ deviation = _number(deviation, "deviation", 0.1, 20)
298
+ if period > len(closes):
299
+ raise ToolError(f"period({period})不能大于序列长度({len(closes)})")
300
+ window = closes[-period:]
301
+ mid = sum(window) / period
302
+ sd = _std(window)
303
+ upper, lower = mid + deviation * sd, mid - deviation * sd
304
+ last = closes[-1]
305
+ pct_b = (last - lower) / (upper - lower) if upper != lower else 0.5
306
+ bandwidth = (upper - lower) / mid if mid != 0 else 0.0
307
+ return {"upper": _r(upper), "mid": _r(mid), "lower": _r(lower),
308
+ "std": _r(sd), "pct_b": _r(pct_b), "bandwidth": _r(bandwidth)}
309
+
310
+
311
+ @tool(
312
+ "zscore",
313
+ "价格Z分数:最新价相对最近 lookback 期的均值偏离程度(标准差单位)。用于均值回归判断。",
314
+ properties={
315
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序)"},
316
+ "lookback": {"type": "integer", "description": "回看窗口,默认20"},
317
+ },
318
+ required=["closes"],
319
+ )
320
+ def tool_zscore(closes, lookback: int = 20) -> dict:
321
+ closes = _series(closes, "closes", 2)
322
+ lookback = _period(lookback, "lookback")
323
+ if lookback > len(closes):
324
+ raise ToolError(f"lookback({lookback})不能大于序列长度({len(closes)})")
325
+ window = closes[-lookback:]
326
+ mean = sum(window) / lookback
327
+ sd = _std(window)
328
+ z = (closes[-1] - mean) / sd if sd > 0 else 0.0
329
+ return {"zscore": _r(z), "mean": _r(mean), "std": _r(sd), "lookback": lookback}
330
+
331
+
332
+ # ---------- 技术指标:OHLC K线序列 ----------
333
+
334
+ @tool(
335
+ "atr",
336
+ "ATR 真实波幅均值(Wilder 平滑)。衡量波动率,常用于止损距离设置。",
337
+ properties={
338
+ "high": {"type": "array", "description": "最高价序列"},
339
+ "low": {"type": "array", "description": "最低价序列"},
340
+ "close": {"type": "array", "description": "收盘价序列"},
341
+ "period": {"type": "integer", "description": "周期,默认14"},
342
+ },
343
+ required=["high", "low", "close"],
344
+ )
345
+ def tool_atr(high, low, close, period: int = 14) -> dict:
346
+ high, low, close = _ohlc(high, low, close)
347
+ period = _period(period, "period")
348
+ if len(close) <= period:
349
+ raise ToolError(f"ATR 需要至少{period + 1}根K线")
350
+ trs = []
351
+ for i in range(1, len(close)):
352
+ trs.append(max(high[i] - low[i],
353
+ abs(high[i] - close[i - 1]),
354
+ abs(low[i] - close[i - 1])))
355
+ values = _wilder_series(trs, period)
356
+ return {"period": period, "latest": _r(values[-1]),
357
+ "values": [_r(v) for v in values]}
358
+
359
+
360
+ @tool(
361
+ "stochastic",
362
+ "随机指标 %K/%D:%K=(收盘-最低)/(最高-最低),%D 为 %K 的SMA。80/20 为常用阈值。",
363
+ properties={
364
+ "high": {"type": "array", "description": "最高价序列"},
365
+ "low": {"type": "array", "description": "最低价序列"},
366
+ "close": {"type": "array", "description": "收盘价序列"},
367
+ "k_period": {"type": "integer", "description": "%K 周期,默认14"},
368
+ "d_period": {"type": "integer", "description": "%D 平滑周期,默认3"},
369
+ },
370
+ required=["high", "low", "close"],
371
+ )
372
+ def tool_stochastic(high, low, close, k_period: int = 14, d_period: int = 3) -> dict:
373
+ high, low, close = _ohlc(high, low, close)
374
+ k_period = _period(k_period, "k_period")
375
+ d_period = _period(d_period, "d_period")
376
+ if len(close) < k_period + d_period - 1:
377
+ raise ToolError(f"随机指标需要至少{k_period + d_period - 1}根K线")
378
+ k_values = []
379
+ for i in range(k_period - 1, len(close)):
380
+ hh = max(high[i - k_period + 1:i + 1])
381
+ ll = min(low[i - k_period + 1:i + 1])
382
+ k_values.append(50.0 if hh == ll else 100.0 * (close[i] - ll) / (hh - ll))
383
+ d_values = _sma_series(k_values, d_period)
384
+ return {"k_period": k_period, "d_period": d_period,
385
+ "k": _r(k_values[-1]), "d": _r(d_values[-1])}
386
+
387
+
388
+ @tool(
389
+ "adx",
390
+ "ADX 趋势强度指标(Wilder)。ADX>25 视为趋势行情,+DI/-DI 指示多空方向。",
391
+ properties={
392
+ "high": {"type": "array", "description": "最高价序列"},
393
+ "low": {"type": "array", "description": "最低价序列"},
394
+ "close": {"type": "array", "description": "收盘价序列"},
395
+ "period": {"type": "integer", "description": "周期,默认14"},
396
+ },
397
+ required=["high", "low", "close"],
398
+ )
399
+ def tool_adx(high, low, close, period: int = 14) -> dict:
400
+ high, low, close = _ohlc(high, low, close)
401
+ period = _period(period, "period")
402
+ if len(close) < 2 * period + 1:
403
+ raise ToolError(f"ADX 需要至少{2 * period + 1}根K线")
404
+ trs, pdms, ndms = [], [], []
405
+ for i in range(1, len(close)):
406
+ h, l, pc = high[i], low[i], close[i - 1]
407
+ trs.append(max(h - l, abs(h - pc), abs(l - pc)))
408
+ up, dn = h - high[i - 1], low[i - 1] - l
409
+ pdms.append(up if up > dn and up > 0 else 0.0)
410
+ ndms.append(dn if dn > up and dn > 0 else 0.0)
411
+ str_ = _wilder_series(trs, period)
412
+ spdm = _wilder_series(pdms, period)
413
+ sndm = _wilder_series(ndms, period)
414
+ dx = []
415
+ for s, p, n in zip(str_, spdm, sndm):
416
+ if s == 0:
417
+ dx.append(0.0)
418
+ continue
419
+ pdi, ndi = 100.0 * p / s, 100.0 * n / s
420
+ denom = pdi + ndi
421
+ dx.append(100.0 * abs(pdi - ndi) / denom if denom > 0 else 0.0)
422
+ adx_values = _wilder_series(dx, period)
423
+ s, p, n = str_[-1], spdm[-1], sndm[-1]
424
+ return {"period": period, "adx": _r(adx_values[-1]),
425
+ "plus_di": _r(100.0 * p / s) if s > 0 else 0.0,
426
+ "minus_di": _r(100.0 * n / s) if s > 0 else 0.0}
427
+
428
+
429
+ @tool(
430
+ "cci",
431
+ "CCI 顺势指标:典型价相对 SMA 的偏离(0.015 归一)。+100/-100 为常用阈值。",
432
+ properties={
433
+ "high": {"type": "array", "description": "最高价序列"},
434
+ "low": {"type": "array", "description": "最低价序列"},
435
+ "close": {"type": "array", "description": "收盘价序列"},
436
+ "period": {"type": "integer", "description": "周期,默认20"},
437
+ },
438
+ required=["high", "low", "close"],
439
+ )
440
+ def tool_cci(high, low, close, period: int = 20) -> dict:
441
+ high, low, close = _ohlc(high, low, close)
442
+ period = _period(period, "period")
443
+ if len(close) < period:
444
+ raise ToolError(f"CCI 需要至少{period}根K线")
445
+ tp = [(h + l + c) / 3 for h, l, c in zip(high, low, close)]
446
+ sma = _sma_series(tp, period)
447
+ window = tp[-period:]
448
+ mean_dev = sum(abs(v - sma[-1]) for v in window) / period
449
+ cci = (tp[-1] - sma[-1]) / (0.015 * mean_dev) if mean_dev > 0 else 0.0
450
+ return {"period": period, "cci": _r(cci)}
451
+
452
+
453
+ @tool(
454
+ "obv",
455
+ "OBV 能量潮:收盘涨跌决定成交量正负的累计值。返回最新累计值与最近一段变化。",
456
+ properties={
457
+ "close": {"type": "array", "description": "收盘价序列"},
458
+ "volume": {"type": "array", "description": "成交量序列(>=0)"},
459
+ },
460
+ required=["close", "volume"],
461
+ )
462
+ def tool_obv(close, volume) -> dict:
463
+ close = _series(close, "close", 2)
464
+ volume = _series(volume, "volume", 2)
465
+ if len(close) != len(volume):
466
+ raise ToolError("close 与 volume 长度必须一致")
467
+ if any(v < 0 for v in volume):
468
+ raise ToolError("volume 不能为负")
469
+ obv = 0.0
470
+ values = [0.0]
471
+ for i in range(1, len(close)):
472
+ if close[i] > close[i - 1]:
473
+ obv += volume[i]
474
+ elif close[i] < close[i - 1]:
475
+ obv -= volume[i]
476
+ values.append(obv)
477
+ return {"latest": _r(obv, 2),
478
+ "change_last10": _r(obv - values[-11], 2) if len(values) >= 11 else None}
479
+
480
+
481
+ @tool(
482
+ "vwap",
483
+ "VWAP 成交量加权平均价:sum(典型价*量)/sum(量)。返回整体 VWAP 与最新价偏离。",
484
+ properties={
485
+ "high": {"type": "array", "description": "最高价序列"},
486
+ "low": {"type": "array", "description": "最低价序列"},
487
+ "close": {"type": "array", "description": "收盘价序列"},
488
+ "volume": {"type": "array", "description": "成交量序列(>=0)"},
489
+ },
490
+ required=["high", "low", "close", "volume"],
491
+ )
492
+ def tool_vwap(high, low, close, volume) -> dict:
493
+ high, low, close = _ohlc(high, low, close)
494
+ volume = _series(volume, "volume", len(close), len(close))
495
+ if any(v < 0 for v in volume):
496
+ raise ToolError("volume 不能为负")
497
+ total_vol = sum(volume)
498
+ if total_vol <= 0:
499
+ raise ToolError("volume 总和必须大于0")
500
+ vwap = sum((h + l + c) / 3 * v for h, l, c, v in zip(high, low, close, volume)) / total_vol
501
+ return {"vwap": _r(vwap), "total_volume": _r(total_vol, 2),
502
+ "last_close": _r(close[-1]),
503
+ "deviation_pct": _r((close[-1] / vwap - 1) * 100) if vwap > 0 else 0.0}
504
+
505
+
506
+ # ---------- 统计与序列分析 ----------
507
+
508
+ @tool(
509
+ "volatility",
510
+ "历史波动率:对数收益率标准差 × sqrt(周期数/年)。periods_per_year 如 252(日)、252*24(小时)。",
511
+ properties={
512
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序,至少3个)"},
513
+ "periods_per_year": {"type": "integer", "description": "每年的K线数量,默认252"},
514
+ },
515
+ required=["closes"],
516
+ )
517
+ def tool_volatility(closes, periods_per_year: int = 252) -> dict:
518
+ closes = _series(closes, "closes", 3)
519
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
520
+ if any(c <= 0 for c in closes):
521
+ raise ToolError("closes 必须全部为正数才能计算对数收益")
522
+ rets = [math.log(closes[i] / closes[i - 1]) for i in range(1, len(closes))]
523
+ sd = _std(rets, ddof=1)
524
+ return {"vol_per_period": _r(sd),
525
+ "vol_annualized": _r(sd * math.sqrt(ppy)),
526
+ "periods": len(rets), "periods_per_year": ppy}
527
+
528
+
529
+ @tool(
530
+ "correlation",
531
+ "皮尔逊相关系数:两序列同向变动程度,+1同向 / -1反向 / 0无关。要求等长且方差非零。",
532
+ properties={
533
+ "a": {"type": "array", "description": "序列A"},
534
+ "b": {"type": "array", "description": "序列B(与A等长)"},
535
+ },
536
+ required=["a", "b"],
537
+ )
538
+ def tool_correlation(a, b) -> dict:
539
+ a = _series(a, "a", 3)
540
+ b = _series(b, "b", 3)
541
+ if len(a) != len(b):
542
+ raise ToolError("两序列长度必须一致")
543
+ ma = sum(a) / len(a)
544
+ mb = sum(b) / len(b)
545
+ cov = sum((x - ma) * (y - mb) for x, y in zip(a, b))
546
+ va = sum((x - ma) ** 2 for x in a)
547
+ vb = sum((y - mb) ** 2 for y in b)
548
+ if va == 0 or vb == 0:
549
+ raise ToolError("存在常数序列(方差为0),相关系数无定义")
550
+ return {"correlation": _r(cov / math.sqrt(va * vb)), "n": len(a)}
551
+
552
+
553
+ def _hurst_estimate(values: List[float]) -> float:
554
+ """R/S 分析估计 Hurst 指数,输入可为任意实数序列(如价差)。返回[0,1]。"""
555
+ lags = []
556
+ lag = 8
557
+ while lag <= len(values) // 2:
558
+ lags.append(lag)
559
+ lag *= 2
560
+ if len(lags) < 3:
561
+ raise ToolError("序列太短,无法做R/S分析")
562
+ rs_values, used_lags = [], []
563
+ for lag in lags:
564
+ chunks = len(values) // lag
565
+ rs_list = []
566
+ for c in range(chunks):
567
+ seg = values[c * lag:(c + 1) * lag]
568
+ mean = sum(seg) / lag
569
+ cum = 0.0
570
+ lo, hi = 0.0, 0.0
571
+ for v in seg:
572
+ cum += v - mean
573
+ lo, hi = min(lo, cum), max(hi, cum)
574
+ rng = hi - lo
575
+ sd = _std(seg)
576
+ if sd > 0 and rng > 0:
577
+ rs_list.append(rng / sd)
578
+ if rs_list:
579
+ rs_values.append(sum(rs_list) / len(rs_list))
580
+ used_lags.append(lag)
581
+ if len(used_lags) < 3:
582
+ raise ToolError("有效R/S点不足,无法拟合Hurst指数")
583
+ xs = [math.log(l) for l in used_lags]
584
+ ys = [math.log(r) for r in rs_values]
585
+ mx, my = sum(xs) / len(xs), sum(ys) / len(ys)
586
+ denom = sum((x - mx) ** 2 for x in xs)
587
+ hurst = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom if denom > 0 else 0.5
588
+ return min(max(hurst, 0.0), 1.0)
589
+
590
+
591
+ def _ar1_half_life(values: List[float]):
592
+ """AR(1)/OU均值回归半衰期:Δy 对 y_{t-1} 回归。返回 (slope, intercept, half_life)。"""
593
+ n = len(values)
594
+ x = values[:-1]
595
+ dx = [values[i] - values[i - 1] for i in range(1, n)]
596
+ mx, md = sum(x) / len(x), sum(dx) / len(dx)
597
+ sxx = sum((v - mx) ** 2 for v in x)
598
+ if sxx <= 0:
599
+ raise ToolError("序列无波动,半衰期无定义")
600
+ sxy = sum((v - mx) * (d - md) for v, d in zip(x, dx))
601
+ slope = sxy / sxx
602
+ intercept = md - slope * mx
603
+ if slope >= 0:
604
+ return slope, intercept, None # 无均值回归
605
+ if 1.0 + slope <= 1e-9:
606
+ raise ToolError("序列呈爆炸性/极端负自相关,半衰期无定义")
607
+ return slope, intercept, -math.log(2) / math.log(1.0 + slope)
608
+
609
+
610
+ def _mat_inverse(m) -> List[List[float]]:
611
+ """高斯-约当部分主元求逆;奇异矩阵抛错。仅用于小规模协方差矩阵(<=25维)。"""
612
+ n = len(m)
613
+ aug = [row[:] + [1.0 if i == j else 0.0 for j in range(n)]
614
+ for i, row in enumerate(m)]
615
+ for col in range(n):
616
+ piv = max(range(col, n), key=lambda r: abs(aug[r][col]))
617
+ if abs(aug[piv][col]) < 1e-12:
618
+ raise ToolError("协方差矩阵奇异,无法求逆")
619
+ aug[col], aug[piv] = aug[piv], aug[col]
620
+ pv = aug[col][col]
621
+ aug[col] = [v / pv for v in aug[col]]
622
+ for r in range(n):
623
+ if r != col and aug[r][col] != 0.0:
624
+ f = aug[r][col]
625
+ aug[r] = [v - f * w for v, w in zip(aug[r], aug[col])]
626
+ return [row[n:] for row in aug]
627
+
628
+
629
+ def _norm_cdf(x: float) -> float:
630
+ return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
631
+
632
+
633
+ def _norm_ppf(p: float) -> float:
634
+ """标准正态分位数(Acklam 逼近,|误差|<1.15e-9)。"""
635
+ if not 0.0 < p < 1.0:
636
+ raise ToolError("分位数概率必须在(0,1)内")
637
+ a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
638
+ 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
639
+ b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
640
+ 6.680131188771972e+01, -1.328068155288572e+01]
641
+ c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
642
+ -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
643
+ d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
644
+ 3.754408661907416e+00]
645
+ plow, phigh = 0.02425, 1 - 0.02425
646
+ if p < plow:
647
+ q = math.sqrt(-2 * math.log(p))
648
+ return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
649
+ ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
650
+ if p > phigh:
651
+ q = math.sqrt(-2 * math.log(1 - p))
652
+ return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
653
+ ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
654
+ q = p - 0.5
655
+ r = q * q
656
+ return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / \
657
+ (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1)
658
+
659
+
660
+ @tool(
661
+ "hurst",
662
+ "Hurst 指数(R/S分析):<0.5 均值回归,≈0.5 随机游走,>0.5 趋势延续。",
663
+ properties={
664
+ "closes": {"type": "array", "description": "价格序列(至少64个点)"},
665
+ },
666
+ required=["closes"],
667
+ )
668
+ def tool_hurst(closes) -> dict:
669
+ closes = _series(closes, "closes", 64, MAX_CANDLES)
670
+ if any(c <= 0 for c in closes):
671
+ raise ToolError("closes 必须全部为正数")
672
+ hurst = _hurst_estimate(closes)
673
+ regime = "均值回归" if hurst < 0.45 else ("趋势延续" if hurst > 0.55 else "接近随机游走")
674
+ return {"hurst": _r(hurst), "regime_hint": regime}
675
+
676
+
677
+ @tool(
678
+ "linear_trend",
679
+ "线性回归趋势:以时间序号为自变量做OLS,返回斜率、截距、R²与归一化斜率。",
680
+ properties={
681
+ "values": {"type": "array", "description": "数值序列(按时间升序)"},
682
+ },
683
+ required=["values"],
684
+ )
685
+ def tool_linear_trend(values) -> dict:
686
+ values = _series(values, "values", 3)
687
+ n = len(values)
688
+ mx = (n - 1) / 2
689
+ my = sum(values) / n
690
+ sxx = sum((i - mx) ** 2 for i in range(n))
691
+ sxy = sum((i - mx) * (v - my) for i, v in enumerate(values))
692
+ slope = sxy / sxx
693
+ intercept = my - slope * mx
694
+ ss_tot = sum((v - my) ** 2 for v in values)
695
+ ss_res = sum((v - (intercept + slope * i)) ** 2 for i, v in enumerate(values))
696
+ r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
697
+ norm = slope / abs(my) if my != 0 else None
698
+ return {"slope": _r(slope), "intercept": _r(intercept), "r2": _r(r2),
699
+ "slope_pct_per_step": _r(norm) if norm is not None else None}
700
+
701
+
702
+ @tool(
703
+ "describe",
704
+ "序列描述统计:均值/标准差/偏度/峰度/分位数/极值。",
705
+ properties={
706
+ "values": {"type": "array", "description": "数值序列(至少3个)"},
707
+ },
708
+ required=["values"],
709
+ )
710
+ def tool_describe(values) -> dict:
711
+ values = _series(values, "values", 3)
712
+ n = len(values)
713
+ mean = sum(values) / n
714
+ sd = _std(values, ddof=1)
715
+ m2 = sum((v - mean) ** 2 for v in values) / n
716
+ m3 = sum((v - mean) ** 3 for v in values) / n
717
+ m4 = sum((v - mean) ** 4 for v in values) / n
718
+ skew = (m3 / m2 ** 1.5) if m2 > 0 else 0.0
719
+ kurt = (m4 / m2 ** 2 - 3) if m2 > 0 else 0.0
720
+ s = sorted(values)
721
+ return {"n": n, "mean": _r(mean), "std": _r(sd), "min": _r(s[0]), "max": _r(s[-1]),
722
+ "skew": _r(skew), "excess_kurtosis": _r(kurt),
723
+ "p25": _r(_percentile(s, 25)), "median": _r(_percentile(s, 50)),
724
+ "p75": _r(_percentile(s, 75))}
725
+
726
+
727
+ # ---------- 绩效与风险 ----------
728
+
729
+ @tool(
730
+ "sharpe_sortino",
731
+ "夏普与索提诺比率:基于算术收益率序列,年化 sqrt(periods_per_year)。下行波动只统计负收益。",
732
+ properties={
733
+ "returns": {"type": "array", "description": "每期收益率序列(小数,如0.01)"},
734
+ "periods_per_year": {"type": "integer", "description": "每年的期数,默认252"},
735
+ },
736
+ required=["returns"],
737
+ )
738
+ def tool_sharpe_sortino(returns, periods_per_year: int = 252) -> dict:
739
+ rets = _series(returns, "returns", 2)
740
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
741
+ n = len(rets)
742
+ mean = sum(rets) / n
743
+ sd = _std(rets, ddof=1)
744
+ downside = math.sqrt(sum(min(r, 0.0) ** 2 for r in rets) / n)
745
+ sharpe = mean / sd * math.sqrt(ppy) if sd > 0 else None
746
+ sortino = mean / downside * math.sqrt(ppy) if downside > 0 else None
747
+ return {"sharpe": _r(sharpe) if sharpe is not None else None,
748
+ "sortino": _r(sortino) if sortino is not None else None,
749
+ "mean_return": _r(mean), "vol_per_period": _r(sd),
750
+ "downside_vol": _r(downside), "periods": n, "periods_per_year": ppy}
751
+
752
+
753
+ def _equity_curve(equity, returns) -> List[float]:
754
+ """equity(净值)或 returns(收益率复利合成)二选一,返回净值曲线。"""
755
+ if equity is not None and returns is not None:
756
+ raise ToolError("equity 与 returns 只能提供其一")
757
+ if equity is not None:
758
+ curve = _series(equity, "equity", 2)
759
+ if any(v <= 0 for v in curve):
760
+ raise ToolError("equity 必须全部为正数")
761
+ return curve
762
+ if returns is not None:
763
+ rets = _series(returns, "returns", 2)
764
+ curve, v = [], 1.0
765
+ for r in rets:
766
+ v *= (1 + r)
767
+ if v <= 0:
768
+ raise ToolError("收益率导致净值归零或为负,无法计算回撤")
769
+ curve.append(v)
770
+ return curve
771
+ raise ToolError("必须提供 equity 或 returns")
772
+
773
+
774
+ @tool(
775
+ "max_drawdown",
776
+ "最大回撤:输入净值曲线或收益率序列(内部复利合成净值)。返回回撤幅度与峰谷位置。",
777
+ properties={
778
+ "equity": {"type": "array", "description": "净值曲线(与returns二选一)"},
779
+ "returns": {"type": "array", "description": "收益率序列(小数,与equity二选一)"},
780
+ },
781
+ )
782
+ def tool_max_drawdown(equity=None, returns=None) -> dict:
783
+ curve = _equity_curve(equity, returns)
784
+ peak, peak_idx = curve[0], 0
785
+ max_dd, max_peak, max_trough = 0.0, 0, 0
786
+ for i, v in enumerate(curve):
787
+ if v > peak:
788
+ peak, peak_idx = v, i
789
+ dd = v / peak - 1
790
+ if dd < max_dd:
791
+ max_dd, max_peak, max_trough = dd, peak_idx, i
792
+ return {"max_drawdown_pct": _r(max_dd * 100), "peak_index": max_peak,
793
+ "trough_index": max_trough, "points": len(curve)}
794
+
795
+
796
+ @tool(
797
+ "var_cvar",
798
+ "历史法 VaR/CVaR:给定置信度下的在险价值与条件在险价值(收益率口径,负数代表亏损)。",
799
+ properties={
800
+ "returns": {"type": "array", "description": "每期收益率序列(小数)"},
801
+ "confidence": {"type": "number", "description": "置信度0.9/0.95/0.99,默认0.95"},
802
+ },
803
+ required=["returns"],
804
+ )
805
+ def tool_var_cvar(returns, confidence: float = 0.95) -> dict:
806
+ rets = _series(returns, "returns", 10)
807
+ confidence = _number(confidence, "confidence", 0.5, 0.9999)
808
+ s = sorted(rets)
809
+ var = _percentile(s, (1 - confidence) * 100)
810
+ tail = [v for v in s if v <= var]
811
+ cvar = sum(tail) / len(tail)
812
+ return {"confidence": confidence, "var": _r(var), "cvar": _r(cvar),
813
+ "var_pct": _r(var * 100), "cvar_pct": _r(cvar * 100), "n": len(rets)}
814
+
815
+
816
+ def _trade_stats_core(pnls: List[float]) -> dict:
817
+ """交易统计内核:胜率/盈亏比/期望/利润因子/连胜连亏,供多个报告工具复用。"""
818
+ wins = [p for p in pnls if p > 0]
819
+ losses = [p for p in pnls if p < 0]
820
+ gross_win = sum(wins)
821
+ gross_loss = abs(sum(losses))
822
+ profit_factor = gross_win / gross_loss if gross_loss > 0 else None
823
+ max_wins = max_losses = cur_w = cur_l = 0
824
+ for p in pnls:
825
+ if p > 0:
826
+ cur_w += 1
827
+ cur_l = 0
828
+ elif p < 0:
829
+ cur_l += 1
830
+ cur_w = 0
831
+ else:
832
+ cur_w = cur_l = 0
833
+ max_wins = max(max_wins, cur_w)
834
+ max_losses = max(max_losses, cur_l)
835
+ payoff = (gross_win / len(wins)) / (gross_loss / len(losses)) if wins and losses else None
836
+ return {
837
+ "trades": len(pnls),
838
+ "wins": len(wins), "losses": len(losses),
839
+ "breakeven": len(pnls) - len(wins) - len(losses),
840
+ "win_rate_pct": _r(len(wins) / len(pnls) * 100),
841
+ "profit_factor": _r(profit_factor) if profit_factor is not None else None,
842
+ "expectancy": _r(sum(pnls) / len(pnls)),
843
+ "avg_win": _r(gross_win / len(wins)) if wins else None,
844
+ "avg_loss": _r(gross_loss / len(losses)) if losses else None,
845
+ "payoff_ratio": _r(payoff) if payoff is not None else None,
846
+ "max_consecutive_wins": max_wins,
847
+ "max_consecutive_losses": max_losses,
848
+ "total_pnl": _r(sum(pnls)),
849
+ }
850
+
851
+
852
+ @tool(
853
+ "trade_stats",
854
+ "交易绩效统计:胜率、盈亏比、期望值、利润因子、最大连胜/连亏。输入每笔盈亏(任意货币单位)。",
855
+ properties={
856
+ "pnls": {"type": "array", "description": "每笔交易盈亏序列(至少5笔)"},
857
+ },
858
+ required=["pnls"],
859
+ )
860
+ def tool_trade_stats(pnls) -> dict:
861
+ return _trade_stats_core(_series(pnls, "pnls", 5))
862
+
863
+
864
+ # ---------- 仓位管理 ----------
865
+
866
+ @tool(
867
+ "position_size",
868
+ "风险仓位计算:按单笔风险百分比与止损距离确定手数(含最小手数校验)。"
869
+ "loss_per_lot = 止损距离/tick_size × tick_value。",
870
+ properties={
871
+ "equity": {"type": "number", "description": "账户净值(>0)"},
872
+ "risk_pct": {"type": "number", "description": "单笔风险占净值百分比(0,50]"},
873
+ "entry": {"type": "number", "description": "计划入场价"},
874
+ "stop": {"type": "number", "description": "止损价(不等于entry)"},
875
+ "tick_value": {"type": "number", "description": "每手每tick价值(报价货币,>0)"},
876
+ "tick_size": {"type": "number", "description": "最小变动价位(>0)"},
877
+ "volume_step": {"type": "number", "description": "手数步长,默认0.01"},
878
+ "volume_min": {"type": "number", "description": "最小手数,默认0.01"},
879
+ "volume_max": {"type": "number", "description": "最大手数,默认100"},
880
+ },
881
+ required=["equity", "risk_pct", "entry", "stop", "tick_value", "tick_size"],
882
+ )
883
+ def tool_position_size(equity, risk_pct, entry, stop, tick_value, tick_size,
884
+ volume_step: float = 0.01, volume_min: float = 0.01,
885
+ volume_max: float = 100.0) -> dict:
886
+ equity = _number(equity, "equity", 1e-9, 1e15)
887
+ risk_pct = _number(risk_pct, "risk_pct", 1e-6, 50)
888
+ entry = _number(entry, "entry", 1e-12, 1e15)
889
+ stop = _number(stop, "stop", 0, 1e15)
890
+ tick_value = _number(tick_value, "tick_value", 1e-12, 1e12)
891
+ tick_size = _number(tick_size, "tick_size", 1e-12, 1e6)
892
+ step = _number(volume_step, "volume_step", 1e-9, 1000)
893
+ vmin = _number(volume_min, "volume_min", 0, 1000)
894
+ vmax = _number(volume_max, "volume_max", 0, 1000)
895
+ if vmax < vmin:
896
+ raise ToolError("volume_max 不能小于 volume_min")
897
+ stop_dist = abs(entry - stop)
898
+ if stop_dist <= 0:
899
+ raise ToolError("entry 与 stop 不能相同")
900
+ loss_per_lot = stop_dist / tick_size * tick_value
901
+ risk_amount = equity * risk_pct / 100
902
+ raw_lots = risk_amount / loss_per_lot
903
+ # +1e-9 容忍浮点误差:1.0/0.01=99.999...不应少一手
904
+ lots = math.floor(raw_lots / step + 1e-9) * step
905
+ lots = min(max(lots, 0.0), vmax)
906
+ return {
907
+ "risk_amount": _r(risk_amount),
908
+ "stop_distance": _r(stop_dist),
909
+ "loss_per_lot": _r(loss_per_lot),
910
+ "raw_lots": _r(raw_lots, 4),
911
+ "lots": _r(lots, 2),
912
+ "below_volume_min": lots < vmin,
913
+ "note": ("建议手数低于最小手数,应放弃该笔交易或放宽止损" if lots < vmin
914
+ else "手数已按步长向下取整,实际风险不超过目标"),
915
+ }
916
+
917
+
918
+ @tool(
919
+ "kelly_criterion",
920
+ "凯利公式:给定胜率与盈亏比,输出满仓/半仓/四分之一凯利比例。结果为分析参考,非加仓建议。",
921
+ properties={
922
+ "win_rate_pct": {"type": "number", "description": "胜率百分比(0,100)"},
923
+ "payoff_ratio": {"type": "number", "description": "盈亏比=平均盈利/平均亏损(>0)"},
924
+ },
925
+ required=["win_rate_pct", "payoff_ratio"],
926
+ )
927
+ def tool_kelly(win_rate_pct, payoff_ratio) -> dict:
928
+ win_rate_pct = _number(win_rate_pct, "win_rate_pct", 1e-9, 99.9999)
929
+ b = _number(payoff_ratio, "payoff_ratio", 1e-9, 1e6)
930
+ p = win_rate_pct / 100
931
+ f = p - (1 - p) / b
932
+ note = None
933
+ if f <= 0:
934
+ note = "期望为负或为零:该系统不具备正凯利敞口,不应使用凯利仓位"
935
+ return {"kelly_fraction": _r(f), "kelly_pct": _r(f * 100),
936
+ "half_kelly_pct": _r(f * 50), "quarter_kelly_pct": _r(f * 25),
937
+ "note": note}
938
+
939
+
940
+ @tool(
941
+ "monte_carlo_risk",
942
+ "蒙特卡洛资金曲线模拟:按胜率/盈亏比/单笔风险复利模拟,输出终值分位、回撤分布与破产概率。"
943
+ "确定性随机(同seed同结果),仅用于风险认知,不预测真实收益。",
944
+ properties={
945
+ "win_rate_pct": {"type": "number", "description": "胜率百分比(0,100)"},
946
+ "payoff_ratio": {"type": "number", "description": "盈亏比(>0)"},
947
+ "risk_pct": {"type": "number", "description": "每笔风险占净值百分比(0,50]"},
948
+ "trades": {"type": "integer", "description": "每条路径交易笔数(1..2000),默认200"},
949
+ "paths": {"type": "integer", "description": "模拟路径数(1..5000),默认1000"},
950
+ "seed": {"type": "integer", "description": "随机种子,默认42"},
951
+ "ruin_pct": {"type": "number", "description": "破产线:净值回撤百分比,默认50"},
952
+ },
953
+ required=["win_rate_pct", "payoff_ratio", "risk_pct"],
954
+ )
955
+ def tool_monte_carlo(win_rate_pct, payoff_ratio, risk_pct, trades: int = 200,
956
+ paths: int = 1000, seed: int = 42, ruin_pct: float = 50) -> dict:
957
+ win_rate_pct = _number(win_rate_pct, "win_rate_pct", 0.0001, 99.9999)
958
+ payoff = _number(payoff_ratio, "payoff_ratio", 1e-9, 1e6)
959
+ risk_pct = _number(risk_pct, "risk_pct", 1e-6, 50)
960
+ trades = _int(trades, "trades", 1, 2000)
961
+ paths = _int(paths, "paths", 1, 5000)
962
+ if trades * paths > 2_000_000:
963
+ raise ToolError("trades × paths 不能超过200万,请降低规模")
964
+ ruin_pct = _number(ruin_pct, "ruin_pct", 1, 100)
965
+ p = win_rate_pct / 100
966
+ rng = random.Random(_int(seed, "seed", 0, 2**31 - 1))
967
+ finals, max_dds, ruined = [], [], 0
968
+ for _ in range(paths):
969
+ equity = 1.0
970
+ peak = 1.0
971
+ dd = 0.0
972
+ for _ in range(trades):
973
+ risked = equity * risk_pct / 100
974
+ if rng.random() < p:
975
+ equity += risked * payoff
976
+ else:
977
+ equity -= risked
978
+ if equity <= 0:
979
+ equity = 0.0
980
+ peak = max(peak, equity)
981
+ dd = min(dd, equity / peak - 1)
982
+ if equity <= (1 - ruin_pct / 100):
983
+ ruined += 1
984
+ finals.append(equity)
985
+ max_dds.append(dd)
986
+ finals.sort()
987
+ max_dds.sort()
988
+ return {
989
+ "paths": paths, "trades": trades, "seed": seed,
990
+ "final_equity_p5": _r(_percentile(finals, 5), 4),
991
+ "final_equity_p25": _r(_percentile(finals, 25), 4),
992
+ "final_equity_p50": _r(_percentile(finals, 50), 4),
993
+ "final_equity_p75": _r(_percentile(finals, 75), 4),
994
+ "final_equity_p95": _r(_percentile(finals, 95), 4),
995
+ "median_max_drawdown_pct": _r(_percentile(max_dds, 50) * 100),
996
+ "worst_max_drawdown_pct": _r(max_dds[0] * 100),
997
+ "prob_ruin_pct": _r(ruined / paths * 100),
998
+ "note": "结果依赖输入假设(胜率/盈亏比恒定),不代表真实市场表现",
999
+ }
1000
+
1001
+
1002
+ # ---------- 第二批:动量/通道/枢轴/比率 ----------
1003
+
1004
+ @tool(
1005
+ "williams_r",
1006
+ "威廉指标 %R:-100..0,-80..-100 超卖、-20..0 超买。",
1007
+ properties={
1008
+ "high": {"type": "array", "description": "最高价序列"},
1009
+ "low": {"type": "array", "description": "最低价序列"},
1010
+ "close": {"type": "array", "description": "收盘价序列"},
1011
+ "period": {"type": "integer", "description": "周期,默认14"},
1012
+ },
1013
+ required=["high", "low", "close"],
1014
+ )
1015
+ def tool_williams_r(high, low, close, period: int = 14) -> dict:
1016
+ high, low, close = _ohlc(high, low, close)
1017
+ period = _period(period, "period")
1018
+ if len(close) < period:
1019
+ raise ToolError(f"威廉指标需要至少{period}根K线")
1020
+ hh = max(high[-period:])
1021
+ ll = min(low[-period:])
1022
+ if hh == ll:
1023
+ return {"williams_r": -50.0, "period": period, "note": "区间无波动"}
1024
+ return {"williams_r": _r(-100.0 * (hh - close[-1]) / (hh - ll)), "period": period}
1025
+
1026
+
1027
+ @tool(
1028
+ "roc",
1029
+ "变动率 ROC:最新价相对 period 期前的涨跌百分比。动量与背离分析。",
1030
+ properties={
1031
+ "closes": {"type": "array", "description": "收盘价序列(正数)"},
1032
+ "period": {"type": "integer", "description": "周期,默认12"},
1033
+ },
1034
+ required=["closes"],
1035
+ )
1036
+ def tool_roc(closes, period: int = 12) -> dict:
1037
+ closes = _series(closes, "closes", 2)
1038
+ period = _period(period, "period")
1039
+ if any(c <= 0 for c in closes):
1040
+ raise ToolError("closes 必须全部为正数")
1041
+ if len(closes) <= period:
1042
+ raise ToolError(f"ROC 需要至少{period + 1}个收盘价")
1043
+ values = [100.0 * (closes[i] / closes[i - period] - 1)
1044
+ for i in range(period, len(closes))]
1045
+ return {"period": period, "latest": _r(values[-1]),
1046
+ "values": [_r(v) for v in values]}
1047
+
1048
+
1049
+ @tool(
1050
+ "donchian",
1051
+ "唐奇安通道:period 期最高/最低构成上下轨,返回最新轨道与收盘价在通道内的位置。突破跟踪常用。",
1052
+ properties={
1053
+ "high": {"type": "array", "description": "最高价序列"},
1054
+ "low": {"type": "array", "description": "最低价序列"},
1055
+ "close": {"type": "array", "description": "收盘价序列"},
1056
+ "period": {"type": "integer", "description": "周期,默认20"},
1057
+ },
1058
+ required=["high", "low", "close"],
1059
+ )
1060
+ def tool_donchian(high, low, close, period: int = 20) -> dict:
1061
+ high, low, close = _ohlc(high, low, close)
1062
+ period = _period(period, "period")
1063
+ if len(close) < period:
1064
+ raise ToolError(f"唐奇安通道需要至少{period}根K线")
1065
+ upper = max(high[-period:])
1066
+ lower = min(low[-period:])
1067
+ position = (close[-1] - lower) / (upper - lower) if upper > lower else 0.5
1068
+ return {"upper": _r(upper), "lower": _r(lower), "mid": _r((upper + lower) / 2),
1069
+ "position_pct": _r(position * 100), "period": period}
1070
+
1071
+
1072
+ @tool(
1073
+ "keltner",
1074
+ "肯特纳通道:EMA ± multiplier×ATR。与布林带对照可判断波动扩张/收缩。",
1075
+ properties={
1076
+ "high": {"type": "array", "description": "最高价序列"},
1077
+ "low": {"type": "array", "description": "最低价序列"},
1078
+ "close": {"type": "array", "description": "收盘价序列"},
1079
+ "period": {"type": "integer", "description": "周期,默认20"},
1080
+ "multiplier": {"type": "number", "description": "ATR倍数,默认2"},
1081
+ },
1082
+ required=["high", "low", "close"],
1083
+ )
1084
+ def tool_keltner(high, low, close, period: int = 20, multiplier: float = 2.0) -> dict:
1085
+ high, low, close = _ohlc(high, low, close)
1086
+ period = _period(period, "period")
1087
+ multiplier = _number(multiplier, "multiplier", 0.1, 20)
1088
+ if len(close) <= period:
1089
+ raise ToolError(f"肯特纳通道需要至少{period + 1}根K线")
1090
+ basis = [v for v in _ema_series(close, period) if v is not None][-1]
1091
+ trs = [max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1]))
1092
+ for i in range(1, len(close))]
1093
+ atr = _wilder_series(trs, period)[-1]
1094
+ return {"basis": _r(basis), "upper": _r(basis + multiplier * atr),
1095
+ "lower": _r(basis - multiplier * atr), "atr": _r(atr), "period": period}
1096
+
1097
+
1098
+ @tool(
1099
+ "choppiness",
1100
+ "盘整指数 CHOP(0..100):>61.8 盘整,<38.2 趋势。基于真实波幅总和与区间宽度之比。",
1101
+ properties={
1102
+ "high": {"type": "array", "description": "最高价序列"},
1103
+ "low": {"type": "array", "description": "最低价序列"},
1104
+ "close": {"type": "array", "description": "收盘价序列"},
1105
+ "period": {"type": "integer", "description": "周期,默认14"},
1106
+ },
1107
+ required=["high", "low", "close"],
1108
+ )
1109
+ def tool_choppiness(high, low, close, period: int = 14) -> dict:
1110
+ high, low, close = _ohlc(high, low, close)
1111
+ period = _period(period, "period")
1112
+ if len(close) <= period:
1113
+ raise ToolError(f"盘整指数需要至少{period + 1}根K线")
1114
+ tr_sum = sum(max(high[i] - low[i], abs(high[i] - close[i - 1]),
1115
+ abs(low[i] - close[i - 1])) for i in range(len(close) - period, len(close)))
1116
+ rng = max(high[-period:]) - min(low[-period:])
1117
+ if rng <= 0 or tr_sum <= 0:
1118
+ raise ToolError("区间无波动,盘整指数无定义")
1119
+ chop = 100.0 * math.log10(tr_sum / rng) / math.log10(period)
1120
+ return {"chop": _r(min(max(chop, 0.0), 100.0)), "period": period}
1121
+
1122
+
1123
+ @tool(
1124
+ "pivot_points",
1125
+ "经典枢轴点:由上一根完成的K线(日/周)H/L/C 计算 P、R1-R3、S1-S3 支撑阻力位。",
1126
+ properties={
1127
+ "high": {"type": "number", "description": "上一根K线最高价"},
1128
+ "low": {"type": "number", "description": "上一根K线最低价"},
1129
+ "close": {"type": "number", "description": "上一根K线收盘价"},
1130
+ },
1131
+ required=["high", "low", "close"],
1132
+ )
1133
+ def tool_pivot_points(high, low, close) -> dict:
1134
+ high = _number(high, "high", 0, 1e15)
1135
+ low = _number(low, "low", 0, 1e15)
1136
+ close = _number(close, "close", 0, 1e15)
1137
+ if not low <= close <= high or high < low:
1138
+ raise ToolError("需满足 low <= close <= high")
1139
+ p = (high + low + close) / 3
1140
+ return {"p": _r(p), "r1": _r(2 * p - low), "s1": _r(2 * p - high),
1141
+ "r2": _r(p + (high - low)), "s2": _r(p - (high - low)),
1142
+ "r3": _r(high + 2 * (p - low)), "s3": _r(low - 2 * (high - p))}
1143
+
1144
+
1145
+ @tool(
1146
+ "fibonacci_retracement",
1147
+ "斐波那契回撤位:给定摆动高低点,返回 23.6/38.2/50/61.8/78.6% 回撤价位。",
1148
+ properties={
1149
+ "swing_high": {"type": "number", "description": "摆动高点(> swing_low)"},
1150
+ "swing_low": {"type": "number", "description": "摆动低点"},
1151
+ },
1152
+ required=["swing_high", "swing_low"],
1153
+ )
1154
+ def tool_fibonacci(swing_high, swing_low) -> dict:
1155
+ swing_high = _number(swing_high, "swing_high", 0, 1e15)
1156
+ swing_low = _number(swing_low, "swing_low", 0, 1e15)
1157
+ if swing_high <= swing_low:
1158
+ raise ToolError("swing_high 必须大于 swing_low")
1159
+ rng = swing_high - swing_low
1160
+ levels = {f"{int(r * 1000) / 10}%": _r(swing_high - r * rng)
1161
+ for r in (0.236, 0.382, 0.5, 0.618, 0.786)}
1162
+ return {"swing_high": _r(swing_high), "swing_low": _r(swing_low),
1163
+ "range": _r(rng), "retracement_levels": levels,
1164
+ "note": "回撤位自摆动高点向下度量;上涨回撤关注38.2-61.8%区间"}
1165
+
1166
+
1167
+ @tool(
1168
+ "ma_crossover",
1169
+ "均线交叉状态:快慢均线当前位置与最近一次金叉/死叉。返回状态、距交叉K线数与交叉价。",
1170
+ properties={
1171
+ "closes": {"type": "array", "description": "收盘价序列(按时间升序)"},
1172
+ "fast_period": {"type": "integer", "description": "快线周期,默认5"},
1173
+ "slow_period": {"type": "integer", "description": "慢线周期,默认20"},
1174
+ "method": {"type": "string", "enum": ["sma", "ema"], "description": "均线类型,默认sma"},
1175
+ },
1176
+ required=["closes"],
1177
+ )
1178
+ def tool_ma_crossover(closes, fast_period: int = 5, slow_period: int = 20,
1179
+ method: str = "sma") -> dict:
1180
+ closes = _series(closes, "closes", 2)
1181
+ fast_period = _period(fast_period, "fast_period")
1182
+ slow_period = _period(slow_period, "slow_period")
1183
+ if fast_period >= slow_period:
1184
+ raise ToolError("fast_period 必须小于 slow_period")
1185
+ if method not in ("sma", "ema"):
1186
+ raise ToolError("method 必须是 sma 或 ema")
1187
+
1188
+ def aligned(values, period):
1189
+ if method == "sma":
1190
+ return [None] * (period - 1) + _sma_series(values, period)
1191
+ return _ema_series(values, period)
1192
+
1193
+ fast, slow = aligned(closes, fast_period), aligned(closes, slow_period)
1194
+ cross_idx, cross_type, cross_price = None, None, None
1195
+ prev_diff = None
1196
+ for i in range(len(closes)):
1197
+ if fast[i] is None or slow[i] is None:
1198
+ continue
1199
+ diff = fast[i] - slow[i]
1200
+ if prev_diff is not None and (prev_diff <= 0 < diff or prev_diff >= 0 > diff):
1201
+ cross_idx = i
1202
+ cross_type = "golden_cross" if diff > 0 else "dead_cross"
1203
+ cross_price = closes[i]
1204
+ prev_diff = diff
1205
+ latest_diff = fast[-1] - slow[-1]
1206
+ state = cross_type if cross_idx == len(closes) - 1 else \
1207
+ ("fast_above" if latest_diff > 0 else "fast_below")
1208
+ return {
1209
+ "state": state,
1210
+ "bars_since_cross": (len(closes) - 1 - cross_idx) if cross_idx is not None else None,
1211
+ "cross_type": cross_type,
1212
+ "cross_price": _r(cross_price) if cross_price is not None else None,
1213
+ "fast": _r(fast[-1]), "slow": _r(slow[-1]), "diff": _r(latest_diff),
1214
+ }
1215
+
1216
+
1217
+ @tool(
1218
+ "beta_alpha",
1219
+ "相对基准的 Beta 与 Alpha:beta=协方差/基准方差,alpha=超额收益(每期与年化)。"
1220
+ "要求两序列等长、基准方差非零。",
1221
+ properties={
1222
+ "asset_returns": {"type": "array", "description": "资产每期收益率"},
1223
+ "benchmark_returns": {"type": "array", "description": "基准每期收益率(等长)"},
1224
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
1225
+ },
1226
+ required=["asset_returns", "benchmark_returns"],
1227
+ )
1228
+ def tool_beta_alpha(asset_returns, benchmark_returns, periods_per_year: int = 252) -> dict:
1229
+ a = _series(asset_returns, "asset_returns", 10)
1230
+ b = _series(benchmark_returns, "benchmark_returns", 10)
1231
+ if len(a) != len(b):
1232
+ raise ToolError("两序列长度必须一致")
1233
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
1234
+ n = len(a)
1235
+ ma, mb = sum(a) / n, sum(b) / n
1236
+ cov = sum((x - ma) * (y - mb) for x, y in zip(a, b)) / n
1237
+ var_b = sum((y - mb) ** 2 for y in b) / n
1238
+ if var_b == 0:
1239
+ raise ToolError("基准收益为常数(方差为0),beta无定义")
1240
+ beta = cov / var_b
1241
+ alpha = ma - beta * mb
1242
+ corr = cov / math.sqrt(var_b * (sum((x - ma) ** 2 for x in a) / n)) \
1243
+ if sum((x - ma) ** 2 for x in a) > 0 else None
1244
+ return {"beta": _r(beta), "alpha_per_period": _r(alpha),
1245
+ "alpha_annualized": _r(alpha * ppy),
1246
+ "correlation": _r(corr) if corr is not None else None,
1247
+ "n": n, "periods_per_year": ppy}
1248
+
1249
+
1250
+ @tool(
1251
+ "ulcer_index",
1252
+ "溃疡指数:回撤深度与持续时间的综合(回撤平方和的均方根)。衡量下行痛苦的深度指标。",
1253
+ properties={
1254
+ "equity": {"type": "array", "description": "净值曲线(与returns二选一)"},
1255
+ "returns": {"type": "array", "description": "收益率序列(与equity二选一)"},
1256
+ },
1257
+ )
1258
+ def tool_ulcer_index(equity=None, returns=None) -> dict:
1259
+ curve = _equity_curve(equity, returns)
1260
+ peak, max_dd, squares = curve[0], 0.0, []
1261
+ for v in curve:
1262
+ peak = max(peak, v)
1263
+ dd = (v / peak - 1) * 100
1264
+ max_dd = min(max_dd, dd)
1265
+ squares.append(dd * dd)
1266
+ return {"ulcer_index": _r(math.sqrt(sum(squares) / len(squares))),
1267
+ "max_drawdown_pct": _r(max_dd), "points": len(curve)}
1268
+
1269
+
1270
+ @tool(
1271
+ "omega_ratio",
1272
+ "Omega 比率:阈值上方收益与下方亏损的概率加权比。>1 表示超过阈值的收益占优。",
1273
+ properties={
1274
+ "returns": {"type": "array", "description": "每期收益率序列"},
1275
+ "threshold": {"type": "number", "description": "目标收益阈值(小数),默认0"},
1276
+ },
1277
+ required=["returns"],
1278
+ )
1279
+ def tool_omega(returns, threshold: float = 0.0) -> dict:
1280
+ rets = _series(returns, "returns", 5)
1281
+ threshold = _number(threshold, "threshold", -1, 10)
1282
+ gains = sum(max(r - threshold, 0.0) for r in rets)
1283
+ losses = sum(max(threshold - r, 0.0) for r in rets)
1284
+ omega = gains / losses if losses > 0 else None
1285
+ return {"omega": _r(omega) if omega is not None else None,
1286
+ "gains": _r(gains), "losses": _r(losses),
1287
+ "threshold": threshold,
1288
+ "note": "无亏损时Omega无上界,返回null" if omega is None else None}
1289
+
1290
+
1291
+ @tool(
1292
+ "calmar_ratio",
1293
+ "卡玛比率:年化收益 / |最大回撤|。回撤为零时返回null。",
1294
+ properties={
1295
+ "equity": {"type": "array", "description": "净值曲线(与returns二选一)"},
1296
+ "returns": {"type": "array", "description": "收益率序列(与equity二选一)"},
1297
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
1298
+ },
1299
+ )
1300
+ def tool_calmar(equity=None, returns=None, periods_per_year: int = 252) -> dict:
1301
+ curve = _equity_curve(equity, returns)
1302
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
1303
+ periods = len(curve) - 1
1304
+ if periods < 1:
1305
+ raise ToolError("净值曲线至少需要2个点")
1306
+ growth = curve[-1] / curve[0]
1307
+ if growth <= 0:
1308
+ raise ToolError("净值终值为非正数,无法年化")
1309
+ try:
1310
+ ann_ret = growth ** (ppy / periods) - 1
1311
+ except OverflowError:
1312
+ raise ToolError("年化收益计算溢出") from None
1313
+ if not math.isfinite(ann_ret):
1314
+ raise ToolError("年化收益计算溢出")
1315
+ peak, max_dd = curve[0], 0.0
1316
+ for v in curve:
1317
+ peak = max(peak, v)
1318
+ max_dd = min(max_dd, v / peak - 1)
1319
+ calmar = ann_ret / abs(max_dd) if max_dd < 0 else None
1320
+ return {"annualized_return_pct": _r(ann_ret * 100),
1321
+ "max_drawdown_pct": _r(max_dd * 100),
1322
+ "calmar": _r(calmar) if calmar is not None else None,
1323
+ "note": "无回撤时Calmar无定义,返回null" if calmar is None else None}
1324
+
1325
+
1326
+ # ---------- 第三批:多策略组合 / 多品种筛选 / 优选 / 前沿算法 / 风控 ----------
1327
+
1328
+ MAX_PORTFOLIO_N = 25
1329
+ MAX_PORTFOLIO_LEN = 5000
1330
+
1331
+
1332
+ def _returns_columns(series_list, label) -> List[List[float]]:
1333
+ """多条等长收益率序列 → 列矩阵(每列一条策略/品种收益)。"""
1334
+ if not isinstance(series_list, list) or not 2 <= len(series_list) <= MAX_PORTFOLIO_N:
1335
+ raise ToolError(f"{label}: 需要2..{MAX_PORTFOLIO_N}条序列")
1336
+ cols = [_series(s, f"{label}[{i}]", 10, MAX_PORTFOLIO_LEN)
1337
+ for i, s in enumerate(series_list)]
1338
+ n = len(cols[0])
1339
+ if any(len(c) != n for c in cols):
1340
+ raise ToolError(f"{label}: 所有序列长度必须一致")
1341
+ return cols
1342
+
1343
+
1344
+ def _cov_matrix(cols) -> List[List[float]]:
1345
+ n = len(cols[0])
1346
+ k = len(cols)
1347
+ means = [sum(c) / n for c in cols]
1348
+ return [[sum((cols[i][t] - means[i]) * (cols[j][t] - means[j]) for t in range(n)) / n
1349
+ for j in range(k)] for i in range(k)]
1350
+
1351
+
1352
+ def _norm_weights(raw: List[float]) -> List[float]:
1353
+ total = sum(raw)
1354
+ if total <= 0 or not all(math.isfinite(v) for v in raw):
1355
+ raise ToolError("权重归一化失败:总和必须为正有限值")
1356
+ return [v / total for v in raw]
1357
+
1358
+
1359
+ def _pearson(a: List[float], b: List[float]) -> float:
1360
+ ma, mb = sum(a) / len(a), sum(b) / len(b)
1361
+ cov = sum((x - ma) * (y - mb) for x, y in zip(a, b))
1362
+ va = sum((x - ma) ** 2 for x in a)
1363
+ vb = sum((y - mb) ** 2 for y in b)
1364
+ if va == 0 or vb == 0:
1365
+ raise ToolError("存在常数序列(方差为0),相关系数无定义")
1366
+ return cov / math.sqrt(va * vb)
1367
+
1368
+
1369
+ def _ranks(values: List[float]) -> List[float]:
1370
+ """平均秩(并列取平均),供Spearman相关使用。"""
1371
+ order = sorted(range(len(values)), key=lambda i: values[i])
1372
+ ranks = [0.0] * len(values)
1373
+ i = 0
1374
+ while i < len(order):
1375
+ j = i
1376
+ while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
1377
+ j += 1
1378
+ avg = (i + j) / 2 + 1
1379
+ for t in range(i, j + 1):
1380
+ ranks[order[t]] = avg
1381
+ i = j + 1
1382
+ return ranks
1383
+
1384
+
1385
+ @tool(
1386
+ "portfolio_allocation",
1387
+ "多策略组合权重求解:equal_weight / inverse_vol / inverse_variance / min_variance / "
1388
+ "risk_parity(风险平价,迭代收敛)。输入每条策略的等长收益率序列。",
1389
+ properties={
1390
+ "returns_matrix": {"type": "array", "description": "等长收益率序列的数组,每条代表一个策略"},
1391
+ "method": {"type": "string", "enum": ["equal_weight", "inverse_vol",
1392
+ "inverse_variance", "min_variance", "risk_parity"],
1393
+ "description": "配置方法,默认risk_parity"},
1394
+ },
1395
+ required=["returns_matrix"],
1396
+ )
1397
+ def tool_portfolio_allocation(returns_matrix, method: str = "risk_parity") -> dict:
1398
+ cols = _returns_columns(returns_matrix, "returns_matrix")
1399
+ k = len(cols)
1400
+ vols = [_std(c, ddof=1) for c in cols]
1401
+ if any(v == 0 for v in vols):
1402
+ raise ToolError("存在零波动率序列,无法配置权重")
1403
+ cov = _cov_matrix(cols)
1404
+ negative = False
1405
+ if method == "equal_weight":
1406
+ w = [1.0 / k] * k
1407
+ elif method == "inverse_vol":
1408
+ w = _norm_weights([1.0 / v for v in vols])
1409
+ elif method == "inverse_variance":
1410
+ w = _norm_weights([1.0 / (v * v) for v in vols])
1411
+ elif method == "min_variance":
1412
+ inv = _mat_inverse(cov)
1413
+ w = _norm_weights([sum(row) for row in inv])
1414
+ if any(x < 0 for x in w):
1415
+ negative = True # 允许负权(隐含做空),如实返回并提示
1416
+ elif method == "risk_parity":
1417
+ w = _norm_weights([1.0 / v for v in vols])
1418
+ for _ in range(1000):
1419
+ marg = [sum(cov[i][j] * w[j] for j in range(k)) for i in range(k)]
1420
+ if any(m <= 0 for m in marg):
1421
+ raise ToolError("风险平价迭代发散:协方差矩阵非正定")
1422
+ w_new = _norm_weights([1.0 / m for m in marg])
1423
+ if max(abs(a - b) for a, b in zip(w_new, w)) < 1e-12:
1424
+ w = w_new
1425
+ break
1426
+ w = w_new
1427
+ else:
1428
+ raise ToolError("未知配置方法")
1429
+ risk_contrib = [w[i] * sum(cov[i][j] * w[j] for j in range(k))
1430
+ for i in range(k)]
1431
+ total_var = sum(risk_contrib)
1432
+ return {"method": method, "k": k,
1433
+ "weights": [_r(x, 4) for x in w],
1434
+ "risk_contribution_pct": ([_r(x / total_var * 100, 2) for x in risk_contrib]
1435
+ if total_var > 0 else None),
1436
+ "has_negative_weight": negative,
1437
+ "note": "min_variance含负权重(隐含做空),如需纯多头请手动裁剪" if negative else None}
1438
+
1439
+
1440
+ @tool(
1441
+ "portfolio_metrics",
1442
+ "组合绩效:按权重合成收益率序列(每期再平衡假设),输出夏普/索提诺/年化收益/最大回撤/分散化比率。",
1443
+ properties={
1444
+ "weights": {"type": "array", "description": "权重数组(与序列数一致,和≈1,自动归一)"},
1445
+ "returns_matrix": {"type": "array", "description": "等长收益率序列的数组"},
1446
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
1447
+ },
1448
+ required=["weights", "returns_matrix"],
1449
+ )
1450
+ def tool_portfolio_metrics(weights, returns_matrix, periods_per_year: int = 252) -> dict:
1451
+ cols = _returns_columns(returns_matrix, "returns_matrix")
1452
+ k = len(cols)
1453
+ weights = _series(weights, "weights", k, k)
1454
+ if any(w < 0 for w in weights):
1455
+ raise ToolError("weights 不能为负")
1456
+ total_w = sum(weights)
1457
+ if not 0.9 <= total_w <= 1.1:
1458
+ raise ToolError("weights 总和应在0.9..1.1内")
1459
+ weights = [w / total_w for w in weights]
1460
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
1461
+ n = len(cols[0])
1462
+ port = [sum(weights[i] * cols[i][t] for i in range(k)) for t in range(n)]
1463
+ mean = sum(port) / n
1464
+ sd = _std(port, ddof=1)
1465
+ downside = math.sqrt(sum(min(r, 0.0) ** 2 for r in port) / n)
1466
+ growth = 1.0
1467
+ for r in port:
1468
+ growth *= (1 + r)
1469
+ if growth <= 0:
1470
+ raise ToolError("组合收益导致净值归零或为负")
1471
+ if not math.isfinite(growth):
1472
+ raise ToolError("组合净值溢出,请检查输入收益量级")
1473
+ try:
1474
+ ann_ret = growth ** (ppy / n) - 1
1475
+ except OverflowError:
1476
+ raise ToolError("年化收益计算溢出") from None
1477
+ if not math.isfinite(ann_ret):
1478
+ raise ToolError("年化收益计算溢出")
1479
+ peak, max_dd = 1.0, 0.0
1480
+ v = 1.0
1481
+ for r in port:
1482
+ v *= (1 + r)
1483
+ peak = max(peak, v)
1484
+ max_dd = min(max_dd, v / peak - 1)
1485
+ port_vol = math.sqrt(sum((r - mean) ** 2 for r in port) / (n - 1)) if n > 1 else 0.0
1486
+ weighted_vol = sum(weights[i] * _std(cols[i], ddof=1) for i in range(k))
1487
+ dr = weighted_vol / port_vol if port_vol > 0 else None
1488
+ return {
1489
+ "sharpe_annualized": _r(mean / sd * math.sqrt(ppy)) if sd > 0 else None,
1490
+ "sortino_annualized": _r(mean / downside * math.sqrt(ppy)) if downside > 0 else None,
1491
+ "annualized_return_pct": _r(ann_ret * 100),
1492
+ "max_drawdown_pct": _r(max_dd * 100),
1493
+ "vol_per_period": _r(sd),
1494
+ "diversification_ratio": _r(dr) if dr is not None else None,
1495
+ "note": "分散化比率>1表示组合波动低于成分加权平均波动" if dr is not None else None,
1496
+ }
1497
+
1498
+
1499
+ @tool(
1500
+ "strategy_correlation",
1501
+ "策略/品种相关性矩阵:两两皮尔逊相关,输出矩阵、平均相关、最高相关对与阈值聚类分组。",
1502
+ properties={
1503
+ "return_series": {"type": "array", "description": "等长收益率序列的数组(2..25条)"},
1504
+ "names": {"type": "array", "description": "可选名称数组(与序列数一致)"},
1505
+ "cluster_threshold": {"type": "number", "description": "聚类阈值(|corr|≥阈值归组),默认0.7"},
1506
+ },
1507
+ required=["return_series"],
1508
+ )
1509
+ def tool_strategy_correlation(return_series, names=None, cluster_threshold: float = 0.7) -> dict:
1510
+ cols = _returns_columns(return_series, "return_series")
1511
+ k = len(cols)
1512
+ cluster_threshold = _number(cluster_threshold, "cluster_threshold", 0, 1)
1513
+ if names is not None:
1514
+ if not isinstance(names, list) or len(names) != k or \
1515
+ any(not isinstance(nm, str) or not nm.strip() for nm in names):
1516
+ raise ToolError("names 必须是与序列数一致的非空字符串数组")
1517
+ labels = names or [f"S{i+1}" for i in range(k)]
1518
+ matrix = [[_r(_pearson(cols[i], cols[j]), 4) for j in range(k)] for i in range(k)]
1519
+ pairs = [(matrix[i][j], labels[i], labels[j])
1520
+ for i in range(k) for j in range(i + 1, k)]
1521
+ avg = sum(abs(p[0]) for p in pairs) / len(pairs) if pairs else 0.0
1522
+ strongest = max(pairs, key=lambda p: abs(p[0])) if pairs else None
1523
+ # 单链聚类:|corr| >= 阈值合并
1524
+ parent = list(range(k))
1525
+
1526
+ def find(x):
1527
+ while parent[x] != x:
1528
+ parent[x] = parent[parent[x]]
1529
+ x = parent[x]
1530
+ return x
1531
+
1532
+ for i in range(k):
1533
+ for j in range(i + 1, k):
1534
+ if abs(matrix[i][j]) >= cluster_threshold:
1535
+ ri, rj = find(i), find(j)
1536
+ if ri != rj:
1537
+ parent[rj] = ri
1538
+ groups = {}
1539
+ for i in range(k):
1540
+ groups.setdefault(labels[find(i)], []).append(labels[i])
1541
+ return {"labels": labels, "matrix": matrix,
1542
+ "avg_abs_correlation": _r(abs(avg)),
1543
+ "strongest_pair": {"a": strongest[1], "b": strongest[2],
1544
+ "correlation": strongest[0]} if strongest else None,
1545
+ "clusters": list(groups.values()),
1546
+ "cluster_threshold": cluster_threshold}
1547
+
1548
+
1549
+ @tool(
1550
+ "symbol_screener",
1551
+ "多品种筛选打分:对每个候选品种计算动量/趋势/稳定性/低波动四个分项(跨品种min-max归一到0..1),"
1552
+ "加权合成分并排名。输入候选品种与其收盘价序列。",
1553
+ properties={
1554
+ "candidates": {"type": "array", "description": "[{symbol, closes}] 数组(2..50个品种,每条至少30个收盘价)"},
1555
+ "lookback": {"type": "integer", "description": "动量回看周期,默认20"},
1556
+ "weights": {"type": "object",
1557
+ "description": "分项权重 {momentum,trend,consistency,low_vol},默认0.4/0.3/0.2/0.1"},
1558
+ },
1559
+ required=["candidates"],
1560
+ )
1561
+ def tool_symbol_screener(candidates, lookback: int = 20,
1562
+ weights: dict = None) -> dict:
1563
+ if not isinstance(candidates, list) or not 2 <= len(candidates) <= 50:
1564
+ raise ToolError("candidates: 需要2..50个品种")
1565
+ lookback = _period(lookback, "lookback")
1566
+ w = weights or {}
1567
+ if not isinstance(w, dict):
1568
+ raise ToolError("weights 必须是对象")
1569
+ w_mom = _number(w.get("momentum", 0.4), "weights.momentum", 0, 1)
1570
+ w_trend = _number(w.get("trend", 0.3), "weights.trend", 0, 1)
1571
+ w_cons = _number(w.get("consistency", 0.2), "weights.consistency", 0, 1)
1572
+ w_lv = _number(w.get("low_vol", 0.1), "weights.low_vol", 0, 1)
1573
+ w_sum = w_mom + w_trend + w_cons + w_lv
1574
+ if w_sum <= 0:
1575
+ raise ToolError("分项权重总和必须大于0")
1576
+ rows = []
1577
+ for i, cand in enumerate(candidates):
1578
+ if not isinstance(cand, dict) or "symbol" not in cand or "closes" not in cand:
1579
+ raise ToolError(f"candidates[{i}]: 需要 {{symbol, closes}}")
1580
+ symbol = cand["symbol"]
1581
+ if not isinstance(symbol, str) or not symbol.strip() or len(symbol) > 40:
1582
+ raise ToolError(f"candidates[{i}].symbol 非法")
1583
+ closes = _series(cand["closes"], f"candidates[{i}].closes", 30, MAX_CANDLES)
1584
+ if any(c <= 0 for c in closes):
1585
+ raise ToolError(f"candidates[{i}].closes 必须全部为正数")
1586
+ if len(closes) <= lookback:
1587
+ raise ToolError(f"candidates[{i}]: 收盘价需多于lookback({lookback})个")
1588
+ mom = 100.0 * (closes[-1] / closes[-1 - lookback] - 1)
1589
+ rets = [math.log(closes[j] / closes[j - 1]) for j in range(1, len(closes))]
1590
+ vol = _std(rets, ddof=1) * math.sqrt(252)
1591
+ n = len(closes)
1592
+ my = sum(closes) / n
1593
+ sxx = sum((j - (n - 1) / 2) ** 2 for j in range(n))
1594
+ slope = sum((j - (n - 1) / 2) * (c - my) for j, c in enumerate(closes)) / sxx
1595
+ ss_tot = sum((c - my) ** 2 for c in closes)
1596
+ r2 = 1 - sum((c - (my + slope * (j - (n - 1) / 2))) ** 2
1597
+ for j, c in enumerate(closes)) / ss_tot if ss_tot > 0 else 0.0
1598
+ rows.append({"symbol": symbol.strip(), "momentum_pct": _r(mom),
1599
+ "trend_pct": _r((closes[-1] / (my + slope * (n - 1)) - 1) * 100
1600
+ if my + slope * (n - 1) > 0 else 0.0),
1601
+ "consistency_r2": _r(max(0.0, min(1.0, r2)), 4),
1602
+ "vol_annualized": _r(vol, 4)})
1603
+
1604
+ def minmax(key, invert=False):
1605
+ vals = [r[key] for r in rows]
1606
+ lo, hi = min(vals), max(vals)
1607
+ span = hi - lo
1608
+ for r in rows:
1609
+ r["_s_" + key] = (0.5 if span == 0 else
1610
+ ((hi - r[key]) / span if invert else (r[key] - lo) / span))
1611
+
1612
+ minmax("momentum_pct")
1613
+ minmax("trend_pct")
1614
+ minmax("consistency_r2")
1615
+ minmax("vol_annualized", invert=True)
1616
+ for r in rows:
1617
+ r["score"] = _r((w_mom * r["_s_momentum_pct"] + w_trend * r["_s_trend_pct"]
1618
+ + w_cons * r["_s_consistency_r2"] + w_lv * r["_s_vol_annualized"])
1619
+ / w_sum * 100, 2)
1620
+ for key in ("_s_momentum_pct", "_s_trend_pct", "_s_consistency_r2", "_s_vol_annualized"):
1621
+ del r[key]
1622
+ rows.sort(key=lambda r: r["score"], reverse=True)
1623
+ return {"count": len(rows), "ranking": rows,
1624
+ "note": "score为跨候选品种相对分,不代表绝对收益预期"}
1625
+
1626
+
1627
+ @tool(
1628
+ "optimizer_rank",
1629
+ "参数/策略优选排名:对回测结果集按夏普-回撤惩罚-样本不足惩罚合成分排序,并标记可疑结果"
1630
+ "(夏普异常高、样本过少、回撤过深)。防止只挑运气最好的参数。",
1631
+ properties={
1632
+ "results": {"type": "array",
1633
+ "description": "[{name, sharpe, max_dd_pct, trades}] 数组(2..100条)"},
1634
+ "min_trades": {"type": "integer", "description": "最低交易笔数,默认30"},
1635
+ "dd_penalty_divisor": {"type": "number", "description": "回撤惩罚除数(|dd|%/该值计入扣分),默认20"},
1636
+ },
1637
+ required=["results"],
1638
+ )
1639
+ def tool_optimizer_rank(results, min_trades: int = 30,
1640
+ dd_penalty_divisor: float = 20.0) -> dict:
1641
+ if not isinstance(results, list) or not 2 <= len(results) <= 100:
1642
+ raise ToolError("results: 需要2..100条")
1643
+ min_trades = _int(min_trades, "min_trades", 1, 100000)
1644
+ dd_div = _number(dd_penalty_divisor, "dd_penalty_divisor", 0.1, 1000)
1645
+ rows = []
1646
+ for i, r in enumerate(results):
1647
+ if not isinstance(r, dict):
1648
+ raise ToolError(f"results[{i}]: 必须是对象")
1649
+ name = r.get("name", f"R{i+1}")
1650
+ if not isinstance(name, str) or len(name) > 60:
1651
+ raise ToolError(f"results[{i}].name 非法")
1652
+ sharpe = _number(r.get("sharpe"), f"results[{i}].sharpe", -100, 100)
1653
+ dd = _number(r.get("max_dd_pct"), f"results[{i}].max_dd_pct", -100, 0)
1654
+ trades = r.get("trades")
1655
+ if isinstance(trades, bool) or not isinstance(trades, int) or trades < 0:
1656
+ raise ToolError(f"results[{i}].trades: 必须是非负整数")
1657
+ score = sharpe - abs(dd) / dd_div
1658
+ flags = []
1659
+ if trades < min_trades:
1660
+ score -= (min_trades - trades) / min_trades
1661
+ flags.append("样本不足")
1662
+ if sharpe > 5:
1663
+ flags.append("夏普异常高,疑过拟合")
1664
+ if dd < -50:
1665
+ flags.append("回撤过深")
1666
+ rows.append({"name": name, "sharpe": _r(sharpe), "max_dd_pct": _r(dd),
1667
+ "trades": trades, "score": _r(score), "flags": flags})
1668
+ rows.sort(key=lambda r: r["score"], reverse=True)
1669
+ clean = [r for r in rows if not r["flags"]]
1670
+ return {"ranking": rows,
1671
+ "best_clean": clean[0]["name"] if clean else None,
1672
+ "flagged_count": len(rows) - len(clean),
1673
+ "note": "score仅用于相对排序,不构成对未来表现的预测"}
1674
+
1675
+
1676
+ @tool(
1677
+ "walk_forward_splits",
1678
+ "前推优化(Walk-Forward)切分:生成滚动或锚定训练/测试窗口,杜绝全样本过拟合。切分确定性。",
1679
+ properties={
1680
+ "total": {"type": "integer", "description": "样本总长度"},
1681
+ "train_size": {"type": "integer", "description": "训练窗口长度"},
1682
+ "test_size": {"type": "integer", "description": "测试窗口长度"},
1683
+ "mode": {"type": "string", "enum": ["rolling", "anchored"],
1684
+ "description": "rolling=滚动窗口,anchored=锚定(训练起点固定0),默认rolling"},
1685
+ "step": {"type": "integer", "description": "窗口步进,默认=test_size"},
1686
+ },
1687
+ required=["total", "train_size", "test_size"],
1688
+ )
1689
+ def tool_walk_forward(total: int, train_size: int, test_size: int,
1690
+ mode: str = "rolling", step: int = None) -> dict:
1691
+ total = _int(total, "total", 2, 1_000_000)
1692
+ train = _int(train_size, "train_size", 1, total)
1693
+ test = _int(test_size, "test_size", 1, total)
1694
+ if mode not in ("rolling", "anchored"):
1695
+ raise ToolError("mode 必须是 rolling 或 anchored")
1696
+ if train < test:
1697
+ raise ToolError("train_size 不应小于 test_size")
1698
+ if total < train + test:
1699
+ raise ToolError(f"total({total})不足以容纳 train+test({train + test})")
1700
+ step = test if step is None else _int(step, "step", 1, total)
1701
+ splits = []
1702
+ start = 0
1703
+ while start + train + test <= total:
1704
+ tr_start = 0 if mode == "anchored" else start
1705
+ splits.append({"train": [tr_start, start + train],
1706
+ "test": [start + train, start + train + test]})
1707
+ start += step
1708
+ if not splits:
1709
+ raise ToolError("无法生成任何切分窗口")
1710
+ return {"mode": mode, "total": total, "splits": splits,
1711
+ "split_count": len(splits),
1712
+ "coverage": _r((splits[-1]["test"][1]) / total * 100, 2)}
1713
+
1714
+
1715
+ @tool(
1716
+ "variance_ratio",
1717
+ "方差比检验(Lo-MacKinlay):VR(q)=Var(q期收益)/(q·Var(1期收益))。VR>1趋势延续,<1均值回归;"
1718
+ "z分数>1.96拒绝随机游走。",
1719
+ properties={
1720
+ "returns": {"type": "array", "description": "每期对数收益率序列(至少3q+1个)"},
1721
+ "q": {"type": "integer", "description": "聚合期数,默认2..64"},
1722
+ },
1723
+ required=["returns"],
1724
+ )
1725
+ def tool_variance_ratio(returns, q: int = 2) -> dict:
1726
+ rets = _series(returns, "returns", 4)
1727
+ q = _int(q, "q", 2, 64)
1728
+ n = len(rets)
1729
+ if n < 3 * q + 1:
1730
+ raise ToolError(f"方差比检验需要至少{3 * q + 1}个收益点")
1731
+ mean = sum(rets) / n
1732
+ var1 = sum((r - mean) ** 2 for r in rets) / (n - 1)
1733
+ if var1 == 0:
1734
+ raise ToolError("收益为常数,方差比无定义")
1735
+ sums = [sum(rets[t:t + q]) for t in range(n - q + 1)]
1736
+ ms = sum(sums) / len(sums)
1737
+ varq = sum((s - ms) ** 2 for s in sums) / (len(sums) - 1)
1738
+ vr = varq / (q * var1)
1739
+ phi = 2.0 * (2 * q - 1) * (q - 1) / (3.0 * q * n) # 同方差假设
1740
+ z = (vr - 1) / math.sqrt(phi) if phi > 0 else 0.0
1741
+ verdict = "拒绝随机游走" if abs(z) > 1.96 else "不能拒绝随机游走"
1742
+ hint = "趋势/动量特征" if vr > 1 else "均值回归特征"
1743
+ return {"q": q, "vr": _r(vr, 4), "z": _r(z, 4), "verdict": verdict, "hint": hint}
1744
+
1745
+
1746
+ @tool(
1747
+ "half_life",
1748
+ "均值回归半衰期:Δy 对 y(t-1) 做AR(1)回归(OU过程离散化)。半衰期越短回归越快,适合配对/网格策略评估。",
1749
+ properties={
1750
+ "values": {"type": "array", "description": "时间序列(价格或价差,至少20个点)"},
1751
+ },
1752
+ required=["values"],
1753
+ )
1754
+ def tool_half_life(values) -> dict:
1755
+ values = _series(values, "values", 20)
1756
+ slope, intercept, hl = _ar1_half_life(values)
1757
+ return {"ar1_slope": _r(slope, 4), "intercept": _r(intercept, 4),
1758
+ "half_life_periods": _r(hl, 2) if hl is not None else None,
1759
+ "mean_reverting": hl is not None,
1760
+ "note": None if hl is not None else "斜率≥0:该序列无均值回归特征"}
1761
+
1762
+
1763
+ @tool(
1764
+ "pair_trade_scan",
1765
+ "配对交易扫描:两价格序列做对数OLS得对冲比率,输出价差的半衰期/Hurst/相关性与配对可行性提示。",
1766
+ properties={
1767
+ "price_a": {"type": "array", "description": "A品种价格序列(正数,至少64个点)"},
1768
+ "price_b": {"type": "array", "description": "B品种价格序列(等长)"},
1769
+ },
1770
+ required=["price_a", "price_b"],
1771
+ )
1772
+ def tool_pair_trade(price_a, price_b) -> dict:
1773
+ a = _series(price_a, "price_a", 64, MAX_CANDLES)
1774
+ b = _series(price_b, "price_b", 64, MAX_CANDLES)
1775
+ if len(a) != len(b):
1776
+ raise ToolError("两序列长度必须一致")
1777
+ if any(x <= 0 for x in a) or any(x <= 0 for x in b):
1778
+ raise ToolError("价格必须全部为正数")
1779
+ la = [math.log(x) for x in a]
1780
+ lb = [math.log(x) for x in b]
1781
+ mlb = sum(lb) / len(lb)
1782
+ sxx = sum((x - mlb) ** 2 for x in lb)
1783
+ if sxx <= 0:
1784
+ raise ToolError("price_b 无波动,无法估计对冲比率")
1785
+ mla = sum(la) / len(la)
1786
+ hedge = sum((x - mla) * (y - mlb) for x, y in zip(la, lb)) / sxx
1787
+ spread = [x - hedge * y for x, y in zip(la, lb)]
1788
+ sd = _std(spread, ddof=1)
1789
+ corr = _pearson(la, lb)
1790
+ result = {"hedge_ratio": _r(hedge, 4), "correlation": _r(corr, 4),
1791
+ "spread_std": _r(sd, 4)}
1792
+ if sd < 1e-12:
1793
+ result.update({"half_life_periods": None, "hurst": None,
1794
+ "candidate": None, "note": "价差为常数,无交易空间"})
1795
+ return result
1796
+ try:
1797
+ _, _, hl = _ar1_half_life(spread)
1798
+ except ToolError:
1799
+ hl = None
1800
+ try:
1801
+ h = _hurst_estimate(spread)
1802
+ except ToolError:
1803
+ h = None
1804
+ candidate = (corr > 0.8 and hl is not None and 0 < hl <= 60)
1805
+ result.update({"half_life_periods": _r(hl, 2) if hl is not None else None,
1806
+ "hurst": _r(h, 4) if h is not None else None,
1807
+ "candidate": candidate,
1808
+ "note": ("相关高且回归较快,可进入纸面验证" if candidate
1809
+ else "未通过初筛:需要corr>0.8且半衰期在0..60期内")})
1810
+ return result
1811
+
1812
+
1813
+ @tool(
1814
+ "sharpe_psr",
1815
+ "概率夏普比率(PSR)与紧缩夏普比率(DSR):衡量夏普显著性与多重试验后的幸存者偏差。"
1816
+ "PSR=夏普超过基准的概率;DSR在trials次试验后仍显著才可信。",
1817
+ properties={
1818
+ "returns": {"type": "array", "description": "每期收益率序列(至少20个)"},
1819
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
1820
+ "benchmark_sr": {"type": "number", "description": "基准夏普(同期口径),默认0"},
1821
+ "trials": {"type": "integer", "description": "尝试过的策略/参数总数,=1时不出DSR,默认1"},
1822
+ },
1823
+ required=["returns"],
1824
+ )
1825
+ def tool_sharpe_psr(returns, periods_per_year: int = 252,
1826
+ benchmark_sr: float = 0.0, trials: int = 1) -> dict:
1827
+ rets = _series(returns, "returns", 20, MAX_PORTFOLIO_LEN)
1828
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
1829
+ benchmark_sr = _number(benchmark_sr, "benchmark_sr", -10, 10)
1830
+ trials = _int(trials, "trials", 1, 100000)
1831
+ n = len(rets)
1832
+ mean = sum(rets) / n
1833
+ sd = _std(rets, ddof=1)
1834
+ if sd == 0:
1835
+ raise ToolError("收益为常数,夏普比率无定义")
1836
+ sr = mean / sd
1837
+ m2 = sum((r - mean) ** 2 for r in rets) / n
1838
+ m3 = sum((r - mean) ** 3 for r in rets) / n
1839
+ m4 = sum((r - mean) ** 4 for r in rets) / n
1840
+ skew = m3 / m2 ** 1.5 if m2 > 0 else 0.0
1841
+ kurt = m4 / m2 ** 2 if m2 > 0 else 3.0
1842
+ denom2 = 1.0 - skew * sr + (kurt - 1) / 4.0 * sr * sr
1843
+ if denom2 <= 0:
1844
+ raise ToolError("夏普方差项非正,无法计算PSR(样本过短或分布极端)")
1845
+ v_sr = denom2 / (n - 1)
1846
+
1847
+ def psr_at(sr_b):
1848
+ return _norm_cdf((sr - sr_b) * math.sqrt(n - 1) / math.sqrt(denom2))
1849
+
1850
+ out = {"sharpe_per_period": _r(sr, 4),
1851
+ "sharpe_annualized": _r(sr * math.sqrt(ppy), 4),
1852
+ "skew": _r(skew, 4), "kurtosis": _r(kurt, 4),
1853
+ "psr_vs_benchmark": _r(psr_at(benchmark_sr), 4),
1854
+ "n": n}
1855
+ if trials > 1:
1856
+ sr0 = math.sqrt(v_sr) * _norm_ppf(1.0 - 1.0 / trials)
1857
+ out["dsr_threshold_sr"] = _r(sr0, 4)
1858
+ out["dsr"] = _r(psr_at(sr0), 4)
1859
+ out["note"] = f"trials={trials}:夏普需超过{out['dsr_threshold_sr']}(同期口径)DSR才>0.5"
1860
+ else:
1861
+ out["note"] = "设置trials为实际尝试的策略/参数总数可获得DSR"
1862
+ return out
1863
+
1864
+
1865
+ @tool(
1866
+ "bootstrap_ci",
1867
+ "自助法(Bootstrap)置信区间:有放回重采样收益序列,给出均值/夏普等指标的置信区间。"
1868
+ "确定性种子,同输入同结果。",
1869
+ properties={
1870
+ "returns": {"type": "array", "description": "每期收益率序列(至少20个)"},
1871
+ "metric": {"type": "string", "enum": ["mean", "sharpe", "median"],
1872
+ "description": "重采样统计量,默认sharpe"},
1873
+ "iterations": {"type": "integer", "description": "重采样次数(100..10000),默认2000"},
1874
+ "confidence": {"type": "number", "description": "置信度,默认0.95"},
1875
+ "seed": {"type": "integer", "description": "随机种子,默认42"},
1876
+ },
1877
+ required=["returns"],
1878
+ )
1879
+ def tool_bootstrap_ci(returns, metric: str = "sharpe", iterations: int = 2000,
1880
+ confidence: float = 0.95, seed: int = 42) -> dict:
1881
+ rets = _series(returns, "returns", 20, MAX_PORTFOLIO_LEN)
1882
+ if metric not in ("mean", "sharpe", "median"):
1883
+ raise ToolError("metric 必须是 mean/sharpe/median")
1884
+ iterations = _int(iterations, "iterations", 100, 10000)
1885
+ confidence = _number(confidence, "confidence", 0.5, 0.9999)
1886
+ rng = random.Random(_int(seed, "seed", 0, 2**31 - 1))
1887
+ n = len(rets)
1888
+
1889
+ def stat(sample):
1890
+ if metric == "mean":
1891
+ return sum(sample) / n
1892
+ if metric == "median":
1893
+ s = sorted(sample)
1894
+ mid = n // 2
1895
+ return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2
1896
+ m = sum(sample) / n
1897
+ sd = _std(sample, ddof=1)
1898
+ return m / sd if sd > 0 else 0.0
1899
+
1900
+ point = stat(rets)
1901
+ samples = []
1902
+ for _ in range(iterations):
1903
+ samples.append(stat([rets[rng.randrange(n)] for _ in range(n)]))
1904
+ samples.sort()
1905
+ alpha = (1 - confidence) / 2
1906
+ lo = _percentile(samples, alpha * 100)
1907
+ hi = _percentile(samples, (1 - alpha) * 100)
1908
+ return {"metric": metric, "point_estimate": _r(point, 4),
1909
+ "ci_low": _r(lo, 4), "ci_high": _r(hi, 4),
1910
+ "confidence": confidence, "iterations": iterations,
1911
+ "significant_away_from_zero": not (lo <= 0 <= hi)}
1912
+
1913
+
1914
+ @tool(
1915
+ "exposure_check",
1916
+ "组合敞口风控检查:单品种/组别/净/总敞口占净值百分比与限额比对,逐条输出违规警示。只做算术,不下单。",
1917
+ properties={
1918
+ "equity": {"type": "number", "description": "账户净值(>0,账户货币)"},
1919
+ "positions": {"type": "array",
1920
+ "description": "[{symbol, value, group?}] value为带符号市值(多头正/空头负)"},
1921
+ "max_single_pct": {"type": "number", "description": "单品种限额%,默认20"},
1922
+ "max_group_pct": {"type": "number", "description": "单组别限额%(按绝对值合计),默认40"},
1923
+ "max_net_pct": {"type": "number", "description": "净敞口限额%,默认100"},
1924
+ "max_gross_pct": {"type": "number", "description": "总敞口限额%,默认200"},
1925
+ },
1926
+ required=["equity", "positions"],
1927
+ )
1928
+ def tool_exposure_check(equity, positions, max_single_pct: float = 20.0,
1929
+ max_group_pct: float = 40.0, max_net_pct: float = 100.0,
1930
+ max_gross_pct: float = 200.0) -> dict:
1931
+ equity = _number(equity, "equity", 1e-9, 1e15)
1932
+ if not isinstance(positions, list) or not 1 <= len(positions) <= 200:
1933
+ raise ToolError("positions: 需要1..200个持仓")
1934
+ max_single_pct = _number(max_single_pct, "max_single_pct", 0.01, 1000)
1935
+ max_group_pct = _number(max_group_pct, "max_group_pct", 0.01, 1000)
1936
+ max_net_pct = _number(max_net_pct, "max_net_pct", 0.01, 1000)
1937
+ max_gross_pct = _number(max_gross_pct, "max_gross_pct", 0.01, 1000)
1938
+ groups, warnings, details = {}, [], []
1939
+ net = 0.0
1940
+ gross = 0.0
1941
+ for i, p in enumerate(positions):
1942
+ if not isinstance(p, dict) or "symbol" not in p or "value" not in p:
1943
+ raise ToolError(f"positions[{i}]: 需要 {{symbol, value}}")
1944
+ symbol = p["symbol"]
1945
+ if not isinstance(symbol, str) or not symbol.strip() or len(symbol) > 40:
1946
+ raise ToolError(f"positions[{i}].symbol 非法")
1947
+ value = _number(p["value"], f"positions[{i}].value", -1e15, 1e15)
1948
+ group = p.get("group", "ungrouped")
1949
+ if not isinstance(group, str) or len(group) > 40:
1950
+ raise ToolError(f"positions[{i}].group 非法")
1951
+ symbol = symbol.strip()
1952
+ weight = value / equity * 100
1953
+ net += value
1954
+ gross += abs(value)
1955
+ groups.setdefault(group, 0.0)
1956
+ groups[group] += abs(value)
1957
+ if abs(weight) > max_single_pct:
1958
+ warnings.append(f"{symbol}: 单品种敞口{weight:.1f}%超限额{max_single_pct}%")
1959
+ details.append({"symbol": symbol, "value": _r(value), "weight_pct": _r(weight)})
1960
+ for gname, gval in sorted(groups.items()):
1961
+ gweight = gval / equity * 100
1962
+ if gweight > max_group_pct:
1963
+ warnings.append(f"组[{gname}]: 合计敞口{gweight:.1f}%超限额{max_group_pct}%")
1964
+ net_pct, gross_pct = net / equity * 100, gross / equity * 100
1965
+ if abs(net_pct) > max_net_pct:
1966
+ warnings.append(f"净敞口{net_pct:.1f}%超限额{max_net_pct}%")
1967
+ if gross_pct > max_gross_pct:
1968
+ warnings.append(f"总敞口{gross_pct:.1f}%超限额{max_gross_pct}%")
1969
+ return {"positions": details, "groups": {g: _r(v / equity * 100, 2)
1970
+ for g, v in sorted(groups.items())},
1971
+ "net_exposure_pct": _r(net_pct), "gross_exposure_pct": _r(gross_pct),
1972
+ "ok": not warnings, "warnings": warnings}
1973
+
1974
+
1975
+ @tool(
1976
+ "risk_ladder",
1977
+ "回撤响应阶梯:给定当前回撤,输出应处风控档位与动作(降仓→停止开新仓→清仓复盘)。"
1978
+ "确定性映射,默认5%/10%/15%三档,可自定义。",
1979
+ properties={
1980
+ "current_dd_pct": {"type": "number", "description": "当前回撤百分比(0..100,正数)"},
1981
+ "levels": {"type": "array",
1982
+ "description": "[{dd_pct, action}] 升序档位,默认5/10/15三档"},
1983
+ },
1984
+ required=["current_dd_pct"],
1985
+ )
1986
+ def tool_risk_ladder(current_dd_pct, levels=None) -> dict:
1987
+ current_dd_pct = _number(current_dd_pct, "current_dd_pct", 0, 100)
1988
+ if levels is None:
1989
+ levels = [{"dd_pct": 5, "action": "reduce_risk_50pct"},
1990
+ {"dd_pct": 10, "action": "stop_new_entries"},
1991
+ {"dd_pct": 15, "action": "close_all_and_review"}]
1992
+ if not isinstance(levels, list) or not 1 <= len(levels) <= 10:
1993
+ raise ToolError("levels: 需要1..10个档位")
1994
+ parsed = []
1995
+ for i, lv in enumerate(levels):
1996
+ if not isinstance(lv, dict) or "dd_pct" not in lv or "action" not in lv:
1997
+ raise ToolError(f"levels[{i}]: 需要 {{dd_pct, action}}")
1998
+ dd = _number(lv["dd_pct"], f"levels[{i}].dd_pct", 0.01, 100)
1999
+ action = lv["action"]
2000
+ if not isinstance(action, str) or not action.strip() or len(action) > 60:
2001
+ raise ToolError(f"levels[{i}].action 非法")
2002
+ parsed.append((dd, action.strip()))
2003
+ parsed.sort(key=lambda x: x[0])
2004
+ tier, action = None, "normal_operation"
2005
+ for idx, (dd, act) in enumerate(parsed):
2006
+ if current_dd_pct >= dd:
2007
+ tier, action = idx, act
2008
+ nxt = parsed[tier + 1][0] if tier is not None and tier + 1 < len(parsed) else \
2009
+ (parsed[0][0] if tier is None else None)
2010
+ return {"current_dd_pct": _r(current_dd_pct),
2011
+ "tier": tier, "action": action,
2012
+ "next_threshold_pct": _r(nxt) if nxt is not None else None,
2013
+ "buffer_pct": _r(nxt - current_dd_pct) if nxt is not None else None,
2014
+ "levels": [{"dd_pct": _r(d), "action": a} for d, a in parsed]}
2015
+
2016
+
2017
+ # ---------- 第四批:回测报告 / 策略挖掘 ----------
2018
+
2019
+ def _ann_return(growth: float, periods: int, ppy: int) -> float:
2020
+ if growth <= 0:
2021
+ raise ToolError("净值终值为非正数,无法年化")
2022
+ try:
2023
+ value = growth ** (ppy / periods) - 1
2024
+ except OverflowError:
2025
+ raise ToolError("年化收益计算溢出") from None
2026
+ if not math.isfinite(value):
2027
+ raise ToolError("年化收益计算溢出")
2028
+ return value
2029
+
2030
+
2031
+ @tool(
2032
+ "backtest_report",
2033
+ "一站式回测报告:输入净值曲线或收益序列(可选交易盈亏列表),输出收益/风险/交易三段完整指标,"
2034
+ "含CAGR、夏普、索提诺、卡玛、溃疡指数、水下时间占比。",
2035
+ properties={
2036
+ "equity": {"type": "array", "description": "净值曲线(与returns二选一)"},
2037
+ "returns": {"type": "array", "description": "收益率序列(与equity二选一)"},
2038
+ "trades": {"type": "array", "description": "可选:每笔盈亏列表(至少5笔)"},
2039
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
2040
+ },
2041
+ )
2042
+ def tool_backtest_report(equity=None, returns=None, trades=None,
2043
+ periods_per_year: int = 252) -> dict:
2044
+ curve = _equity_curve(equity, returns)
2045
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
2046
+ n = len(curve)
2047
+ rets = [curve[i] / curve[i - 1] - 1 for i in range(1, n)]
2048
+ mean = sum(rets) / len(rets)
2049
+ sd = _std(rets, ddof=1)
2050
+ downside = math.sqrt(sum(min(r, 0.0) ** 2 for r in rets) / len(rets))
2051
+ total = curve[-1] / curve[0] - 1
2052
+ cagr = _ann_return(curve[-1] / curve[0], n - 1, ppy)
2053
+ peak, max_dd, under_water = curve[0], 0.0, 0
2054
+ squares = []
2055
+ for v in curve:
2056
+ if v < peak:
2057
+ under_water += 1
2058
+ peak = max(peak, v)
2059
+ dd = (v / peak - 1) * 100
2060
+ max_dd = min(max_dd, dd)
2061
+ squares.append(dd * dd)
2062
+ ulcer = math.sqrt(sum(squares) / n)
2063
+ report = {
2064
+ "periods": n, "periods_per_year": ppy,
2065
+ "returns": {
2066
+ "total_return_pct": _r(total * 100),
2067
+ "cagr_pct": _r(cagr * 100),
2068
+ "best_period_pct": _r(max(rets) * 100),
2069
+ "worst_period_pct": _r(min(rets) * 100),
2070
+ "sharpe": _r(mean / sd * math.sqrt(ppy)) if sd > 0 else None,
2071
+ "sortino": _r(mean / downside * math.sqrt(ppy)) if downside > 0 else None,
2072
+ },
2073
+ "risk": {
2074
+ "vol_annualized_pct": _r(sd * math.sqrt(ppy) * 100),
2075
+ "max_drawdown_pct": _r(max_dd),
2076
+ "calmar": _r(cagr / abs(max_dd)) if max_dd < 0 else None,
2077
+ "ulcer_index": _r(ulcer),
2078
+ "time_underwater_pct": _r(under_water / n * 100),
2079
+ },
2080
+ }
2081
+ if trades is not None:
2082
+ report["trades"] = _trade_stats_core(_series(trades, "trades", 5))
2083
+ report["note"] = "交易统计与净值曲线的口径需一致(同一回测输出)"
2084
+ return report
2085
+
2086
+
2087
+ @tool(
2088
+ "drawdown_episodes",
2089
+ "回撤事件表:从净值曲线提取各回撤区间(峰值→谷底→修复),按深度排序输出Top N,"
2090
+ "并给出水下时间占比。用于回答'历史上最难熬的回撤长什么样'。",
2091
+ properties={
2092
+ "equity": {"type": "array", "description": "净值曲线(与returns二选一)"},
2093
+ "returns": {"type": "array", "description": "收益率序列(与equity二选一)"},
2094
+ "top_n": {"type": "integer", "description": "返回最深的N个回撤事件,默认5"},
2095
+ },
2096
+ )
2097
+ def tool_drawdown_episodes(equity=None, returns=None, top_n: int = 5) -> dict:
2098
+ curve = _equity_curve(equity, returns)
2099
+ top_n = _int(top_n, "top_n", 1, 50)
2100
+ episodes = []
2101
+ peak, peak_idx = curve[0], 0
2102
+ trough, trough_idx = None, None
2103
+ under_water = 0
2104
+ for i, v in enumerate(curve):
2105
+ if v >= peak and trough is None:
2106
+ peak, peak_idx = v, i
2107
+ elif v < peak:
2108
+ under_water += 1
2109
+ if trough is None:
2110
+ trough, trough_idx = v, i
2111
+ elif v < trough:
2112
+ trough, trough_idx = v, i
2113
+ elif v >= peak and trough is not None:
2114
+ episodes.append({"peak_index": peak_idx, "trough_index": trough_idx,
2115
+ "recovery_index": i,
2116
+ "depth_pct": _r((trough / peak - 1) * 100),
2117
+ "duration_bars": i - peak_idx,
2118
+ "ongoing": False})
2119
+ peak, peak_idx = v, i
2120
+ trough, trough_idx = None, None
2121
+ if trough is not None:
2122
+ episodes.append({"peak_index": peak_idx, "trough_index": trough_idx,
2123
+ "recovery_index": None,
2124
+ "depth_pct": _r((trough / peak - 1) * 100),
2125
+ "duration_bars": len(curve) - 1 - peak_idx,
2126
+ "ongoing": True})
2127
+ episodes.sort(key=lambda e: e["depth_pct"])
2128
+ return {"episodes": episodes[:top_n], "total_episodes": len(episodes),
2129
+ "ongoing": any(e["ongoing"] for e in episodes),
2130
+ "time_underwater_pct": _r(under_water / len(curve) * 100),
2131
+ "note": "duration_bars自峰值起算,未修复事件ongoing=true"}
2132
+
2133
+
2134
+ @tool(
2135
+ "segment_returns",
2136
+ "分段收益稳定性:把回测期等分为N段,逐段给出收益与最大回撤。回答'收益是均匀的还是靠某一段'。",
2137
+ properties={
2138
+ "equity": {"type": "array", "description": "净值曲线(与returns二选一)"},
2139
+ "returns": {"type": "array", "description": "收益率序列(与equity二选一)"},
2140
+ "segments": {"type": "integer", "description": "分段数(2..50),默认5"},
2141
+ },
2142
+ )
2143
+ def tool_segment_returns(equity=None, returns=None, segments: int = 5) -> dict:
2144
+ curve = _equity_curve(equity, returns)
2145
+ segments = _int(segments, "segments", 2, 50)
2146
+ block, remainder = divmod(len(curve), segments)
2147
+ if block < 2:
2148
+ raise ToolError(f"每段至少需要2个点:序列长度{len(curve)}不足{segments}段")
2149
+ rows = []
2150
+ start = 0
2151
+ for s in range(segments):
2152
+ end = start + block + (1 if s < remainder else 0)
2153
+ seg = curve[start:end]
2154
+ seg_peak, seg_dd = seg[0], 0.0
2155
+ for v in seg:
2156
+ seg_peak = max(seg_peak, v)
2157
+ seg_dd = min(seg_dd, v / seg_peak - 1)
2158
+ rows.append({"segment": s + 1, "range": [start, end - 1],
2159
+ "return_pct": _r((seg[-1] / seg[0] - 1) * 100),
2160
+ "max_drawdown_pct": _r(seg_dd * 100)})
2161
+ start = end
2162
+ positive = sum(1 for r in rows if r["return_pct"] > 0)
2163
+ best = max(rows, key=lambda r: r["return_pct"])
2164
+ worst = min(rows, key=lambda r: r["return_pct"])
2165
+ concentrated = _segment_concentration(rows)
2166
+ return {"segments": rows, "positive_blocks": positive,
2167
+ "consistency_pct": _r(positive / segments * 100),
2168
+ "best_segment": best["segment"], "worst_segment": worst["segment"],
2169
+ "best_share_note": ("收益高度集中:最好一段超过其余各段绝对收益之和,警惕样本外衰减"
2170
+ if concentrated else None)}
2171
+
2172
+
2173
+ def _segment_concentration(rows) -> bool:
2174
+ """最好一段的收益绝对值是否超过其余各段绝对值之和。"""
2175
+ rows_sorted = sorted(rows, key=lambda r: abs(r["return_pct"]), reverse=True)
2176
+ if len(rows_sorted) < 2:
2177
+ return False
2178
+ top = abs(rows_sorted[0]["return_pct"])
2179
+ rest = sum(abs(r["return_pct"]) for r in rows_sorted[1:])
2180
+ return top > 0 and top > rest
2181
+
2182
+
2183
+ @tool(
2184
+ "trade_deep_dive",
2185
+ "交易明细深挖:多空方向拆分、盈亏分布尾部、持仓时长对比、极值交易。"
2186
+ "输入[{pnl, direction?, hold_bars?}],direction为long/short,hold_bars为持仓K线数。",
2187
+ properties={
2188
+ "trades": {"type": "array",
2189
+ "description": "[{pnl必填, direction?: long|short, hold_bars?: >=0整数}](至少5笔)"},
2190
+ },
2191
+ required=["trades"],
2192
+ )
2193
+ def tool_trade_deep_dive(trades) -> dict:
2194
+ if not isinstance(trades, list) or not 5 <= len(trades) <= 100000:
2195
+ raise ToolError("trades: 需要5..100000笔")
2196
+ pnls, directions, holds = [], {}, []
2197
+ for i, t in enumerate(trades):
2198
+ if not isinstance(t, dict) or "pnl" not in t:
2199
+ raise ToolError(f"trades[{i}]: 必须包含 pnl")
2200
+ pnls.append(_number(t["pnl"], f"trades[{i}].pnl", -1e15, 1e15))
2201
+ if "direction" in t:
2202
+ d = t["direction"]
2203
+ if d not in ("long", "short"):
2204
+ raise ToolError(f"trades[{i}].direction 必须是 long 或 short")
2205
+ directions.setdefault(d, []).append(t["pnl"])
2206
+ if "hold_bars" in t:
2207
+ holds.append(_int(t["hold_bars"], f"trades[{i}].hold_bars", 0, 10**9))
2208
+ result = {"overall": _trade_stats_core(pnls)}
2209
+ if directions:
2210
+ split = {}
2211
+ for d in ("long", "short"):
2212
+ sub = directions.get(d)
2213
+ if not sub:
2214
+ split[d] = None
2215
+ continue
2216
+ wins = sum(1 for p in sub if p > 0)
2217
+ split[d] = {"count": len(sub),
2218
+ "win_rate_pct": _r(wins / len(sub) * 100),
2219
+ "total_pnl": _r(sum(sub)),
2220
+ "expectancy": _r(sum(sub) / len(sub))}
2221
+ result["by_direction"] = split
2222
+ sorted_p = sorted(pnls)
2223
+ p5, p95 = _percentile(sorted_p, 5), _percentile(sorted_p, 95)
2224
+ if p5 < 0 < p95:
2225
+ result["tail_ratio"] = _r(p95 / abs(p5), 4) # 盈利尾/亏损尾相对厚度
2226
+ result["largest_win"] = _r(max(pnls))
2227
+ result["largest_loss"] = _r(min(pnls))
2228
+ if holds:
2229
+ win_holds = [h for h, p in zip(holds, pnls) if p > 0]
2230
+ loss_holds = [h for h, p in zip(holds, pnls) if p < 0]
2231
+ result["holding"] = {
2232
+ "avg_win_hold_bars": _r(sum(win_holds) / len(win_holds), 1) if win_holds else None,
2233
+ "avg_loss_hold_bars": _r(sum(loss_holds) / len(loss_holds), 1) if loss_holds else None,
2234
+ "note": ("止损比止盈更慢(亏损单持仓更久),需检查止损纪律"
2235
+ if loss_holds and win_holds and
2236
+ sum(loss_holds) / len(loss_holds) > sum(win_holds) / len(win_holds)
2237
+ else None)}
2238
+ return result
2239
+
2240
+
2241
+ @tool(
2242
+ "signal_quality",
2243
+ "信号质量评估:信号(+1/0/-1)与随后实际收益对齐后,分信号统计命中率/平均收益/t统计量。"
2244
+ "调用方必须保证forward_returns无前视偏差。",
2245
+ properties={
2246
+ "signals": {"type": "array", "description": "信号序列,每项为-1/0/1"},
2247
+ "forward_returns": {"type": "array", "description": "信号发出后实现的收益(等长)"},
2248
+ },
2249
+ required=["signals", "forward_returns"],
2250
+ )
2251
+ def tool_signal_quality(signals, forward_returns) -> dict:
2252
+ if not isinstance(signals, list) or len(signals) < 10:
2253
+ raise ToolError("signals: 至少10个信号")
2254
+ rets = _series(forward_returns, "forward_returns", len(signals), len(signals))
2255
+ for i, s in enumerate(signals):
2256
+ if s not in (-1, 0, 1) or isinstance(s, bool):
2257
+ raise ToolError(f"signals[{i}]: 必须是-1/0/1")
2258
+ groups = {s: [] for s in (-1, 0, 1)}
2259
+ for s, r in zip(signals, rets):
2260
+ groups[s].append(r)
2261
+ rows = {}
2262
+ for s in (1, -1, 0):
2263
+ sub = groups[s]
2264
+ if not sub:
2265
+ rows[str(s)] = {"count": 0}
2266
+ continue
2267
+ n = len(sub)
2268
+ m = sum(sub) / n
2269
+ sd = _std(sub, ddof=1) if n > 1 else 0.0
2270
+ if s != 0:
2271
+ hits = sum(1 for r in sub if (r > 0) == (s > 0) and r != 0)
2272
+ hit_rate = hits / n * 100
2273
+ else:
2274
+ hit_rate = None
2275
+ rows[str(s)] = {"count": n, "avg_return_pct": _r(m * 100),
2276
+ "hit_rate_pct": _r(hit_rate) if hit_rate is not None else None,
2277
+ "total_return_pct": _r(sum(sub) * 100),
2278
+ "t_stat": _r(m / (sd / math.sqrt(n)), 3) if sd > 0 else None}
2279
+ spread = None
2280
+ if groups[1] and groups[-1]:
2281
+ spread = sum(groups[1]) / len(groups[1]) - sum(groups[-1]) / len(groups[-1])
2282
+ return {"signals": rows,
2283
+ "long_short_spread_pct": _r(spread * 100) if spread is not None else None,
2284
+ "note": "t_stat≈2以上才谈得上统计显著;调用方保证forward_returns无前视"}
2285
+
2286
+
2287
+ @tool(
2288
+ "factor_ic",
2289
+ "因子信息系数:因子值与前瞻收益的Spearman秩相关(IC),并按窗口分块计算ICIR与IC为正占比。"
2290
+ "因子挖掘的基础评估。",
2291
+ properties={
2292
+ "factor_values": {"type": "array", "description": "因子值序列(与前瞻收益等长)"},
2293
+ "forward_returns": {"type": "array", "description": "前瞻收益序列"},
2294
+ "window": {"type": "integer", "description": "分块窗口(计算ICIR),默认20"},
2295
+ },
2296
+ required=["factor_values", "forward_returns"],
2297
+ )
2298
+ def tool_factor_ic(factor_values, forward_returns, window: int = 20) -> dict:
2299
+ fac = _series(factor_values, "factor_values", 30)
2300
+ rets = _series(forward_returns, "forward_returns", len(fac), len(fac))
2301
+ window = _int(window, "window", 2, len(fac))
2302
+ ic = _pearson(_ranks(fac), _ranks(rets))
2303
+ blocks = []
2304
+ for start in range(0, len(fac) - window + 1, window):
2305
+ sf, sr = fac[start:start + window], rets[start:start + window]
2306
+ try:
2307
+ blocks.append(_pearson(_ranks(sf), _ranks(sr)))
2308
+ except ToolError:
2309
+ pass # 常数块跳过
2310
+ icir = None
2311
+ positive_share = None
2312
+ if len(blocks) >= 2:
2313
+ m = sum(blocks) / len(blocks)
2314
+ sd = _std(blocks, ddof=1)
2315
+ icir = _r(m / sd, 4) if sd > 0 else None
2316
+ positive_share = _r(sum(1 for b in blocks if b > 0) / len(blocks) * 100, 1)
2317
+ return {"ic": _r(ic, 4), "ic_blocks": len(blocks),
2318
+ "icir": icir, "positive_ic_pct": positive_share,
2319
+ "note": ("|IC|>0.03且ICIR>0.5才值得跟进" if icir is not None
2320
+ else "数据不足两个有效块,ICIR不可得")}
2321
+
2322
+
2323
+ @tool(
2324
+ "param_robustness",
2325
+ "参数高原稳健性:对一维参数扫描结果,检查最优参数邻域的表现是否同样好。"
2326
+ "'刀锋参数'(只有窄邻域有效)样本外大概率失效。",
2327
+ properties={
2328
+ "param_values": {"type": "array", "description": "参数值序列(数值,3..1000个)"},
2329
+ "scores": {"type": "array", "description": "对应绩效分数(如夏普),等长"},
2330
+ "plateau_pct": {"type": "number", "description": "邻域带宽占参数区间的比例,默认0.1"},
2331
+ },
2332
+ required=["param_values", "scores"],
2333
+ )
2334
+ def tool_param_robustness(param_values, scores, plateau_pct: float = 0.1) -> dict:
2335
+ params = _series(param_values, "param_values", 3, 1000)
2336
+ sc = _series(scores, "scores", len(params), len(params))
2337
+ plateau_pct = _number(plateau_pct, "plateau_pct", 0.01, 0.5)
2338
+ pairs = sorted(zip(params, sc))
2339
+ best_idx = max(range(len(pairs)), key=lambda i: pairs[i][1])
2340
+ best_param, best_score = pairs[best_idx]
2341
+ lo_param, hi_param = pairs[0][0], pairs[-1][0]
2342
+ rng = hi_param - lo_param
2343
+ if rng <= 0:
2344
+ raise ToolError("参数值全部相同,无法评估稳健性")
2345
+ band = [s for p, s in pairs if abs(p - best_param) <= plateau_pct * rng]
2346
+ plateau_mean = sum(band) / len(band)
2347
+ ratio = plateau_mean / best_score if best_score > 0 else None
2348
+ if best_score <= 0:
2349
+ verdict = "最优分数非正:该参数域整体不可用"
2350
+ elif len(band) < 3 or plateau_mean < 0.5 * best_score:
2351
+ verdict = "knife_edge刀锋参数:邻域表现远差于最优点,慎用"
2352
+ elif plateau_mean >= 0.8 * best_score:
2353
+ verdict = "robust参数高原:邻域表现接近最优,可信度较高"
2354
+ else:
2355
+ verdict = "moderate:邻域有一定衰减"
2356
+ spearman = _pearson(_ranks(params), _ranks(sc)) if len(pairs) >= 3 else None
2357
+ return {"best_param": _r(best_param), "best_score": _r(best_score),
2358
+ "band_points": len(band), "plateau_mean": _r(plateau_mean),
2359
+ "plateau_ratio": _r(ratio) if ratio is not None else None,
2360
+ "monotonic_spearman": _r(spearman, 4) if spearman is not None else None,
2361
+ "verdict": verdict}
2362
+
2363
+
2364
+ @tool(
2365
+ "regime_split",
2366
+ "分市场状态绩效:把收益序列按状态标签(趋势/震荡/高波动等)拆分,逐状态统计胜率、夏普与年化。"
2367
+ "回答'策略赚的是什么状态的钱'。",
2368
+ properties={
2369
+ "returns": {"type": "array", "description": "收益序列(至少10个)"},
2370
+ "regimes": {"type": "array", "description": "状态标签序列(等长,最多12种,字符串或数字)"},
2371
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
2372
+ },
2373
+ required=["returns", "regimes"],
2374
+ )
2375
+ def tool_regime_split(returns, regimes, periods_per_year: int = 252) -> dict:
2376
+ rets = _series(returns, "returns", 10)
2377
+ if not isinstance(regimes, list) or len(regimes) != len(rets):
2378
+ raise ToolError("regimes: 必须与returns等长")
2379
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
2380
+ labels = []
2381
+ for i, r in enumerate(regimes):
2382
+ if isinstance(r, bool) or not isinstance(r, (str, int, float)):
2383
+ raise ToolError(f"regimes[{i}]: 必须是字符串或数字")
2384
+ label = str(r).strip() if isinstance(r, str) else str(r)
2385
+ if not label or len(label) > 30:
2386
+ raise ToolError(f"regimes[{i}]: 标签长度非法")
2387
+ labels.append(label)
2388
+ distinct = set(labels)
2389
+ if len(distinct) > 12:
2390
+ raise ToolError("状态种类超过12种,请先合并状态")
2391
+ rows = []
2392
+ for label in sorted(distinct):
2393
+ sub = [r for r, l in zip(rets, labels) if l == label]
2394
+ n = len(sub)
2395
+ m = sum(sub) / n
2396
+ sd = _std(sub, ddof=1) if n > 1 else 0.0
2397
+ rows.append({"regime": label, "n": n, "share_pct": _r(n / len(rets) * 100),
2398
+ "mean_per_period_pct": _r(m * 100),
2399
+ "annualized_pct": _r(m * ppy * 100),
2400
+ "vol_annualized_pct": _r(sd * math.sqrt(ppy) * 100),
2401
+ "sharpe": _r(m / sd * math.sqrt(ppy), 3) if sd > 0 else None,
2402
+ "hit_rate_pct": _r(sum(1 for r in sub if r > 0) / n * 100)})
2403
+ rows.sort(key=lambda r: (r["sharpe"] is None, -(r["sharpe"] or 0)))
2404
+ return {"regimes": rows,
2405
+ "note": "算术年化(各期收益均值×期数),非复利;样本少的状态统计意义弱"}
2406
+
2407
+
2408
+ @tool(
2409
+ "greedy_portfolio_search",
2410
+ "贪心策略组合搜索:从候选策略池中逐步挑选使等权组合夏普提升最大的策略,直到无提升或达到上限。"
2411
+ "挖掘互补策略组合的快速启发式。",
2412
+ properties={
2413
+ "return_series": {"type": "array", "description": "等长收益率序列数组(2..15条,每条至少20点)"},
2414
+ "names": {"type": "array", "description": "可选策略名(与序列数一致)"},
2415
+ "max_k": {"type": "integer", "description": "最多选择的策略数,默认不设限"},
2416
+ "periods_per_year": {"type": "integer", "description": "每年期数,默认252"},
2417
+ },
2418
+ required=["return_series"],
2419
+ )
2420
+ def tool_greedy_search(return_series, names=None, max_k: int = None,
2421
+ periods_per_year: int = 252) -> dict:
2422
+ cols = _returns_columns(return_series, "return_series")
2423
+ k = len(cols)
2424
+ if names is not None:
2425
+ if not isinstance(names, list) or len(names) != k or \
2426
+ any(not isinstance(nm, str) or not nm.strip() for nm in names):
2427
+ raise ToolError("names 必须是与序列数一致的非空字符串数组")
2428
+ labels = names or [f"S{i+1}" for i in range(k)]
2429
+ ppy = _int(periods_per_year, "periods_per_year", 1, 10_000_000)
2430
+ max_k = k if max_k is None else _int(max_k, "max_k", 1, k)
2431
+ n = len(cols[0])
2432
+
2433
+ def combo_sharpe(idxs):
2434
+ combo = [sum(cols[i][t] for i in idxs) / len(idxs) for t in range(n)]
2435
+ m = sum(combo) / n
2436
+ sd = _std(combo, ddof=1)
2437
+ return (m / sd if sd > 0 else 0.0), m, sd
2438
+
2439
+ chosen = []
2440
+ remaining = list(range(k))
2441
+ steps = []
2442
+ best_sharpe = None
2443
+ while remaining and len(chosen) < max_k:
2444
+ cand_best = None
2445
+ for c in remaining:
2446
+ sharpe, _, _ = combo_sharpe(chosen + [c])
2447
+ if cand_best is None or sharpe > cand_best[1]:
2448
+ cand_best = (c, sharpe)
2449
+ c, sharpe = cand_best
2450
+ if best_sharpe is not None and sharpe <= best_sharpe + 1e-9:
2451
+ break
2452
+ chosen.append(c)
2453
+ remaining.remove(c)
2454
+ best_sharpe = sharpe
2455
+ steps.append({"added": labels[c], "combo_sharpe": _r(sharpe, 4)})
2456
+ if not chosen:
2457
+ raise ToolError("无法形成有效组合(所有候选夏普均不可得)")
2458
+ sharpe, mean, sd = combo_sharpe(chosen)
2459
+ return {"selected": [labels[i] for i in chosen],
2460
+ "weights": [_r(1.0 / len(chosen), 4)] * len(chosen),
2461
+ "steps": steps,
2462
+ "combo_sharpe": _r(sharpe, 4),
2463
+ "combo_annualized_pct": _r(mean * ppy * 100),
2464
+ "note": "贪心启发式不保证全局最优;等权假设,实际配置请配合portfolio_allocation"}