@dshtrading/strategies 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/plugin.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { run } from "./engine.js";
2
2
  import { compileStrategySource } from "./validate.js";
3
3
  import { getStrategyById } from "./paradigms/index.js";
4
+ import { isBuiltinScreenerId, isBuiltinStrategyId } from "./management.js";
4
5
  import "./index.js";
5
6
  import { createFileCustomStrategyStore } from "./custom-fs.js";
6
- import { validateCustomStrategyNode } from "./validate-node.js";
7
+ import { createFileBuiltinTombstonesStore } from "./builtin-tombstones-fs.js";
8
+ import { createFileCustomScreenerStore } from "./custom-screener-fs.js";
9
+ import { validateCustomScreenerNode, validateCustomStrategyNode } from "./validate-node.js";
10
+ import { Service } from "@deepseek-ai/cordis";
7
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
- import os from "node:os";
9
12
  import path from "node:path";
13
+ import { dshHomeDir } from "@dshtrading/dsh-home";
10
14
  //#region src/plugin.ts
11
15
  /** Cordis 插件名 = patch 行 id(TEMPLATES §8),市场无关共享行命名空间。 */
12
16
  const name = "dsh-trading-strategies";
@@ -19,10 +23,20 @@ const MARKET_SERVICE_KEYS = {
19
23
  cn: "tradingCnMarketData",
20
24
  hk: "tradingHkMarketData"
21
25
  };
22
- /** 默认存储路径:~/.dsh/strategies/custom.json。 */
26
+ /** 默认存储路径:$DSH_HOME/strategies/custom.json(缺省 ~/.dsh)。 */
23
27
  function defaultStorePath() {
24
- return path.join(os.homedir(), ".dsh", "strategies", "custom.json");
28
+ return path.join(dshHomeDir(), "strategies", "custom.json");
25
29
  }
30
+ /** 默认墓碑路径:$DSH_HOME/strategies/builtin-tombstones.json(策略管理)。 */
31
+ function defaultTombstonesStorePath() {
32
+ return path.join(dshHomeDir(), "strategies", "builtin-tombstones.json");
33
+ }
34
+ /** 默认自定义选股器路径:$DSH_HOME/strategies/custom-screeners.json(选股器管理)。 */
35
+ function defaultScreenerStorePath() {
36
+ return path.join(dshHomeDir(), "strategies", "custom-screeners.json");
37
+ }
38
+ /** SDK 服务键:自定义策略 store 单实例(桥与工具共享同一缓存)。 */
39
+ const TRADING_STRATEGIES_KEY = "tradingStrategies";
26
40
  function eventsOf(ctx) {
27
41
  return ctx.get?.("tradingEvents", false);
28
42
  }
@@ -36,15 +50,15 @@ function createMarketDataResolver(ctx) {
36
50
  }
37
51
  /** strategy_author 工厂(独立导出便于单测)。 */
38
52
  function createStrategyAuthorTool(options) {
39
- const { store, onWritten } = options;
53
+ const { store, tombstones, onWritten } = options;
40
54
  return defineTool({
41
55
  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.",
56
+ 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
57
  parameters: {
44
58
  id: {
45
59
  type: "string",
46
60
  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"
61
+ 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
62
  },
49
63
  title: {
50
64
  type: "string",
@@ -91,15 +105,31 @@ function createStrategyAuthorTool(options) {
91
105
  };
92
106
  const result = await validateCustomStrategyNode(candidate);
93
107
  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).`;
108
+ const overridesBuiltin = isBuiltinStrategyId(result.record.id);
109
+ if (overridesBuiltin) {
110
+ if (await store.get(result.record.id) !== void 0) await store.remove(result.record.id, true);
111
+ await tombstones?.remove(result.record.id);
112
+ }
94
113
  await store.save(result.record);
95
114
  onWritten?.(result.record);
96
115
  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.`;
116
+ 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.";
117
+ 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;
98
118
  }
99
119
  });
100
120
  }
101
- /** 自定义或范式策略 → 回测用 StrategyDefinition(自定义 compute 经编译落定)。 */
102
- async function resolveStrategyDefinition(store, strategyId) {
121
+ /** 查询墓碑表(缺席或未命中 = 未删)。 */
122
+ async function isTombstoned(tombstones, id) {
123
+ if (tombstones === void 0) return false;
124
+ return (await tombstones.list()).includes(id);
125
+ }
126
+ /**
127
+ * 自定义或范式策略 → 回测用 StrategyDefinition(自定义 compute 经编译落定)。
128
+ * 覆盖 + 墓碑合成:store 同 id 记录优先(含内置覆盖);墓碑命中的 id 一律
129
+ * 视为不存在(含误留覆盖记录的边界——删除语义优先)。
130
+ */
131
+ async function resolveStrategyDefinition(store, strategyId, options) {
132
+ if (await isTombstoned(options?.tombstones, strategyId)) return void 0;
103
133
  const record = await store.get(strategyId);
104
134
  if (record !== void 0) {
105
135
  let params = [];
@@ -109,14 +139,19 @@ async function resolveStrategyDefinition(store, strategyId) {
109
139
  } catch {
110
140
  params = [];
111
141
  }
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
- };
142
+ try {
143
+ return {
144
+ id: record.id,
145
+ horizon: record.horizon,
146
+ name: record.title,
147
+ summary: record.summary,
148
+ params,
149
+ compute: compileStrategySource(record.computeSource)
150
+ };
151
+ } catch {
152
+ if (isBuiltinStrategyId(record.id)) return getStrategyById(record.id);
153
+ return;
154
+ }
120
155
  }
121
156
  return getStrategyById(strategyId);
122
157
  }
@@ -165,8 +200,11 @@ function createStrategyBacktestTool(deps) {
165
200
  const interval = typeof args.interval === "string" && args.interval.trim() ? args.interval.trim() : "1d";
166
201
  const limit = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0 ? Math.min(Math.floor(args.limit), 1e3) : 200;
167
202
  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)`);
203
+ const definition = await resolveStrategyDefinition(deps.store, strategyId, { tombstones: deps.tombstones });
204
+ if (definition === void 0) {
205
+ 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.`);
206
+ 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)`);
207
+ }
170
208
  const service = deps.marketData(market);
171
209
  if (service === void 0) throw new Error(`strategy_backtest: no market data service for market "${market}" — install/activate a market connector first`);
172
210
  const bars = await service.getKlines(symbol, interval, limit);
@@ -192,23 +230,326 @@ function createStrategyBacktestTool(deps) {
192
230
  }
193
231
  });
194
232
  }
195
- /** Host plugin body:注册 strategy_author / strategy_backtest(host 平面,全会话可见)。 */
233
+ /**
234
+ * strategy_delete 工厂(策略管理):自定义策略 = 移除记录;内置范式 = 落墓碑
235
+ * (出厂代码不动,strategy_reset 可恢复)。覆盖 + 墓碑模型见 management.ts。
236
+ */
237
+ function createStrategyDeleteTool(options) {
238
+ const { store, tombstones, onDeleted } = options;
239
+ return defineTool({
240
+ name: "strategy_delete",
241
+ 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.",
242
+ parameters: { id: {
243
+ type: "string",
244
+ required: true,
245
+ description: "Strategy id to delete (a custom id or a built-in paradigm id)"
246
+ } },
247
+ output: {
248
+ schema: { type: "string" },
249
+ render: (_args, value) => [{
250
+ type: "text",
251
+ text: value
252
+ }]
253
+ },
254
+ async execute(raw) {
255
+ const args = raw ?? {};
256
+ const id = typeof args.id === "string" ? args.id.trim() : "";
257
+ if (!id) throw new Error("strategy_delete: id is required");
258
+ if (isBuiltinStrategyId(id)) {
259
+ await tombstones?.add(id);
260
+ const discardedOverride = await store.remove(id, true);
261
+ onDeleted?.(id, "builtin", true);
262
+ return JSON.stringify({
263
+ ok: true,
264
+ scope: "builtin",
265
+ deleted: true,
266
+ discardedOverride,
267
+ note: `Built-in strategy "${id}" is now tombstoned — hidden from the roster and backtest. Call strategy_reset to restore the factory default.`
268
+ });
269
+ }
270
+ const removed = await store.remove(id);
271
+ onDeleted?.(id, "custom", removed);
272
+ return JSON.stringify({
273
+ ok: true,
274
+ scope: "custom",
275
+ deleted: removed,
276
+ 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).`
277
+ });
278
+ }
279
+ });
280
+ }
281
+ /**
282
+ * strategy_reset 工厂(策略管理):恢复内置策略出厂默认——清覆盖记录与墓碑。
283
+ * 仅对内置范式 id 有意义;自定义策略无出厂版本,明确报错引导 strategy_delete。
284
+ */
285
+ function createStrategyResetTool(options) {
286
+ const { store, tombstones, onReset } = options;
287
+ return defineTool({
288
+ name: "strategy_reset",
289
+ 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.",
290
+ parameters: { id: {
291
+ type: "string",
292
+ required: true,
293
+ description: "Built-in paradigm strategy id to restore"
294
+ } },
295
+ output: {
296
+ schema: { type: "string" },
297
+ render: (_args, value) => [{
298
+ type: "text",
299
+ text: value
300
+ }]
301
+ },
302
+ async execute(raw) {
303
+ const args = raw ?? {};
304
+ const id = typeof args.id === "string" ? args.id.trim() : "";
305
+ if (!id) throw new Error("strategy_reset: id is required");
306
+ 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`);
307
+ const removedOverride = await store.remove(id, true);
308
+ const liftedTombstone = await tombstones?.remove(id) ?? false;
309
+ const changed = removedOverride || liftedTombstone;
310
+ onReset?.(id, changed);
311
+ return JSON.stringify({
312
+ ok: true,
313
+ reset: true,
314
+ changed,
315
+ removedOverride,
316
+ liftedTombstone,
317
+ note: changed ? `Built-in strategy "${id}" restored to factory default (override/tombstone cleared).` : `"${id}" was already at factory default — nothing to restore.`
318
+ });
319
+ }
320
+ });
321
+ }
322
+ /**
323
+ * screener_author 工厂(选股器管理):提交 → 结构/语法/多场景试算校验(vm 熔断)
324
+ * → 落盘。evaluate(bars, params) 返回 ScreenerMatch | null(单时点截面判断,
325
+ * 无信号序列语义)。id 为内置选股器('scr.*')→ 覆盖该内置(strategy_reset/
326
+ * screener_reset 可恢复出厂)。
327
+ */
328
+ function createScreenerAuthorTool(options) {
329
+ const { store, tombstones, onWritten } = options;
330
+ return defineTool({
331
+ name: "screener_author",
332
+ 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).",
333
+ parameters: {
334
+ id: {
335
+ type: "string",
336
+ required: true,
337
+ description: "Unique screener id with \"scr.\" prefix (e.g. \"scr.custom-momentum\"); a built-in screener id means overriding that built-in"
338
+ },
339
+ title: {
340
+ type: "string",
341
+ required: true,
342
+ description: "Display name (1-32 chars), e.g. \"量价双确认\""
343
+ },
344
+ summary: {
345
+ type: "string",
346
+ required: true,
347
+ description: "One-sentence idea summary (≤120 chars), shown in the screener roster"
348
+ },
349
+ paramsJson: {
350
+ type: "string",
351
+ description: "JSON string of StrategyParamSpec[] (optional, default []). Each spec: { key, label, default, min, max } with numeric default/min/max and min < max."
352
+ },
353
+ columnsJson: {
354
+ type: "string",
355
+ required: true,
356
+ 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\":\"量比(倍)\"}]"
357
+ },
358
+ evaluateSource: {
359
+ type: "string",
360
+ required: true,
361
+ 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."
362
+ }
363
+ },
364
+ output: {
365
+ schema: { type: "string" },
366
+ render: (_args, value) => [{
367
+ type: "text",
368
+ text: value
369
+ }]
370
+ },
371
+ async execute(raw) {
372
+ const args = raw ?? {};
373
+ const candidate = {
374
+ id: typeof args.id === "string" ? args.id : "",
375
+ title: typeof args.title === "string" ? args.title : "",
376
+ horizon: "swing",
377
+ summary: typeof args.summary === "string" ? args.summary : "",
378
+ paramsJson: typeof args.paramsJson === "string" && args.paramsJson.trim() ? args.paramsJson.trim() : "[]",
379
+ columnsJson: typeof args.columnsJson === "string" ? args.columnsJson : "",
380
+ evaluateSource: typeof args.evaluateSource === "string" ? args.evaluateSource : "",
381
+ createdAt: Date.now()
382
+ };
383
+ const result = await validateCustomScreenerNode(candidate);
384
+ 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.`;
385
+ const overridesBuiltin = isBuiltinScreenerId(result.record.id);
386
+ if (overridesBuiltin) {
387
+ if (await store.get(result.record.id) !== void 0) await store.remove(result.record.id, true);
388
+ await tombstones?.remove(result.record.id);
389
+ }
390
+ await store.save(result.record);
391
+ onWritten?.(result.record);
392
+ const columnKeys = result.definition.columns.map((c) => c.key).join(", ");
393
+ 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.");
394
+ }
395
+ });
396
+ }
397
+ /**
398
+ * screener_delete 工厂(选股器管理):自定义选股器 = 移除记录;内置选股器 =
399
+ * 落墓碑(出厂代码不动,screener_reset 可恢复)。与 strategy_delete 共用墓碑表。
400
+ */
401
+ function createScreenerDeleteTool(options) {
402
+ const { store, tombstones, onDeleted } = options;
403
+ return defineTool({
404
+ name: "screener_delete",
405
+ 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.",
406
+ parameters: { id: {
407
+ type: "string",
408
+ required: true,
409
+ description: "Screener id to delete (a custom id or a built-in screener id)"
410
+ } },
411
+ output: {
412
+ schema: { type: "string" },
413
+ render: (_args, value) => [{
414
+ type: "text",
415
+ text: value
416
+ }]
417
+ },
418
+ async execute(raw) {
419
+ const args = raw ?? {};
420
+ const id = typeof args.id === "string" ? args.id.trim() : "";
421
+ if (!id) throw new Error("screener_delete: id is required");
422
+ if (isBuiltinScreenerId(id)) {
423
+ await tombstones?.add(id);
424
+ const discardedOverride = await store.remove(id, true);
425
+ onDeleted?.(id, "builtin", true);
426
+ return JSON.stringify({
427
+ ok: true,
428
+ scope: "builtin",
429
+ deleted: true,
430
+ discardedOverride,
431
+ note: `Built-in screener "${id}" is now tombstoned — hidden from the GUI screener roster. Call screener_reset to restore the factory default.`
432
+ });
433
+ }
434
+ const removed = await store.remove(id);
435
+ onDeleted?.(id, "custom", removed);
436
+ return JSON.stringify({
437
+ ok: true,
438
+ scope: "custom",
439
+ deleted: removed,
440
+ 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).`
441
+ });
442
+ }
443
+ });
444
+ }
445
+ /**
446
+ * screener_reset 工厂(选股器管理):恢复内置选股器出厂默认——清覆盖记录与墓碑。
447
+ * 仅对内置选股器 id 有意义;自定义选股器无出厂版本,明确报错引导 screener_delete。
448
+ */
449
+ function createScreenerResetTool(options) {
450
+ const { store, tombstones, onReset } = options;
451
+ return defineTool({
452
+ name: "screener_reset",
453
+ 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.",
454
+ parameters: { id: {
455
+ type: "string",
456
+ required: true,
457
+ description: "Built-in screener id to restore"
458
+ } },
459
+ output: {
460
+ schema: { type: "string" },
461
+ render: (_args, value) => [{
462
+ type: "text",
463
+ text: value
464
+ }]
465
+ },
466
+ async execute(raw) {
467
+ const args = raw ?? {};
468
+ const id = typeof args.id === "string" ? args.id.trim() : "";
469
+ if (!id) throw new Error("screener_reset: id is required");
470
+ 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`);
471
+ const removedOverride = await store.remove(id, true);
472
+ const liftedTombstone = await tombstones?.remove(id) ?? false;
473
+ const changed = removedOverride || liftedTombstone;
474
+ onReset?.(id, changed);
475
+ return JSON.stringify({
476
+ ok: true,
477
+ reset: true,
478
+ changed,
479
+ removedOverride,
480
+ liftedTombstone,
481
+ note: changed ? `Built-in screener "${id}" restored to factory default (override/tombstone cleared).` : `"${id}" was already at factory default — nothing to restore.`
482
+ });
483
+ }
484
+ });
485
+ }
486
+ /** Host plugin body:provide store 服务 + 注册策略工具族(host 平面,全会话可见)。 */
196
487
  function apply(ctx) {
197
488
  const store = createFileCustomStrategyStore(defaultStorePath());
489
+ const tombstones = createFileBuiltinTombstonesStore(defaultTombstonesStorePath());
490
+ const screenerStore = createFileCustomScreenerStore(defaultScreenerStorePath());
491
+ new StrategiesStoreService(ctx, {
492
+ store,
493
+ tombstones,
494
+ screenerStore
495
+ });
198
496
  ctx.inject(["tools"], (toolCtx) => {
199
497
  const tools = toolCtx.tools;
200
498
  if (!tools || typeof tools.register !== "function") return;
201
- const authorTool = createStrategyAuthorTool({
499
+ const events = () => eventsOf(ctx)?.emit("strategies");
500
+ const register = (tool) => {
501
+ if (tools.get(tool.name) === void 0) tools.register(tool);
502
+ };
503
+ register(createStrategyAuthorTool({
202
504
  store,
203
- onWritten: () => eventsOf(ctx)?.emit("strategies")
204
- });
205
- if (tools.get(authorTool.name) === void 0) tools.register(authorTool);
206
- const backtestTool = createStrategyBacktestTool({
505
+ tombstones,
506
+ onWritten: () => events()
507
+ }));
508
+ register(createStrategyBacktestTool({
207
509
  store,
510
+ tombstones,
208
511
  marketData: createMarketDataResolver(ctx)
209
- });
210
- if (tools.get(backtestTool.name) === void 0) tools.register(backtestTool);
512
+ }));
513
+ register(createStrategyDeleteTool({
514
+ store,
515
+ tombstones,
516
+ onDeleted: () => events()
517
+ }));
518
+ register(createStrategyResetTool({
519
+ store,
520
+ tombstones,
521
+ onReset: () => events()
522
+ }));
523
+ register(createScreenerAuthorTool({
524
+ store: screenerStore,
525
+ tombstones,
526
+ onWritten: () => events()
527
+ }));
528
+ register(createScreenerDeleteTool({
529
+ store: screenerStore,
530
+ tombstones,
531
+ onDeleted: () => events()
532
+ }));
533
+ register(createScreenerResetTool({
534
+ store: screenerStore,
535
+ tombstones,
536
+ onReset: () => events()
537
+ }));
211
538
  });
212
539
  }
540
+ /** 策略 store 服务(桥与工具的单实例共享点,issue #33 收口模式)。 */
541
+ var StrategiesStoreService = class extends Service {
542
+ store;
543
+ /** 内置墓碑表(策略/选股器管理共用;桥 DELETE/POST /strategies/* 与工具共享)。 */
544
+ tombstones;
545
+ /** 自定义选股器 store(选股器管理)。 */
546
+ screenerStore;
547
+ constructor(ctx, deps, serviceName = TRADING_STRATEGIES_KEY) {
548
+ super(ctx, serviceName);
549
+ this.store = deps.store;
550
+ this.tombstones = deps.tombstones;
551
+ this.screenerStore = deps.screenerStore;
552
+ }
553
+ };
213
554
  //#endregion
214
- export { apply, createFileCustomStrategyStore, createMarketDataResolver, createStrategyAuthorTool, createStrategyBacktestTool, defaultStorePath, inject, name, resolveStrategyDefinition };
555
+ export { StrategiesStoreService, TRADING_STRATEGIES_KEY, apply, createFileBuiltinTombstonesStore, createFileCustomScreenerStore, createFileCustomStrategyStore, createMarketDataResolver, createScreenerAuthorTool, createScreenerDeleteTool, createScreenerResetTool, createStrategyAuthorTool, createStrategyBacktestTool, createStrategyDeleteTool, createStrategyResetTool, defaultScreenerStorePath, defaultStorePath, defaultTombstonesStorePath, inject, isBuiltinScreenerId, isBuiltinStrategyId, name, resolveStrategyDefinition, validateCustomScreenerNode, validateCustomStrategyNode };
@@ -0,0 +1,16 @@
1
+ //#region src/price-format.d.ts
2
+ /**
3
+ * 标的价格显示小数位(自适应,全局显示约定单一来源):
4
+ * ≥1 默认 2 位;当 2 位舍入会丢失第 3 位有效小数(如港股 0.001 tick、
5
+ * 低价权证/债券)时升到 3 位;<1 按量级 4/6 位(低价加密对)。
6
+ * 1e-9 容差只滤浮点表示噪声——常规价位下它比第 3 位小数量级低多个数量级。
7
+ *
8
+ * client-ui-trading / client-ui-strategies 经本包复用同一规则;范式策略的
9
+ * compute 因需自包含(compute.toString() 导出可编译源码)内联同式实现,
10
+ * 两处改动必须同步。
11
+ */
12
+ declare function priceDigits(value: number | undefined): number;
13
+ /** 价格文本:undefined/NaN → '—',否则按 priceDigits 定点舍入。 */
14
+ declare function fmtPrice(value: number | undefined): string;
15
+ //#endregion
16
+ export { fmtPrice, priceDigits };
@@ -0,0 +1,25 @@
1
+ //#region src/price-format.ts
2
+ /**
3
+ * 标的价格显示小数位(自适应,全局显示约定单一来源):
4
+ * ≥1 默认 2 位;当 2 位舍入会丢失第 3 位有效小数(如港股 0.001 tick、
5
+ * 低价权证/债券)时升到 3 位;<1 按量级 4/6 位(低价加密对)。
6
+ * 1e-9 容差只滤浮点表示噪声——常规价位下它比第 3 位小数量级低多个数量级。
7
+ *
8
+ * client-ui-trading / client-ui-strategies 经本包复用同一规则;范式策略的
9
+ * compute 因需自包含(compute.toString() 导出可编译源码)内联同式实现,
10
+ * 两处改动必须同步。
11
+ */
12
+ function priceDigits(value) {
13
+ if (value === void 0 || !Number.isFinite(value)) return 2;
14
+ const abs = Math.abs(value);
15
+ if (abs < 1) return abs >= .01 ? 4 : 6;
16
+ const rounded = Number(value.toFixed(2));
17
+ return Math.abs(rounded - value) < 1e-9 ? 2 : 3;
18
+ }
19
+ /** 价格文本:undefined/NaN → '—',否则按 priceDigits 定点舍入。 */
20
+ function fmtPrice(value) {
21
+ if (value === void 0 || !Number.isFinite(value)) return "—";
22
+ return value.toFixed(priceDigits(value));
23
+ }
24
+ //#endregion
25
+ export { fmtPrice, priceDigits };
@@ -1,9 +1,4 @@
1
- import { sma } from "@dshtrading/indicators";
2
1
  //#region src/screeners/above-ma.ts
3
- /**
4
- * 牛熊线之上:现价站上长期均线且均线自身斜率向上——与量化「200 日均线
5
- * 牛熊择时基线」同源的长线标的筛选(Faber GTAA 的截面版)。
6
- */
7
2
  const aboveMaScreener = {
8
3
  id: "scr.above-ma",
9
4
  name: "站上牛熊线",
@@ -37,8 +32,18 @@ const aboveMaScreener = {
37
32
  const slopeBars = Math.max(5, Math.round(params.slopeBars ?? 20));
38
33
  const i = bars.length - 1;
39
34
  if (i < period + slopeBars - 1) return null;
40
- const closes = bars.map((b) => b.close);
41
- const maSeries = sma(closes, period);
35
+ const smaOf = (values, period) => {
36
+ const out = new Array(values.length).fill(void 0);
37
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
38
+ let sum = 0;
39
+ for (let index = 0; index < values.length; index++) {
40
+ sum += values[index];
41
+ if (index >= period) sum -= values[index - period];
42
+ if (index >= period - 1) out[index] = sum / period;
43
+ }
44
+ return out;
45
+ };
46
+ const maSeries = smaOf(bars.map((b) => b.close), period);
42
47
  const ma = maSeries[i];
43
48
  const maPrev = maSeries[i - slopeBars];
44
49
  if (ma === void 0 || maPrev === void 0 || !(maPrev > 0)) return null;
@@ -1,9 +1,4 @@
1
- import { sma } from "@dshtrading/indicators";
2
1
  //#region src/screeners/ma-bull-align.ts
3
- /**
4
- * 均线多头排列:现价站上三线,且短中期均线自上而下依次压制
5
- * (SMA(短) > SMA(中) > SMA(长)),趋势结构完整的顺势筛选。
6
- */
7
2
  const maBullAlignScreener = {
8
3
  id: "scr.ma-bull-align",
9
4
  name: "均线多头排列",
@@ -45,10 +40,21 @@ const maBullAlignScreener = {
45
40
  const n3 = Math.max(n2, Math.round(params.n3 ?? 120));
46
41
  const i = bars.length - 1;
47
42
  if (i + 1 < n3) return null;
43
+ const smaOf = (values, period) => {
44
+ const out = new Array(values.length).fill(void 0);
45
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
46
+ let sum = 0;
47
+ for (let index = 0; index < values.length; index++) {
48
+ sum += values[index];
49
+ if (index >= period) sum -= values[index - period];
50
+ if (index >= period - 1) out[index] = sum / period;
51
+ }
52
+ return out;
53
+ };
48
54
  const closes = bars.map((b) => b.close);
49
- const s1 = sma(closes, n1)[i];
50
- const s2 = sma(closes, n2)[i];
51
- const s3 = sma(closes, n3)[i];
55
+ const s1 = smaOf(closes, n1)[i];
56
+ const s2 = smaOf(closes, n2)[i];
57
+ const s3 = smaOf(closes, n3)[i];
52
58
  if (s1 === void 0 || s2 === void 0 || s3 === void 0) return null;
53
59
  const close = bars[i].close;
54
60
  if (!(close > s1 && s1 > s2 && s2 > s3)) return null;
@@ -1,9 +1,4 @@
1
- import { rsi } from "@dshtrading/indicators";
2
1
  //#region src/screeners/rsi-oversold.ts
3
- /**
4
- * RSI 超卖:RSI(period) 跌入阈值以下的逆势关注筛选——超卖≠见底,
5
- * 定位是「值得盯的反转候选池」,命中理由里明示是逆势信号。
6
- */
7
2
  const rsiOversoldScreener = {
8
3
  id: "scr.rsi-oversold",
9
4
  name: "RSI 超卖",
@@ -32,7 +27,28 @@ const rsiOversoldScreener = {
32
27
  const threshold = Math.min(50, Math.max(10, params.threshold ?? 30));
33
28
  const i = bars.length - 1;
34
29
  if (i < period) return null;
35
- const value = rsi(bars.map((b) => b.close), period)[i];
30
+ const rsiOf = (values, period) => {
31
+ const out = new Array(values.length).fill(void 0);
32
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
33
+ let gain = 0;
34
+ let loss = 0;
35
+ for (let index = 1; index <= period; index++) {
36
+ const delta = values[index] - values[index - 1];
37
+ if (delta >= 0) gain += delta;
38
+ else loss -= delta;
39
+ }
40
+ gain /= period;
41
+ loss /= period;
42
+ out[period] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
43
+ for (let index = period + 1; index < values.length; index++) {
44
+ const delta = values[index] - values[index - 1];
45
+ gain = (gain * (period - 1) + Math.max(delta, 0)) / period;
46
+ loss = (loss * (period - 1) + Math.max(-delta, 0)) / period;
47
+ out[index] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
48
+ }
49
+ return out;
50
+ };
51
+ const value = rsiOf(bars.map((b) => b.close), period)[i];
36
52
  if (value === void 0) return null;
37
53
  if (!(value < threshold)) return null;
38
54
  return {
@@ -1,9 +1,4 @@
1
- import { sma } from "@dshtrading/indicators";
2
1
  //#region src/screeners/volume-breakout.ts
3
- /**
4
- * 放量突破:收盘价创 lookback 日新高,且成交量 ≥ 均量的 volMultiple 倍——
5
- * 突破有效性的量能确认(无量突破不入选)。量比口径跨市场可比(比值无量纲)。
6
- */
7
2
  const volumeBreakoutScreener = {
8
3
  id: "scr.volume-breakout",
9
4
  name: "放量突破",
@@ -36,10 +31,20 @@ const volumeBreakoutScreener = {
36
31
  const volMultiple = Math.max(1, params.volMultiple ?? 2);
37
32
  const i = bars.length - 1;
38
33
  if (i < lookback) return null;
34
+ const smaOf = (values, period) => {
35
+ const out = new Array(values.length).fill(void 0);
36
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
37
+ let sum = 0;
38
+ for (let index = 0; index < values.length; index++) {
39
+ sum += values[index];
40
+ if (index >= period) sum -= values[index - period];
41
+ if (index >= period - 1) out[index] = sum / period;
42
+ }
43
+ return out;
44
+ };
39
45
  const window = bars.slice(i - lookback, i);
40
46
  const priorHigh = Math.max(...window.map((b) => b.high));
41
- const volumes = bars.map((b) => b.volume);
42
- const avgVolume = sma(volumes, lookback)[i];
47
+ const avgVolume = smaOf(bars.map((b) => b.volume), lookback)[i];
43
48
  if (avgVolume === void 0 || avgVolume <= 0) return null;
44
49
  const close = bars[i].close;
45
50
  const volume = bars[i].volume;