@dshtrading/strategies 0.1.6 → 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.
@@ -1,5 +1,5 @@
1
- import { dirname } from "node:path";
2
- import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
1
+ import { writeJsonAtomic } from "@dshtrading/dsh-home";
2
+ import { readFile } from "node:fs/promises";
3
3
  //#region src/builtin-tombstones-fs.ts
4
4
  /**
5
5
  * 文件持久化版内置策略/选股器墓碑存储(Node.js 宿主侧使用,
@@ -28,27 +28,7 @@ function createFileBuiltinTombstonesStore(filePath) {
28
28
  }
29
29
  }
30
30
  async function flush(set) {
31
- const dir = dirname(filePath);
32
- const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
33
- const data = JSON.stringify({ deleted: [...set] }, null, 2);
34
- try {
35
- await mkdir(dir, { recursive: true });
36
- await writeFile(tmpPath, data, "utf8");
37
- let lastError;
38
- for (let attempt = 0; attempt < 3; attempt++) try {
39
- await rename(tmpPath, filePath);
40
- return;
41
- } catch (err) {
42
- if (err?.code !== "EPERM" && err?.code !== "EBUSY") throw err;
43
- lastError = err;
44
- await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
45
- }
46
- throw lastError;
47
- } catch (error) {
48
- console.error(`[dsh-trading/strategies] failed to atomic flush builtin tombstones to ${filePath}:`, error);
49
- await unlink(tmpPath).catch(() => {});
50
- throw error;
51
- }
31
+ await writeJsonAtomic(filePath, { deleted: [...set] }, "[dsh-trading/strategies] failed to atomic flush builtin tombstones to");
52
32
  }
53
33
  return {
54
34
  async list() {
package/lib/custom-fs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dirname } from "node:path";
2
- import { appendFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { writeJsonAtomic } from "@dshtrading/dsh-home";
3
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
3
4
  //#region src/custom-fs.ts
4
5
  /**
5
6
  * 文件持久化版自定义策略存储(Node.js 宿主侧使用,落 ~/.dsh/strategies/custom.json)。
@@ -28,27 +29,7 @@ function createFileCustomStrategyStore(filePath) {
28
29
  }
29
30
  }
30
31
  async function flush(map) {
31
- const dir = dirname(filePath);
32
- const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
33
- const data = JSON.stringify([...map.values()], null, 2);
34
- try {
35
- await mkdir(dir, { recursive: true });
36
- await writeFile(tmpPath, data, "utf8");
37
- let lastError;
38
- for (let attempt = 0; attempt < 3; attempt++) try {
39
- await rename(tmpPath, filePath);
40
- return;
41
- } catch (err) {
42
- if (err?.code !== "EPERM" && err?.code !== "EBUSY") throw err;
43
- lastError = err;
44
- await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
45
- }
46
- throw lastError;
47
- } catch (error) {
48
- console.error(`[dsh-trading/strategies] failed to atomic flush custom strategies to ${filePath}:`, error);
49
- await unlink(tmpPath).catch(() => {});
50
- throw error;
51
- }
32
+ await writeJsonAtomic(filePath, [...map.values()], "[dsh-trading/strategies] failed to atomic flush custom strategies to");
52
33
  }
53
34
  return {
54
35
  async list() {
@@ -1,5 +1,6 @@
1
1
  import { dirname } from "node:path";
2
- import { appendFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { writeJsonAtomic } from "@dshtrading/dsh-home";
3
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
3
4
  //#region src/custom-screener-fs.ts
4
5
  /**
5
6
  * 文件持久化版自定义选股器存储(Node.js 宿主侧使用,
@@ -29,27 +30,7 @@ function createFileCustomScreenerStore(filePath) {
29
30
  }
30
31
  }
31
32
  async function flush(map) {
32
- const dir = dirname(filePath);
33
- const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
34
- const data = JSON.stringify([...map.values()], null, 2);
35
- try {
36
- await mkdir(dir, { recursive: true });
37
- await writeFile(tmpPath, data, "utf8");
38
- let lastError;
39
- for (let attempt = 0; attempt < 3; attempt++) try {
40
- await rename(tmpPath, filePath);
41
- return;
42
- } catch (err) {
43
- if (err?.code !== "EPERM" && err?.code !== "EBUSY") throw err;
44
- lastError = err;
45
- await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
46
- }
47
- throw lastError;
48
- } catch (error) {
49
- console.error(`[dsh-trading/strategies] failed to atomic flush custom screeners to ${filePath}:`, error);
50
- await unlink(tmpPath).catch(() => {});
51
- throw error;
52
- }
33
+ await writeJsonAtomic(filePath, [...map.values()], "[dsh-trading/strategies] failed to atomic flush custom screeners to");
53
34
  }
54
35
  return {
55
36
  async list() {
@@ -25,7 +25,7 @@ interface CustomScreenerStore {
25
25
  /** archive = 删除前把记录归档(file 版落 .archive.jsonl;memory 版忽略)。 */
26
26
  remove(id: string, archive?: boolean): Promise<boolean>;
27
27
  }
28
- /** 内存版自定义选股器存储(纯浏览器与单测用)。 */
28
+ /** 内存版自定义选股器存储(纯浏览器与单测用);CRUD 机制见 memory-store.ts 单一实现。 */
29
29
  declare function createMemoryCustomScreenerStore(initial?: CustomScreenerRecord[]): CustomScreenerStore;
30
30
  //#endregion
31
31
  export { CustomScreenerRecord, CustomScreenerStore, createMemoryCustomScreenerStore };
@@ -1,16 +1,8 @@
1
+ import { createMemoryRecordStore } from "./memory-store.js";
1
2
  //#region src/custom-screener.ts
2
- /** 内存版自定义选股器存储(纯浏览器与单测用)。 */
3
+ /** 内存版自定义选股器存储(纯浏览器与单测用);CRUD 机制见 memory-store.ts 单一实现。 */
3
4
  function createMemoryCustomScreenerStore(initial = []) {
4
- const map = /* @__PURE__ */ new Map();
5
- for (const item of initial) map.set(item.id, item);
6
- return {
7
- list: async () => [...map.values()],
8
- get: async (id) => map.get(id),
9
- save: async (record) => {
10
- map.set(record.id, { ...record });
11
- },
12
- remove: async (id) => map.delete(id)
13
- };
5
+ return createMemoryRecordStore(initial);
14
6
  }
15
7
  //#endregion
16
8
  export { createMemoryCustomScreenerStore };
package/lib/custom.d.ts CHANGED
@@ -23,7 +23,7 @@ interface CustomStrategyStore {
23
23
  /** archive = 删除前把记录归档(file 版落 .archive.jsonl;memory 版忽略)。 */
24
24
  remove(id: string, archive?: boolean): Promise<boolean>;
25
25
  }
26
- /** 内存版自定义策略存储(纯浏览器与单测用)。 */
26
+ /** 内存版自定义策略存储(纯浏览器与单测用);CRUD 机制见 memory-store.ts 单一实现。 */
27
27
  declare function createMemoryCustomStrategyStore(initial?: CustomStrategyRecord[]): CustomStrategyStore;
28
28
  //#endregion
29
29
  export { CustomStrategyRecord, CustomStrategyStore, createMemoryCustomStrategyStore };
package/lib/custom.js CHANGED
@@ -1,16 +1,8 @@
1
+ import { createMemoryRecordStore } from "./memory-store.js";
1
2
  //#region src/custom.ts
2
- /** 内存版自定义策略存储(纯浏览器与单测用)。 */
3
+ /** 内存版自定义策略存储(纯浏览器与单测用);CRUD 机制见 memory-store.ts 单一实现。 */
3
4
  function createMemoryCustomStrategyStore(initial = []) {
4
- const map = /* @__PURE__ */ new Map();
5
- for (const item of initial) map.set(item.id, item);
6
- return {
7
- list: async () => [...map.values()],
8
- get: async (id) => map.get(id),
9
- save: async (record) => {
10
- map.set(record.id, { ...record });
11
- },
12
- remove: async (id) => map.delete(id)
13
- };
5
+ return createMemoryRecordStore(initial);
14
6
  }
15
7
  //#endregion
16
8
  export { createMemoryCustomStrategyStore };
@@ -0,0 +1,15 @@
1
+ //#region src/memory-store.ts
2
+ function createMemoryRecordStore(initial) {
3
+ const map = /* @__PURE__ */ new Map();
4
+ for (const item of initial) map.set(item.id, item);
5
+ return {
6
+ list: async () => [...map.values()],
7
+ get: async (id) => map.get(id),
8
+ save: async (record) => {
9
+ map.set(record.id, { ...record });
10
+ },
11
+ remove: async (id) => map.delete(id)
12
+ };
13
+ }
14
+ //#endregion
15
+ export { createMemoryRecordStore };
package/lib/plugin.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { StrategyDefinition } from "./types.js";
1
+ import { StrategyDefinition, StrategyParamSpec } from "./types.js";
2
2
  import { CustomStrategyRecord, CustomStrategyStore } from "./custom.js";
3
+ import { ScreenerColumnSpec, ScreenerDefinition } from "./screeners/types.js";
3
4
  import { CustomScreenerRecord, CustomScreenerStore } from "./custom-screener.js";
4
5
  import { isBuiltinScreenerId, isBuiltinStrategyId } from "./management.js";
5
6
  import { BuiltinTombstonesStore } from "./builtin-tombstones.js";
@@ -40,6 +41,24 @@ interface StrategyAuthorToolOptions {
40
41
  }
41
42
  /** strategy_author 工厂(独立导出便于单测)。 */
42
43
  declare function createStrategyAuthorTool(options: StrategyAuthorToolOptions): import("@deepseek-ai/dsh-tools").ToolDefinition;
44
+ interface StrategyListToolOptions {
45
+ store: CustomStrategyStore;
46
+ /** 可选:墓碑表(内置删除状态回显)。 */
47
+ tombstones?: BuiltinTombstonesStore;
48
+ }
49
+ /**
50
+ * strategy_list 工厂(issue #86 / G3):名册 + 覆盖 + 墓碑三态一次读全。
51
+ * 此前 strategy_delete / strategy_backtest 都要 id 而 agent 拿不到 id,
52
+ * 只能让用户口述或去读 ~/.dsh-trading/strategies/custom.json。
53
+ */
54
+ declare function createStrategyListTool(options: StrategyListToolOptions): import("@deepseek-ai/dsh-tools").ToolDefinition;
55
+ interface ScreenerListToolOptions {
56
+ store: CustomScreenerStore;
57
+ /** 可选:墓碑表(内置选股器删除状态回显)。 */
58
+ tombstones?: BuiltinTombstonesStore;
59
+ }
60
+ /** screener_list 工厂(issue #86 / G3):选股器名册三态一次读全。 */
61
+ declare function createScreenerListTool(options: ScreenerListToolOptions): import("@deepseek-ai/dsh-tools").ToolDefinition;
43
62
  interface StrategyBacktestToolOptions {
44
63
  store: CustomStrategyStore;
45
64
  /** 可选:墓碑表(内置删除后回测拒绝并引导恢复,策略管理语义)。 */
@@ -121,6 +140,49 @@ interface ScreenerResetToolOptions {
121
140
  declare function createScreenerResetTool(options: ScreenerResetToolOptions): import("@deepseek-ai/dsh-tools").ToolDefinition;
122
141
  /** Host plugin body:provide store 服务 + 注册策略工具族(host 平面,全会话可见)。 */
123
142
  declare function apply(ctx: Context): void;
143
+ /** 解析后的选股器:内置(可信代码)或自定义(源码经 vm 熔断 runner 执行)。 */
144
+ type ResolvedScreener = {
145
+ kind: 'builtin';
146
+ definition: ScreenerDefinition;
147
+ } | {
148
+ kind: 'custom';
149
+ id: string;
150
+ name: string;
151
+ summary: string;
152
+ params: StrategyParamSpec[];
153
+ columns: ScreenerColumnSpec[];
154
+ evaluateSource: string;
155
+ };
156
+ /**
157
+ * 选股器解析(覆盖 + 墓碑合成,与 resolveStrategyDefinition 同模型):
158
+ * 墓碑命中 → undefined;自定义记录优先(覆盖内置同 id);损坏记录回落内置
159
+ * 出厂定义,非内置损坏记录视为不存在(与名册语义一致)。
160
+ */
161
+ declare function resolveScreener(store: CustomScreenerStore, screenerId: string, options?: {
162
+ tombstones?: BuiltinTombstonesStore | undefined;
163
+ }): Promise<ResolvedScreener | undefined>;
164
+ interface ScreenerRunToolDeps {
165
+ store: CustomScreenerStore;
166
+ tombstones?: BuiltinTombstonesStore;
167
+ /** registry-first 解析(provider 标签 + 行情服务)。 */
168
+ active: (market: string) => {
169
+ provider: string;
170
+ service: MarketDataService;
171
+ } | undefined;
172
+ /** 老部署回退:市场键直读行情服务(无 registry 时)。 */
173
+ fallback?: (market: string) => MarketDataService | undefined;
174
+ /** 单次扫描总预算 ms(缺省 90s;测试注入小值/假时钟)。 */
175
+ budgetMs?: number;
176
+ /** 注入时钟(测试用;缺省 Date.now)。 */
177
+ now?: () => number;
178
+ }
179
+ /**
180
+ * screener_run 工厂(issue #86 / G4):把选股器从「只对 UI 可用」提升为 agent 可跑。
181
+ * 扫描调度 = 名册 → 截断扫描池 → 受限并发拉 500 根日 K → 纯函数 evaluate;
182
+ * 数据不足(evaluate 返回 null)静默跳过,单标的失败只计数不中断。
183
+ * 只读行情 + 本地计算,无交易语义(铁律 #3 不涉及)。
184
+ */
185
+ declare function createScreenerRunTool(deps: ScreenerRunToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
124
186
  /** 策略 store 服务(桥与工具的单实例共享点,issue #33 收口模式)。 */
125
187
  declare class StrategiesStoreService extends Service {
126
188
  readonly store: CustomStrategyStore;
@@ -135,4 +197,4 @@ declare class StrategiesStoreService extends Service {
135
197
  }, serviceName?: string);
136
198
  }
137
199
  //#endregion
138
- export { ScreenerAuthorToolOptions, ScreenerDeleteToolOptions, ScreenerResetToolOptions, StrategiesStoreService, StrategyAuthorToolOptions, StrategyBacktestToolDeps, StrategyBacktestToolOptions, StrategyDeleteToolOptions, StrategyMarketDataResolver, StrategyResetToolOptions, TRADING_STRATEGIES_KEY, TradingEventsPublisher, apply, createFileBuiltinTombstonesStore, createFileCustomScreenerStore, createFileCustomStrategyStore, createMarketDataResolver, createScreenerAuthorTool, createScreenerDeleteTool, createScreenerResetTool, createStrategyAuthorTool, createStrategyBacktestTool, createStrategyDeleteTool, createStrategyResetTool, defaultScreenerStorePath, defaultStorePath, defaultTombstonesStorePath, inject, isBuiltinScreenerId, isBuiltinStrategyId, name, resolveStrategyDefinition, validateCustomScreenerNode, validateCustomStrategyNode };
200
+ export { ResolvedScreener, ScreenerAuthorToolOptions, ScreenerDeleteToolOptions, ScreenerListToolOptions, ScreenerResetToolOptions, ScreenerRunToolDeps, StrategiesStoreService, StrategyAuthorToolOptions, StrategyBacktestToolDeps, StrategyBacktestToolOptions, StrategyDeleteToolOptions, StrategyListToolOptions, StrategyMarketDataResolver, StrategyResetToolOptions, TRADING_STRATEGIES_KEY, TradingEventsPublisher, apply, createFileBuiltinTombstonesStore, createFileCustomScreenerStore, createFileCustomStrategyStore, createMarketDataResolver, createScreenerAuthorTool, createScreenerDeleteTool, createScreenerListTool, createScreenerResetTool, createScreenerRunTool, createStrategyAuthorTool, createStrategyBacktestTool, createStrategyDeleteTool, createStrategyListTool, createStrategyResetTool, defaultScreenerStorePath, defaultStorePath, defaultTombstonesStorePath, inject, isBuiltinScreenerId, isBuiltinStrategyId, name, resolveScreener, resolveStrategyDefinition, validateCustomScreenerNode, validateCustomStrategyNode };
package/lib/plugin.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { run } from "./engine.js";
2
2
  import { compileStrategySource } from "./validate.js";
3
- import { getStrategyById } from "./paradigms/index.js";
3
+ import { getStrategyById, strategyParadigms } from "./paradigms/index.js";
4
+ import { getScreenerById, screenerParadigms } from "./screeners/index.js";
4
5
  import { isBuiltinScreenerId, isBuiltinStrategyId } from "./management.js";
5
6
  import "./index.js";
6
7
  import { createFileCustomStrategyStore } from "./custom-fs.js";
7
8
  import { createFileBuiltinTombstonesStore } from "./builtin-tombstones-fs.js";
8
9
  import { createFileCustomScreenerStore } from "./custom-screener-fs.js";
9
- import { validateCustomScreenerNode, validateCustomStrategyNode } from "./validate-node.js";
10
+ import { nodeScreenerEvaluateRunner, validateCustomScreenerNode, validateCustomStrategyNode } from "./validate-node.js";
10
11
  import { Service } from "@deepseek-ai/cordis";
11
12
  import { defineTool } from "@deepseek-ai/dsh-tools";
12
13
  import path from "node:path";
@@ -118,6 +119,141 @@ function createStrategyAuthorTool(options) {
118
119
  }
119
120
  });
120
121
  }
122
+ /**
123
+ * strategy_list 工厂(issue #86 / G3):名册 + 覆盖 + 墓碑三态一次读全。
124
+ * 此前 strategy_delete / strategy_backtest 都要 id 而 agent 拿不到 id,
125
+ * 只能让用户口述或去读 ~/.dsh-trading/strategies/custom.json。
126
+ */
127
+ function createStrategyListTool(options) {
128
+ return defineTool({
129
+ name: "strategy_list",
130
+ description: "List the user strategy roster: built-in paradigms (with their parameter keys/defaults), custom strategies authored via strategy_author, and tombstoned (deleted) built-in ids. Read-only. ALWAYS call this before strategy_backtest / strategy_delete / strategy_reset when you do not already have an id from this session, and before strategy_backtest with paramsJson so you know the declared parameter keys. A paradigm with overridden=true is a built-in currently replaced by a user record; deleted=true means the built-in is tombstoned and hidden from the roster until strategy_reset restores it.",
131
+ parameters: {},
132
+ output: {
133
+ schema: { type: "string" },
134
+ render: (_args, value) => [{
135
+ type: "text",
136
+ text: value
137
+ }]
138
+ },
139
+ async execute() {
140
+ const deleted = options.tombstones === void 0 ? [] : await options.tombstones.list();
141
+ const records = await options.store.list();
142
+ const overridden = new Set(records.filter((record) => isBuiltinStrategyId(record.id)).map((record) => record.id));
143
+ const paradigms = strategyParadigms.map((definition) => ({
144
+ id: definition.id,
145
+ name: definition.name,
146
+ horizon: definition.horizon,
147
+ summary: definition.summary,
148
+ params: definition.params.map((spec) => ({
149
+ key: spec.key,
150
+ label: spec.label,
151
+ default: spec.default,
152
+ min: spec.min,
153
+ max: spec.max
154
+ })),
155
+ overridden: overridden.has(definition.id),
156
+ deleted: deleted.includes(definition.id)
157
+ }));
158
+ const custom = records.filter((record) => !isBuiltinStrategyId(record.id)).map((record) => ({
159
+ id: record.id,
160
+ title: record.title,
161
+ horizon: record.horizon,
162
+ summary: record.summary,
163
+ createdAt: record.createdAt
164
+ }));
165
+ const deletedStrategies = deleted.filter((id) => isBuiltinStrategyId(id));
166
+ return JSON.stringify({
167
+ ok: true,
168
+ paradigms,
169
+ custom,
170
+ deleted: deletedStrategies
171
+ });
172
+ }
173
+ });
174
+ }
175
+ /** 选股器记录 → 名册条目(paramsJson/columnsJson 解析在读取边界,坏数据降级为空数组)。 */
176
+ function screenerRecordEntry(record) {
177
+ let params = [];
178
+ let columns = [];
179
+ try {
180
+ const parsed = JSON.parse(record.paramsJson);
181
+ if (Array.isArray(parsed)) params = parsed;
182
+ } catch {
183
+ params = [];
184
+ }
185
+ try {
186
+ const parsed = JSON.parse(record.columnsJson);
187
+ if (Array.isArray(parsed)) columns = parsed;
188
+ } catch {
189
+ columns = [];
190
+ }
191
+ return {
192
+ id: record.id,
193
+ title: record.title,
194
+ summary: record.summary,
195
+ params: params.map((spec) => ({
196
+ key: spec.key,
197
+ label: spec.label,
198
+ default: spec.default,
199
+ min: spec.min,
200
+ max: spec.max
201
+ })),
202
+ columns: columns.map((column) => ({
203
+ key: column.key,
204
+ label: column.label,
205
+ ...column.format !== void 0 ? { format: column.format } : {}
206
+ })),
207
+ createdAt: record.createdAt
208
+ };
209
+ }
210
+ /** screener_list 工厂(issue #86 / G3):选股器名册三态一次读全。 */
211
+ function createScreenerListTool(options) {
212
+ return defineTool({
213
+ name: "screener_list",
214
+ description: "List the screener roster: built-in screeners (with their parameter keys and result column keys), custom screeners authored via screener_author, and tombstoned (deleted) built-in screener ids. Read-only. ALWAYS call this before screener_run / screener_delete / screener_reset when you do not already have an id from this session. A screener with overridden=true is a built-in currently replaced by a user record; deleted=true means the built-in is tombstoned and hidden from the roster until screener_reset restores it.",
215
+ parameters: {},
216
+ output: {
217
+ schema: { type: "string" },
218
+ render: (_args, value) => [{
219
+ type: "text",
220
+ text: value
221
+ }]
222
+ },
223
+ async execute() {
224
+ const deleted = options.tombstones === void 0 ? [] : await options.tombstones.list();
225
+ const records = await options.store.list();
226
+ const overridden = new Set(records.filter((record) => isBuiltinScreenerId(record.id)).map((record) => record.id));
227
+ const paradigms = screenerParadigms.map((definition) => ({
228
+ id: definition.id,
229
+ name: definition.name,
230
+ summary: definition.summary,
231
+ params: definition.params.map((spec) => ({
232
+ key: spec.key,
233
+ label: spec.label,
234
+ default: spec.default,
235
+ min: spec.min,
236
+ max: spec.max
237
+ })),
238
+ columns: definition.columns.map((column) => ({
239
+ key: column.key,
240
+ label: column.label,
241
+ ...column.format !== void 0 ? { format: column.format } : {}
242
+ })),
243
+ overridden: overridden.has(definition.id),
244
+ deleted: deleted.includes(definition.id)
245
+ }));
246
+ const custom = records.filter((record) => !isBuiltinScreenerId(record.id)).map((record) => screenerRecordEntry(record));
247
+ const deletedScreeners = deleted.filter((id) => isBuiltinScreenerId(id));
248
+ return JSON.stringify({
249
+ ok: true,
250
+ paradigms,
251
+ custom,
252
+ deleted: deletedScreeners
253
+ });
254
+ }
255
+ });
256
+ }
121
257
  /** 查询墓碑表(缺席或未命中 = 未删)。 */
122
258
  async function isTombstoned(tombstones, id) {
123
259
  if (tombstones === void 0) return false;
@@ -159,7 +295,7 @@ async function resolveStrategyDefinition(store, strategyId, options) {
159
295
  function createStrategyBacktestTool(deps) {
160
296
  return defineTool({
161
297
  name: "strategy_backtest",
162
- description: "Backtest a strategy (custom authored via strategy_author, or a built-in paradigm like ema-crossover / donchian-breakout / rsi-reversion / bollinger-reversion / sma-baseline / momentum-12m) on a symbol and interval using the pure-function engine. Returns 8 metrics (totalReturn, cagr, maxDrawdown, sharpe, winRate, profitFactor, tradeCount, exposure), the trade list, and the equity curve. Signals confirm at bar close and fill at the next bar open with fee/slippage modeling; this is simulation only — it never places orders.",
298
+ description: "Backtest a strategy (custom authored via strategy_author, or a built-in paradigm like ema-crossover / donchian-breakout / rsi-reversion / bollinger-reversion / sma-baseline / momentum-12m) on a symbol and interval using the pure-function engine. Returns 8 metrics (totalReturn, cagr, maxDrawdown, sharpe, winRate, profitFactor, tradeCount, exposure), the trade list, and the equity curve. Signals confirm at bar close and fill at the next bar open with fee/slippage modeling; this is simulation only — it never places orders. Pass paramsJson to override declared parameters (values clamped to min/max) and read the echoed params.effective; call strategy_list first when you need an id or its parameter keys.",
163
299
  parameters: {
164
300
  strategyId: {
165
301
  type: "string",
@@ -183,6 +319,10 @@ function createStrategyBacktestTool(deps) {
183
319
  limit: {
184
320
  type: "number",
185
321
  description: "Kline count to backtest (default 200, capped by the provider)"
322
+ },
323
+ paramsJson: {
324
+ type: "string",
325
+ description: "Optional JSON object string overriding strategy parameters for this run, e.g. {\"fast\":10,\"slow\":30}. Keys must be declared by the strategy (see strategy_list); values are clamped to each param min/max and the effective values are echoed back in the result. Use this to reproduce a parameter set the user tuned in the GUI."
186
326
  }
187
327
  },
188
328
  output: {
@@ -209,7 +349,29 @@ function createStrategyBacktestTool(deps) {
209
349
  if (service === void 0) throw new Error(`strategy_backtest: no market data service for market "${market}" — install/activate a market connector first`);
210
350
  const bars = await service.getKlines(symbol, interval, limit);
211
351
  if (!Array.isArray(bars) || bars.length === 0) throw new Error(`strategy_backtest: no klines returned for ${symbol} (${market}, ${interval}) — check the symbol/interval vocabulary`);
212
- const result = run(bars, definition);
352
+ const requested = {};
353
+ const paramsOverride = {};
354
+ const paramsJson = typeof args.paramsJson === "string" && args.paramsJson.trim() ? args.paramsJson.trim() : void 0;
355
+ if (paramsJson !== void 0) {
356
+ let parsed;
357
+ try {
358
+ parsed = JSON.parse(paramsJson);
359
+ } catch {
360
+ throw new Error("strategy_backtest: paramsJson must be a JSON object string like {\"fast\":10,\"slow\":30}");
361
+ }
362
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("strategy_backtest: paramsJson must be a JSON object string like {\"fast\":10,\"slow\":30}");
363
+ const specs = new Map(definition.params.map((spec) => [spec.key, spec]));
364
+ for (const [key, value] of Object.entries(parsed)) {
365
+ const spec = specs.get(key);
366
+ if (spec === void 0) throw new Error(`strategy_backtest: unknown param ${JSON.stringify(key)} for strategy "${definition.id}" — valid keys: ` + (definition.params.length > 0 ? definition.params.map((item) => item.key).join(", ") : "(this strategy declares no params)"));
367
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`strategy_backtest: param ${JSON.stringify(key)} must be a finite number (got ${JSON.stringify(value)})`);
368
+ requested[key] = value;
369
+ paramsOverride[key] = Math.min(spec.max, Math.max(spec.min, value));
370
+ }
371
+ }
372
+ const effectiveParams = {};
373
+ for (const spec of definition.params) effectiveParams[spec.key] = paramsOverride[spec.key] ?? spec.default;
374
+ const result = run(bars, definition, paramsOverride);
213
375
  return JSON.stringify({
214
376
  ok: true,
215
377
  strategy: {
@@ -221,6 +383,10 @@ function createStrategyBacktestTool(deps) {
221
383
  symbol,
222
384
  interval,
223
385
  barsTested: bars.length,
386
+ params: {
387
+ requested,
388
+ effective: effectiveParams
389
+ },
224
390
  metrics: result.metrics,
225
391
  trades: result.trades,
226
392
  equity: result.equity,
@@ -505,6 +671,10 @@ function apply(ctx) {
505
671
  tombstones,
506
672
  onWritten: () => events()
507
673
  }));
674
+ register(createStrategyListTool({
675
+ store,
676
+ tombstones
677
+ }));
508
678
  register(createStrategyBacktestTool({
509
679
  store,
510
680
  tombstones,
@@ -535,6 +705,279 @@ function apply(ctx) {
535
705
  tombstones,
536
706
  onReset: () => events()
537
707
  }));
708
+ register(createScreenerListTool({
709
+ store: screenerStore,
710
+ tombstones
711
+ }));
712
+ const marketDataFallback = createMarketDataResolver(ctx);
713
+ register(createScreenerRunTool({
714
+ store: screenerStore,
715
+ tombstones,
716
+ active: (market) => {
717
+ return (ctx.get?.("tradingMarketDataRegistry", false))?.active(market);
718
+ },
719
+ fallback: marketDataFallback
720
+ }));
721
+ });
722
+ }
723
+ /** 扫描护栏(对齐 client-ui-strategies 视图层口径,显式回显给模型)。 */
724
+ const SCREENER_SCAN_CONCURRENCY = 5;
725
+ const SCREENER_KLINE_WINDOW = 500;
726
+ const SCREENER_DEFAULT_POOL = 100;
727
+ const SCREENER_MAX_POOL = 500;
728
+ const SCREENER_RESULT_LIMIT = 50;
729
+ const SCREENER_EVAL_TIMEOUT_MS = 1e3;
730
+ const SCREENER_FETCH_TIMEOUT_MS = 8e3;
731
+ /** 单次扫描总预算:超时后不再领取新标的(agent 工具无取消通道,用预算收敛成本)。 */
732
+ const SCREENER_TOTAL_BUDGET_MS = 9e4;
733
+ function parseScreenerSpecs(record) {
734
+ let params = [];
735
+ let columns = [];
736
+ try {
737
+ const parsed = JSON.parse(record.paramsJson);
738
+ if (Array.isArray(parsed)) params = parsed;
739
+ } catch {
740
+ params = [];
741
+ }
742
+ try {
743
+ const parsed = JSON.parse(record.columnsJson);
744
+ if (Array.isArray(parsed)) columns = parsed;
745
+ } catch {
746
+ columns = [];
747
+ }
748
+ return {
749
+ params,
750
+ columns
751
+ };
752
+ }
753
+ /**
754
+ * 选股器解析(覆盖 + 墓碑合成,与 resolveStrategyDefinition 同模型):
755
+ * 墓碑命中 → undefined;自定义记录优先(覆盖内置同 id);损坏记录回落内置
756
+ * 出厂定义,非内置损坏记录视为不存在(与名册语义一致)。
757
+ */
758
+ async function resolveScreener(store, screenerId, options) {
759
+ if (await isTombstoned(options?.tombstones, screenerId)) return void 0;
760
+ const record = await store.get(screenerId);
761
+ if (record !== void 0) try {
762
+ compileStrategySource(record.evaluateSource);
763
+ const { params, columns } = parseScreenerSpecs(record);
764
+ return {
765
+ kind: "custom",
766
+ id: record.id,
767
+ name: record.title,
768
+ summary: record.summary,
769
+ params,
770
+ columns,
771
+ evaluateSource: record.evaluateSource
772
+ };
773
+ } catch {
774
+ if (isBuiltinScreenerId(record.id)) {
775
+ const builtin = getScreenerById(record.id);
776
+ return builtin === void 0 ? void 0 : {
777
+ kind: "builtin",
778
+ definition: builtin
779
+ };
780
+ }
781
+ return;
782
+ }
783
+ const builtin = getScreenerById(screenerId);
784
+ return builtin === void 0 ? void 0 : {
785
+ kind: "builtin",
786
+ definition: builtin
787
+ };
788
+ }
789
+ /** 单次 await 的超时熔断(数据源挂死不能让一次工具调用无限期占用)。 */
790
+ async function withTimeout(promise, ms, label) {
791
+ let timer;
792
+ try {
793
+ return await Promise.race([promise, new Promise((_resolve, reject) => {
794
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${label} timed out after ${ms}ms`)), ms);
795
+ })]);
796
+ } finally {
797
+ if (timer !== void 0) clearTimeout(timer);
798
+ }
799
+ }
800
+ /** 解析 paramsJson(与 strategy_backtest 同口径:声明键校验 + clamp)。 */
801
+ function parseParamsOverride(paramsJson, specs, tool, ownerId) {
802
+ const requested = {};
803
+ const override = {};
804
+ if (paramsJson !== void 0) {
805
+ let parsed;
806
+ try {
807
+ parsed = JSON.parse(paramsJson);
808
+ } catch {
809
+ throw new Error(`${tool}: paramsJson must be a JSON object string like {"window":120}`);
810
+ }
811
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${tool}: paramsJson must be a JSON object string like {"window":120}`);
812
+ const byKey = new Map(specs.map((spec) => [spec.key, spec]));
813
+ for (const [key, value] of Object.entries(parsed)) {
814
+ const spec = byKey.get(key);
815
+ if (spec === void 0) throw new Error(`${tool}: unknown param ${JSON.stringify(key)} for "${ownerId}" — valid keys: ` + (specs.length > 0 ? specs.map((item) => item.key).join(", ") : "(no params declared)"));
816
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${tool}: param ${JSON.stringify(key)} must be a finite number (got ${JSON.stringify(value)})`);
817
+ requested[key] = value;
818
+ override[key] = Math.min(spec.max, Math.max(spec.min, value));
819
+ }
820
+ }
821
+ const effective = {};
822
+ for (const spec of specs) effective[spec.key] = override[spec.key] ?? spec.default;
823
+ return {
824
+ requested,
825
+ effective
826
+ };
827
+ }
828
+ /**
829
+ * screener_run 工厂(issue #86 / G4):把选股器从「只对 UI 可用」提升为 agent 可跑。
830
+ * 扫描调度 = 名册 → 截断扫描池 → 受限并发拉 500 根日 K → 纯函数 evaluate;
831
+ * 数据不足(evaluate 返回 null)静默跳过,单标的失败只计数不中断。
832
+ * 只读行情 + 本地计算,无交易语义(铁律 #3 不涉及)。
833
+ */
834
+ function createScreenerRunTool(deps) {
835
+ return defineTool({
836
+ name: "screener_run",
837
+ description: "Run one screener (built-in or custom, by id from screener_list) over a market and return the matched instruments with their metric columns and a one-line reason. Read-only cross-sectional scan: it fetches daily candles from the routed market data provider, evaluates a pure function per instrument, and never places orders or emits trading signals. Instruments whose window is too short are skipped silently (contract semantics); per-instrument fetch/evaluate failures are counted, never fatal. Cost guards are explicit in the result: scanPool (pool cap), scanned, failed, insufficient, resultLimit, truncated, budgetMs and deadlineExceeded (no cancellation channel exists — the scan stops taking new instruments once the time budget is spent). ALWAYS call screener_list first to get the id and its parameter/column keys.",
838
+ parameters: {
839
+ screenerId: {
840
+ type: "string",
841
+ required: true,
842
+ description: "Screener id from screener_list (built-in like scr.ma-bull-align, or a custom id)"
843
+ },
844
+ market: {
845
+ type: "string",
846
+ required: true,
847
+ description: "Market vocabulary: crypto | us | cn | hk"
848
+ },
849
+ limit: {
850
+ type: "number",
851
+ description: `Scan pool cap: how many instruments of the market roster to scan (default ${SCREENER_DEFAULT_POOL}, max ${SCREENER_MAX_POOL})`
852
+ },
853
+ paramsJson: {
854
+ type: "string",
855
+ description: "Optional JSON object string overriding screener parameters, e.g. {\"window\":120}; clamped to each param min/max and echoed in params.effective"
856
+ }
857
+ },
858
+ output: {
859
+ schema: { type: "string" },
860
+ render: (_args, value) => [{
861
+ type: "text",
862
+ text: value
863
+ }]
864
+ },
865
+ async execute(raw) {
866
+ const args = raw ?? {};
867
+ const screenerId = typeof args.screenerId === "string" ? args.screenerId.trim() : "";
868
+ const market = typeof args.market === "string" ? args.market.trim() : "";
869
+ if (!screenerId || !market) throw new Error("screener_run: screenerId and market are required");
870
+ const poolCap = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0 ? Math.min(SCREENER_MAX_POOL, Math.max(1, Math.floor(args.limit))) : SCREENER_DEFAULT_POOL;
871
+ const resolved = await resolveScreener(deps.store, screenerId, { tombstones: deps.tombstones });
872
+ if (resolved === void 0) {
873
+ if (await isTombstoned(deps.tombstones, screenerId)) throw new Error(`screener_run: screener "${screenerId}" has been deleted by the user — call screener_reset with this id to restore the built-in, or screener_author to re-create it.`);
874
+ throw new Error(`screener_run: unknown screenerId "${screenerId}" — call screener_list for the current ids, or author one with screener_author.`);
875
+ }
876
+ const active = deps.active(market);
877
+ const service = active?.service ?? deps.fallback?.(market);
878
+ if (service === void 0) throw new Error(`screener_run: no market data service for market "${market}" — market keys are lowercase slugs (crypto | us | cn | hk); if the key is right, install/activate a market connector for it first.`);
879
+ const provider = active?.provider ?? "unknown";
880
+ if (typeof service.listInstruments !== "function") return JSON.stringify({
881
+ ok: false,
882
+ code: "TRADING_NO_UNIVERSE",
883
+ market,
884
+ provider,
885
+ note: `market "${market}" provider "${provider}" exposes no instrument roster (listInstruments) — screener_run cannot build a scan pool. Scan a specific symbol instead with strategy_backtest, or pick a provider that lists instruments.`
886
+ });
887
+ const universe = await service.listInstruments();
888
+ if (!Array.isArray(universe) || universe.length === 0) return JSON.stringify({
889
+ ok: false,
890
+ code: "TRADING_NO_UNIVERSE",
891
+ market,
892
+ provider,
893
+ note: `market "${market}" provider "${provider}" returned an empty instrument roster — nothing to scan.`
894
+ });
895
+ const specs = resolved.kind === "builtin" ? resolved.definition.params : resolved.params;
896
+ const { requested, effective } = parseParamsOverride(typeof args.paramsJson === "string" && args.paramsJson.trim() ? args.paramsJson.trim() : void 0, specs, "screener_run", resolved.kind === "builtin" ? resolved.definition.id : resolved.id);
897
+ const pool = universe.slice(0, poolCap);
898
+ const matched = [];
899
+ const budgetMs = deps.budgetMs ?? SCREENER_TOTAL_BUDGET_MS;
900
+ const now = deps.now ?? (() => Date.now());
901
+ const deadline = now() + budgetMs;
902
+ let scanned = 0;
903
+ let failed = 0;
904
+ let insufficient = 0;
905
+ let cursor = 0;
906
+ let deadlineExceeded = false;
907
+ const evaluate = async (bars) => {
908
+ if (resolved.kind === "builtin") return resolved.definition.evaluate(bars, effective);
909
+ return withTimeout(nodeScreenerEvaluateRunner(resolved.evaluateSource, bars, effective, SCREENER_EVAL_TIMEOUT_MS), 1500, `screener_run evaluate(${resolved.id})`);
910
+ };
911
+ const worker = async () => {
912
+ while (cursor < pool.length) {
913
+ if (now() > deadline) {
914
+ deadlineExceeded = true;
915
+ return;
916
+ }
917
+ const instrument = pool[cursor];
918
+ cursor += 1;
919
+ if (instrument === void 0) return;
920
+ try {
921
+ const bars = await withTimeout(service.getKlines(instrument.symbol, "1d", SCREENER_KLINE_WINDOW), SCREENER_FETCH_TIMEOUT_MS, `screener_run getKlines(${instrument.symbol})`);
922
+ if (!Array.isArray(bars) || bars.length === 0) {
923
+ failed += 1;
924
+ continue;
925
+ }
926
+ const match = await evaluate(bars);
927
+ if (match === null || match === void 0) {
928
+ insufficient += 1;
929
+ continue;
930
+ }
931
+ if (typeof match !== "object" || typeof match.reason !== "string") {
932
+ failed += 1;
933
+ continue;
934
+ }
935
+ const hit = match;
936
+ matched.push({
937
+ symbol: instrument.symbol,
938
+ ...instrument.name !== void 0 ? { name: instrument.name } : {},
939
+ price: bars[bars.length - 1]?.close ?? null,
940
+ metrics: hit.metrics ?? {},
941
+ reason: hit.reason,
942
+ ...hit.reasonKey !== void 0 ? { reasonKey: hit.reasonKey } : {},
943
+ ...hit.reasonParams !== void 0 ? { reasonParams: hit.reasonParams } : {}
944
+ });
945
+ } catch {
946
+ failed += 1;
947
+ } finally {
948
+ scanned += 1;
949
+ }
950
+ }
951
+ };
952
+ await Promise.all(Array.from({ length: Math.min(SCREENER_SCAN_CONCURRENCY, pool.length) }, () => worker()));
953
+ const results = matched.slice(0, SCREENER_RESULT_LIMIT);
954
+ return JSON.stringify({
955
+ ok: true,
956
+ screenerId: resolved.kind === "builtin" ? resolved.definition.id : resolved.id,
957
+ screenerName: resolved.kind === "builtin" ? resolved.definition.name : resolved.name,
958
+ market,
959
+ provider,
960
+ interval: "1d",
961
+ klineWindow: SCREENER_KLINE_WINDOW,
962
+ universeSize: universe.length,
963
+ scanPool: pool.length,
964
+ scanned,
965
+ failed,
966
+ insufficient,
967
+ matched: matched.length,
968
+ returned: results.length,
969
+ resultLimit: SCREENER_RESULT_LIMIT,
970
+ truncated: matched.length > SCREENER_RESULT_LIMIT,
971
+ budgetMs,
972
+ deadlineExceeded,
973
+ params: {
974
+ requested,
975
+ effective
976
+ },
977
+ results,
978
+ note: `Read-only cross-sectional scan (no trading signals). failed = fetch/evaluate errors; insufficient = window too short for the screener (contract: skipped silently). Raise limit to widen the pool (max 500). The scan stops taking new instruments after budgetMs (${budgetMs}ms); deadlineExceeded=true means the pool was not fully scanned.`
979
+ });
980
+ }
538
981
  });
539
982
  }
540
983
  /** 策略 store 服务(桥与工具的单实例共享点,issue #33 收口模式)。 */
@@ -552,4 +995,4 @@ var StrategiesStoreService = class extends Service {
552
995
  }
553
996
  };
554
997
  //#endregion
555
- export { StrategiesStoreService, TRADING_STRATEGIES_KEY, apply, createFileBuiltinTombstonesStore, createFileCustomScreenerStore, createFileCustomStrategyStore, createMarketDataResolver, createScreenerAuthorTool, createScreenerDeleteTool, createScreenerResetTool, createStrategyAuthorTool, createStrategyBacktestTool, createStrategyDeleteTool, createStrategyResetTool, defaultScreenerStorePath, defaultStorePath, defaultTombstonesStorePath, inject, isBuiltinScreenerId, isBuiltinStrategyId, name, resolveStrategyDefinition, validateCustomScreenerNode, validateCustomStrategyNode };
998
+ export { StrategiesStoreService, TRADING_STRATEGIES_KEY, apply, createFileBuiltinTombstonesStore, createFileCustomScreenerStore, createFileCustomStrategyStore, createMarketDataResolver, createScreenerAuthorTool, createScreenerDeleteTool, createScreenerListTool, createScreenerResetTool, createScreenerRunTool, createStrategyAuthorTool, createStrategyBacktestTool, createStrategyDeleteTool, createStrategyListTool, createStrategyResetTool, defaultScreenerStorePath, defaultStorePath, defaultTombstonesStorePath, inject, isBuiltinScreenerId, isBuiltinStrategyId, name, resolveScreener, resolveStrategyDefinition, validateCustomScreenerNode, validateCustomStrategyNode };
@@ -1,37 +1,7 @@
1
1
  import { validateCustomScreener, validateCustomStrategy } from "./validate.js";
2
- import * as vm from "node:vm";
2
+ import { runSourceInVmSandbox } from "@dshtrading/indicators/tool";
3
3
  //#region src/validate-node.ts
4
- /**
5
- * Node.js 宿主端专用策略校验执行器(node:vm 100ms 超时熔断,仿 indicators
6
- * 的 validate-node.ts——issue #31 规格:Node 侧 vm 沙箱 + 超时熔断)。
7
- */
8
- const nodeStrategyComputeRunner = (computeSource, bars, params, timeoutMs = 100) => {
9
- const sandbox = {
10
- bars,
11
- params,
12
- result: null,
13
- Math,
14
- Array,
15
- Object,
16
- Number,
17
- String,
18
- Boolean,
19
- Date
20
- };
21
- const trimmed = computeSource.trim();
22
- let code;
23
- if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) code = `"use strict"; const fn = (${trimmed}); result = fn(bars, params);`;
24
- else code = `"use strict"; const fn = (function(bars, params) { ${trimmed} }); result = fn(bars, params);`;
25
- const script = new vm.Script(code);
26
- const context = vm.createContext(sandbox);
27
- try {
28
- script.runInContext(context, { timeout: timeoutMs });
29
- return sandbox.result;
30
- } catch (error) {
31
- if (error?.code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || String(error?.message).includes("timed out")) throw new Error(`策略试算执行超时(超过 ${timeoutMs}ms),可能存在死循环(如 while/for 未退出)`);
32
- throw error;
33
- }
34
- };
4
+ const nodeStrategyComputeRunner = (computeSource, bars, params, timeoutMs = 100) => runSourceInVmSandbox(computeSource, bars, params, timeoutMs, "策略试算");
35
5
  /** Node.js 宿主端策略校验器:自动启用 node:vm 超时熔断保护。 */
36
6
  function validateCustomStrategyNode(raw) {
37
7
  return validateCustomStrategy(raw, { runner: nodeStrategyComputeRunner });
@@ -39,13 +9,12 @@ function validateCustomStrategyNode(raw) {
39
9
  /**
40
10
  * Node 宿主端选股器 evaluate 试算 runner(同一 vm 沙箱形态;返回值形状由
41
11
  * validateCustomScreener 的 ScreenerMatch 校验把关,此处不约束类型)。
42
- * 复用策略 vm runner,但超时文案归位为「选股器」(策略 runner 硬编码「策略试算」)。
12
+ * Promise 形态:抛错转拒绝,超时文案经 label 归位为「选股器试算」。
43
13
  */
44
14
  const nodeScreenerEvaluateRunner = (evaluateSource, bars, params, timeoutMs = 100) => {
45
15
  try {
46
- return Promise.resolve(nodeStrategyComputeRunner(evaluateSource, bars, params, timeoutMs));
16
+ return Promise.resolve(runSourceInVmSandbox(evaluateSource, bars, params, timeoutMs, "选股器试算"));
47
17
  } 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
18
  return Promise.reject(error);
50
19
  }
51
20
  };
package/lib/validate.d.ts CHANGED
@@ -12,8 +12,12 @@ type StrategyValidationResult = {
12
12
  ok: false;
13
13
  reason: string;
14
14
  };
15
- /** 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。 */
16
- declare function compileStrategySource(source: string): (bars: readonly Kline[], params: Readonly<Record<string, number>>) => StrategySignal[];
15
+ /**
16
+ * 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。
17
+ * 编译语义(形态嗅探 + new Function 双形态)的单一来源是 @dshtrading/indicators 的
18
+ * compileComputeSource——此处仅按策略信号类型收窄返回值(2026-09-09 收敛同体副本)。
19
+ */
20
+ declare const compileStrategySource: (source: string) => (bars: readonly Kline[], params: Readonly<Record<string, number>>) => StrategySignal[];
17
21
  /**
18
22
  * 信号序列专用校验(引擎语义对齐:i 收盘确认、i+1 开盘成交可复算)。
19
23
  * 返回 undefined = 通过;否则返回人话诊断(模型与 UI 直接可读)。
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 依赖):
@@ -29,12 +29,12 @@ const HORIZONS = [
29
29
  "long"
30
30
  ];
31
31
  const DEFAULT_TIMEOUT_MS = 100;
32
- /** 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。 */
33
- function compileStrategySource(source) {
34
- const trimmed = source.trim();
35
- if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) return new Function(`"use strict"; return (${trimmed});`)();
36
- return new Function("bars", "params", `"use strict";\n${trimmed}`);
37
- }
32
+ /**
33
+ * 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。
34
+ * 编译语义(形态嗅探 + new Function 双形态)的单一来源是 @dshtrading/indicators 的
35
+ * compileComputeSource——此处仅按策略信号类型收窄返回值(2026-09-09 收敛同体副本)。
36
+ */
37
+ const compileStrategySource = compileComputeSource;
38
38
  /**
39
39
  * 信号序列专用校验(引擎语义对齐:i 收盘确认、i+1 开盘成交可复算)。
40
40
  * 返回 undefined = 通过;否则返回人话诊断(模型与 UI 直接可读)。
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.6",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,8 +21,8 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/dsh-home": "^0.1.6",
25
- "@dshtrading/indicators": "0.1.6"
24
+ "@dshtrading/indicators": "0.2.0",
25
+ "@dshtrading/dsh-home": "^0.2.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@deepseek-ai/cordis": ">=4.0.0",
@@ -32,7 +32,7 @@
32
32
  "tsdown": "^0.22.0",
33
33
  "vitest": "^3.0.0",
34
34
  "typescript": "^5.5.0",
35
- "@dshtrading/api": "0.1.6"
35
+ "@dshtrading/api": "0.2.0"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsdown",