@dshtrading/strategies 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/validate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createSampleBars, workerComputeRunner } from "@dshtrading/indicators";
1
+ import { compileComputeSource, createSampleBars, workerComputeRunner } from "@dshtrading/indicators";
2
2
  //#region src/validate.ts
3
3
  /**
4
4
  * 自定义策略安全校验器(issue #31 / P2)。浏览器端与宿主两侧共用(纯库,零 Node 依赖):
@@ -23,27 +23,18 @@ const MAX_SOURCE_LENGTH = 16384;
23
23
  const MAX_PARAMS_COUNT = 8;
24
24
  const MAX_SUMMARY_LENGTH = 120;
25
25
  const MAX_REASON_LENGTH = 200;
26
- /** 6 大范式 id 是系统保留名(custom_* 前缀或自定义名避免冲突)。 */
27
- const RESERVED_IDS = /* @__PURE__ */ new Set([
28
- "donchian-breakout",
29
- "rsi-reversion",
30
- "ema-crossover",
31
- "bollinger-reversion",
32
- "sma-baseline",
33
- "momentum-12m"
34
- ]);
35
26
  const HORIZONS = [
36
27
  "short",
37
28
  "swing",
38
29
  "long"
39
30
  ];
40
31
  const DEFAULT_TIMEOUT_MS = 100;
41
- /** 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。 */
42
- function compileStrategySource(source) {
43
- const trimmed = source.trim();
44
- if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) return new Function(`"use strict"; return (${trimmed});`)();
45
- return new Function("bars", "params", `"use strict";\n${trimmed}`);
46
- }
32
+ /**
33
+ * 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。
34
+ * 编译语义(形态嗅探 + new Function 双形态)的单一来源是 @dshtrading/indicators 的
35
+ * compileComputeSource——此处仅按策略信号类型收窄返回值(2026-09-09 收敛同体副本)。
36
+ */
37
+ const compileStrategySource = compileComputeSource;
47
38
  /**
48
39
  * 信号序列专用校验(引擎语义对齐:i 收盘确认、i+1 开盘成交可复算)。
49
40
  * 返回 undefined = 通过;否则返回人话诊断(模型与 UI 直接可读)。
@@ -74,6 +65,81 @@ function validateSignalSequence(signals, bars) {
74
65
  }
75
66
  }
76
67
  /**
68
+ * 参数规格数组解析(策略/选股器校验共用):JSON 解析 → StrategyParamSpec[],
69
+ * 保留合法正值 step(内置覆盖保存后参数步进不失真)。
70
+ */
71
+ function parseParamSpecs(paramsJson) {
72
+ if (paramsJson === void 0) return {
73
+ ok: true,
74
+ params: []
75
+ };
76
+ if (typeof paramsJson !== "string") return {
77
+ ok: false,
78
+ reason: "paramsJson 必须是字符串(StrategyParamSpec[] 的 JSON)"
79
+ };
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(paramsJson);
83
+ } catch (error) {
84
+ return {
85
+ ok: false,
86
+ reason: `paramsJson 不是合法 JSON: ${String(error?.message ?? error)}`
87
+ };
88
+ }
89
+ if (!Array.isArray(parsed)) return {
90
+ ok: false,
91
+ reason: "paramsJson 解析结果必须是数组(StrategyParamSpec[])"
92
+ };
93
+ if (parsed.length > MAX_PARAMS_COUNT) return {
94
+ ok: false,
95
+ reason: `策略参数数量超出上限(至多 ${MAX_PARAMS_COUNT} 个)`
96
+ };
97
+ const params = [];
98
+ for (let index = 0; index < parsed.length; index++) {
99
+ const p = parsed[index];
100
+ if (typeof p !== "object" || p === null) return {
101
+ ok: false,
102
+ reason: `paramsJson[${index}] 必须是一个对象`
103
+ };
104
+ const spec = p;
105
+ const key = typeof spec.key === "string" ? spec.key.trim() : "";
106
+ if (!PARAM_KEY_PATTERN.test(key)) return {
107
+ ok: false,
108
+ reason: `paramsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
109
+ };
110
+ const label = typeof spec.label === "string" ? spec.label.trim() : key;
111
+ const defVal = Number(spec.default);
112
+ const minVal = Number(spec.min);
113
+ const maxVal = Number(spec.max);
114
+ if (!Number.isFinite(defVal) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) return {
115
+ ok: false,
116
+ reason: `paramsJson[${index}] (${key}) 的 default、min、max 必须是有限数字`
117
+ };
118
+ if (minVal >= maxVal) return {
119
+ ok: false,
120
+ reason: `paramsJson[${index}] (${key}) 的 min (${minVal}) 必须严格小于 max (${maxVal})`
121
+ };
122
+ if (defVal < minVal || defVal > maxVal) return {
123
+ ok: false,
124
+ reason: `paramsJson[${index}] (${key}) 的 default (${defVal}) 必须在 [min, max] (${minVal}..${maxVal}) 范围内`
125
+ };
126
+ const stepVal = Number(spec.step);
127
+ const step = Number.isFinite(stepVal) && stepVal > 0 ? stepVal : 1;
128
+ params.push({
129
+ key,
130
+ label,
131
+ default: defVal,
132
+ min: minVal,
133
+ max: maxVal,
134
+ step
135
+ });
136
+ }
137
+ return {
138
+ ok: true,
139
+ params
140
+ };
141
+ }
142
+ /**
77
143
  * 结构校验(无试算)——browser/node 校验器共用。
78
144
  */
79
145
  function checkCustomStrategyStructure(raw) {
@@ -91,10 +157,6 @@ function checkCustomStrategyStructure(raw) {
91
157
  ok: false,
92
158
  reason: `策略 id "${id}" 不合法:必须由 2-32 位小写字母、数字、下划线或连字符组成`
93
159
  };
94
- if (RESERVED_IDS.has(id)) return {
95
- ok: false,
96
- reason: `策略 id "${id}" 是系统范式保留名称,请使用其他名称(如 custom_${id})`
97
- };
98
160
  const title = typeof input.title === "string" ? input.title.trim() : "";
99
161
  if (!title || title.length > 32) return {
100
162
  ok: false,
@@ -110,67 +172,8 @@ function checkCustomStrategyStructure(raw) {
110
172
  ok: false,
111
173
  reason: `策略 summary 必须是 1-${MAX_SUMMARY_LENGTH} 字符的非空字符串`
112
174
  };
113
- let params = [];
114
- if (input.paramsJson !== void 0) {
115
- if (typeof input.paramsJson !== "string") return {
116
- ok: false,
117
- reason: "策略 paramsJson 必须是字符串(StrategyParamSpec[] 的 JSON)"
118
- };
119
- let parsed;
120
- try {
121
- parsed = JSON.parse(input.paramsJson);
122
- } catch (error) {
123
- return {
124
- ok: false,
125
- reason: `paramsJson 不是合法 JSON: ${String(error?.message ?? error)}`
126
- };
127
- }
128
- if (!Array.isArray(parsed)) return {
129
- ok: false,
130
- reason: "paramsJson 解析结果必须是数组(StrategyParamSpec[])"
131
- };
132
- if (parsed.length > MAX_PARAMS_COUNT) return {
133
- ok: false,
134
- reason: `策略参数数量超出上限(至多 ${MAX_PARAMS_COUNT} 个)`
135
- };
136
- for (let index = 0; index < parsed.length; index++) {
137
- const p = parsed[index];
138
- if (typeof p !== "object" || p === null) return {
139
- ok: false,
140
- reason: `paramsJson[${index}] 必须是一个对象`
141
- };
142
- const spec = p;
143
- const key = typeof spec.key === "string" ? spec.key.trim() : "";
144
- if (!PARAM_KEY_PATTERN.test(key)) return {
145
- ok: false,
146
- reason: `paramsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
147
- };
148
- const label = typeof spec.label === "string" ? spec.label.trim() : key;
149
- const defVal = Number(spec.default);
150
- const minVal = Number(spec.min);
151
- const maxVal = Number(spec.max);
152
- if (!Number.isFinite(defVal) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) return {
153
- ok: false,
154
- reason: `paramsJson[${index}] (${key}) 的 default、min、max 必须是有限数字`
155
- };
156
- if (minVal >= maxVal) return {
157
- ok: false,
158
- reason: `paramsJson[${index}] (${key}) 的 min (${minVal}) 必须严格小于 max (${maxVal})`
159
- };
160
- if (defVal < minVal || defVal > maxVal) return {
161
- ok: false,
162
- reason: `paramsJson[${index}] (${key}) 的 default (${defVal}) 必须在 [min, max] (${minVal}..${maxVal}) 范围内`
163
- };
164
- params.push({
165
- key,
166
- label,
167
- default: defVal,
168
- min: minVal,
169
- max: maxVal,
170
- step: 1
171
- });
172
- }
173
- }
175
+ const params = parseParamSpecs(input.paramsJson);
176
+ if (!params.ok) return params;
174
177
  const computeSource = typeof input.computeSource === "string" ? input.computeSource.trim() : "";
175
178
  if (!computeSource) return {
176
179
  ok: false,
@@ -194,7 +197,7 @@ function checkCustomStrategyStructure(raw) {
194
197
  title,
195
198
  horizon,
196
199
  summary,
197
- params,
200
+ params: params.params,
198
201
  computeSource,
199
202
  input
200
203
  };
@@ -260,5 +263,218 @@ async function validateCustomStrategy(raw, options) {
260
263
  }
261
264
  };
262
265
  }
266
+ /** 选股器 id:强制 'scr.' 前缀与范式策略 id 空间隔离(内置 id 同形,可覆盖)。 */
267
+ const SCREENER_ID_PATTERN = /^scr\.[a-z0-9_][a-z0-9_-]{1,31}$/;
268
+ const MAX_COLUMNS_COUNT = 8;
269
+ /**
270
+ * 结构校验(无试算):id / title / summary / paramsJson / columnsJson / evaluateSource。
271
+ * evaluate 与 compute 同为 (bars, params) 纯函数,编译走同一 compileStrategySource。
272
+ */
273
+ function checkCustomScreenerStructure(raw) {
274
+ if (typeof raw !== "object" || raw === null) return {
275
+ ok: false,
276
+ reason: "选股器配置必须是一个非空对象"
277
+ };
278
+ const input = raw;
279
+ const id = typeof input.id === "string" ? input.id.trim().toLowerCase() : "";
280
+ if (!id) return {
281
+ ok: false,
282
+ reason: "缺少选股器 id"
283
+ };
284
+ if (!SCREENER_ID_PATTERN.test(id)) return {
285
+ ok: false,
286
+ reason: `选股器 id "${id}" 不合法:必须以 "scr." 开头,后接 2-32 位小写字母、数字、下划线或连字符`
287
+ };
288
+ const title = typeof input.title === "string" ? input.title.trim() : "";
289
+ if (!title || title.length > 32) return {
290
+ ok: false,
291
+ reason: "选股器 title 必须是 1-32 字符的非空字符串"
292
+ };
293
+ const summary = typeof input.summary === "string" ? input.summary.trim() : "";
294
+ if (!summary || summary.length > MAX_SUMMARY_LENGTH) return {
295
+ ok: false,
296
+ reason: `选股器 summary 必须是 1-${MAX_SUMMARY_LENGTH} 字符的非空字符串`
297
+ };
298
+ const params = parseParamSpecs(input.paramsJson);
299
+ if (!params.ok) return params;
300
+ let columns = [];
301
+ if (typeof input.columnsJson !== "string" || !input.columnsJson.trim()) return {
302
+ ok: false,
303
+ reason: "缺少 columnsJson(ScreenerColumnSpec[] 的 JSON,至少 1 列)"
304
+ };
305
+ let parsedColumns;
306
+ try {
307
+ parsedColumns = JSON.parse(input.columnsJson);
308
+ } catch (error) {
309
+ return {
310
+ ok: false,
311
+ reason: `columnsJson 不是合法 JSON: ${String(error?.message ?? error)}`
312
+ };
313
+ }
314
+ if (!Array.isArray(parsedColumns) || parsedColumns.length === 0) return {
315
+ ok: false,
316
+ reason: "columnsJson 解析结果必须是非空数组(ScreenerColumnSpec[])"
317
+ };
318
+ if (parsedColumns.length > MAX_COLUMNS_COUNT) return {
319
+ ok: false,
320
+ reason: `结果列数量超出上限(至多 ${MAX_COLUMNS_COUNT} 列)`
321
+ };
322
+ for (let index = 0; index < parsedColumns.length; index++) {
323
+ const c = parsedColumns[index];
324
+ if (typeof c !== "object" || c === null) return {
325
+ ok: false,
326
+ reason: `columnsJson[${index}] 必须是一个对象`
327
+ };
328
+ const spec = c;
329
+ const key = typeof spec.key === "string" ? spec.key.trim() : "";
330
+ if (!PARAM_KEY_PATTERN.test(key)) return {
331
+ ok: false,
332
+ reason: `columnsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
333
+ };
334
+ const label = typeof spec.label === "string" ? spec.label.trim() : key;
335
+ const format = spec.format === "percent" || spec.format === "number" ? spec.format : void 0;
336
+ columns.push(format === void 0 ? {
337
+ key,
338
+ label
339
+ } : {
340
+ key,
341
+ label,
342
+ format
343
+ });
344
+ }
345
+ if (new Set(columns.map((c) => c.key)).size !== columns.length) return {
346
+ ok: false,
347
+ reason: "columnsJson 存在重复的列 key"
348
+ };
349
+ const evaluateSource = typeof input.evaluateSource === "string" ? input.evaluateSource.trim() : "";
350
+ if (!evaluateSource) return {
351
+ ok: false,
352
+ reason: "缺少 evaluateSource 源码"
353
+ };
354
+ if (evaluateSource.length > MAX_SOURCE_LENGTH) return {
355
+ ok: false,
356
+ reason: `evaluateSource 源码长度 (${evaluateSource.length} B) 超出限制 (${MAX_SOURCE_LENGTH} B / 16KB)`
357
+ };
358
+ try {
359
+ compileStrategySource(evaluateSource);
360
+ } catch (error) {
361
+ return {
362
+ ok: false,
363
+ reason: `源码语法错误或无法编译: ${String(error?.message ?? error)}`
364
+ };
365
+ }
366
+ return {
367
+ ok: true,
368
+ id,
369
+ title,
370
+ summary,
371
+ params: params.params,
372
+ columns,
373
+ evaluateSource,
374
+ input
375
+ };
376
+ }
377
+ /** 单个命中结果校验(null = 未命中合法;其余必须符合 ScreenerMatch 形状)。 */
378
+ function checkScreenerMatch(value, columns, scenario) {
379
+ if (value === null || value === void 0) return void 0;
380
+ if (typeof value !== "object" || Array.isArray(value)) return `evaluate 返回值必须是 ScreenerMatch 对象或 null,${scenario} 场景实际为: ${typeof value}`;
381
+ const match = value;
382
+ if (typeof match.metrics !== "object" || match.metrics === null || Array.isArray(match.metrics)) return `ScreenerMatch.metrics 必须是 Record<string, number>(${scenario} 场景)`;
383
+ const columnKeys = new Set(columns.map((c) => c.key));
384
+ for (const [key, val] of Object.entries(match.metrics)) {
385
+ if (!columnKeys.has(key)) return `metrics 键 "${key}" 未在 columns 中声明(${scenario} 场景)——动态列必须先声明再产出`;
386
+ if (typeof val !== "number" || !Number.isFinite(val)) return `metrics["${key}"] 必须是有限数字(${scenario} 场景),收到: ${String(val)}`;
387
+ }
388
+ if (typeof match.reason !== "string" || !match.reason.trim()) return `ScreenerMatch.reason 必须是非空字符串(${scenario} 场景)`;
389
+ if (match.reason.length > MAX_REASON_LENGTH) return `reason 超长(${match.reason.length} > ${MAX_REASON_LENGTH},${scenario} 场景)`;
390
+ }
391
+ /**
392
+ * 自定义选股器校验(异步——试算走可等待 runner,默认浏览器 Worker 超时熔断;
393
+ * Node 宿主侧传 validate-node.ts 的 vm 熔断 runner)。
394
+ *
395
+ * 与策略校验的差异:evaluate 是「单时点截面判断」——无信号序列语义,
396
+ * 返回 null(未命中/数据不足跳过)或 ScreenerMatch(metrics 键 ⊆ columns 声明、
397
+ * 数值有限、reason 非空)。内置选股器 id 可覆盖(不再保留名)。
398
+ */
399
+ async function validateCustomScreener(raw, options) {
400
+ const checked = checkCustomScreenerStructure(raw);
401
+ if (!checked.ok) return checked;
402
+ const { id, title, summary, params, columns, evaluateSource, input } = checked;
403
+ const sampleScenarios = createSampleBars();
404
+ const scenarioNames = [
405
+ "uptrend",
406
+ "downtrend",
407
+ "flat",
408
+ "gap",
409
+ "short"
410
+ ];
411
+ const runner = options?.runner ?? workerComputeRunner;
412
+ const defaultParamsMap = {};
413
+ for (const p of params) defaultParamsMap[p.key] = p.default;
414
+ const longScenario = [];
415
+ let longPrice = 100;
416
+ for (let i = 0; i < 300; i++) {
417
+ const drift = i < 100 ? .6 : i < 200 ? -.5 : .55;
418
+ longPrice = Math.max(1, longPrice + drift);
419
+ longScenario.push({
420
+ openTime: 17e11 + i * 864e5,
421
+ open: longPrice - drift / 2,
422
+ high: longPrice + .4,
423
+ low: longPrice - .4,
424
+ close: longPrice,
425
+ volume: i % 7 === 5 ? 5e3 : 800
426
+ });
427
+ }
428
+ const scenarios = [...scenarioNames.map((name) => ({
429
+ name,
430
+ bars: sampleScenarios[name]
431
+ })), {
432
+ name: "long",
433
+ bars: longScenario
434
+ }];
435
+ let matchedScenario = false;
436
+ for (const { name: scenario, bars } of scenarios) {
437
+ let match;
438
+ try {
439
+ match = await runner(evaluateSource, bars, { ...defaultParamsMap }, DEFAULT_TIMEOUT_MS);
440
+ } catch (error) {
441
+ return {
442
+ ok: false,
443
+ reason: `在 ${scenario} 样例数据上试算执行报错: ${String(error?.message ?? error)}`
444
+ };
445
+ }
446
+ const matchReason = checkScreenerMatch(match, columns, scenario);
447
+ if (matchReason !== void 0) return {
448
+ ok: false,
449
+ reason: matchReason
450
+ };
451
+ if (match !== null && match !== void 0) matchedScenario = true;
452
+ }
453
+ if (!matchedScenario) return {
454
+ ok: false,
455
+ reason: "选股器在全部样例场景(上涨/下跌/震荡/跳空/短序列/300根长序列)上都返回 null——请检查入场条件(阈值方向、数据长度 guard),至少一个场景应命中"
456
+ };
457
+ return {
458
+ ok: true,
459
+ definition: {
460
+ id,
461
+ name: title,
462
+ summary,
463
+ params,
464
+ columns,
465
+ evaluate: compileStrategySource(evaluateSource)
466
+ },
467
+ record: {
468
+ id,
469
+ title,
470
+ horizon: "swing",
471
+ summary,
472
+ paramsJson: JSON.stringify(params),
473
+ columnsJson: JSON.stringify(columns),
474
+ evaluateSource,
475
+ createdAt: typeof input.createdAt === "number" ? input.createdAt : Date.now()
476
+ }
477
+ };
478
+ }
263
479
  //#endregion
264
- export { compileStrategySource, validateCustomStrategy, validateSignalSequence };
480
+ export { compileStrategySource, validateCustomScreener, validateCustomStrategy, validateSignalSequence };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/strategies",
3
3
  "description": "Trading strategies kernel for dsh-trading: pure math backtesting engine, strategy contracts, and 6 reference paradigms (short, swing, long). Pure library (zero runtime dependencies, browser-packable).",
4
- "version": "0.1.5",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,7 +21,8 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/indicators": "0.1.5"
24
+ "@dshtrading/indicators": "0.2.0",
25
+ "@dshtrading/dsh-home": "^0.2.0"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "@deepseek-ai/cordis": ">=4.0.0",
@@ -31,7 +32,7 @@
31
32
  "tsdown": "^0.22.0",
32
33
  "vitest": "^3.0.0",
33
34
  "typescript": "^5.5.0",
34
- "@dshtrading/api": "0.1.5"
35
+ "@dshtrading/api": "0.2.0"
35
36
  },
36
37
  "scripts": {
37
38
  "build": "tsdown",