@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/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 { validateCustomStrategyNode } from "./validate-node.js";
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
- /** 默认存储路径:~/.dsh/strategies/custom.json。 */
27
+ /** 默认存储路径:$DSH_HOME/strategies/custom.json(缺省 ~/.dsh)。 */
23
28
  function defaultStorePath() {
24
- return path.join(os.homedir(), ".dsh", "strategies", "custom.json");
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\"); the 6 built-in paradigm ids are reserved"
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
- 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 — call strategy_backtest with this id to backtest it.`;
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
- /** 自定义或范式策略 → 回测用 StrategyDefinition(自定义 compute 经编译落定)。 */
102
- async function resolveStrategyDefinition(store, strategyId) {
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
- return {
113
- id: record.id,
114
- horizon: record.horizon,
115
- name: record.title,
116
- summary: record.summary,
117
- params,
118
- compute: compileStrategySource(record.computeSource)
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) 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)`);
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 result = run(bars, definition);
352
+ const requested = {};
353
+ const paramsOverride = {};
354
+ const paramsJson = typeof args.paramsJson === "string" && args.paramsJson.trim() ? args.paramsJson.trim() : void 0;
355
+ if (paramsJson !== void 0) {
356
+ let parsed;
357
+ try {
358
+ parsed = JSON.parse(paramsJson);
359
+ } catch {
360
+ throw new Error("strategy_backtest: paramsJson must be a JSON object string like {\"fast\":10,\"slow\":30}");
361
+ }
362
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("strategy_backtest: paramsJson must be a JSON object string like {\"fast\":10,\"slow\":30}");
363
+ const specs = new Map(definition.params.map((spec) => [spec.key, spec]));
364
+ for (const [key, value] of Object.entries(parsed)) {
365
+ const spec = specs.get(key);
366
+ if (spec === void 0) throw new Error(`strategy_backtest: unknown param ${JSON.stringify(key)} for strategy "${definition.id}" — valid keys: ` + (definition.params.length > 0 ? definition.params.map((item) => item.key).join(", ") : "(this strategy declares no params)"));
367
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`strategy_backtest: param ${JSON.stringify(key)} must be a finite number (got ${JSON.stringify(value)})`);
368
+ requested[key] = value;
369
+ paramsOverride[key] = Math.min(spec.max, Math.max(spec.min, value));
370
+ }
371
+ }
372
+ const effectiveParams = {};
373
+ for (const spec of definition.params) effectiveParams[spec.key] = paramsOverride[spec.key] ?? spec.default;
374
+ const result = run(bars, definition, paramsOverride);
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
- /** Host plugin body:注册 strategy_author / strategy_backtest(host 平面,全会话可见)。 */
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 authorTool = createStrategyAuthorTool({
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
- onWritten: () => eventsOf(ctx)?.emit("strategies")
204
- });
205
- if (tools.get(authorTool.name) === void 0) tools.register(authorTool);
206
- const backtestTool = createStrategyBacktestTool({
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
- if (tools.get(backtestTool.name) === void 0) tools.register(backtestTool);
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 };