@dshtrading/strategies 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/builtin-tombstones-fs.d.ts +5 -0
- package/lib/builtin-tombstones-fs.js +55 -0
- package/lib/builtin-tombstones.d.ts +24 -0
- package/lib/builtin-tombstones.js +16 -0
- package/lib/custom-fs.js +17 -24
- package/lib/custom-screener-fs.d.ts +5 -0
- package/lib/custom-screener-fs.js +66 -0
- package/lib/custom-screener.d.ts +31 -0
- package/lib/custom-screener.js +8 -0
- package/lib/custom.d.ts +3 -2
- package/lib/custom.js +3 -11
- package/lib/index.d.ts +7 -2
- package/lib/index.js +8 -3
- package/lib/management.d.ts +46 -0
- package/lib/management.js +135 -0
- package/lib/memory-store.js +15 -0
- package/lib/paradigms/bollinger-reversion.js +46 -14
- package/lib/paradigms/donchian-breakout.js +12 -6
- package/lib/paradigms/ema-crossover.js +28 -15
- package/lib/paradigms/momentum-12m.js +22 -13
- package/lib/paradigms/rsi-reversion.js +22 -9
- package/lib/paradigms/sma-baseline.js +24 -15
- package/lib/plugin.d.ts +163 -7
- package/lib/plugin.js +816 -32
- package/lib/price-format.d.ts +16 -0
- package/lib/price-format.js +25 -0
- package/lib/screeners/above-ma.js +12 -7
- package/lib/screeners/ma-bull-align.js +14 -8
- package/lib/screeners/rsi-oversold.js +22 -6
- package/lib/screeners/volume-breakout.js +12 -7
- package/lib/validate-node.d.ts +10 -0
- package/lib/validate-node.js +17 -31
- package/lib/validate.d.ts +28 -3
- package/lib/validate.js +299 -83
- package/package.json +4 -3
package/lib/plugin.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
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";
|
|
5
|
+
import { isBuiltinScreenerId, isBuiltinStrategyId } from "./management.js";
|
|
4
6
|
import "./index.js";
|
|
5
7
|
import { createFileCustomStrategyStore } from "./custom-fs.js";
|
|
6
|
-
import {
|
|
8
|
+
import { createFileBuiltinTombstonesStore } from "./builtin-tombstones-fs.js";
|
|
9
|
+
import { createFileCustomScreenerStore } from "./custom-screener-fs.js";
|
|
10
|
+
import { nodeScreenerEvaluateRunner, validateCustomScreenerNode, validateCustomStrategyNode } from "./validate-node.js";
|
|
11
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
7
12
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
-
import os from "node:os";
|
|
9
13
|
import path from "node:path";
|
|
14
|
+
import { dshHomeDir } from "@dshtrading/dsh-home";
|
|
10
15
|
//#region src/plugin.ts
|
|
11
16
|
/** Cordis 插件名 = patch 行 id(TEMPLATES §8),市场无关共享行命名空间。 */
|
|
12
17
|
const name = "dsh-trading-strategies";
|
|
@@ -19,10 +24,20 @@ const MARKET_SERVICE_KEYS = {
|
|
|
19
24
|
cn: "tradingCnMarketData",
|
|
20
25
|
hk: "tradingHkMarketData"
|
|
21
26
|
};
|
|
22
|
-
/**
|
|
27
|
+
/** 默认存储路径:$DSH_HOME/strategies/custom.json(缺省 ~/.dsh)。 */
|
|
23
28
|
function defaultStorePath() {
|
|
24
|
-
return path.join(
|
|
29
|
+
return path.join(dshHomeDir(), "strategies", "custom.json");
|
|
25
30
|
}
|
|
31
|
+
/** 默认墓碑路径:$DSH_HOME/strategies/builtin-tombstones.json(策略管理)。 */
|
|
32
|
+
function defaultTombstonesStorePath() {
|
|
33
|
+
return path.join(dshHomeDir(), "strategies", "builtin-tombstones.json");
|
|
34
|
+
}
|
|
35
|
+
/** 默认自定义选股器路径:$DSH_HOME/strategies/custom-screeners.json(选股器管理)。 */
|
|
36
|
+
function defaultScreenerStorePath() {
|
|
37
|
+
return path.join(dshHomeDir(), "strategies", "custom-screeners.json");
|
|
38
|
+
}
|
|
39
|
+
/** SDK 服务键:自定义策略 store 单实例(桥与工具共享同一缓存)。 */
|
|
40
|
+
const TRADING_STRATEGIES_KEY = "tradingStrategies";
|
|
26
41
|
function eventsOf(ctx) {
|
|
27
42
|
return ctx.get?.("tradingEvents", false);
|
|
28
43
|
}
|
|
@@ -36,15 +51,15 @@ function createMarketDataResolver(ctx) {
|
|
|
36
51
|
}
|
|
37
52
|
/** strategy_author 工厂(独立导出便于单测)。 */
|
|
38
53
|
function createStrategyAuthorTool(options) {
|
|
39
|
-
const { store, onWritten } = options;
|
|
54
|
+
const { store, tombstones, onWritten } = options;
|
|
40
55
|
return defineTool({
|
|
41
56
|
name: "strategy_author",
|
|
42
|
-
description: "Author, validate, and persist a custom trading strategy from JavaScript compute source. compute(bars, params) must return StrategySignal[] (entry/exit at bar close, filled at next bar open by the backtest engine). The validator runs sandbox trial calculations across multiple kline scenarios and replays the signal sequence for engine-replayability. If valid, the strategy is persisted and immediately available for backtesting and the strategy roster.",
|
|
57
|
+
description: "Author, validate, and persist a custom trading strategy from JavaScript compute source. compute(bars, params) must return StrategySignal[] (entry/exit at bar close, filled at next bar open by the backtest engine). The validator runs sandbox trial calculations across multiple kline scenarios and replays the signal sequence for engine-replayability. If valid, the strategy is persisted and immediately available for backtesting and the strategy roster. Submitting an id equal to a built-in paradigm id (donchian-breakout / rsi-reversion / ema-crossover / bollinger-reversion / sma-baseline / momentum-12m) OVERRIDES that built-in strategy (the factory default stays recoverable via strategy_reset).",
|
|
43
58
|
parameters: {
|
|
44
59
|
id: {
|
|
45
60
|
type: "string",
|
|
46
61
|
required: true,
|
|
47
|
-
description: "Unique strategy id (2-32 chars: lowercase letters/digits/underscore/hyphen, e.g. \"ema-stop-takeprofit\");
|
|
62
|
+
description: "Unique strategy id (2-32 chars: lowercase letters/digits/underscore/hyphen, e.g. \"ema-stop-takeprofit\"); a built-in paradigm id means overriding that built-in strategy"
|
|
48
63
|
},
|
|
49
64
|
title: {
|
|
50
65
|
type: "string",
|
|
@@ -91,15 +106,166 @@ function createStrategyAuthorTool(options) {
|
|
|
91
106
|
};
|
|
92
107
|
const result = await validateCustomStrategyNode(candidate);
|
|
93
108
|
if (!result.ok) return `[strategy_author] Validation failed: ${result.reason}\nReview the requirements: compute(bars, params) returns StrategySignal[]; each signal confirms at bar close (price === bars[index].close, time === bars[index].openTime), indices strictly increase, the sequence starts with entry and strictly alternates entry/exit (the engine fills at the next bar's open).`;
|
|
109
|
+
const overridesBuiltin = isBuiltinStrategyId(result.record.id);
|
|
110
|
+
if (overridesBuiltin) {
|
|
111
|
+
if (await store.get(result.record.id) !== void 0) await store.remove(result.record.id, true);
|
|
112
|
+
await tombstones?.remove(result.record.id);
|
|
113
|
+
}
|
|
94
114
|
await store.save(result.record);
|
|
95
115
|
onWritten?.(result.record);
|
|
96
116
|
const specSummary = result.definition.params.map((p) => `${p.key}=${p.default}`).join(", ");
|
|
97
|
-
|
|
117
|
+
const scopeNote = overridesBuiltin ? "This id is a built-in paradigm — the factory default remains recoverable via strategy_reset." : "Call strategy_backtest with this id to backtest it.";
|
|
118
|
+
return `[strategy_author] Successfully authored strategy "${result.record.title}" (id: ${result.record.id}, horizon: ${result.record.horizon}${specSummary ? `, params: ${specSummary}` : ""}). The strategy passed sandbox trials across 5 kline scenarios with engine-replayable signal sequences and is now persisted — ` + scopeNote;
|
|
119
|
+
}
|
|
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
|
+
});
|
|
98
254
|
}
|
|
99
255
|
});
|
|
100
256
|
}
|
|
101
|
-
/**
|
|
102
|
-
async function
|
|
257
|
+
/** 查询墓碑表(缺席或未命中 = 未删)。 */
|
|
258
|
+
async function isTombstoned(tombstones, id) {
|
|
259
|
+
if (tombstones === void 0) return false;
|
|
260
|
+
return (await tombstones.list()).includes(id);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 自定义或范式策略 → 回测用 StrategyDefinition(自定义 compute 经编译落定)。
|
|
264
|
+
* 覆盖 + 墓碑合成:store 同 id 记录优先(含内置覆盖);墓碑命中的 id 一律
|
|
265
|
+
* 视为不存在(含误留覆盖记录的边界——删除语义优先)。
|
|
266
|
+
*/
|
|
267
|
+
async function resolveStrategyDefinition(store, strategyId, options) {
|
|
268
|
+
if (await isTombstoned(options?.tombstones, strategyId)) return void 0;
|
|
103
269
|
const record = await store.get(strategyId);
|
|
104
270
|
if (record !== void 0) {
|
|
105
271
|
let params = [];
|
|
@@ -109,14 +275,19 @@ async function resolveStrategyDefinition(store, strategyId) {
|
|
|
109
275
|
} catch {
|
|
110
276
|
params = [];
|
|
111
277
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
278
|
+
try {
|
|
279
|
+
return {
|
|
280
|
+
id: record.id,
|
|
281
|
+
horizon: record.horizon,
|
|
282
|
+
name: record.title,
|
|
283
|
+
summary: record.summary,
|
|
284
|
+
params,
|
|
285
|
+
compute: compileStrategySource(record.computeSource)
|
|
286
|
+
};
|
|
287
|
+
} catch {
|
|
288
|
+
if (isBuiltinStrategyId(record.id)) return getStrategyById(record.id);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
120
291
|
}
|
|
121
292
|
return getStrategyById(strategyId);
|
|
122
293
|
}
|
|
@@ -124,7 +295,7 @@ async function resolveStrategyDefinition(store, strategyId) {
|
|
|
124
295
|
function createStrategyBacktestTool(deps) {
|
|
125
296
|
return defineTool({
|
|
126
297
|
name: "strategy_backtest",
|
|
127
|
-
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.",
|
|
128
299
|
parameters: {
|
|
129
300
|
strategyId: {
|
|
130
301
|
type: "string",
|
|
@@ -148,6 +319,10 @@ function createStrategyBacktestTool(deps) {
|
|
|
148
319
|
limit: {
|
|
149
320
|
type: "number",
|
|
150
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."
|
|
151
326
|
}
|
|
152
327
|
},
|
|
153
328
|
output: {
|
|
@@ -165,13 +340,38 @@ function createStrategyBacktestTool(deps) {
|
|
|
165
340
|
const interval = typeof args.interval === "string" && args.interval.trim() ? args.interval.trim() : "1d";
|
|
166
341
|
const limit = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0 ? Math.min(Math.floor(args.limit), 1e3) : 200;
|
|
167
342
|
if (!strategyId || !market || !symbol) throw new Error("strategy_backtest: strategyId, market and symbol are required");
|
|
168
|
-
const definition = await resolveStrategyDefinition(deps.store, strategyId);
|
|
169
|
-
if (definition === void 0)
|
|
343
|
+
const definition = await resolveStrategyDefinition(deps.store, strategyId, { tombstones: deps.tombstones });
|
|
344
|
+
if (definition === void 0) {
|
|
345
|
+
if (await isTombstoned(deps.tombstones, strategyId)) throw new Error(`strategy_backtest: strategy "${strategyId}" has been deleted by the user — call strategy_reset with this id to restore the factory default, or strategy_author to re-create it.`);
|
|
346
|
+
throw new Error(`strategy_backtest: unknown strategyId "${strategyId}" — author one with strategy_author first, or use a built-in paradigm id (donchian-breakout, rsi-reversion, ema-crossover, bollinger-reversion, sma-baseline, momentum-12m)`);
|
|
347
|
+
}
|
|
170
348
|
const service = deps.marketData(market);
|
|
171
349
|
if (service === void 0) throw new Error(`strategy_backtest: no market data service for market "${market}" — install/activate a market connector first`);
|
|
172
350
|
const bars = await service.getKlines(symbol, interval, limit);
|
|
173
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`);
|
|
174
|
-
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);
|
|
175
375
|
return JSON.stringify({
|
|
176
376
|
ok: true,
|
|
177
377
|
strategy: {
|
|
@@ -183,6 +383,10 @@ function createStrategyBacktestTool(deps) {
|
|
|
183
383
|
symbol,
|
|
184
384
|
interval,
|
|
185
385
|
barsTested: bars.length,
|
|
386
|
+
params: {
|
|
387
|
+
requested,
|
|
388
|
+
effective: effectiveParams
|
|
389
|
+
},
|
|
186
390
|
metrics: result.metrics,
|
|
187
391
|
trades: result.trades,
|
|
188
392
|
equity: result.equity,
|
|
@@ -192,23 +396,603 @@ function createStrategyBacktestTool(deps) {
|
|
|
192
396
|
}
|
|
193
397
|
});
|
|
194
398
|
}
|
|
195
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* strategy_delete 工厂(策略管理):自定义策略 = 移除记录;内置范式 = 落墓碑
|
|
401
|
+
* (出厂代码不动,strategy_reset 可恢复)。覆盖 + 墓碑模型见 management.ts。
|
|
402
|
+
*/
|
|
403
|
+
function createStrategyDeleteTool(options) {
|
|
404
|
+
const { store, tombstones, onDeleted } = options;
|
|
405
|
+
return defineTool({
|
|
406
|
+
name: "strategy_delete",
|
|
407
|
+
description: "Delete a strategy by id. Custom strategies (authored via strategy_author) are removed from the library; built-in paradigm ids (donchian-breakout / rsi-reversion / ema-crossover / bollinger-reversion / sma-baseline / momentum-12m) are tombstoned instead — the built-in disappears from the roster and backtest but stays recoverable via strategy_reset. Deleting a built-in also discards any user override of it. The GUI strategy roster refreshes live over the SSE channel.",
|
|
408
|
+
parameters: { id: {
|
|
409
|
+
type: "string",
|
|
410
|
+
required: true,
|
|
411
|
+
description: "Strategy id to delete (a custom id or a built-in paradigm id)"
|
|
412
|
+
} },
|
|
413
|
+
output: {
|
|
414
|
+
schema: { type: "string" },
|
|
415
|
+
render: (_args, value) => [{
|
|
416
|
+
type: "text",
|
|
417
|
+
text: value
|
|
418
|
+
}]
|
|
419
|
+
},
|
|
420
|
+
async execute(raw) {
|
|
421
|
+
const args = raw ?? {};
|
|
422
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
423
|
+
if (!id) throw new Error("strategy_delete: id is required");
|
|
424
|
+
if (isBuiltinStrategyId(id)) {
|
|
425
|
+
await tombstones?.add(id);
|
|
426
|
+
const discardedOverride = await store.remove(id, true);
|
|
427
|
+
onDeleted?.(id, "builtin", true);
|
|
428
|
+
return JSON.stringify({
|
|
429
|
+
ok: true,
|
|
430
|
+
scope: "builtin",
|
|
431
|
+
deleted: true,
|
|
432
|
+
discardedOverride,
|
|
433
|
+
note: `Built-in strategy "${id}" is now tombstoned — hidden from the roster and backtest. Call strategy_reset to restore the factory default.`
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const removed = await store.remove(id);
|
|
437
|
+
onDeleted?.(id, "custom", removed);
|
|
438
|
+
return JSON.stringify({
|
|
439
|
+
ok: true,
|
|
440
|
+
scope: "custom",
|
|
441
|
+
deleted: removed,
|
|
442
|
+
note: removed ? `Deleted custom strategy "${id}".` : `"${id}" was not found in the custom strategy library (built-in ids are handled by tombstone — double-check the id).`
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* strategy_reset 工厂(策略管理):恢复内置策略出厂默认——清覆盖记录与墓碑。
|
|
449
|
+
* 仅对内置范式 id 有意义;自定义策略无出厂版本,明确报错引导 strategy_delete。
|
|
450
|
+
*/
|
|
451
|
+
function createStrategyResetTool(options) {
|
|
452
|
+
const { store, tombstones, onReset } = options;
|
|
453
|
+
return defineTool({
|
|
454
|
+
name: "strategy_reset",
|
|
455
|
+
description: "Restore a built-in paradigm strategy (donchian-breakout / rsi-reversion / ema-crossover / bollinger-reversion / sma-baseline / momentum-12m) to its factory default: any user override is discarded and a deletion tombstone is lifted. Custom strategy ids have no factory default — use strategy_delete for those.",
|
|
456
|
+
parameters: { id: {
|
|
457
|
+
type: "string",
|
|
458
|
+
required: true,
|
|
459
|
+
description: "Built-in paradigm strategy id to restore"
|
|
460
|
+
} },
|
|
461
|
+
output: {
|
|
462
|
+
schema: { type: "string" },
|
|
463
|
+
render: (_args, value) => [{
|
|
464
|
+
type: "text",
|
|
465
|
+
text: value
|
|
466
|
+
}]
|
|
467
|
+
},
|
|
468
|
+
async execute(raw) {
|
|
469
|
+
const args = raw ?? {};
|
|
470
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
471
|
+
if (!id) throw new Error("strategy_reset: id is required");
|
|
472
|
+
if (!isBuiltinStrategyId(id)) throw new Error(`strategy_reset: "${id}" is not a built-in paradigm strategy id — custom strategies have no factory default; use strategy_delete to remove them`);
|
|
473
|
+
const removedOverride = await store.remove(id, true);
|
|
474
|
+
const liftedTombstone = await tombstones?.remove(id) ?? false;
|
|
475
|
+
const changed = removedOverride || liftedTombstone;
|
|
476
|
+
onReset?.(id, changed);
|
|
477
|
+
return JSON.stringify({
|
|
478
|
+
ok: true,
|
|
479
|
+
reset: true,
|
|
480
|
+
changed,
|
|
481
|
+
removedOverride,
|
|
482
|
+
liftedTombstone,
|
|
483
|
+
note: changed ? `Built-in strategy "${id}" restored to factory default (override/tombstone cleared).` : `"${id}" was already at factory default — nothing to restore.`
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* screener_author 工厂(选股器管理):提交 → 结构/语法/多场景试算校验(vm 熔断)
|
|
490
|
+
* → 落盘。evaluate(bars, params) 返回 ScreenerMatch | null(单时点截面判断,
|
|
491
|
+
* 无信号序列语义)。id 为内置选股器('scr.*')→ 覆盖该内置(strategy_reset/
|
|
492
|
+
* screener_reset 可恢复出厂)。
|
|
493
|
+
*/
|
|
494
|
+
function createScreenerAuthorTool(options) {
|
|
495
|
+
const { store, tombstones, onWritten } = options;
|
|
496
|
+
return defineTool({
|
|
497
|
+
name: "screener_author",
|
|
498
|
+
description: "Author, validate, and persist a custom stock screener from JavaScript evaluate source. evaluate(bars, params) is a single-point cross-section predicate: return a ScreenerMatch ({ metrics: Record<string, number> with keys declared in columns, reason }) or null when the symbol does not match / lacks data. The validator runs sandbox trial calculations across multiple kline scenarios. If valid, the screener is persisted and immediately available in the GUI screener roster. Submitting an id equal to a built-in screener id (scr.ma-bull-align / scr.volume-breakout / scr.rsi-oversold / scr.near-high / scr.above-ma) OVERRIDES that built-in screener (factory default stays recoverable via screener_reset).",
|
|
499
|
+
parameters: {
|
|
500
|
+
id: {
|
|
501
|
+
type: "string",
|
|
502
|
+
required: true,
|
|
503
|
+
description: "Unique screener id with \"scr.\" prefix (e.g. \"scr.custom-momentum\"); a built-in screener id means overriding that built-in"
|
|
504
|
+
},
|
|
505
|
+
title: {
|
|
506
|
+
type: "string",
|
|
507
|
+
required: true,
|
|
508
|
+
description: "Display name (1-32 chars), e.g. \"量价双确认\""
|
|
509
|
+
},
|
|
510
|
+
summary: {
|
|
511
|
+
type: "string",
|
|
512
|
+
required: true,
|
|
513
|
+
description: "One-sentence idea summary (≤120 chars), shown in the screener roster"
|
|
514
|
+
},
|
|
515
|
+
paramsJson: {
|
|
516
|
+
type: "string",
|
|
517
|
+
description: "JSON string of StrategyParamSpec[] (optional, default []). Each spec: { key, label, default, min, max } with numeric default/min/max and min < max."
|
|
518
|
+
},
|
|
519
|
+
columnsJson: {
|
|
520
|
+
type: "string",
|
|
521
|
+
required: true,
|
|
522
|
+
description: "JSON string of ScreenerColumnSpec[] (1-8 columns). Each spec: { key, label, format?: \"percent\" | \"number\" }. Match metrics keys must be declared here. JSON example: [{\"key\":\"volRatio\",\"label\":\"量比(倍)\"}]"
|
|
523
|
+
},
|
|
524
|
+
evaluateSource: {
|
|
525
|
+
type: "string",
|
|
526
|
+
required: true,
|
|
527
|
+
description: "JavaScript pure function source, signature (bars, params) => ScreenerMatch | null. bars has { openTime, open, high, low, close, volume }. Return null to skip the symbol (no match or insufficient data). metrics values must be finite numbers with keys declared in columnsJson; reason is a non-empty human-readable string."
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
output: {
|
|
531
|
+
schema: { type: "string" },
|
|
532
|
+
render: (_args, value) => [{
|
|
533
|
+
type: "text",
|
|
534
|
+
text: value
|
|
535
|
+
}]
|
|
536
|
+
},
|
|
537
|
+
async execute(raw) {
|
|
538
|
+
const args = raw ?? {};
|
|
539
|
+
const candidate = {
|
|
540
|
+
id: typeof args.id === "string" ? args.id : "",
|
|
541
|
+
title: typeof args.title === "string" ? args.title : "",
|
|
542
|
+
horizon: "swing",
|
|
543
|
+
summary: typeof args.summary === "string" ? args.summary : "",
|
|
544
|
+
paramsJson: typeof args.paramsJson === "string" && args.paramsJson.trim() ? args.paramsJson.trim() : "[]",
|
|
545
|
+
columnsJson: typeof args.columnsJson === "string" ? args.columnsJson : "",
|
|
546
|
+
evaluateSource: typeof args.evaluateSource === "string" ? args.evaluateSource : "",
|
|
547
|
+
createdAt: Date.now()
|
|
548
|
+
};
|
|
549
|
+
const result = await validateCustomScreenerNode(candidate);
|
|
550
|
+
if (!result.ok) return `[screener_author] Validation failed: ${result.reason}\nReview the requirements: evaluate(bars, params) returns ScreenerMatch | null; metrics keys must be declared in columnsJson with finite number values; reason must be a non-empty string; return null to skip a symbol.`;
|
|
551
|
+
const overridesBuiltin = isBuiltinScreenerId(result.record.id);
|
|
552
|
+
if (overridesBuiltin) {
|
|
553
|
+
if (await store.get(result.record.id) !== void 0) await store.remove(result.record.id, true);
|
|
554
|
+
await tombstones?.remove(result.record.id);
|
|
555
|
+
}
|
|
556
|
+
await store.save(result.record);
|
|
557
|
+
onWritten?.(result.record);
|
|
558
|
+
const columnKeys = result.definition.columns.map((c) => c.key).join(", ");
|
|
559
|
+
return `[screener_author] Successfully authored screener "${result.record.title}" (id: ${result.record.id}` + (columnKeys ? `, columns: ${columnKeys}` : "") + "). The screener passed sandbox trials across 5 kline scenarios and is now persisted to the GUI screener roster. " + (overridesBuiltin ? "This id is a built-in screener — the factory default remains recoverable via screener_reset." : "Run scans from the GUI screener pane.");
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* screener_delete 工厂(选股器管理):自定义选股器 = 移除记录;内置选股器 =
|
|
565
|
+
* 落墓碑(出厂代码不动,screener_reset 可恢复)。与 strategy_delete 共用墓碑表。
|
|
566
|
+
*/
|
|
567
|
+
function createScreenerDeleteTool(options) {
|
|
568
|
+
const { store, tombstones, onDeleted } = options;
|
|
569
|
+
return defineTool({
|
|
570
|
+
name: "screener_delete",
|
|
571
|
+
description: "Delete a screener by id. Custom screeners (authored via screener_author) are removed from the library; built-in screener ids (scr.ma-bull-align / scr.volume-breakout / scr.rsi-oversold / scr.near-high / scr.above-ma) are tombstoned instead — the built-in disappears from the GUI screener roster but stays recoverable via screener_reset. Deleting a built-in also discards any user override of it.",
|
|
572
|
+
parameters: { id: {
|
|
573
|
+
type: "string",
|
|
574
|
+
required: true,
|
|
575
|
+
description: "Screener id to delete (a custom id or a built-in screener id)"
|
|
576
|
+
} },
|
|
577
|
+
output: {
|
|
578
|
+
schema: { type: "string" },
|
|
579
|
+
render: (_args, value) => [{
|
|
580
|
+
type: "text",
|
|
581
|
+
text: value
|
|
582
|
+
}]
|
|
583
|
+
},
|
|
584
|
+
async execute(raw) {
|
|
585
|
+
const args = raw ?? {};
|
|
586
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
587
|
+
if (!id) throw new Error("screener_delete: id is required");
|
|
588
|
+
if (isBuiltinScreenerId(id)) {
|
|
589
|
+
await tombstones?.add(id);
|
|
590
|
+
const discardedOverride = await store.remove(id, true);
|
|
591
|
+
onDeleted?.(id, "builtin", true);
|
|
592
|
+
return JSON.stringify({
|
|
593
|
+
ok: true,
|
|
594
|
+
scope: "builtin",
|
|
595
|
+
deleted: true,
|
|
596
|
+
discardedOverride,
|
|
597
|
+
note: `Built-in screener "${id}" is now tombstoned — hidden from the GUI screener roster. Call screener_reset to restore the factory default.`
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const removed = await store.remove(id);
|
|
601
|
+
onDeleted?.(id, "custom", removed);
|
|
602
|
+
return JSON.stringify({
|
|
603
|
+
ok: true,
|
|
604
|
+
scope: "custom",
|
|
605
|
+
deleted: removed,
|
|
606
|
+
note: removed ? `Deleted custom screener "${id}".` : `"${id}" was not found in the custom screener library (built-in ids are handled by tombstone — double-check the id).`
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* screener_reset 工厂(选股器管理):恢复内置选股器出厂默认——清覆盖记录与墓碑。
|
|
613
|
+
* 仅对内置选股器 id 有意义;自定义选股器无出厂版本,明确报错引导 screener_delete。
|
|
614
|
+
*/
|
|
615
|
+
function createScreenerResetTool(options) {
|
|
616
|
+
const { store, tombstones, onReset } = options;
|
|
617
|
+
return defineTool({
|
|
618
|
+
name: "screener_reset",
|
|
619
|
+
description: "Restore a built-in screener (scr.ma-bull-align / scr.volume-breakout / scr.rsi-oversold / scr.near-high / scr.above-ma) to its factory default: any user override is discarded and a deletion tombstone is lifted. Custom screener ids have no factory default — use screener_delete for those.",
|
|
620
|
+
parameters: { id: {
|
|
621
|
+
type: "string",
|
|
622
|
+
required: true,
|
|
623
|
+
description: "Built-in screener id to restore"
|
|
624
|
+
} },
|
|
625
|
+
output: {
|
|
626
|
+
schema: { type: "string" },
|
|
627
|
+
render: (_args, value) => [{
|
|
628
|
+
type: "text",
|
|
629
|
+
text: value
|
|
630
|
+
}]
|
|
631
|
+
},
|
|
632
|
+
async execute(raw) {
|
|
633
|
+
const args = raw ?? {};
|
|
634
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
635
|
+
if (!id) throw new Error("screener_reset: id is required");
|
|
636
|
+
if (!isBuiltinScreenerId(id)) throw new Error(`screener_reset: "${id}" is not a built-in screener id — custom screeners have no factory default; use screener_delete to remove them`);
|
|
637
|
+
const removedOverride = await store.remove(id, true);
|
|
638
|
+
const liftedTombstone = await tombstones?.remove(id) ?? false;
|
|
639
|
+
const changed = removedOverride || liftedTombstone;
|
|
640
|
+
onReset?.(id, changed);
|
|
641
|
+
return JSON.stringify({
|
|
642
|
+
ok: true,
|
|
643
|
+
reset: true,
|
|
644
|
+
changed,
|
|
645
|
+
removedOverride,
|
|
646
|
+
liftedTombstone,
|
|
647
|
+
note: changed ? `Built-in screener "${id}" restored to factory default (override/tombstone cleared).` : `"${id}" was already at factory default — nothing to restore.`
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/** Host plugin body:provide store 服务 + 注册策略工具族(host 平面,全会话可见)。 */
|
|
196
653
|
function apply(ctx) {
|
|
197
654
|
const store = createFileCustomStrategyStore(defaultStorePath());
|
|
655
|
+
const tombstones = createFileBuiltinTombstonesStore(defaultTombstonesStorePath());
|
|
656
|
+
const screenerStore = createFileCustomScreenerStore(defaultScreenerStorePath());
|
|
657
|
+
new StrategiesStoreService(ctx, {
|
|
658
|
+
store,
|
|
659
|
+
tombstones,
|
|
660
|
+
screenerStore
|
|
661
|
+
});
|
|
198
662
|
ctx.inject(["tools"], (toolCtx) => {
|
|
199
663
|
const tools = toolCtx.tools;
|
|
200
664
|
if (!tools || typeof tools.register !== "function") return;
|
|
201
|
-
const
|
|
665
|
+
const events = () => eventsOf(ctx)?.emit("strategies");
|
|
666
|
+
const register = (tool) => {
|
|
667
|
+
if (tools.get(tool.name) === void 0) tools.register(tool);
|
|
668
|
+
};
|
|
669
|
+
register(createStrategyAuthorTool({
|
|
202
670
|
store,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
671
|
+
tombstones,
|
|
672
|
+
onWritten: () => events()
|
|
673
|
+
}));
|
|
674
|
+
register(createStrategyListTool({
|
|
207
675
|
store,
|
|
676
|
+
tombstones
|
|
677
|
+
}));
|
|
678
|
+
register(createStrategyBacktestTool({
|
|
679
|
+
store,
|
|
680
|
+
tombstones,
|
|
208
681
|
marketData: createMarketDataResolver(ctx)
|
|
209
|
-
});
|
|
210
|
-
|
|
682
|
+
}));
|
|
683
|
+
register(createStrategyDeleteTool({
|
|
684
|
+
store,
|
|
685
|
+
tombstones,
|
|
686
|
+
onDeleted: () => events()
|
|
687
|
+
}));
|
|
688
|
+
register(createStrategyResetTool({
|
|
689
|
+
store,
|
|
690
|
+
tombstones,
|
|
691
|
+
onReset: () => events()
|
|
692
|
+
}));
|
|
693
|
+
register(createScreenerAuthorTool({
|
|
694
|
+
store: screenerStore,
|
|
695
|
+
tombstones,
|
|
696
|
+
onWritten: () => events()
|
|
697
|
+
}));
|
|
698
|
+
register(createScreenerDeleteTool({
|
|
699
|
+
store: screenerStore,
|
|
700
|
+
tombstones,
|
|
701
|
+
onDeleted: () => events()
|
|
702
|
+
}));
|
|
703
|
+
register(createScreenerResetTool({
|
|
704
|
+
store: screenerStore,
|
|
705
|
+
tombstones,
|
|
706
|
+
onReset: () => events()
|
|
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
|
+
}));
|
|
211
721
|
});
|
|
212
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
|
+
}
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
/** 策略 store 服务(桥与工具的单实例共享点,issue #33 收口模式)。 */
|
|
984
|
+
var StrategiesStoreService = class extends Service {
|
|
985
|
+
store;
|
|
986
|
+
/** 内置墓碑表(策略/选股器管理共用;桥 DELETE/POST /strategies/* 与工具共享)。 */
|
|
987
|
+
tombstones;
|
|
988
|
+
/** 自定义选股器 store(选股器管理)。 */
|
|
989
|
+
screenerStore;
|
|
990
|
+
constructor(ctx, deps, serviceName = TRADING_STRATEGIES_KEY) {
|
|
991
|
+
super(ctx, serviceName);
|
|
992
|
+
this.store = deps.store;
|
|
993
|
+
this.tombstones = deps.tombstones;
|
|
994
|
+
this.screenerStore = deps.screenerStore;
|
|
995
|
+
}
|
|
996
|
+
};
|
|
213
997
|
//#endregion
|
|
214
|
-
export { apply, createFileCustomStrategyStore, createMarketDataResolver, createStrategyAuthorTool, createStrategyBacktestTool, defaultStorePath, inject, name, resolveStrategyDefinition };
|
|
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 };
|