@dshtrading/strategies 0.1.4 → 0.1.6
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/builtin-tombstones-fs.d.ts +5 -0
- package/lib/builtin-tombstones-fs.js +75 -0
- package/lib/builtin-tombstones.d.ts +24 -0
- package/lib/builtin-tombstones.js +16 -0
- package/lib/custom-fs.js +15 -3
- package/lib/custom-screener-fs.d.ts +5 -0
- package/lib/custom-screener-fs.js +85 -0
- package/lib/custom-screener.d.ts +31 -0
- package/lib/custom-screener.js +16 -0
- package/lib/custom.d.ts +2 -1
- package/lib/index.d.ts +7 -2
- package/lib/index.js +8 -3
- package/lib/management.d.ts +46 -0
- package/lib/management.js +135 -0
- package/lib/paradigms/bollinger-reversion.js +46 -14
- package/lib/paradigms/donchian-breakout.js +12 -6
- package/lib/paradigms/ema-crossover.js +28 -15
- package/lib/paradigms/momentum-12m.js +22 -13
- package/lib/paradigms/rsi-reversion.js +22 -9
- package/lib/paradigms/sma-baseline.js +24 -15
- package/lib/plugin.d.ts +100 -6
- package/lib/plugin.js +370 -29
- package/lib/price-format.d.ts +16 -0
- package/lib/price-format.js +25 -0
- package/lib/screeners/above-ma.js +12 -7
- package/lib/screeners/ma-bull-align.js +14 -8
- package/lib/screeners/rsi-oversold.js +22 -6
- package/lib/screeners/volume-breakout.js +12 -7
- package/lib/validate-node.d.ts +10 -0
- package/lib/validate-node.js +19 -2
- package/lib/validate.d.ts +22 -1
- package/lib/validate.js +292 -76
- package/package.json +4 -3
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "./types.js";
|
|
2
|
+
import { ScreenerValidationResult, StrategyValidationResult } from "./validate.js";
|
|
3
|
+
import { Kline } from "@dshtrading/indicators";
|
|
4
|
+
//#region src/validate-node.d.ts
|
|
5
|
+
/** Node.js 宿主端策略校验器:自动启用 node:vm 超时熔断保护。 */
|
|
6
|
+
declare function validateCustomStrategyNode(raw: unknown): Promise<StrategyValidationResult>;
|
|
7
|
+
/** Node.js 宿主端选股器校验器:vm 熔断 runner 注入。 */
|
|
8
|
+
declare function validateCustomScreenerNode(raw: unknown): Promise<ScreenerValidationResult>;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { validateCustomScreenerNode, validateCustomStrategyNode };
|
package/lib/validate-node.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateCustomStrategy } from "./validate.js";
|
|
1
|
+
import { validateCustomScreener, validateCustomStrategy } from "./validate.js";
|
|
2
2
|
import * as vm from "node:vm";
|
|
3
3
|
//#region src/validate-node.ts
|
|
4
4
|
/**
|
|
@@ -36,5 +36,22 @@ const nodeStrategyComputeRunner = (computeSource, bars, params, timeoutMs = 100)
|
|
|
36
36
|
function validateCustomStrategyNode(raw) {
|
|
37
37
|
return validateCustomStrategy(raw, { runner: nodeStrategyComputeRunner });
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Node 宿主端选股器 evaluate 试算 runner(同一 vm 沙箱形态;返回值形状由
|
|
41
|
+
* validateCustomScreener 的 ScreenerMatch 校验把关,此处不约束类型)。
|
|
42
|
+
* 复用策略 vm runner,但超时文案归位为「选股器」(策略 runner 硬编码「策略试算」)。
|
|
43
|
+
*/
|
|
44
|
+
const nodeScreenerEvaluateRunner = (evaluateSource, bars, params, timeoutMs = 100) => {
|
|
45
|
+
try {
|
|
46
|
+
return Promise.resolve(nodeStrategyComputeRunner(evaluateSource, bars, params, timeoutMs));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error?.code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || String(error?.message).includes("timed out")) return Promise.reject(/* @__PURE__ */ new Error(`选股器试算执行超时(超过 ${timeoutMs}ms),可能存在死循环(如 while/for 未退出)`));
|
|
49
|
+
return Promise.reject(error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** Node.js 宿主端选股器校验器:vm 熔断 runner 注入。 */
|
|
53
|
+
function validateCustomScreenerNode(raw) {
|
|
54
|
+
return validateCustomScreener(raw, { runner: nodeScreenerEvaluateRunner });
|
|
55
|
+
}
|
|
39
56
|
//#endregion
|
|
40
|
-
export { nodeStrategyComputeRunner, validateCustomStrategyNode };
|
|
57
|
+
export { nodeScreenerEvaluateRunner, nodeStrategyComputeRunner, validateCustomScreenerNode, validateCustomStrategyNode };
|
package/lib/validate.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { StrategyDefinition, StrategySignal } from "./types.js";
|
|
2
2
|
import { CustomStrategyRecord } from "./custom.js";
|
|
3
|
+
import { ScreenerDefinition } from "./screeners/types.js";
|
|
4
|
+
import { CustomScreenerRecord } from "./custom-screener.js";
|
|
3
5
|
import { AsyncComputeRunner, Kline } from "@dshtrading/indicators";
|
|
4
6
|
//#region src/validate.d.ts
|
|
5
7
|
type StrategyValidationResult = {
|
|
@@ -24,5 +26,24 @@ declare function validateSignalSequence(signals: readonly unknown[], bars: reado
|
|
|
24
26
|
declare function validateCustomStrategy(raw: unknown, options?: {
|
|
25
27
|
runner?: AsyncComputeRunner;
|
|
26
28
|
}): Promise<StrategyValidationResult>;
|
|
29
|
+
type ScreenerValidationResult = {
|
|
30
|
+
ok: true;
|
|
31
|
+
definition: ScreenerDefinition;
|
|
32
|
+
record: CustomScreenerRecord;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
reason: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* 自定义选股器校验(异步——试算走可等待 runner,默认浏览器 Worker 超时熔断;
|
|
39
|
+
* Node 宿主侧传 validate-node.ts 的 vm 熔断 runner)。
|
|
40
|
+
*
|
|
41
|
+
* 与策略校验的差异:evaluate 是「单时点截面判断」——无信号序列语义,
|
|
42
|
+
* 返回 null(未命中/数据不足跳过)或 ScreenerMatch(metrics 键 ⊆ columns 声明、
|
|
43
|
+
* 数值有限、reason 非空)。内置选股器 id 可覆盖(不再保留名)。
|
|
44
|
+
*/
|
|
45
|
+
declare function validateCustomScreener(raw: unknown, options?: {
|
|
46
|
+
runner?: AsyncComputeRunner;
|
|
47
|
+
}): Promise<ScreenerValidationResult>;
|
|
27
48
|
//#endregion
|
|
28
|
-
export { StrategyValidationResult, compileStrategySource, validateCustomStrategy, validateSignalSequence };
|
|
49
|
+
export { ScreenerValidationResult, StrategyValidationResult, compileStrategySource, validateCustomScreener, validateCustomStrategy, validateSignalSequence };
|
package/lib/validate.js
CHANGED
|
@@ -23,15 +23,6 @@ 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",
|
|
@@ -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
|
-
|
|
114
|
-
if (
|
|
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.
|
|
4
|
+
"version": "0.1.6",
|
|
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/
|
|
24
|
+
"@dshtrading/dsh-home": "^0.1.6",
|
|
25
|
+
"@dshtrading/indicators": "0.1.6"
|
|
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.
|
|
35
|
+
"@dshtrading/api": "0.1.6"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
|
37
38
|
"build": "tsdown",
|