@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.
- package/lib/builtin-tombstones-fs.js +3 -23
- package/lib/custom-fs.js +3 -22
- package/lib/custom-screener-fs.js +3 -22
- package/lib/custom-screener.d.ts +1 -1
- package/lib/custom-screener.js +3 -11
- package/lib/custom.d.ts +1 -1
- package/lib/custom.js +3 -11
- package/lib/memory-store.js +15 -0
- package/lib/plugin.d.ts +64 -2
- package/lib/plugin.js +448 -5
- package/lib/validate-node.js +4 -35
- package/lib/validate.d.ts +6 -2
- package/lib/validate.js +7 -7
- package/package.json +4 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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() {
|
package/lib/custom-screener.d.ts
CHANGED
|
@@ -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 };
|
package/lib/custom-screener.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|
package/lib/validate-node.js
CHANGED
|
@@ -1,37 +1,7 @@
|
|
|
1
1
|
import { validateCustomScreener, validateCustomStrategy } from "./validate.js";
|
|
2
|
-
import
|
|
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
|
-
*
|
|
12
|
+
* Promise 形态:抛错转拒绝,超时文案经 label 归位为「选股器试算」。
|
|
43
13
|
*/
|
|
44
14
|
const nodeScreenerEvaluateRunner = (evaluateSource, bars, params, timeoutMs = 100) => {
|
|
45
15
|
try {
|
|
46
|
-
return Promise.resolve(
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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/
|
|
25
|
-
"@dshtrading/
|
|
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.
|
|
35
|
+
"@dshtrading/api": "0.2.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsdown",
|