@dshtrading/client-ui-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/client.js CHANGED
@@ -8236,100 +8236,28 @@ window.__ModuleLoader__.load({
8236
8236
  };
8237
8237
  }
8238
8238
  //#endregion
8239
- //#region ../indicators/lib/math.js
8240
- /** 简单移动平均(滚动和,O(n))。 */
8241
- function sma(values, period) {
8242
- const out = new Array(values.length).fill(void 0);
8243
- if (!valid(period, values.length)) return out;
8244
- let sum = 0;
8245
- for (let index = 0; index < values.length; index++) {
8246
- sum += values[index];
8247
- if (index >= period) sum -= values[index - period];
8248
- if (index >= period - 1) out[index] = sum / period;
8249
- }
8250
- return out;
8251
- }
8252
- /** 指数移动平均(标准 EMA:种子 = 前 period 个的 SMA)。 */
8253
- function ema(values, period) {
8254
- const out = new Array(values.length).fill(void 0);
8255
- if (!valid(period, values.length)) return out;
8256
- const k = 2 / (period + 1);
8257
- let seed = 0;
8258
- for (let index = 0; index < period; index++) seed += values[index];
8259
- let prev = seed / period;
8260
- out[period - 1] = prev;
8261
- for (let index = period; index < values.length; index++) {
8262
- prev = values[index] * k + prev * (1 - k);
8263
- out[index] = prev;
8264
- }
8265
- return out;
8266
- }
8267
- /** 滚动样本标准差(无偏校正关——与主流行情软件一致用总体口径)。 */
8268
- function stdev(values, period) {
8269
- const out = new Array(values.length).fill(void 0);
8270
- if (!valid(period, values.length)) return out;
8271
- for (let index = period - 1; index < values.length; index++) {
8272
- let mean = 0;
8273
- for (let offset = 0; offset < period; offset++) mean += values[index - offset];
8274
- mean /= period;
8275
- let variance = 0;
8276
- for (let offset = 0; offset < period; offset++) {
8277
- const delta = values[index - offset] - mean;
8278
- variance += delta * delta;
8279
- }
8280
- out[index] = Math.sqrt(variance / period);
8281
- }
8282
- return out;
8283
- }
8284
- /**
8285
- * 布林带:返回中轨(SMA)与上下轨(中轨 ± N 倍标准差)。
8286
- * 主图叠加指标,三条线共享 warm-up。
8287
- */
8288
- function bollinger(values, period, mult) {
8289
- const mid = sma(values, period);
8290
- const sd = stdev(values, period);
8291
- const upper = new Array(values.length).fill(void 0);
8292
- const lower = new Array(values.length).fill(void 0);
8293
- for (let index = 0; index < values.length; index++) {
8294
- const base = mid[index];
8295
- const dev = sd[index];
8296
- if (base === void 0 || dev === void 0) continue;
8297
- upper[index] = base + mult * dev;
8298
- lower[index] = base - mult * dev;
8299
- }
8300
- return {
8301
- mid,
8302
- upper,
8303
- lower
8304
- };
8305
- }
8239
+ //#region ../strategies/lib/price-format.js
8306
8240
  /**
8307
- * RSI(Wilder 平滑)。种子 = 前 period 个涨跌均值,其后
8308
- * avgGain/avgLoss 递推;前 period 位为 undefined。
8241
+ * 标的价格显示小数位(自适应,全局显示约定单一来源):
8242
+ * ≥1 默认 2 位;当 2 位舍入会丢失第 3 位有效小数(如港股 0.001 tick、
8243
+ * 低价权证/债券)时升到 3 位;<1 按量级 4/6 位(低价加密对)。
8244
+ * 1e-9 容差只滤浮点表示噪声——常规价位下它比第 3 位小数量级低多个数量级。
8245
+ *
8246
+ * client-ui-trading / client-ui-strategies 经本包复用同一规则;范式策略的
8247
+ * compute 因需自包含(compute.toString() 导出可编译源码)内联同式实现,
8248
+ * 两处改动必须同步。
8309
8249
  */
8310
- function rsi(values, period) {
8311
- const out = new Array(values.length).fill(void 0);
8312
- if (!valid(period, values.length)) return out;
8313
- let gain = 0;
8314
- let loss = 0;
8315
- for (let index = 1; index <= period; index++) {
8316
- const delta = values[index] - values[index - 1];
8317
- if (delta >= 0) gain += delta;
8318
- else loss -= delta;
8319
- }
8320
- gain /= period;
8321
- loss /= period;
8322
- out[period] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
8323
- for (let index = period + 1; index < values.length; index++) {
8324
- const delta = values[index] - values[index - 1];
8325
- gain = (gain * (period - 1) + Math.max(delta, 0)) / period;
8326
- loss = (loss * (period - 1) + Math.max(-delta, 0)) / period;
8327
- out[index] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
8328
- }
8329
- return out;
8330
- }
8331
- function valid(period, length) {
8332
- return Number.isFinite(period) && period >= 1 && length >= period;
8250
+ function priceDigits(value) {
8251
+ if (value === void 0 || !Number.isFinite(value)) return 2;
8252
+ const abs = Math.abs(value);
8253
+ if (abs < 1) return abs >= .01 ? 4 : 6;
8254
+ const rounded = Number(value.toFixed(2));
8255
+ return Math.abs(rounded - value) < 1e-9 ? 2 : 3;
8256
+ }
8257
+ /** 价格文本:undefined/NaN → '—',否则按 priceDigits 定点舍入。 */
8258
+ function fmtPrice(value) {
8259
+ if (value === void 0 || !Number.isFinite(value)) return "—";
8260
+ return value.toFixed(priceDigits(value));
8333
8261
  }
8334
8262
  //#endregion
8335
8263
  //#region ../indicators/lib/validate.js
@@ -8422,7 +8350,11 @@ window.__ModuleLoader__.load({
8422
8350
  if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) return new Function(`"use strict"; return (${trimmed});`)();
8423
8351
  return new Function("bars", "params", `"use strict";\n${trimmed}`);
8424
8352
  }
8425
- /** blob Worker 内执行的编译+试算脚本(自包含,不依赖宿主任何模块)。 */
8353
+ /**
8354
+ * blob Worker 内执行的编译+试算脚本(自包含,不依赖宿主任何模块)。
8355
+ * 与 compileComputeSource 同构的第三处实现——Worker 内无法 import,镜像不可避免;
8356
+ * 形态嗅探正则与编译语义改动必须同步本段(单一事实来源 = compileComputeSource)。
8357
+ */
8426
8358
  const WORKER_TRIAL_SCRIPT = `self.onmessage = (event) => {
8427
8359
  const { source, bars, params } = event.data
8428
8360
  try {
@@ -8510,27 +8442,18 @@ window.__ModuleLoader__.load({
8510
8442
  const MAX_PARAMS_COUNT = 8;
8511
8443
  const MAX_SUMMARY_LENGTH = 120;
8512
8444
  const MAX_REASON_LENGTH = 200;
8513
- /** 6 大范式 id 是系统保留名(custom_* 前缀或自定义名避免冲突)。 */
8514
- const RESERVED_IDS = /* @__PURE__ */ new Set([
8515
- "donchian-breakout",
8516
- "rsi-reversion",
8517
- "ema-crossover",
8518
- "bollinger-reversion",
8519
- "sma-baseline",
8520
- "momentum-12m"
8521
- ]);
8522
- const HORIZONS = [
8445
+ const HORIZONS$1 = [
8523
8446
  "short",
8524
8447
  "swing",
8525
8448
  "long"
8526
8449
  ];
8527
8450
  const DEFAULT_TIMEOUT_MS = 100;
8528
- /** 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。 */
8529
- function compileStrategySource(source) {
8530
- const trimmed = source.trim();
8531
- if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) return new Function(`"use strict"; return (${trimmed});`)();
8532
- return new Function("bars", "params", `"use strict";\n${trimmed}`);
8533
- }
8451
+ /**
8452
+ * 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。
8453
+ * 编译语义(形态嗅探 + new Function 双形态)的单一来源是 @dshtrading/indicators 的
8454
+ * compileComputeSource——此处仅按策略信号类型收窄返回值(2026-09-09 收敛同体副本)。
8455
+ */
8456
+ const compileStrategySource = compileComputeSource;
8534
8457
  /**
8535
8458
  * 信号序列专用校验(引擎语义对齐:i 收盘确认、i+1 开盘成交可复算)。
8536
8459
  * 返回 undefined = 通过;否则返回人话诊断(模型与 UI 直接可读)。
@@ -8561,6 +8484,81 @@ window.__ModuleLoader__.load({
8561
8484
  }
8562
8485
  }
8563
8486
  /**
8487
+ * 参数规格数组解析(策略/选股器校验共用):JSON 解析 → StrategyParamSpec[],
8488
+ * 保留合法正值 step(内置覆盖保存后参数步进不失真)。
8489
+ */
8490
+ function parseParamSpecs(paramsJson) {
8491
+ if (paramsJson === void 0) return {
8492
+ ok: true,
8493
+ params: []
8494
+ };
8495
+ if (typeof paramsJson !== "string") return {
8496
+ ok: false,
8497
+ reason: "paramsJson 必须是字符串(StrategyParamSpec[] 的 JSON)"
8498
+ };
8499
+ let parsed;
8500
+ try {
8501
+ parsed = JSON.parse(paramsJson);
8502
+ } catch (error) {
8503
+ return {
8504
+ ok: false,
8505
+ reason: `paramsJson 不是合法 JSON: ${String(error?.message ?? error)}`
8506
+ };
8507
+ }
8508
+ if (!Array.isArray(parsed)) return {
8509
+ ok: false,
8510
+ reason: "paramsJson 解析结果必须是数组(StrategyParamSpec[])"
8511
+ };
8512
+ if (parsed.length > MAX_PARAMS_COUNT) return {
8513
+ ok: false,
8514
+ reason: `策略参数数量超出上限(至多 ${MAX_PARAMS_COUNT} 个)`
8515
+ };
8516
+ const params = [];
8517
+ for (let index = 0; index < parsed.length; index++) {
8518
+ const p = parsed[index];
8519
+ if (typeof p !== "object" || p === null) return {
8520
+ ok: false,
8521
+ reason: `paramsJson[${index}] 必须是一个对象`
8522
+ };
8523
+ const spec = p;
8524
+ const key = typeof spec.key === "string" ? spec.key.trim() : "";
8525
+ if (!PARAM_KEY_PATTERN.test(key)) return {
8526
+ ok: false,
8527
+ reason: `paramsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
8528
+ };
8529
+ const label = typeof spec.label === "string" ? spec.label.trim() : key;
8530
+ const defVal = Number(spec.default);
8531
+ const minVal = Number(spec.min);
8532
+ const maxVal = Number(spec.max);
8533
+ if (!Number.isFinite(defVal) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) return {
8534
+ ok: false,
8535
+ reason: `paramsJson[${index}] (${key}) 的 default、min、max 必须是有限数字`
8536
+ };
8537
+ if (minVal >= maxVal) return {
8538
+ ok: false,
8539
+ reason: `paramsJson[${index}] (${key}) 的 min (${minVal}) 必须严格小于 max (${maxVal})`
8540
+ };
8541
+ if (defVal < minVal || defVal > maxVal) return {
8542
+ ok: false,
8543
+ reason: `paramsJson[${index}] (${key}) 的 default (${defVal}) 必须在 [min, max] (${minVal}..${maxVal}) 范围内`
8544
+ };
8545
+ const stepVal = Number(spec.step);
8546
+ const step = Number.isFinite(stepVal) && stepVal > 0 ? stepVal : 1;
8547
+ params.push({
8548
+ key,
8549
+ label,
8550
+ default: defVal,
8551
+ min: minVal,
8552
+ max: maxVal,
8553
+ step
8554
+ });
8555
+ }
8556
+ return {
8557
+ ok: true,
8558
+ params
8559
+ };
8560
+ }
8561
+ /**
8564
8562
  * 结构校验(无试算)——browser/node 校验器共用。
8565
8563
  */
8566
8564
  function checkCustomStrategyStructure(raw) {
@@ -8578,17 +8576,13 @@ window.__ModuleLoader__.load({
8578
8576
  ok: false,
8579
8577
  reason: `策略 id "${id}" 不合法:必须由 2-32 位小写字母、数字、下划线或连字符组成`
8580
8578
  };
8581
- if (RESERVED_IDS.has(id)) return {
8582
- ok: false,
8583
- reason: `策略 id "${id}" 是系统范式保留名称,请使用其他名称(如 custom_${id})`
8584
- };
8585
8579
  const title = typeof input.title === "string" ? input.title.trim() : "";
8586
8580
  if (!title || title.length > 32) return {
8587
8581
  ok: false,
8588
8582
  reason: "策略 title 必须是 1-32 字符的非空字符串"
8589
8583
  };
8590
8584
  const horizon = input.horizon;
8591
- if (!HORIZONS.includes(horizon)) return {
8585
+ if (!HORIZONS$1.includes(horizon)) return {
8592
8586
  ok: false,
8593
8587
  reason: `策略 horizon 必须是 "short"、"swing" 或 "long",收到: ${JSON.stringify(input.horizon)}`
8594
8588
  };
@@ -8597,67 +8591,8 @@ window.__ModuleLoader__.load({
8597
8591
  ok: false,
8598
8592
  reason: `策略 summary 必须是 1-${MAX_SUMMARY_LENGTH} 字符的非空字符串`
8599
8593
  };
8600
- let params = [];
8601
- if (input.paramsJson !== void 0) {
8602
- if (typeof input.paramsJson !== "string") return {
8603
- ok: false,
8604
- reason: "策略 paramsJson 必须是字符串(StrategyParamSpec[] 的 JSON)"
8605
- };
8606
- let parsed;
8607
- try {
8608
- parsed = JSON.parse(input.paramsJson);
8609
- } catch (error) {
8610
- return {
8611
- ok: false,
8612
- reason: `paramsJson 不是合法 JSON: ${String(error?.message ?? error)}`
8613
- };
8614
- }
8615
- if (!Array.isArray(parsed)) return {
8616
- ok: false,
8617
- reason: "paramsJson 解析结果必须是数组(StrategyParamSpec[])"
8618
- };
8619
- if (parsed.length > MAX_PARAMS_COUNT) return {
8620
- ok: false,
8621
- reason: `策略参数数量超出上限(至多 ${MAX_PARAMS_COUNT} 个)`
8622
- };
8623
- for (let index = 0; index < parsed.length; index++) {
8624
- const p = parsed[index];
8625
- if (typeof p !== "object" || p === null) return {
8626
- ok: false,
8627
- reason: `paramsJson[${index}] 必须是一个对象`
8628
- };
8629
- const spec = p;
8630
- const key = typeof spec.key === "string" ? spec.key.trim() : "";
8631
- if (!PARAM_KEY_PATTERN.test(key)) return {
8632
- ok: false,
8633
- reason: `paramsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
8634
- };
8635
- const label = typeof spec.label === "string" ? spec.label.trim() : key;
8636
- const defVal = Number(spec.default);
8637
- const minVal = Number(spec.min);
8638
- const maxVal = Number(spec.max);
8639
- if (!Number.isFinite(defVal) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) return {
8640
- ok: false,
8641
- reason: `paramsJson[${index}] (${key}) 的 default、min、max 必须是有限数字`
8642
- };
8643
- if (minVal >= maxVal) return {
8644
- ok: false,
8645
- reason: `paramsJson[${index}] (${key}) 的 min (${minVal}) 必须严格小于 max (${maxVal})`
8646
- };
8647
- if (defVal < minVal || defVal > maxVal) return {
8648
- ok: false,
8649
- reason: `paramsJson[${index}] (${key}) 的 default (${defVal}) 必须在 [min, max] (${minVal}..${maxVal}) 范围内`
8650
- };
8651
- params.push({
8652
- key,
8653
- label,
8654
- default: defVal,
8655
- min: minVal,
8656
- max: maxVal,
8657
- step: 1
8658
- });
8659
- }
8660
- }
8594
+ const params = parseParamSpecs(input.paramsJson);
8595
+ if (!params.ok) return params;
8661
8596
  const computeSource = typeof input.computeSource === "string" ? input.computeSource.trim() : "";
8662
8597
  if (!computeSource) return {
8663
8598
  ok: false,
@@ -8681,7 +8616,7 @@ window.__ModuleLoader__.load({
8681
8616
  title,
8682
8617
  horizon,
8683
8618
  summary,
8684
- params,
8619
+ params: params.params,
8685
8620
  computeSource,
8686
8621
  input
8687
8622
  };
@@ -8747,279 +8682,230 @@ window.__ModuleLoader__.load({
8747
8682
  }
8748
8683
  };
8749
8684
  }
8750
- //#endregion
8751
- //#region ../strategies/lib/screeners/index.js
8752
- const screenerParadigms = [
8753
- {
8754
- id: "scr.ma-bull-align",
8755
- name: "均线多头排列",
8756
- summary: "现价站上三线且 SMA(短) > SMA(中) > SMA(长),筛选趋势结构完整的标的",
8757
- params: [
8758
- {
8759
- key: "n1",
8760
- label: "短期均线",
8761
- default: 20,
8762
- min: 5,
8763
- max: 120,
8764
- step: 5
8765
- },
8766
- {
8767
- key: "n2",
8768
- label: "中期均线",
8769
- default: 60,
8770
- min: 20,
8771
- max: 250,
8772
- step: 10
8773
- },
8774
- {
8775
- key: "n3",
8776
- label: "长期均线",
8777
- default: 120,
8778
- min: 50,
8779
- max: 300,
8780
- step: 10
8781
- }
8782
- ],
8783
- columns: [{
8784
- key: "distLongPct",
8785
- label: "距长期均线",
8786
- format: "percent"
8787
- }],
8788
- evaluate(bars, params) {
8789
- const n1 = Math.max(5, Math.round(params.n1 ?? 20));
8790
- const n2 = Math.max(n1, Math.round(params.n2 ?? 60));
8791
- const n3 = Math.max(n2, Math.round(params.n3 ?? 120));
8792
- const i = bars.length - 1;
8793
- if (i + 1 < n3) return null;
8794
- const closes = bars.map((b) => b.close);
8795
- const s1 = sma(closes, n1)[i];
8796
- const s2 = sma(closes, n2)[i];
8797
- const s3 = sma(closes, n3)[i];
8798
- if (s1 === void 0 || s2 === void 0 || s3 === void 0) return null;
8799
- const close = bars[i].close;
8800
- if (!(close > s1 && s1 > s2 && s2 > s3)) return null;
8685
+ /** 选股器 id:强制 'scr.' 前缀与范式策略 id 空间隔离(内置 id 同形,可覆盖)。 */
8686
+ const SCREENER_ID_PATTERN = /^scr\.[a-z0-9_][a-z0-9_-]{1,31}$/;
8687
+ const MAX_COLUMNS_COUNT = 8;
8688
+ /**
8689
+ * 结构校验(无试算):id / title / summary / paramsJson / columnsJson / evaluateSource。
8690
+ * evaluate 与 compute 同为 (bars, params) 纯函数,编译走同一 compileStrategySource。
8691
+ */
8692
+ function checkCustomScreenerStructure(raw) {
8693
+ if (typeof raw !== "object" || raw === null) return {
8694
+ ok: false,
8695
+ reason: "选股器配置必须是一个非空对象"
8696
+ };
8697
+ const input = raw;
8698
+ const id = typeof input.id === "string" ? input.id.trim().toLowerCase() : "";
8699
+ if (!id) return {
8700
+ ok: false,
8701
+ reason: "缺少选股器 id"
8702
+ };
8703
+ if (!SCREENER_ID_PATTERN.test(id)) return {
8704
+ ok: false,
8705
+ reason: `选股器 id "${id}" 不合法:必须以 "scr." 开头,后接 2-32 位小写字母、数字、下划线或连字符`
8706
+ };
8707
+ const title = typeof input.title === "string" ? input.title.trim() : "";
8708
+ if (!title || title.length > 32) return {
8709
+ ok: false,
8710
+ reason: "选股器 title 必须是 1-32 字符的非空字符串"
8711
+ };
8712
+ const summary = typeof input.summary === "string" ? input.summary.trim() : "";
8713
+ if (!summary || summary.length > MAX_SUMMARY_LENGTH) return {
8714
+ ok: false,
8715
+ reason: `选股器 summary 必须是 1-${MAX_SUMMARY_LENGTH} 字符的非空字符串`
8716
+ };
8717
+ const params = parseParamSpecs(input.paramsJson);
8718
+ if (!params.ok) return params;
8719
+ let columns = [];
8720
+ if (typeof input.columnsJson !== "string" || !input.columnsJson.trim()) return {
8721
+ ok: false,
8722
+ reason: "缺少 columnsJson(ScreenerColumnSpec[] 的 JSON,至少 1 列)"
8723
+ };
8724
+ let parsedColumns;
8725
+ try {
8726
+ parsedColumns = JSON.parse(input.columnsJson);
8727
+ } catch (error) {
8728
+ return {
8729
+ ok: false,
8730
+ reason: `columnsJson 不是合法 JSON: ${String(error?.message ?? error)}`
8731
+ };
8732
+ }
8733
+ if (!Array.isArray(parsedColumns) || parsedColumns.length === 0) return {
8734
+ ok: false,
8735
+ reason: "columnsJson 解析结果必须是非空数组(ScreenerColumnSpec[])"
8736
+ };
8737
+ if (parsedColumns.length > MAX_COLUMNS_COUNT) return {
8738
+ ok: false,
8739
+ reason: `结果列数量超出上限(至多 ${MAX_COLUMNS_COUNT} 列)`
8740
+ };
8741
+ for (let index = 0; index < parsedColumns.length; index++) {
8742
+ const c = parsedColumns[index];
8743
+ if (typeof c !== "object" || c === null) return {
8744
+ ok: false,
8745
+ reason: `columnsJson[${index}] 必须是一个对象`
8746
+ };
8747
+ const spec = c;
8748
+ const key = typeof spec.key === "string" ? spec.key.trim() : "";
8749
+ if (!PARAM_KEY_PATTERN.test(key)) return {
8750
+ ok: false,
8751
+ reason: `columnsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
8752
+ };
8753
+ const label = typeof spec.label === "string" ? spec.label.trim() : key;
8754
+ const format = spec.format === "percent" || spec.format === "number" ? spec.format : void 0;
8755
+ columns.push(format === void 0 ? {
8756
+ key,
8757
+ label
8758
+ } : {
8759
+ key,
8760
+ label,
8761
+ format
8762
+ });
8763
+ }
8764
+ if (new Set(columns.map((c) => c.key)).size !== columns.length) return {
8765
+ ok: false,
8766
+ reason: "columnsJson 存在重复的列 key"
8767
+ };
8768
+ const evaluateSource = typeof input.evaluateSource === "string" ? input.evaluateSource.trim() : "";
8769
+ if (!evaluateSource) return {
8770
+ ok: false,
8771
+ reason: "缺少 evaluateSource 源码"
8772
+ };
8773
+ if (evaluateSource.length > MAX_SOURCE_LENGTH) return {
8774
+ ok: false,
8775
+ reason: `evaluateSource 源码长度 (${evaluateSource.length} B) 超出限制 (${MAX_SOURCE_LENGTH} B / 16KB)`
8776
+ };
8777
+ try {
8778
+ compileStrategySource(evaluateSource);
8779
+ } catch (error) {
8780
+ return {
8781
+ ok: false,
8782
+ reason: `源码语法错误或无法编译: ${String(error?.message ?? error)}`
8783
+ };
8784
+ }
8785
+ return {
8786
+ ok: true,
8787
+ id,
8788
+ title,
8789
+ summary,
8790
+ params: params.params,
8791
+ columns,
8792
+ evaluateSource,
8793
+ input
8794
+ };
8795
+ }
8796
+ /** 单个命中结果校验(null = 未命中合法;其余必须符合 ScreenerMatch 形状)。 */
8797
+ function checkScreenerMatch(value, columns, scenario) {
8798
+ if (value === null || value === void 0) return void 0;
8799
+ if (typeof value !== "object" || Array.isArray(value)) return `evaluate 返回值必须是 ScreenerMatch 对象或 null,${scenario} 场景实际为: ${typeof value}`;
8800
+ const match = value;
8801
+ if (typeof match.metrics !== "object" || match.metrics === null || Array.isArray(match.metrics)) return `ScreenerMatch.metrics 必须是 Record<string, number>(${scenario} 场景)`;
8802
+ const columnKeys = new Set(columns.map((c) => c.key));
8803
+ for (const [key, val] of Object.entries(match.metrics)) {
8804
+ if (!columnKeys.has(key)) return `metrics 键 "${key}" 未在 columns 中声明(${scenario} 场景)——动态列必须先声明再产出`;
8805
+ if (typeof val !== "number" || !Number.isFinite(val)) return `metrics["${key}"] 必须是有限数字(${scenario} 场景),收到: ${String(val)}`;
8806
+ }
8807
+ if (typeof match.reason !== "string" || !match.reason.trim()) return `ScreenerMatch.reason 必须是非空字符串(${scenario} 场景)`;
8808
+ if (match.reason.length > MAX_REASON_LENGTH) return `reason 超长(${match.reason.length} > ${MAX_REASON_LENGTH},${scenario} 场景)`;
8809
+ }
8810
+ /**
8811
+ * 自定义选股器校验(异步——试算走可等待 runner,默认浏览器 Worker 超时熔断;
8812
+ * Node 宿主侧传 validate-node.ts 的 vm 熔断 runner)。
8813
+ *
8814
+ * 与策略校验的差异:evaluate 是「单时点截面判断」——无信号序列语义,
8815
+ * 返回 null(未命中/数据不足跳过)或 ScreenerMatch(metrics 键 ⊆ columns 声明、
8816
+ * 数值有限、reason 非空)。内置选股器 id 可覆盖(不再保留名)。
8817
+ */
8818
+ async function validateCustomScreener(raw, options) {
8819
+ const checked = checkCustomScreenerStructure(raw);
8820
+ if (!checked.ok) return checked;
8821
+ const { id, title, summary, params, columns, evaluateSource, input } = checked;
8822
+ const sampleScenarios = createSampleBars();
8823
+ const scenarioNames = [
8824
+ "uptrend",
8825
+ "downtrend",
8826
+ "flat",
8827
+ "gap",
8828
+ "short"
8829
+ ];
8830
+ const runner = options?.runner ?? workerComputeRunner;
8831
+ const defaultParamsMap = {};
8832
+ for (const p of params) defaultParamsMap[p.key] = p.default;
8833
+ const longScenario = [];
8834
+ let longPrice = 100;
8835
+ for (let i = 0; i < 300; i++) {
8836
+ const drift = i < 100 ? .6 : i < 200 ? -.5 : .55;
8837
+ longPrice = Math.max(1, longPrice + drift);
8838
+ longScenario.push({
8839
+ openTime: 17e11 + i * 864e5,
8840
+ open: longPrice - drift / 2,
8841
+ high: longPrice + .4,
8842
+ low: longPrice - .4,
8843
+ close: longPrice,
8844
+ volume: i % 7 === 5 ? 5e3 : 800
8845
+ });
8846
+ }
8847
+ const scenarios = [...scenarioNames.map((name) => ({
8848
+ name,
8849
+ bars: sampleScenarios[name]
8850
+ })), {
8851
+ name: "long",
8852
+ bars: longScenario
8853
+ }];
8854
+ let matchedScenario = false;
8855
+ for (const { name: scenario, bars } of scenarios) {
8856
+ let match;
8857
+ try {
8858
+ match = await runner(evaluateSource, bars, { ...defaultParamsMap }, DEFAULT_TIMEOUT_MS);
8859
+ } catch (error) {
8801
8860
  return {
8802
- metrics: { distLongPct: (close - s3) / s3 * 100 },
8803
- reason: `现价站上三线且 SMA(${n1}) > SMA(${n2}) > SMA(${n3}),多头排列`,
8804
- reasonKey: "scr.ma-bull-align.reason",
8805
- reasonParams: {
8806
- n1,
8807
- n2,
8808
- n3
8809
- }
8861
+ ok: false,
8862
+ reason: `在 ${scenario} 样例数据上试算执行报错: ${String(error?.message ?? error)}`
8810
8863
  };
8811
8864
  }
8812
- },
8865
+ const matchReason = checkScreenerMatch(match, columns, scenario);
8866
+ if (matchReason !== void 0) return {
8867
+ ok: false,
8868
+ reason: matchReason
8869
+ };
8870
+ if (match !== null && match !== void 0) matchedScenario = true;
8871
+ }
8872
+ if (!matchedScenario) return {
8873
+ ok: false,
8874
+ reason: "选股器在全部样例场景(上涨/下跌/震荡/跳空/短序列/300根长序列)上都返回 null——请检查入场条件(阈值方向、数据长度 guard),至少一个场景应命中"
8875
+ };
8876
+ return {
8877
+ ok: true,
8878
+ definition: {
8879
+ id,
8880
+ name: title,
8881
+ summary,
8882
+ params,
8883
+ columns,
8884
+ evaluate: compileStrategySource(evaluateSource)
8885
+ },
8886
+ record: {
8887
+ id,
8888
+ title,
8889
+ horizon: "swing",
8890
+ summary,
8891
+ paramsJson: JSON.stringify(params),
8892
+ columnsJson: JSON.stringify(columns),
8893
+ evaluateSource,
8894
+ createdAt: typeof input.createdAt === "number" ? input.createdAt : Date.now()
8895
+ }
8896
+ };
8897
+ }
8898
+ //#endregion
8899
+ //#region ../strategies/lib/paradigms/index.js
8900
+ /**
8901
+ * 6 个经典策略参考范式汇聚。
8902
+ */
8903
+ const strategyParadigms = [
8813
8904
  {
8814
- id: "scr.volume-breakout",
8815
- name: "放量突破",
8816
- summary: "收盘价创 N 日新高且成交量 ≥ 均量 M 倍,量价配合的突破筛选",
8817
- params: [{
8818
- key: "lookback",
8819
- label: "突破窗口(日)",
8820
- default: 20,
8821
- min: 5,
8822
- max: 60,
8823
- step: 5
8824
- }, {
8825
- key: "volMultiple",
8826
- label: "量能倍数",
8827
- default: 2,
8828
- min: 1,
8829
- max: 10,
8830
- step: .5
8831
- }],
8832
- columns: [{
8833
- key: "volRatio",
8834
- label: "量比(倍)"
8835
- }, {
8836
- key: "breakoutPct",
8837
- label: "突破幅度",
8838
- format: "percent"
8839
- }],
8840
- evaluate(bars, params) {
8841
- const lookback = Math.max(5, Math.round(params.lookback ?? 20));
8842
- const volMultiple = Math.max(1, params.volMultiple ?? 2);
8843
- const i = bars.length - 1;
8844
- if (i < lookback) return null;
8845
- const window = bars.slice(i - lookback, i);
8846
- const priorHigh = Math.max(...window.map((b) => b.high));
8847
- const avgVolume = sma(bars.map((b) => b.volume), lookback)[i];
8848
- if (avgVolume === void 0 || avgVolume <= 0) return null;
8849
- const close = bars[i].close;
8850
- const volume = bars[i].volume;
8851
- if (!(close > priorHigh) || !(volume >= volMultiple * avgVolume)) return null;
8852
- const volRatio = volume / avgVolume;
8853
- return {
8854
- metrics: {
8855
- volRatio,
8856
- breakoutPct: (close - priorHigh) / priorHigh * 100
8857
- },
8858
- reason: `放量 ${volRatio.toFixed(2)} 倍突破 ${lookback} 日高点`,
8859
- reasonKey: "scr.volume-breakout.reason",
8860
- reasonParams: {
8861
- ratio: volRatio.toFixed(2),
8862
- n: lookback
8863
- }
8864
- };
8865
- }
8866
- },
8867
- {
8868
- id: "scr.rsi-oversold",
8869
- name: "RSI 超卖",
8870
- summary: "RSI 跌入超卖区(默认 <30),筛选值得盯的反转候选(逆势信号)",
8871
- params: [{
8872
- key: "period",
8873
- label: "RSI 周期",
8874
- default: 14,
8875
- min: 5,
8876
- max: 30,
8877
- step: 1
8878
- }, {
8879
- key: "threshold",
8880
- label: "超卖阈值",
8881
- default: 30,
8882
- min: 10,
8883
- max: 50,
8884
- step: 5
8885
- }],
8886
- columns: [{
8887
- key: "rsi",
8888
- label: "RSI"
8889
- }],
8890
- evaluate(bars, params) {
8891
- const period = Math.max(5, Math.round(params.period ?? 14));
8892
- const threshold = Math.min(50, Math.max(10, params.threshold ?? 30));
8893
- const i = bars.length - 1;
8894
- if (i < period) return null;
8895
- const value = rsi(bars.map((b) => b.close), period)[i];
8896
- if (value === void 0) return null;
8897
- if (!(value < threshold)) return null;
8898
- return {
8899
- metrics: { rsi: value },
8900
- reason: `RSI(${period}) = ${value.toFixed(1)},进入超卖区(逆势信号)`,
8901
- reasonKey: "scr.rsi-oversold.reason",
8902
- reasonParams: {
8903
- period,
8904
- val: value.toFixed(1)
8905
- }
8906
- };
8907
- }
8908
- },
8909
- {
8910
- id: "scr.near-high",
8911
- name: "接近一年新高",
8912
- summary: "现价距 N 日最高价不超过 X%,强势整理/突破前夜的动量筛选",
8913
- params: [{
8914
- key: "window",
8915
- label: "窗口(日)",
8916
- default: 250,
8917
- min: 60,
8918
- max: 500,
8919
- step: 10
8920
- }, {
8921
- key: "withinPct",
8922
- label: "接近阈值(%)",
8923
- default: 5,
8924
- min: 1,
8925
- max: 30,
8926
- step: 1
8927
- }],
8928
- columns: [{
8929
- key: "offHighPct",
8930
- label: "距高点",
8931
- format: "percent"
8932
- }],
8933
- evaluate(bars, params) {
8934
- const window = Math.max(60, Math.round(params.window ?? 250));
8935
- const withinPct = Math.max(1, params.withinPct ?? 5);
8936
- const i = bars.length - 1;
8937
- if (i + 1 < window) return null;
8938
- const windowBars = bars.slice(i + 1 - window);
8939
- const highMax = Math.max(...windowBars.map((b) => b.high));
8940
- if (!(highMax > 0)) return null;
8941
- const offHighPct = (highMax - bars[i].close) / highMax * 100;
8942
- if (!(offHighPct >= 0 && offHighPct <= withinPct)) return null;
8943
- return {
8944
- metrics: { offHighPct },
8945
- reason: `距 ${window} 日高点仅 ${offHighPct.toFixed(2)}%,处于突破前夜`,
8946
- reasonKey: "scr.near-high.reason",
8947
- reasonParams: {
8948
- n: window,
8949
- pct: offHighPct.toFixed(2)
8950
- }
8951
- };
8952
- }
8953
- },
8954
- {
8955
- id: "scr.above-ma",
8956
- name: "站上牛熊线",
8957
- summary: "现价站上长期均线且均线斜率向上,筛选长线多头环境的标的",
8958
- params: [{
8959
- key: "period",
8960
- label: "牛熊线周期",
8961
- default: 200,
8962
- min: 50,
8963
- max: 300,
8964
- step: 10
8965
- }, {
8966
- key: "slopeBars",
8967
- label: "斜率窗口(日)",
8968
- default: 20,
8969
- min: 5,
8970
- max: 60,
8971
- step: 5
8972
- }],
8973
- columns: [{
8974
- key: "aboveMaPct",
8975
- label: "高于均线",
8976
- format: "percent"
8977
- }, {
8978
- key: "maSlopePct",
8979
- label: "均线斜率",
8980
- format: "percent"
8981
- }],
8982
- evaluate(bars, params) {
8983
- const period = Math.max(50, Math.round(params.period ?? 200));
8984
- const slopeBars = Math.max(5, Math.round(params.slopeBars ?? 20));
8985
- const i = bars.length - 1;
8986
- if (i < period + slopeBars - 1) return null;
8987
- const maSeries = sma(bars.map((b) => b.close), period);
8988
- const ma = maSeries[i];
8989
- const maPrev = maSeries[i - slopeBars];
8990
- if (ma === void 0 || maPrev === void 0 || !(maPrev > 0)) return null;
8991
- const close = bars[i].close;
8992
- if (!(close > ma) || !(ma > maPrev)) return null;
8993
- const aboveMaPct = (close - ma) / ma * 100;
8994
- const maSlopePct = (ma - maPrev) / maPrev * 100;
8995
- return {
8996
- metrics: {
8997
- aboveMaPct,
8998
- maSlopePct
8999
- },
9000
- reason: `现价高于 SMA(${period}) ${aboveMaPct.toFixed(2)}%,均线 ${slopeBars} 日斜率 +${maSlopePct.toFixed(2)}%`,
9001
- reasonKey: "scr.above-ma.reason",
9002
- reasonParams: {
9003
- period,
9004
- above: aboveMaPct.toFixed(2),
9005
- slope: slopeBars,
9006
- slopePct: maSlopePct.toFixed(2)
9007
- }
9008
- };
9009
- }
9010
- }
9011
- ];
9012
- //#endregion
9013
- //#region ../strategies/lib/paradigms/index.js
9014
- /**
9015
- * 6 个经典策略参考范式汇聚。
9016
- */
9017
- const strategyParadigms = [
9018
- {
9019
- id: "donchian-breakout",
9020
- horizon: "short",
9021
- name: "唐奇安通道突破",
9022
- summary: "收盘价突破前 N1 根最高价做多,跌破前 N2 根最低价离场(海龟经典简化版)",
8905
+ id: "donchian-breakout",
8906
+ horizon: "short",
8907
+ name: "唐奇安通道突破",
8908
+ summary: "收盘价突破前 N1 根最高价做多,跌破前 N2 根最低价离场(海龟经典简化版)",
9023
8909
  params: [{
9024
8910
  key: "lookbackEntry",
9025
8911
  label: "突破周期 (N1)",
@@ -9038,6 +8924,12 @@ window.__ModuleLoader__.load({
9038
8924
  compute(bars, params) {
9039
8925
  const n1 = Math.max(2, Math.round(params.lookbackEntry ?? 20));
9040
8926
  const n2 = Math.max(1, Math.round(params.lookbackExit ?? 10));
8927
+ const fmtPrice = (value) => {
8928
+ if (!Number.isFinite(value)) return "—";
8929
+ const abs = Math.abs(value);
8930
+ if (abs < 1) return value.toFixed(abs >= .01 ? 4 : 6);
8931
+ return value.toFixed(Math.abs(Number(value.toFixed(2)) - value) < 1e-9 ? 2 : 3);
8932
+ };
9041
8933
  const signals = [];
9042
8934
  let inPosition = false;
9043
8935
  for (let i = Math.max(n1, n2); i < bars.length; i++) {
@@ -9053,12 +8945,12 @@ window.__ModuleLoader__.load({
9053
8945
  action: "entry",
9054
8946
  direction: "long",
9055
8947
  price: currentClose,
9056
- reason: `收盘价 (${currentClose.toFixed(2)}) 突破前 ${n1} 根最高价 (${highestHigh.toFixed(2)})`,
8948
+ reason: `收盘价 (${fmtPrice(currentClose)}) 突破前 ${n1} 根最高价 (${fmtPrice(highestHigh)})`,
9057
8949
  reasonKey: "strat.donchian-breakout.reason.entry",
9058
8950
  reasonParams: {
9059
- close: currentClose.toFixed(2),
8951
+ close: fmtPrice(currentClose),
9060
8952
  n: n1,
9061
- high: highestHigh.toFixed(2)
8953
+ high: fmtPrice(highestHigh)
9062
8954
  }
9063
8955
  });
9064
8956
  inPosition = true;
@@ -9069,12 +8961,12 @@ window.__ModuleLoader__.load({
9069
8961
  action: "exit",
9070
8962
  direction: "flat",
9071
8963
  price: currentClose,
9072
- reason: `收盘价 (${currentClose.toFixed(2)}) 跌破前 ${n2} 根最低价 (${lowestLow.toFixed(2)})`,
8964
+ reason: `收盘价 (${fmtPrice(currentClose)}) 跌破前 ${n2} 根最低价 (${fmtPrice(lowestLow)})`,
9073
8965
  reasonKey: "strat.donchian-breakout.reason.exit",
9074
8966
  reasonParams: {
9075
- close: currentClose.toFixed(2),
8967
+ close: fmtPrice(currentClose),
9076
8968
  n: n2,
9077
- low: lowestLow.toFixed(2)
8969
+ low: fmtPrice(lowestLow)
9078
8970
  }
9079
8971
  });
9080
8972
  inPosition = false;
@@ -9118,7 +9010,28 @@ window.__ModuleLoader__.load({
9118
9010
  const period = Math.max(2, Math.round(params.rsiPeriod ?? 2));
9119
9011
  const enterThresh = Number(params.entryThreshold ?? 10);
9120
9012
  const exitThresh = Number(params.exitThreshold ?? 60);
9121
- const rsiValues = rsi(bars.map((b) => b.close), period);
9013
+ const rsiOf = (values, period) => {
9014
+ const out = new Array(values.length).fill(void 0);
9015
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9016
+ let gain = 0;
9017
+ let loss = 0;
9018
+ for (let index = 1; index <= period; index++) {
9019
+ const delta = values[index] - values[index - 1];
9020
+ if (delta >= 0) gain += delta;
9021
+ else loss -= delta;
9022
+ }
9023
+ gain /= period;
9024
+ loss /= period;
9025
+ out[period] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
9026
+ for (let index = period + 1; index < values.length; index++) {
9027
+ const delta = values[index] - values[index - 1];
9028
+ gain = (gain * (period - 1) + Math.max(delta, 0)) / period;
9029
+ loss = (loss * (period - 1) + Math.max(-delta, 0)) / period;
9030
+ out[index] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
9031
+ }
9032
+ return out;
9033
+ };
9034
+ const rsiValues = rsiOf(bars.map((b) => b.close), period);
9122
9035
  const signals = [];
9123
9036
  let inPosition = false;
9124
9037
  for (let i = 0; i < bars.length; i++) {
@@ -9184,9 +9097,29 @@ window.__ModuleLoader__.load({
9184
9097
  compute(bars, params) {
9185
9098
  const fastP = Math.max(2, Math.round(params.fastPeriod ?? 20));
9186
9099
  const slowP = Math.max(fastP + 1, Math.round(params.slowPeriod ?? 60));
9100
+ const fmtPrice = (value) => {
9101
+ if (!Number.isFinite(value)) return "—";
9102
+ const abs = Math.abs(value);
9103
+ if (abs < 1) return value.toFixed(abs >= .01 ? 4 : 6);
9104
+ return value.toFixed(Math.abs(Number(value.toFixed(2)) - value) < 1e-9 ? 2 : 3);
9105
+ };
9106
+ const emaOf = (values, period) => {
9107
+ const out = new Array(values.length).fill(void 0);
9108
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9109
+ const k = 2 / (period + 1);
9110
+ let seed = 0;
9111
+ for (let index = 0; index < period; index++) seed += values[index];
9112
+ let prev = seed / period;
9113
+ out[period - 1] = prev;
9114
+ for (let index = period; index < values.length; index++) {
9115
+ prev = values[index] * k + prev * (1 - k);
9116
+ out[index] = prev;
9117
+ }
9118
+ return out;
9119
+ };
9187
9120
  const closes = bars.map((b) => b.close);
9188
- const fastEma = ema(closes, fastP);
9189
- const slowEma = ema(closes, slowP);
9121
+ const fastEma = emaOf(closes, fastP);
9122
+ const slowEma = emaOf(closes, slowP);
9190
9123
  const signals = [];
9191
9124
  let inPosition = false;
9192
9125
  for (let i = 1; i < bars.length; i++) {
@@ -9202,13 +9135,13 @@ window.__ModuleLoader__.load({
9202
9135
  action: "entry",
9203
9136
  direction: "long",
9204
9137
  price: bars[i].close,
9205
- reason: `EMA(${fastP}) (${currFast.toFixed(2)}) 上穿 EMA(${slowP}) (${currSlow.toFixed(2)}) 形成金叉`,
9138
+ reason: `EMA(${fastP}) (${fmtPrice(currFast)}) 上穿 EMA(${slowP}) (${fmtPrice(currSlow)}) 形成金叉`,
9206
9139
  reasonKey: "strat.ema-crossover.reason.entry",
9207
9140
  reasonParams: {
9208
9141
  fastP,
9209
- fast: currFast.toFixed(2),
9142
+ fast: fmtPrice(currFast),
9210
9143
  slowP,
9211
- slow: currSlow.toFixed(2)
9144
+ slow: fmtPrice(currSlow)
9212
9145
  }
9213
9146
  });
9214
9147
  inPosition = true;
@@ -9219,13 +9152,13 @@ window.__ModuleLoader__.load({
9219
9152
  action: "exit",
9220
9153
  direction: "flat",
9221
9154
  price: bars[i].close,
9222
- reason: `EMA(${fastP}) (${currFast.toFixed(2)}) 下穿 EMA(${slowP}) (${currSlow.toFixed(2)}) 形成死叉`,
9155
+ reason: `EMA(${fastP}) (${fmtPrice(currFast)}) 下穿 EMA(${slowP}) (${fmtPrice(currSlow)}) 形成死叉`,
9223
9156
  reasonKey: "strat.ema-crossover.reason.exit",
9224
9157
  reasonParams: {
9225
9158
  fastP,
9226
- fast: currFast.toFixed(2),
9159
+ fast: fmtPrice(currFast),
9227
9160
  slowP,
9228
- slow: currSlow.toFixed(2)
9161
+ slow: fmtPrice(currSlow)
9229
9162
  }
9230
9163
  });
9231
9164
  inPosition = false;
@@ -9257,7 +9190,47 @@ window.__ModuleLoader__.load({
9257
9190
  compute(bars, params) {
9258
9191
  const period = Math.max(5, Math.round(params.period ?? 20));
9259
9192
  const k = Number(params.multiplier ?? 2);
9260
- const { mid, lower } = bollinger(bars.map((b) => b.close), period, k);
9193
+ const fmtPrice = (value) => {
9194
+ if (!Number.isFinite(value)) return "—";
9195
+ const abs = Math.abs(value);
9196
+ if (abs < 1) return value.toFixed(abs >= .01 ? 4 : 6);
9197
+ return value.toFixed(Math.abs(Number(value.toFixed(2)) - value) < 1e-9 ? 2 : 3);
9198
+ };
9199
+ const smaOf = (values, period) => {
9200
+ const out = new Array(values.length).fill(void 0);
9201
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9202
+ let sum = 0;
9203
+ for (let index = 0; index < values.length; index++) {
9204
+ sum += values[index];
9205
+ if (index >= period) sum -= values[index - period];
9206
+ if (index >= period - 1) out[index] = sum / period;
9207
+ }
9208
+ return out;
9209
+ };
9210
+ const stdevOf = (values, period) => {
9211
+ const out = new Array(values.length).fill(void 0);
9212
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9213
+ for (let index = period - 1; index < values.length; index++) {
9214
+ let mean = 0;
9215
+ for (let offset = 0; offset < period; offset++) mean += values[index - offset];
9216
+ mean /= period;
9217
+ let variance = 0;
9218
+ for (let offset = 0; offset < period; offset++) {
9219
+ const delta = values[index - offset] - mean;
9220
+ variance += delta * delta;
9221
+ }
9222
+ out[index] = Math.sqrt(variance / period);
9223
+ }
9224
+ return out;
9225
+ };
9226
+ const closes = bars.map((b) => b.close);
9227
+ const mid = smaOf(closes, period);
9228
+ const sd = stdevOf(closes, period);
9229
+ const lower = closes.map((_, index) => {
9230
+ const base = mid[index];
9231
+ const dev = sd[index];
9232
+ return base === void 0 || dev === void 0 ? void 0 : base - k * dev;
9233
+ });
9261
9234
  const signals = [];
9262
9235
  let inPosition = false;
9263
9236
  for (let i = period - 1; i < bars.length; i++) {
@@ -9272,11 +9245,11 @@ window.__ModuleLoader__.load({
9272
9245
  action: "entry",
9273
9246
  direction: "long",
9274
9247
  price: currentClose,
9275
- reason: `收盘价 (${currentClose.toFixed(2)}) 跌破布林下轨 (${currentLower.toFixed(2)}),触发波段均值回归`,
9248
+ reason: `收盘价 (${fmtPrice(currentClose)}) 跌破布林下轨 (${fmtPrice(currentLower)}),触发波段均值回归`,
9276
9249
  reasonKey: "strat.bollinger-reversion.reason.entry",
9277
9250
  reasonParams: {
9278
- close: currentClose.toFixed(2),
9279
- band: currentLower.toFixed(2)
9251
+ close: fmtPrice(currentClose),
9252
+ band: fmtPrice(currentLower)
9280
9253
  }
9281
9254
  });
9282
9255
  inPosition = true;
@@ -9287,11 +9260,11 @@ window.__ModuleLoader__.load({
9287
9260
  action: "exit",
9288
9261
  direction: "flat",
9289
9262
  price: currentClose,
9290
- reason: `收盘价 (${currentClose.toFixed(2)}) 成功回归至布林中轨 (${currentMid.toFixed(2)}),完成目标止盈`,
9263
+ reason: `收盘价 (${fmtPrice(currentClose)}) 成功回归至布林中轨 (${fmtPrice(currentMid)}),完成目标止盈`,
9291
9264
  reasonKey: "strat.bollinger-reversion.reason.exit",
9292
9265
  reasonParams: {
9293
- close: currentClose.toFixed(2),
9294
- mid: currentMid.toFixed(2)
9266
+ close: fmtPrice(currentClose),
9267
+ mid: fmtPrice(currentMid)
9295
9268
  }
9296
9269
  });
9297
9270
  inPosition = false;
@@ -9315,7 +9288,24 @@ window.__ModuleLoader__.load({
9315
9288
  }],
9316
9289
  compute(bars, params) {
9317
9290
  const period = Math.max(10, Math.round(params.period ?? 200));
9318
- const smaValues = sma(bars.map((b) => b.close), period);
9291
+ const fmtPrice = (value) => {
9292
+ if (!Number.isFinite(value)) return "—";
9293
+ const abs = Math.abs(value);
9294
+ if (abs < 1) return value.toFixed(abs >= .01 ? 4 : 6);
9295
+ return value.toFixed(Math.abs(Number(value.toFixed(2)) - value) < 1e-9 ? 2 : 3);
9296
+ };
9297
+ const smaOf = (values, period) => {
9298
+ const out = new Array(values.length).fill(void 0);
9299
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9300
+ let sum = 0;
9301
+ for (let index = 0; index < values.length; index++) {
9302
+ sum += values[index];
9303
+ if (index >= period) sum -= values[index - period];
9304
+ if (index >= period - 1) out[index] = sum / period;
9305
+ }
9306
+ return out;
9307
+ };
9308
+ const smaValues = smaOf(bars.map((b) => b.close), period);
9319
9309
  const signals = [];
9320
9310
  let inPosition = false;
9321
9311
  for (let i = period - 1; i < bars.length; i++) {
@@ -9329,12 +9319,12 @@ window.__ModuleLoader__.load({
9329
9319
  action: "entry",
9330
9320
  direction: "long",
9331
9321
  price: currentClose,
9332
- reason: `收盘价 (${currentClose.toFixed(2)}) 站上长期基线 SMA(${period}) (${currentSma.toFixed(2)}),确立多头趋势`,
9322
+ reason: `收盘价 (${fmtPrice(currentClose)}) 站上长期基线 SMA(${period}) (${fmtPrice(currentSma)}),确立多头趋势`,
9333
9323
  reasonKey: "strat.sma-baseline.reason.entry",
9334
9324
  reasonParams: {
9335
- close: currentClose.toFixed(2),
9325
+ close: fmtPrice(currentClose),
9336
9326
  period,
9337
- sma: currentSma.toFixed(2)
9327
+ sma: fmtPrice(currentSma)
9338
9328
  }
9339
9329
  });
9340
9330
  inPosition = true;
@@ -9345,12 +9335,12 @@ window.__ModuleLoader__.load({
9345
9335
  action: "exit",
9346
9336
  direction: "flat",
9347
9337
  price: currentClose,
9348
- reason: `收盘价 (${currentClose.toFixed(2)}) 跌破长期基线 SMA(${period}) (${currentSma.toFixed(2)}),转入防御避险`,
9338
+ reason: `收盘价 (${fmtPrice(currentClose)}) 跌破长期基线 SMA(${period}) (${fmtPrice(currentSma)}),转入防御避险`,
9349
9339
  reasonKey: "strat.sma-baseline.reason.exit",
9350
9340
  reasonParams: {
9351
- close: currentClose.toFixed(2),
9341
+ close: fmtPrice(currentClose),
9352
9342
  period,
9353
- sma: currentSma.toFixed(2)
9343
+ sma: fmtPrice(currentSma)
9354
9344
  }
9355
9345
  });
9356
9346
  inPosition = false;
@@ -9374,7 +9364,24 @@ window.__ModuleLoader__.load({
9374
9364
  }],
9375
9365
  compute(bars, params) {
9376
9366
  const lookback = Math.max(10, Math.round(params.lookbackBars ?? 250));
9377
- const smaValues = sma(bars.map((b) => b.close), lookback);
9367
+ const fmtPrice = (value) => {
9368
+ if (!Number.isFinite(value)) return "—";
9369
+ const abs = Math.abs(value);
9370
+ if (abs < 1) return value.toFixed(abs >= .01 ? 4 : 6);
9371
+ return value.toFixed(Math.abs(Number(value.toFixed(2)) - value) < 1e-9 ? 2 : 3);
9372
+ };
9373
+ const smaOf = (values, period) => {
9374
+ const out = new Array(values.length).fill(void 0);
9375
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9376
+ let sum = 0;
9377
+ for (let index = 0; index < values.length; index++) {
9378
+ sum += values[index];
9379
+ if (index >= period) sum -= values[index - period];
9380
+ if (index >= period - 1) out[index] = sum / period;
9381
+ }
9382
+ return out;
9383
+ };
9384
+ const smaValues = smaOf(bars.map((b) => b.close), lookback);
9378
9385
  const signals = [];
9379
9386
  let inPosition = false;
9380
9387
  for (let i = lookback; i < bars.length; i++) {
@@ -9390,12 +9397,12 @@ window.__ModuleLoader__.load({
9390
9397
  action: "entry",
9391
9398
  direction: "long",
9392
9399
  price: currentClose,
9393
- reason: `近 ${lookback} 周期动量为正 (+${(momentumReturn * 100).toFixed(1)}%) 且位于均线 (${currentSma.toFixed(2)}) 之上,确认强动量`,
9400
+ reason: `近 ${lookback} 周期动量为正 (+${(momentumReturn * 100).toFixed(1)}%) 且位于均线 (${fmtPrice(currentSma)}) 之上,确认强动量`,
9394
9401
  reasonKey: "strat.momentum-12m.reason.entry",
9395
9402
  reasonParams: {
9396
9403
  n: lookback,
9397
9404
  pct: (momentumReturn * 100).toFixed(1),
9398
- sma: currentSma.toFixed(2)
9405
+ sma: fmtPrice(currentSma)
9399
9406
  }
9400
9407
  });
9401
9408
  inPosition = true;
@@ -9407,12 +9414,12 @@ window.__ModuleLoader__.load({
9407
9414
  action: "exit",
9408
9415
  direction: "flat",
9409
9416
  price: currentClose,
9410
- reason: `${exitCause} (${(momentumReturn * 100).toFixed(1)}% / SMA ${currentSma.toFixed(2)}),动量衰减平仓`,
9417
+ reason: `${exitCause} (${(momentumReturn * 100).toFixed(1)}% / SMA ${fmtPrice(currentSma)}),动量衰减平仓`,
9411
9418
  reasonKey: "strat.momentum-12m.reason.exit",
9412
9419
  reasonParams: {
9413
9420
  cause: momentumReturn <= 0 ? "momentumNegative" : "belowBaseline",
9414
9421
  pct: (momentumReturn * 100).toFixed(1),
9415
- sma: currentSma.toFixed(2)
9422
+ sma: fmtPrice(currentSma)
9416
9423
  }
9417
9424
  });
9418
9425
  inPosition = false;
@@ -9423,40 +9430,488 @@ window.__ModuleLoader__.load({
9423
9430
  }
9424
9431
  ];
9425
9432
  //#endregion
9426
- //#region src/client/shell-faces.ts
9427
- /** localStorage read that survives unavailable storage (privacy mode) and corrupt JSON. */
9428
- function readJson(key, fallback) {
9429
- try {
9430
- const raw = localStorage.getItem(key);
9431
- if (raw === null) return fallback;
9432
- return JSON.parse(raw);
9433
- } catch {
9434
- return fallback;
9435
- }
9436
- }
9437
- /** localStorage write that survives unavailable storage. */
9438
- function writeJson(key, value) {
9439
- try {
9440
- localStorage.setItem(key, JSON.stringify(value));
9441
- } catch {}
9442
- }
9443
- //#endregion
9444
- //#region src/client/icons.tsx
9445
- /** 策略回测图表/仪表盘图标。 */
9446
- function IconStrategy({ size = 32 }) {
9447
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
9448
- viewBox: "0 0 24 24",
9449
- width: size,
9450
- height: size,
9451
- "aria-hidden": "true",
9452
- fill: "none",
9453
- stroke: "currentColor",
9454
- strokeWidth: "1.5",
9455
- strokeLinecap: "round",
9456
- strokeLinejoin: "round",
9457
- children: [
9458
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
9459
- x: "3",
9433
+ //#region ../strategies/lib/screeners/index.js
9434
+ const screenerParadigms = [
9435
+ {
9436
+ id: "scr.ma-bull-align",
9437
+ name: "均线多头排列",
9438
+ summary: "现价站上三线且 SMA(短) > SMA(中) > SMA(长),筛选趋势结构完整的标的",
9439
+ params: [
9440
+ {
9441
+ key: "n1",
9442
+ label: "短期均线",
9443
+ default: 20,
9444
+ min: 5,
9445
+ max: 120,
9446
+ step: 5
9447
+ },
9448
+ {
9449
+ key: "n2",
9450
+ label: "中期均线",
9451
+ default: 60,
9452
+ min: 20,
9453
+ max: 250,
9454
+ step: 10
9455
+ },
9456
+ {
9457
+ key: "n3",
9458
+ label: "长期均线",
9459
+ default: 120,
9460
+ min: 50,
9461
+ max: 300,
9462
+ step: 10
9463
+ }
9464
+ ],
9465
+ columns: [{
9466
+ key: "distLongPct",
9467
+ label: "距长期均线",
9468
+ format: "percent"
9469
+ }],
9470
+ evaluate(bars, params) {
9471
+ const n1 = Math.max(5, Math.round(params.n1 ?? 20));
9472
+ const n2 = Math.max(n1, Math.round(params.n2 ?? 60));
9473
+ const n3 = Math.max(n2, Math.round(params.n3 ?? 120));
9474
+ const i = bars.length - 1;
9475
+ if (i + 1 < n3) return null;
9476
+ const smaOf = (values, period) => {
9477
+ const out = new Array(values.length).fill(void 0);
9478
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9479
+ let sum = 0;
9480
+ for (let index = 0; index < values.length; index++) {
9481
+ sum += values[index];
9482
+ if (index >= period) sum -= values[index - period];
9483
+ if (index >= period - 1) out[index] = sum / period;
9484
+ }
9485
+ return out;
9486
+ };
9487
+ const closes = bars.map((b) => b.close);
9488
+ const s1 = smaOf(closes, n1)[i];
9489
+ const s2 = smaOf(closes, n2)[i];
9490
+ const s3 = smaOf(closes, n3)[i];
9491
+ if (s1 === void 0 || s2 === void 0 || s3 === void 0) return null;
9492
+ const close = bars[i].close;
9493
+ if (!(close > s1 && s1 > s2 && s2 > s3)) return null;
9494
+ return {
9495
+ metrics: { distLongPct: (close - s3) / s3 * 100 },
9496
+ reason: `现价站上三线且 SMA(${n1}) > SMA(${n2}) > SMA(${n3}),多头排列`,
9497
+ reasonKey: "scr.ma-bull-align.reason",
9498
+ reasonParams: {
9499
+ n1,
9500
+ n2,
9501
+ n3
9502
+ }
9503
+ };
9504
+ }
9505
+ },
9506
+ {
9507
+ id: "scr.volume-breakout",
9508
+ name: "放量突破",
9509
+ summary: "收盘价创 N 日新高且成交量 ≥ 均量 M 倍,量价配合的突破筛选",
9510
+ params: [{
9511
+ key: "lookback",
9512
+ label: "突破窗口(日)",
9513
+ default: 20,
9514
+ min: 5,
9515
+ max: 60,
9516
+ step: 5
9517
+ }, {
9518
+ key: "volMultiple",
9519
+ label: "量能倍数",
9520
+ default: 2,
9521
+ min: 1,
9522
+ max: 10,
9523
+ step: .5
9524
+ }],
9525
+ columns: [{
9526
+ key: "volRatio",
9527
+ label: "量比(倍)"
9528
+ }, {
9529
+ key: "breakoutPct",
9530
+ label: "突破幅度",
9531
+ format: "percent"
9532
+ }],
9533
+ evaluate(bars, params) {
9534
+ const lookback = Math.max(5, Math.round(params.lookback ?? 20));
9535
+ const volMultiple = Math.max(1, params.volMultiple ?? 2);
9536
+ const i = bars.length - 1;
9537
+ if (i < lookback) return null;
9538
+ const smaOf = (values, period) => {
9539
+ const out = new Array(values.length).fill(void 0);
9540
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9541
+ let sum = 0;
9542
+ for (let index = 0; index < values.length; index++) {
9543
+ sum += values[index];
9544
+ if (index >= period) sum -= values[index - period];
9545
+ if (index >= period - 1) out[index] = sum / period;
9546
+ }
9547
+ return out;
9548
+ };
9549
+ const window = bars.slice(i - lookback, i);
9550
+ const priorHigh = Math.max(...window.map((b) => b.high));
9551
+ const avgVolume = smaOf(bars.map((b) => b.volume), lookback)[i];
9552
+ if (avgVolume === void 0 || avgVolume <= 0) return null;
9553
+ const close = bars[i].close;
9554
+ const volume = bars[i].volume;
9555
+ if (!(close > priorHigh) || !(volume >= volMultiple * avgVolume)) return null;
9556
+ const volRatio = volume / avgVolume;
9557
+ return {
9558
+ metrics: {
9559
+ volRatio,
9560
+ breakoutPct: (close - priorHigh) / priorHigh * 100
9561
+ },
9562
+ reason: `放量 ${volRatio.toFixed(2)} 倍突破 ${lookback} 日高点`,
9563
+ reasonKey: "scr.volume-breakout.reason",
9564
+ reasonParams: {
9565
+ ratio: volRatio.toFixed(2),
9566
+ n: lookback
9567
+ }
9568
+ };
9569
+ }
9570
+ },
9571
+ {
9572
+ id: "scr.rsi-oversold",
9573
+ name: "RSI 超卖",
9574
+ summary: "RSI 跌入超卖区(默认 <30),筛选值得盯的反转候选(逆势信号)",
9575
+ params: [{
9576
+ key: "period",
9577
+ label: "RSI 周期",
9578
+ default: 14,
9579
+ min: 5,
9580
+ max: 30,
9581
+ step: 1
9582
+ }, {
9583
+ key: "threshold",
9584
+ label: "超卖阈值",
9585
+ default: 30,
9586
+ min: 10,
9587
+ max: 50,
9588
+ step: 5
9589
+ }],
9590
+ columns: [{
9591
+ key: "rsi",
9592
+ label: "RSI"
9593
+ }],
9594
+ evaluate(bars, params) {
9595
+ const period = Math.max(5, Math.round(params.period ?? 14));
9596
+ const threshold = Math.min(50, Math.max(10, params.threshold ?? 30));
9597
+ const i = bars.length - 1;
9598
+ if (i < period) return null;
9599
+ const rsiOf = (values, period) => {
9600
+ const out = new Array(values.length).fill(void 0);
9601
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9602
+ let gain = 0;
9603
+ let loss = 0;
9604
+ for (let index = 1; index <= period; index++) {
9605
+ const delta = values[index] - values[index - 1];
9606
+ if (delta >= 0) gain += delta;
9607
+ else loss -= delta;
9608
+ }
9609
+ gain /= period;
9610
+ loss /= period;
9611
+ out[period] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
9612
+ for (let index = period + 1; index < values.length; index++) {
9613
+ const delta = values[index] - values[index - 1];
9614
+ gain = (gain * (period - 1) + Math.max(delta, 0)) / period;
9615
+ loss = (loss * (period - 1) + Math.max(-delta, 0)) / period;
9616
+ out[index] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
9617
+ }
9618
+ return out;
9619
+ };
9620
+ const value = rsiOf(bars.map((b) => b.close), period)[i];
9621
+ if (value === void 0) return null;
9622
+ if (!(value < threshold)) return null;
9623
+ return {
9624
+ metrics: { rsi: value },
9625
+ reason: `RSI(${period}) = ${value.toFixed(1)},进入超卖区(逆势信号)`,
9626
+ reasonKey: "scr.rsi-oversold.reason",
9627
+ reasonParams: {
9628
+ period,
9629
+ val: value.toFixed(1)
9630
+ }
9631
+ };
9632
+ }
9633
+ },
9634
+ {
9635
+ id: "scr.near-high",
9636
+ name: "接近一年新高",
9637
+ summary: "现价距 N 日最高价不超过 X%,强势整理/突破前夜的动量筛选",
9638
+ params: [{
9639
+ key: "window",
9640
+ label: "窗口(日)",
9641
+ default: 250,
9642
+ min: 60,
9643
+ max: 500,
9644
+ step: 10
9645
+ }, {
9646
+ key: "withinPct",
9647
+ label: "接近阈值(%)",
9648
+ default: 5,
9649
+ min: 1,
9650
+ max: 30,
9651
+ step: 1
9652
+ }],
9653
+ columns: [{
9654
+ key: "offHighPct",
9655
+ label: "距高点",
9656
+ format: "percent"
9657
+ }],
9658
+ evaluate(bars, params) {
9659
+ const window = Math.max(60, Math.round(params.window ?? 250));
9660
+ const withinPct = Math.max(1, params.withinPct ?? 5);
9661
+ const i = bars.length - 1;
9662
+ if (i + 1 < window) return null;
9663
+ const windowBars = bars.slice(i + 1 - window);
9664
+ const highMax = Math.max(...windowBars.map((b) => b.high));
9665
+ if (!(highMax > 0)) return null;
9666
+ const offHighPct = (highMax - bars[i].close) / highMax * 100;
9667
+ if (!(offHighPct >= 0 && offHighPct <= withinPct)) return null;
9668
+ return {
9669
+ metrics: { offHighPct },
9670
+ reason: `距 ${window} 日高点仅 ${offHighPct.toFixed(2)}%,处于突破前夜`,
9671
+ reasonKey: "scr.near-high.reason",
9672
+ reasonParams: {
9673
+ n: window,
9674
+ pct: offHighPct.toFixed(2)
9675
+ }
9676
+ };
9677
+ }
9678
+ },
9679
+ {
9680
+ id: "scr.above-ma",
9681
+ name: "站上牛熊线",
9682
+ summary: "现价站上长期均线且均线斜率向上,筛选长线多头环境的标的",
9683
+ params: [{
9684
+ key: "period",
9685
+ label: "牛熊线周期",
9686
+ default: 200,
9687
+ min: 50,
9688
+ max: 300,
9689
+ step: 10
9690
+ }, {
9691
+ key: "slopeBars",
9692
+ label: "斜率窗口(日)",
9693
+ default: 20,
9694
+ min: 5,
9695
+ max: 60,
9696
+ step: 5
9697
+ }],
9698
+ columns: [{
9699
+ key: "aboveMaPct",
9700
+ label: "高于均线",
9701
+ format: "percent"
9702
+ }, {
9703
+ key: "maSlopePct",
9704
+ label: "均线斜率",
9705
+ format: "percent"
9706
+ }],
9707
+ evaluate(bars, params) {
9708
+ const period = Math.max(50, Math.round(params.period ?? 200));
9709
+ const slopeBars = Math.max(5, Math.round(params.slopeBars ?? 20));
9710
+ const i = bars.length - 1;
9711
+ if (i < period + slopeBars - 1) return null;
9712
+ const smaOf = (values, period) => {
9713
+ const out = new Array(values.length).fill(void 0);
9714
+ if (!Number.isFinite(period) || period < 1 || values.length < period) return out;
9715
+ let sum = 0;
9716
+ for (let index = 0; index < values.length; index++) {
9717
+ sum += values[index];
9718
+ if (index >= period) sum -= values[index - period];
9719
+ if (index >= period - 1) out[index] = sum / period;
9720
+ }
9721
+ return out;
9722
+ };
9723
+ const maSeries = smaOf(bars.map((b) => b.close), period);
9724
+ const ma = maSeries[i];
9725
+ const maPrev = maSeries[i - slopeBars];
9726
+ if (ma === void 0 || maPrev === void 0 || !(maPrev > 0)) return null;
9727
+ const close = bars[i].close;
9728
+ if (!(close > ma) || !(ma > maPrev)) return null;
9729
+ const aboveMaPct = (close - ma) / ma * 100;
9730
+ const maSlopePct = (ma - maPrev) / maPrev * 100;
9731
+ return {
9732
+ metrics: {
9733
+ aboveMaPct,
9734
+ maSlopePct
9735
+ },
9736
+ reason: `现价高于 SMA(${period}) ${aboveMaPct.toFixed(2)}%,均线 ${slopeBars} 日斜率 +${maSlopePct.toFixed(2)}%`,
9737
+ reasonKey: "scr.above-ma.reason",
9738
+ reasonParams: {
9739
+ period,
9740
+ above: aboveMaPct.toFixed(2),
9741
+ slope: slopeBars,
9742
+ slopePct: maSlopePct.toFixed(2)
9743
+ }
9744
+ };
9745
+ }
9746
+ }
9747
+ ];
9748
+ //#endregion
9749
+ //#region ../strategies/lib/management.js
9750
+ /**
9751
+ * 策略管理名册合成与内置源码导出(策略管理,2026-09-07)。
9752
+ *
9753
+ * 覆盖 + 墓碑模型的唯一合成逻辑:内置范式(代码常量)为出厂默认层,
9754
+ * 自定义 store 中同 id 的记录是覆盖层,墓碑表是删除层。agent 侧
9755
+ * (resolveStrategyDefinition)与 GUI 侧(StrategyView)共用本模块,
9756
+ * 保证两侧名册一致。纯函数、零 Node 依赖,浏览器安全。
9757
+ */
9758
+ /** 内置范式策略 id 集合(稳定词汇;选股器 id 带 'scr.' 前缀,不在此列)。 */
9759
+ const BUILTIN_STRATEGY_IDS = new Set(strategyParadigms.map((d) => d.id));
9760
+ /** 内置选股器 id 集合('scr.' 前缀词汇)。 */
9761
+ const BUILTIN_SCREENER_IDS = new Set(screenerParadigms.map((d) => d.id));
9762
+ function isBuiltinStrategyId(id) {
9763
+ return BUILTIN_STRATEGY_IDS.has(id);
9764
+ }
9765
+ function isBuiltinScreenerId(id) {
9766
+ return BUILTIN_SCREENER_IDS.has(id);
9767
+ }
9768
+ /**
9769
+ * 名册合成:内置 − 墓碑,覆盖记录原位替换同 id 内置,自定义按传入顺序追加。
9770
+ * @param paradigms 出厂默认名册(生产传 strategyParadigms,单测可传定制名册)
9771
+ * @param defs 自定义 store 校验后的定义(含覆盖内置的同 id 记录)
9772
+ * @param deletedIds 墓碑 id(内置删除标记;命中者从名册剔除)
9773
+ */
9774
+ function applyStrategyManagement(paradigms, defs, deletedIds) {
9775
+ const deleted = new Set(deletedIds);
9776
+ const overrides = /* @__PURE__ */ new Map();
9777
+ const customs = [];
9778
+ for (const def of defs) if (BUILTIN_STRATEGY_IDS.has(def.id)) overrides.set(def.id, def);
9779
+ else customs.push(def);
9780
+ const roster = [];
9781
+ for (const builtin of paradigms) {
9782
+ if (deleted.has(builtin.id)) continue;
9783
+ roster.push(overrides.get(builtin.id) ?? builtin);
9784
+ }
9785
+ return [...roster, ...customs];
9786
+ }
9787
+ /**
9788
+ * 内置策略 compute 源码导出(GUI「编辑内置」预填用)。
9789
+ *
9790
+ * 前提:内置 compute 一律自包含(指标数学内联,不引用外部闭包),方法速记
9791
+ * 形态 `compute(bars, params) { ... }` 归一为箭头形态 `(bars, params) => { ... }`
9792
+ * ——后者是 compileStrategySource 直接接受的形状。打包器若改写为箭头/function
9793
+ * 形态则原样透传(同样被 compileStrategySource 支持)。
9794
+ */
9795
+ function builtinStrategySource(def) {
9796
+ const raw = def.compute.toString().trim();
9797
+ const method = raw.match(/^compute\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/);
9798
+ if (method !== null) return `(${method[1]}) => {${method[2]}}`;
9799
+ return raw;
9800
+ }
9801
+ /**
9802
+ * 内置策略 → 可编辑记录预填(GUI 编辑器初值)。params 序列化保留 step,
9803
+ * 让覆盖保存后参数步进与出厂一致(校验器接受合法 step)。
9804
+ */
9805
+ function builtinStrategyRecord(def) {
9806
+ const params = def.params.map((p) => ({
9807
+ key: p.key,
9808
+ label: p.label,
9809
+ default: p.default,
9810
+ min: p.min,
9811
+ max: p.max,
9812
+ step: p.step
9813
+ }));
9814
+ return {
9815
+ id: def.id,
9816
+ title: def.name,
9817
+ horizon: def.horizon,
9818
+ summary: def.summary,
9819
+ paramsJson: JSON.stringify(params),
9820
+ computeSource: builtinStrategySource(def),
9821
+ createdAt: Date.now()
9822
+ };
9823
+ }
9824
+ /**
9825
+ * 选股器名册合成(覆盖 + 墓碑,与 applyStrategyManagement 同模型):
9826
+ * 内置 − 墓碑,覆盖记录原位替换同 id 内置,自定义按传入顺序追加。
9827
+ */
9828
+ function applyScreenerManagement(builtins, defs, deletedIds) {
9829
+ const deleted = new Set(deletedIds);
9830
+ const overrides = /* @__PURE__ */ new Map();
9831
+ const customs = [];
9832
+ for (const def of defs) if (BUILTIN_SCREENER_IDS.has(def.id)) overrides.set(def.id, def);
9833
+ else customs.push(def);
9834
+ const roster = [];
9835
+ for (const builtin of builtins) {
9836
+ if (deleted.has(builtin.id)) continue;
9837
+ roster.push(overrides.get(builtin.id) ?? builtin);
9838
+ }
9839
+ return [...roster, ...customs];
9840
+ }
9841
+ /**
9842
+ * 内置选股器 evaluate 源码导出(GUI「编辑内置」预填用),归一化规则同
9843
+ * builtinStrategySource:方法速记 `evaluate(bars, params) { ... }` → 箭头形态。
9844
+ */
9845
+ function builtinScreenerSource(def) {
9846
+ const raw = def.evaluate.toString().trim();
9847
+ const method = raw.match(/^evaluate\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/);
9848
+ if (method !== null) return `(${method[1]}) => {${method[2]}}`;
9849
+ return raw;
9850
+ }
9851
+ /** 内置选股器 → 可编辑记录预填(columns 序列化保真)。 */
9852
+ function builtinScreenerRecord(def) {
9853
+ const params = def.params.map((p) => ({
9854
+ key: p.key,
9855
+ label: p.label,
9856
+ default: p.default,
9857
+ min: p.min,
9858
+ max: p.max,
9859
+ step: p.step
9860
+ }));
9861
+ const columns = def.columns.map((c) => c.format === void 0 ? {
9862
+ key: c.key,
9863
+ label: c.label
9864
+ } : {
9865
+ key: c.key,
9866
+ label: c.label,
9867
+ format: c.format
9868
+ });
9869
+ return {
9870
+ id: def.id,
9871
+ title: def.name,
9872
+ horizon: "swing",
9873
+ summary: def.summary,
9874
+ paramsJson: JSON.stringify(params),
9875
+ columnsJson: JSON.stringify(columns),
9876
+ evaluateSource: builtinScreenerSource(def),
9877
+ createdAt: Date.now()
9878
+ };
9879
+ }
9880
+ //#endregion
9881
+ //#region src/client/shell-faces.ts
9882
+ /** localStorage read that survives unavailable storage (privacy mode) and corrupt JSON. */
9883
+ function readJson(key, fallback) {
9884
+ try {
9885
+ const raw = localStorage.getItem(key);
9886
+ if (raw === null) return fallback;
9887
+ return JSON.parse(raw);
9888
+ } catch {
9889
+ return fallback;
9890
+ }
9891
+ }
9892
+ /** localStorage write that survives unavailable storage. */
9893
+ function writeJson(key, value) {
9894
+ try {
9895
+ localStorage.setItem(key, JSON.stringify(value));
9896
+ } catch {}
9897
+ }
9898
+ //#endregion
9899
+ //#region src/client/icons.tsx
9900
+ /** 策略回测图表/仪表盘图标。 */
9901
+ function IconStrategy({ size = 32 }) {
9902
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
9903
+ viewBox: "0 0 24 24",
9904
+ width: size,
9905
+ height: size,
9906
+ "aria-hidden": "true",
9907
+ fill: "none",
9908
+ stroke: "currentColor",
9909
+ strokeWidth: "1.5",
9910
+ strokeLinecap: "round",
9911
+ strokeLinejoin: "round",
9912
+ children: [
9913
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
9914
+ x: "3",
9460
9915
  y: "3",
9461
9916
  width: "18",
9462
9917
  height: "18",
@@ -9518,8 +9973,484 @@ window.__ModuleLoader__.load({
9518
9973
  return translateOr(tr.exitReasonKey, tr.exitReason, t, params);
9519
9974
  }
9520
9975
  //#endregion
9976
+ //#region \0dsh-css:/home/runner/work/dsh-trading/dsh-trading/packages/client-ui-strategies/src/client/StrategyEditor.module.css.mjs
9977
+ const css$2 = "._9mrMhW_overlay{z-index:1000;background:#0f121673;justify-content:center;align-items:center;padding:24px;display:flex;position:fixed;inset:0}._9mrMhW_panel{background:var(--dsw-futu-bg-base);border:1px solid var(--dsw-futu-border-default);border-radius:var(--dsw-futu-radius-lg);box-shadow:var(--dsw-futu-shadow-lg,0 12px 40px #00000040);flex-direction:column;gap:12px;width:min(720px,100%);max-height:90vh;padding:16px 20px;display:flex;overflow-y:auto}._9mrMhW_panelHeader{justify-content:space-between;align-items:center;display:flex}._9mrMhW_panelTitle{color:var(--dsw-futu-text-primary);font-size:14px;font-weight:600}._9mrMhW_closeBtn{color:var(--dsw-futu-text-secondary);cursor:pointer;border-radius:var(--dsw-futu-radius-sm);background:0 0;border:none;padding:4px 6px;font-size:13px}._9mrMhW_closeBtn:hover{color:var(--dsw-futu-text-primary);background:var(--dsw-futu-bg-surface-hover)}._9mrMhW_formGrid{grid-template-columns:1fr 1fr 120px;gap:10px;display:grid}._9mrMhW_field{flex-direction:column;gap:4px;display:flex}._9mrMhW_fieldLabel{color:var(--dsw-futu-text-secondary);font-size:11.5px}._9mrMhW_fieldInput{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-sm);color:var(--dsw-futu-text-primary);outline:none;padding:6px 8px;font-size:12.5px}._9mrMhW_fieldInput:focus{border-color:var(--dsw-futu-border-active)}._9mrMhW_fieldInput:disabled{opacity:.6}._9mrMhW_paramTable{border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-sm);flex-direction:column;gap:6px;padding:8px;display:flex}._9mrMhW_paramHeadRow,._9mrMhW_paramRow{grid-template-columns:1.2fr 1.6fr .8fr .8fr .8fr .7fr 24px;align-items:center;gap:6px;display:grid}._9mrMhW_paramHeadRow{color:var(--dsw-futu-text-secondary);font-size:11px}._9mrMhW_paramCell{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-sm);color:var(--dsw-futu-text-primary);outline:none;min-width:0;padding:4px 6px;font-size:12px}._9mrMhW_paramCell:focus{border-color:var(--dsw-futu-border-active)}._9mrMhW_paramRemove{color:var(--dsw-futu-text-secondary);cursor:pointer;background:0 0;border:none;padding:2px;font-size:11px}._9mrMhW_paramRemove:hover{color:var(--dsw-futu-text-primary)}._9mrMhW_addParamBtn{border:1px dashed var(--dsw-futu-border-default);border-radius:var(--dsw-futu-radius-sm);color:var(--dsw-futu-text-secondary);cursor:pointer;background:0 0;align-self:flex-start;padding:4px 10px;font-size:12px}._9mrMhW_addParamBtn:hover{color:var(--dsw-futu-text-primary);border-color:var(--dsw-futu-border-active)}._9mrMhW_sourceArea{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-sm);color:var(--dsw-futu-text-primary);resize:vertical;white-space:pre;outline:none;padding:8px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.5}._9mrMhW_sourceArea:focus{border-color:var(--dsw-futu-border-active)}._9mrMhW_noticeOk{color:#2ba471;border-radius:var(--dsw-futu-radius-sm);background:#2ba47114;border:1px solid #2ba47159;padding:8px 10px;font-size:12.5px}._9mrMhW_noticeError{color:#e64545;border-radius:var(--dsw-futu-radius-sm);white-space:pre-wrap;word-break:break-all;background:#e6454514;border:1px solid #e6454559;padding:8px 10px;font-size:12.5px}._9mrMhW_actionBar{align-items:center;gap:8px;display:flex}._9mrMhW_actionSpacer{flex:1}._9mrMhW_primaryBtn,._9mrMhW_secondaryBtn{border-radius:var(--dsw-futu-radius-sm);cursor:pointer;border:1px solid #0000;padding:6px 16px;font-size:12.5px;font-weight:500}._9mrMhW_primaryBtn{background:var(--dsw-futu-border-active);color:#fff}._9mrMhW_primaryBtn:disabled,._9mrMhW_secondaryBtn:disabled{opacity:.5;cursor:default}._9mrMhW_secondaryBtn{background:var(--dsw-futu-bg-surface);border-color:var(--dsw-futu-border-subtle);color:var(--dsw-futu-text-secondary)}._9mrMhW_secondaryBtn:hover{color:var(--dsw-futu-text-primary);background:var(--dsw-futu-bg-surface-hover)}._9mrMhW_columnHeadRow,._9mrMhW_columnRow{grid-template-columns:1.4fr 2fr 1fr 24px;align-items:center;gap:6px;display:grid}._9mrMhW_columnHeadRow{color:var(--dsw-futu-text-secondary);font-size:11px}";
9978
+ const tagId$2 = "@dshtrading/client-ui-strategies/StrategyEditor.module.css";
9979
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]") === null) {
9980
+ const tag = document.createElement("style");
9981
+ tag.dataset.plugin = "@dshtrading/client-ui-strategies";
9982
+ tag.dataset.pluginCss = tagId$2;
9983
+ tag.textContent = css$2;
9984
+ document.head.appendChild(tag);
9985
+ }
9986
+ var StrategyEditor_module_css_default = {
9987
+ "panelHeader": "_9mrMhW_panelHeader",
9988
+ "paramHeadRow": "_9mrMhW_paramHeadRow",
9989
+ "fieldInput": "_9mrMhW_fieldInput",
9990
+ "sourceArea": "_9mrMhW_sourceArea",
9991
+ "paramRemove": "_9mrMhW_paramRemove",
9992
+ "closeBtn": "_9mrMhW_closeBtn",
9993
+ "fieldLabel": "_9mrMhW_fieldLabel",
9994
+ "paramTable": "_9mrMhW_paramTable",
9995
+ "panelTitle": "_9mrMhW_panelTitle",
9996
+ "overlay": "_9mrMhW_overlay",
9997
+ "paramRow": "_9mrMhW_paramRow",
9998
+ "paramCell": "_9mrMhW_paramCell",
9999
+ "field": "_9mrMhW_field",
10000
+ "formGrid": "_9mrMhW_formGrid",
10001
+ "panel": "_9mrMhW_panel",
10002
+ "actionBar": "_9mrMhW_actionBar",
10003
+ "primaryBtn": "_9mrMhW_primaryBtn",
10004
+ "secondaryBtn": "_9mrMhW_secondaryBtn",
10005
+ "columnRow": "_9mrMhW_columnRow",
10006
+ "noticeOk": "_9mrMhW_noticeOk",
10007
+ "columnHeadRow": "_9mrMhW_columnHeadRow",
10008
+ "addParamBtn": "_9mrMhW_addParamBtn",
10009
+ "actionSpacer": "_9mrMhW_actionSpacer",
10010
+ "noticeError": "_9mrMhW_noticeError"
10011
+ };
10012
+ //#endregion
10013
+ //#region src/client/ScreenerEditor.tsx
10014
+ /**
10015
+ * 选股器编辑器(选股器管理,2026-09-07):新建/编辑选股器的表单面板。
10016
+ *
10017
+ * 与 StrategyEditor 同构,差异:columns 行编辑(key/label/format)+ evaluate
10018
+ * 源码 textarea(单时点截面判断,无信号序列语义);无期限字段(记录恒 'swing')。
10019
+ * 「校验」走浏览器 Worker 版 validateCustomScreener,「保存」本地预校验后桥 PUT
10020
+ * (host vm 复校验)。编辑内置选股器时经 builtinScreenerRecord 预填自包含源码。
10021
+ */
10022
+ function rowsFromParamsJson$1(paramsJson) {
10023
+ try {
10024
+ const parsed = JSON.parse(paramsJson);
10025
+ if (!Array.isArray(parsed)) return [];
10026
+ return parsed.map((p) => ({
10027
+ key: String(p.key ?? ""),
10028
+ label: String(p.label ?? ""),
10029
+ default: String(p.default ?? ""),
10030
+ min: String(p.min ?? ""),
10031
+ max: String(p.max ?? ""),
10032
+ step: String(p.step ?? 1)
10033
+ }));
10034
+ } catch {
10035
+ return [];
10036
+ }
10037
+ }
10038
+ function rowsFromColumnsJson(columnsJson) {
10039
+ try {
10040
+ const parsed = JSON.parse(columnsJson);
10041
+ if (!Array.isArray(parsed)) return [];
10042
+ return parsed.map((c) => ({
10043
+ key: String(c.key ?? ""),
10044
+ label: String(c.label ?? ""),
10045
+ format: c.format === "percent" || c.format === "number" ? c.format : ""
10046
+ }));
10047
+ } catch {
10048
+ return [];
10049
+ }
10050
+ }
10051
+ function ScreenerEditor({ t, initial, onSave, onClose, onSaved }) {
10052
+ const isEdit = initial !== null;
10053
+ const [id, setId] = (0, react.useState)(initial?.id ?? "");
10054
+ const [title, setTitle] = (0, react.useState)(initial?.title ?? "");
10055
+ const [summary, setSummary] = (0, react.useState)(initial?.summary ?? "");
10056
+ const [rows, setRows] = (0, react.useState)(() => rowsFromParamsJson$1(initial?.paramsJson ?? "[]"));
10057
+ const [cols, setCols] = (0, react.useState)(() => rowsFromColumnsJson(initial?.columnsJson ?? "[]"));
10058
+ const [source, setSource] = (0, react.useState)(initial?.evaluateSource ?? "");
10059
+ const [busy, setBusy] = (0, react.useState)("");
10060
+ const [notice, setNotice] = (0, react.useState)(null);
10061
+ const idIsBuiltin = (0, react.useMemo)(() => BUILTIN_SCREENER_IDS.has(id.trim().toLowerCase()), [id]);
10062
+ const buildInput = () => {
10063
+ const trimmedId = id.trim().toLowerCase();
10064
+ if (!trimmedId) return { error: `${t("sv.mgmt.field.id")}: ${t("sv.mgmt.fieldRequired")}` };
10065
+ const params = [];
10066
+ for (const row of rows) {
10067
+ const key = row.key.trim();
10068
+ if (!key) continue;
10069
+ const def = Number(row.default);
10070
+ const min = Number(row.min);
10071
+ const max = Number(row.max);
10072
+ const step = Number(row.step);
10073
+ if (!Number.isFinite(def) || !Number.isFinite(min) || !Number.isFinite(max)) return { error: `${t("sv.mgmt.field.params")} [${key}]: ${t("sv.mgmt.fieldRequired")}` };
10074
+ params.push({
10075
+ key,
10076
+ label: row.label.trim() || key,
10077
+ default: def,
10078
+ min,
10079
+ max,
10080
+ step: Number.isFinite(step) && step > 0 ? step : 1
10081
+ });
10082
+ }
10083
+ const columns = [];
10084
+ for (const col of cols) {
10085
+ const key = col.key.trim();
10086
+ if (!key) continue;
10087
+ columns.push(col.format === "" ? {
10088
+ key,
10089
+ label: col.label.trim() || key
10090
+ } : {
10091
+ key,
10092
+ label: col.label.trim() || key,
10093
+ format: col.format
10094
+ });
10095
+ }
10096
+ if (columns.length === 0) return { error: `${t("sv.mgmt.field.columns")}: ${t("sv.mgmt.fieldRequired")}` };
10097
+ return {
10098
+ id: trimmedId,
10099
+ title: title.trim(),
10100
+ summary: summary.trim(),
10101
+ paramsJson: JSON.stringify(params),
10102
+ columnsJson: JSON.stringify(columns),
10103
+ evaluateSource: source,
10104
+ overridesScreener: idIsBuiltin
10105
+ };
10106
+ };
10107
+ const validateCurrent = async () => {
10108
+ const input = buildInput();
10109
+ if ("error" in input) return input.error;
10110
+ const result = await validateCustomScreener({
10111
+ id: input.id,
10112
+ title: input.title,
10113
+ horizon: "swing",
10114
+ summary: input.summary,
10115
+ paramsJson: input.paramsJson,
10116
+ columnsJson: input.columnsJson,
10117
+ evaluateSource: input.evaluateSource,
10118
+ createdAt: initial?.createdAt ?? Date.now()
10119
+ });
10120
+ return result.ok ? void 0 : result.reason;
10121
+ };
10122
+ const handleValidate = async () => {
10123
+ setBusy("validating");
10124
+ setNotice(null);
10125
+ try {
10126
+ const reason = await validateCurrent();
10127
+ setNotice(reason ? {
10128
+ kind: "error",
10129
+ text: reason
10130
+ } : {
10131
+ kind: "ok",
10132
+ text: t("sv.mgmt.validationOkScreener")
10133
+ });
10134
+ } finally {
10135
+ setBusy("");
10136
+ }
10137
+ };
10138
+ const handleSave = async () => {
10139
+ const input = buildInput();
10140
+ if ("error" in input) {
10141
+ setNotice({
10142
+ kind: "error",
10143
+ text: input.error
10144
+ });
10145
+ return;
10146
+ }
10147
+ if (!isEdit && input.overridesScreener) {
10148
+ if (!window.confirm(t("sv.mgmt.confirmOverrideScreener"))) return;
10149
+ }
10150
+ setBusy("saving");
10151
+ setNotice(null);
10152
+ try {
10153
+ const reason = await validateCurrent();
10154
+ if (reason !== void 0) {
10155
+ setNotice({
10156
+ kind: "error",
10157
+ text: reason
10158
+ });
10159
+ return;
10160
+ }
10161
+ const result = await onSave(input);
10162
+ if (result === null) {
10163
+ setNotice({
10164
+ kind: "error",
10165
+ text: t("sv.mgmt.saveFailed")
10166
+ });
10167
+ return;
10168
+ }
10169
+ if (!result.ok) {
10170
+ setNotice({
10171
+ kind: "error",
10172
+ text: `${t("sv.mgmt.saveFailed")}: ${result.reason}`
10173
+ });
10174
+ return;
10175
+ }
10176
+ onSaved();
10177
+ onClose();
10178
+ } finally {
10179
+ setBusy("");
10180
+ }
10181
+ };
10182
+ const updateRow = (index, patch) => {
10183
+ setRows((prev) => prev.map((row, i) => i === index ? {
10184
+ ...row,
10185
+ ...patch
10186
+ } : row));
10187
+ };
10188
+ const updateCol = (index, patch) => {
10189
+ setCols((prev) => prev.map((col, i) => i === index ? {
10190
+ ...col,
10191
+ ...patch
10192
+ } : col));
10193
+ };
10194
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10195
+ className: StrategyEditor_module_css_default.overlay,
10196
+ role: "dialog",
10197
+ "aria-modal": "true",
10198
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10199
+ className: StrategyEditor_module_css_default.panel,
10200
+ children: [
10201
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10202
+ className: StrategyEditor_module_css_default.panelHeader,
10203
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
10204
+ className: StrategyEditor_module_css_default.panelTitle,
10205
+ children: [t("sv.mgmt.editorTitleScreener"), isEdit ? ` · ${initial.id}` : ""]
10206
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10207
+ type: "button",
10208
+ className: StrategyEditor_module_css_default.closeBtn,
10209
+ onClick: onClose,
10210
+ children: "✕"
10211
+ })]
10212
+ }),
10213
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10214
+ className: StrategyEditor_module_css_default.formGrid,
10215
+ children: [
10216
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10217
+ className: StrategyEditor_module_css_default.field,
10218
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10219
+ className: StrategyEditor_module_css_default.fieldLabel,
10220
+ children: t("sv.mgmt.field.id")
10221
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10222
+ className: StrategyEditor_module_css_default.fieldInput,
10223
+ value: id,
10224
+ disabled: isEdit,
10225
+ onChange: (e) => setId(e.target.value),
10226
+ placeholder: "scr.custom-momentum"
10227
+ })]
10228
+ }),
10229
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10230
+ className: StrategyEditor_module_css_default.field,
10231
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10232
+ className: StrategyEditor_module_css_default.fieldLabel,
10233
+ children: t("sv.mgmt.field.title")
10234
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10235
+ className: StrategyEditor_module_css_default.fieldInput,
10236
+ value: title,
10237
+ onChange: (e) => setTitle(e.target.value)
10238
+ })]
10239
+ }),
10240
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {})
10241
+ ]
10242
+ }),
10243
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10244
+ className: StrategyEditor_module_css_default.field,
10245
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10246
+ className: StrategyEditor_module_css_default.fieldLabel,
10247
+ children: t("sv.mgmt.field.summary")
10248
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10249
+ className: StrategyEditor_module_css_default.fieldInput,
10250
+ value: summary,
10251
+ onChange: (e) => setSummary(e.target.value)
10252
+ })]
10253
+ }),
10254
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10255
+ className: StrategyEditor_module_css_default.field,
10256
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10257
+ className: StrategyEditor_module_css_default.fieldLabel,
10258
+ children: t("sv.mgmt.field.params")
10259
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10260
+ className: StrategyEditor_module_css_default.paramTable,
10261
+ children: [
10262
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10263
+ className: StrategyEditor_module_css_default.paramHeadRow,
10264
+ children: [
10265
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.key") }),
10266
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.label") }),
10267
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.default") }),
10268
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.min") }),
10269
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.max") }),
10270
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.step") }),
10271
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {})
10272
+ ]
10273
+ }),
10274
+ rows.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10275
+ className: StrategyEditor_module_css_default.paramRow,
10276
+ children: [
10277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10278
+ className: StrategyEditor_module_css_default.paramCell,
10279
+ value: row.key,
10280
+ onChange: (e) => updateRow(index, { key: e.target.value })
10281
+ }),
10282
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10283
+ className: StrategyEditor_module_css_default.paramCell,
10284
+ value: row.label,
10285
+ onChange: (e) => updateRow(index, { label: e.target.value })
10286
+ }),
10287
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10288
+ className: StrategyEditor_module_css_default.paramCell,
10289
+ type: "number",
10290
+ value: row.default,
10291
+ onChange: (e) => updateRow(index, { default: e.target.value })
10292
+ }),
10293
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10294
+ className: StrategyEditor_module_css_default.paramCell,
10295
+ type: "number",
10296
+ value: row.min,
10297
+ onChange: (e) => updateRow(index, { min: e.target.value })
10298
+ }),
10299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10300
+ className: StrategyEditor_module_css_default.paramCell,
10301
+ type: "number",
10302
+ value: row.max,
10303
+ onChange: (e) => updateRow(index, { max: e.target.value })
10304
+ }),
10305
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10306
+ className: StrategyEditor_module_css_default.paramCell,
10307
+ type: "number",
10308
+ value: row.step,
10309
+ onChange: (e) => updateRow(index, { step: e.target.value })
10310
+ }),
10311
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10312
+ type: "button",
10313
+ className: StrategyEditor_module_css_default.paramRemove,
10314
+ title: t("sv.mgmt.removeParam"),
10315
+ onClick: () => setRows((prev) => prev.filter((_, i) => i !== index)),
10316
+ children: "✕"
10317
+ })
10318
+ ]
10319
+ }, index)),
10320
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
10321
+ type: "button",
10322
+ className: StrategyEditor_module_css_default.addParamBtn,
10323
+ onClick: () => setRows((prev) => [...prev, {
10324
+ key: "",
10325
+ label: "",
10326
+ default: "0",
10327
+ min: "0",
10328
+ max: "100",
10329
+ step: "1"
10330
+ }]),
10331
+ children: ["+ ", t("sv.mgmt.addParam")]
10332
+ })
10333
+ ]
10334
+ })]
10335
+ }),
10336
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10337
+ className: StrategyEditor_module_css_default.field,
10338
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10339
+ className: StrategyEditor_module_css_default.fieldLabel,
10340
+ children: t("sv.mgmt.field.columns")
10341
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10342
+ className: StrategyEditor_module_css_default.paramTable,
10343
+ children: [
10344
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10345
+ className: StrategyEditor_module_css_default.columnHeadRow,
10346
+ children: [
10347
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.key") }),
10348
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.label") }),
10349
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.column.format") }),
10350
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {})
10351
+ ]
10352
+ }),
10353
+ cols.map((col, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10354
+ className: StrategyEditor_module_css_default.columnRow,
10355
+ children: [
10356
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10357
+ className: StrategyEditor_module_css_default.paramCell,
10358
+ value: col.key,
10359
+ onChange: (e) => updateCol(index, { key: e.target.value })
10360
+ }),
10361
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10362
+ className: StrategyEditor_module_css_default.paramCell,
10363
+ value: col.label,
10364
+ onChange: (e) => updateCol(index, { label: e.target.value })
10365
+ }),
10366
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
10367
+ className: StrategyEditor_module_css_default.paramCell,
10368
+ value: col.format,
10369
+ onChange: (e) => updateCol(index, { format: e.target.value }),
10370
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
10371
+ value: "",
10372
+ children: "number"
10373
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
10374
+ value: "percent",
10375
+ children: "percent"
10376
+ })]
10377
+ }),
10378
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10379
+ type: "button",
10380
+ className: StrategyEditor_module_css_default.paramRemove,
10381
+ title: t("sv.mgmt.removeParam"),
10382
+ onClick: () => setCols((prev) => prev.filter((_, i) => i !== index)),
10383
+ children: "✕"
10384
+ })
10385
+ ]
10386
+ }, index)),
10387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
10388
+ type: "button",
10389
+ className: StrategyEditor_module_css_default.addParamBtn,
10390
+ onClick: () => setCols((prev) => [...prev, {
10391
+ key: "",
10392
+ label: "",
10393
+ format: ""
10394
+ }]),
10395
+ children: ["+ ", t("sv.mgmt.addColumn")]
10396
+ })
10397
+ ]
10398
+ })]
10399
+ }),
10400
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10401
+ className: StrategyEditor_module_css_default.field,
10402
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10403
+ className: StrategyEditor_module_css_default.fieldLabel,
10404
+ children: t("sv.mgmt.field.evaluate")
10405
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
10406
+ className: StrategyEditor_module_css_default.sourceArea,
10407
+ value: source,
10408
+ spellCheck: false,
10409
+ onChange: (e) => setSource(e.target.value),
10410
+ rows: 12
10411
+ })]
10412
+ }),
10413
+ notice !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10414
+ className: notice.kind === "ok" ? StrategyEditor_module_css_default.noticeOk : StrategyEditor_module_css_default.noticeError,
10415
+ children: notice.text
10416
+ }),
10417
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10418
+ className: StrategyEditor_module_css_default.actionBar,
10419
+ children: [
10420
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10421
+ type: "button",
10422
+ className: StrategyEditor_module_css_default.secondaryBtn,
10423
+ disabled: busy !== "",
10424
+ onClick: () => {
10425
+ handleValidate();
10426
+ },
10427
+ children: busy === "validating" ? t("sv.mgmt.validating") : t("sv.mgmt.validate")
10428
+ }),
10429
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: StrategyEditor_module_css_default.actionSpacer }),
10430
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10431
+ type: "button",
10432
+ className: StrategyEditor_module_css_default.secondaryBtn,
10433
+ onClick: onClose,
10434
+ children: t("sv.mgmt.cancel")
10435
+ }),
10436
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10437
+ type: "button",
10438
+ className: StrategyEditor_module_css_default.primaryBtn,
10439
+ disabled: busy !== "",
10440
+ onClick: () => {
10441
+ handleSave();
10442
+ },
10443
+ children: busy === "saving" ? t("sv.mgmt.validating") : t("sv.mgmt.save")
10444
+ })
10445
+ ]
10446
+ })
10447
+ ]
10448
+ })
10449
+ });
10450
+ }
10451
+ //#endregion
9521
10452
  //#region \0dsh-css:/home/runner/work/dsh-trading/dsh-trading/packages/client-ui-strategies/src/client/StrategyView.module.css.mjs
9522
- const css$1 = ".jg958W_root{background:var(--dsw-futu-bg-base);width:100%;height:100%;color:var(--dsw-futu-text-primary);box-sizing:border-box;flex-direction:column;gap:16px;padding:16px 20px;display:flex;overflow-y:auto}.jg958W_header{flex-direction:column;gap:12px;display:flex}.jg958W_horizonSelector{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-md);gap:6px;width:fit-content;padding:3px;display:flex}.jg958W_horizonBtn{color:var(--dsw-futu-text-secondary);border-radius:var(--dsw-futu-radius-sm);cursor:pointer;background:0 0;border:none;padding:5px 14px;font-size:12.5px;font-weight:500;transition:all .15s}.jg958W_horizonBtn:hover{color:var(--dsw-futu-text-primary);background:var(--dsw-futu-bg-surface-hover)}.jg958W_horizonBtn[data-active=true]{background:var(--dsw-futu-bg-base);color:var(--dsw-futu-text-primary);box-shadow:var(--dsw-futu-shadow-sm);font-weight:600}.jg958W_strategyCards{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;display:grid}.jg958W_strategyCard{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);cursor:pointer;text-align:left;flex-direction:column;gap:6px;padding:12px 14px;transition:all .15s;display:flex}.jg958W_strategyCard:hover{border-color:var(--dsw-futu-border-default);background:var(--dsw-futu-bg-surface-hover)}.jg958W_strategyCard[data-active=true]{border-color:var(--dsw-futu-border-active);background:var(--dsw-futu-bg-surface-active)}.jg958W_strategyCard[data-disabled=true]{cursor:default;opacity:.55}.jg958W_strategyCard[data-disabled=true]:hover{border-color:var(--dsw-futu-border-subtle);background:var(--dsw-futu-bg-surface)}.jg958W_cardTitle{color:var(--dsw-futu-text-primary);font-size:13.5px;font-weight:600}.jg958W_cardSummary{color:var(--dsw-futu-text-secondary);font-size:12px;line-height:1.45}.jg958W_configBar{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);flex-wrap:wrap;align-items:center;gap:14px;padding:10px 16px;display:flex}.jg958W_paramGroup{align-items:center;gap:6px;display:flex}.jg958W_paramLabel{color:var(--dsw-futu-text-secondary);white-space:nowrap;font-size:12px}.jg958W_symbolValue{color:var(--dsw-futu-border-active);font-size:12.5px;font-weight:600;font-family:var(--dsw-futu-font-mono)}.jg958W_paramInput{background:var(--dsw-futu-bg-base);border:1px solid var(--dsw-futu-border-default);color:var(--dsw-futu-text-primary);border-radius:var(--dsw-futu-radius-sm);width:64px;font-size:12px;font-family:var(--dsw-futu-font-mono);font-variant-numeric:tabular-nums;padding:4px 8px}.jg958W_paramInput:focus{border-color:var(--dsw-futu-border-active);outline:none}.jg958W_runBtn{background:var(--dsw-futu-border-active);color:var(--dsw-futu-text-inverse);border-radius:var(--dsw-futu-radius-md);cursor:pointer;border:none;margin-left:auto;padding:6px 18px;font-size:12.5px;font-weight:600;transition:opacity .15s}.jg958W_runBtn:hover:not(:disabled){opacity:.9}.jg958W_runBtn:disabled{opacity:.5;cursor:not-allowed}.jg958W_errorMessage{color:var(--dsw-futu-up);padding:4px 0;font-size:12.5px}.jg958W_scanMeta{color:var(--dsw-futu-text-secondary);font-variant-numeric:tabular-nums;align-items:center;gap:12px;font-size:12px;display:flex}.jg958W_progressWrap{background:var(--dsw-futu-bg-surface-hover);border-radius:2px;flex:1;min-width:120px;height:4px;overflow:hidden}.jg958W_progressFill{background:var(--dsw-futu-border-active);border-radius:2px;height:100%;transition:width .2s}.jg958W_nameCell{font-family:var(--dsw-futu-font-sans,inherit);text-overflow:ellipsis;white-space:nowrap;max-width:140px;overflow:hidden}.jg958W_metricsGrid{grid-template-columns:repeat(auto-fill,minmax(135px,1fr));gap:10px;display:grid}.jg958W_metricCard{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-md);flex-direction:column;gap:4px;padding:10px 12px;display:flex}.jg958W_metricLabel{color:var(--dsw-futu-text-muted);font-size:11px}.jg958W_metricValue{font-variant-numeric:tabular-nums;font-size:15px;font-weight:700;font-family:var(--dsw-futu-font-mono);color:var(--dsw-futu-text-primary)}.jg958W_chartContainer{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);width:100%;height:240px;position:relative;overflow:hidden}.jg958W_chartWrapper{width:100%;height:100%}.jg958W_tableSection{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);flex-direction:column;gap:10px;padding:12px 16px;display:flex}.jg958W_tableTitle{color:var(--dsw-futu-text-secondary);font-size:13px;font-weight:600}.jg958W_tradesTableWrapper{max-height:260px;overflow-y:auto}.jg958W_tradesTable{border-collapse:collapse;text-align:left;width:100%;font-size:12px}.jg958W_tradesTable th{color:var(--dsw-futu-text-muted);border-bottom:1px solid var(--dsw-futu-border-default);background:var(--dsw-futu-bg-surface);padding:8px 10px;font-weight:500;position:sticky;top:0}.jg958W_tradesTable td{border-bottom:1px solid var(--dsw-futu-border-subtle);font-variant-numeric:tabular-nums;font-family:var(--dsw-futu-font-mono);color:var(--dsw-futu-text-primary);padding:8px 10px}.jg958W_tableEmptyCell{text-align:center;color:var(--dsw-futu-text-muted);padding:20px}.jg958W_reasonCell{color:var(--dsw-futu-text-secondary);text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.jg958W_emptyState{color:var(--dsw-futu-text-muted);text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px 16px;display:flex}.jg958W_emptyIcon{color:var(--dsw-futu-text-muted);opacity:.7}.jg958W_trendUp{color:var(--dsw-futu-up)}.jg958W_trendDown{color:var(--dsw-futu-down)}";
10453
+ const css$1 = ".jg958W_root{background:var(--dsw-futu-bg-base);width:100%;height:100%;color:var(--dsw-futu-text-primary);box-sizing:border-box;flex-direction:column;gap:16px;padding:16px 20px;display:flex;overflow-y:auto}.jg958W_header{flex-direction:column;gap:12px;display:flex}.jg958W_horizonSelector{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-md);gap:6px;width:fit-content;padding:3px;display:flex}.jg958W_horizonBtn{color:var(--dsw-futu-text-secondary);border-radius:var(--dsw-futu-radius-sm);cursor:pointer;background:0 0;border:none;padding:5px 14px;font-size:12.5px;font-weight:500;transition:all .15s}.jg958W_horizonBtn:hover{color:var(--dsw-futu-text-primary);background:var(--dsw-futu-bg-surface-hover)}.jg958W_horizonBtn[data-active=true]{background:var(--dsw-futu-bg-base);color:var(--dsw-futu-text-primary);box-shadow:var(--dsw-futu-shadow-sm);font-weight:600}.jg958W_strategyCards{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;display:grid}.jg958W_strategyCard{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);cursor:pointer;text-align:left;flex-direction:column;gap:6px;padding:12px 14px;transition:all .15s;display:flex}.jg958W_strategyCard:hover{border-color:var(--dsw-futu-border-default);background:var(--dsw-futu-bg-surface-hover)}.jg958W_strategyCard[data-active=true]{border-color:var(--dsw-futu-border-active);background:var(--dsw-futu-bg-surface-active)}.jg958W_strategyCard[data-disabled=true]{cursor:default;opacity:.55}.jg958W_strategyCard[data-disabled=true]:hover{border-color:var(--dsw-futu-border-subtle);background:var(--dsw-futu-bg-surface)}.jg958W_cardTitle{color:var(--dsw-futu-text-primary);font-size:13.5px;font-weight:600}.jg958W_cardSummary{color:var(--dsw-futu-text-secondary);font-size:12px;line-height:1.45}.jg958W_configBar{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);flex-wrap:wrap;align-items:center;gap:14px;padding:10px 16px;display:flex}.jg958W_paramGroup{align-items:center;gap:6px;display:flex}.jg958W_paramLabel{color:var(--dsw-futu-text-secondary);white-space:nowrap;font-size:12px}.jg958W_symbolValue{color:var(--dsw-futu-border-active);font-size:12.5px;font-weight:600;font-family:var(--dsw-futu-font-mono)}.jg958W_paramInput{background:var(--dsw-futu-bg-base);border:1px solid var(--dsw-futu-border-default);color:var(--dsw-futu-text-primary);border-radius:var(--dsw-futu-radius-sm);width:64px;font-size:12px;font-family:var(--dsw-futu-font-mono);font-variant-numeric:tabular-nums;padding:4px 8px}.jg958W_paramInput:focus{border-color:var(--dsw-futu-border-active);outline:none}.jg958W_runBtn{background:var(--dsw-futu-border-active);color:var(--dsw-futu-text-inverse);border-radius:var(--dsw-futu-radius-md);cursor:pointer;border:none;margin-left:auto;padding:6px 18px;font-size:12.5px;font-weight:600;transition:opacity .15s}.jg958W_runBtn:hover:not(:disabled){opacity:.9}.jg958W_runBtn:disabled{opacity:.5;cursor:not-allowed}.jg958W_errorMessage{color:var(--dsw-futu-up);padding:4px 0;font-size:12.5px}.jg958W_scanMeta{color:var(--dsw-futu-text-secondary);font-variant-numeric:tabular-nums;align-items:center;gap:12px;font-size:12px;display:flex}.jg958W_progressWrap{background:var(--dsw-futu-bg-surface-hover);border-radius:2px;flex:1;min-width:120px;height:4px;overflow:hidden}.jg958W_progressFill{background:var(--dsw-futu-border-active);border-radius:2px;height:100%;transition:width .2s}.jg958W_nameCell{font-family:var(--dsw-futu-font-sans,inherit);text-overflow:ellipsis;white-space:nowrap;max-width:140px;overflow:hidden}.jg958W_metricsGrid{grid-template-columns:repeat(auto-fill,minmax(135px,1fr));gap:10px;display:grid}.jg958W_metricCard{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-md);flex-direction:column;gap:4px;padding:10px 12px;display:flex}.jg958W_metricLabel{color:var(--dsw-futu-text-muted);font-size:11px}.jg958W_metricValue{font-variant-numeric:tabular-nums;font-size:15px;font-weight:700;font-family:var(--dsw-futu-font-mono);color:var(--dsw-futu-text-primary)}.jg958W_chartContainer{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);width:100%;height:240px;position:relative;overflow:hidden}.jg958W_chartWrapper{width:100%;height:100%}.jg958W_tableSection{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-lg);flex-direction:column;gap:10px;padding:12px 16px;display:flex}.jg958W_tableTitle{color:var(--dsw-futu-text-secondary);font-size:13px;font-weight:600}.jg958W_tradesTableWrapper{max-height:260px;overflow-y:auto}.jg958W_tradesTable{border-collapse:collapse;text-align:left;width:100%;font-size:12px}.jg958W_tradesTable th{color:var(--dsw-futu-text-muted);border-bottom:1px solid var(--dsw-futu-border-default);background:var(--dsw-futu-bg-surface);padding:8px 10px;font-weight:500;position:sticky;top:0}.jg958W_tradesTable td{border-bottom:1px solid var(--dsw-futu-border-subtle);font-variant-numeric:tabular-nums;font-family:var(--dsw-futu-font-mono);color:var(--dsw-futu-text-primary);padding:8px 10px}.jg958W_tableEmptyCell{text-align:center;color:var(--dsw-futu-text-muted);padding:20px}.jg958W_reasonCell{color:var(--dsw-futu-text-secondary);text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.jg958W_emptyState{color:var(--dsw-futu-text-muted);text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px 16px;display:flex}.jg958W_emptyIcon{color:var(--dsw-futu-text-muted);opacity:.7}.jg958W_trendUp{color:var(--dsw-futu-up)}.jg958W_trendDown{color:var(--dsw-futu-down)}.jg958W_mgmtRow{justify-content:space-between;align-items:center;gap:12px;display:flex}.jg958W_newStrategyBtn{background:var(--dsw-futu-bg-surface);border:1px solid var(--dsw-futu-border-default);border-radius:var(--dsw-futu-radius-sm);color:var(--dsw-futu-text-primary);cursor:pointer;padding:5px 14px;font-size:12.5px;font-weight:500;transition:all .15s}.jg958W_newStrategyBtn:hover{border-color:var(--dsw-futu-border-active);background:var(--dsw-futu-bg-surface-hover)}.jg958W_cardTitleRow{justify-content:space-between;align-items:center;gap:8px;display:flex}.jg958W_cardBadge{border:1px solid var(--dsw-futu-border-subtle);color:var(--dsw-futu-text-secondary);background:var(--dsw-futu-bg-base);border-radius:999px;flex-shrink:0;padding:3px 7px;font-size:10.5px;font-weight:500;line-height:1}.jg958W_cardBadge[data-kind=builtin]{color:var(--dsw-futu-text-secondary)}.jg958W_cardBadge[data-kind=modified]{color:#d97706;background:#d9770614;border-color:#d9770666}.jg958W_cardBadge[data-kind=custom]{color:#2ba471;background:#2ba47114;border-color:#2ba47166}.jg958W_cardBadge[data-kind=deleted]{color:#e64545;background:#e6454514;border-color:#e6454566}.jg958W_cardActions{gap:6px;margin-top:2px;display:flex}.jg958W_cardActionBtn{border:1px solid var(--dsw-futu-border-subtle);border-radius:var(--dsw-futu-radius-sm);color:var(--dsw-futu-text-secondary);cursor:pointer;background:0 0;padding:3px 10px;font-size:11.5px;transition:all .15s}.jg958W_cardActionBtn:hover{color:var(--dsw-futu-text-primary);border-color:var(--dsw-futu-border-default);background:var(--dsw-futu-bg-surface-hover)}.jg958W_cardActionBtn[data-danger=true]:hover{color:#e64545;border-color:#e6454573}.jg958W_strategyCard[data-deleted=true]{opacity:.55;cursor:default;border-style:dashed}.jg958W_strategyCard[data-deleted=true]:hover{border-color:var(--dsw-futu-border-subtle);background:var(--dsw-futu-bg-surface)}";
9523
10454
  const tagId$1 = "@dshtrading/client-ui-strategies/StrategyView.module.css";
9524
10455
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
9525
10456
  const tag = document.createElement("style");
@@ -9529,55 +10460,66 @@ window.__ModuleLoader__.load({
9529
10460
  document.head.appendChild(tag);
9530
10461
  }
9531
10462
  var StrategyView_module_css_default = {
9532
- "symbolValue": "jg958W_symbolValue",
9533
- "cardSummary": "jg958W_cardSummary",
9534
- "runBtn": "jg958W_runBtn",
9535
- "progressWrap": "jg958W_progressWrap",
9536
- "horizonSelector": "jg958W_horizonSelector",
9537
- "metricsGrid": "jg958W_metricsGrid",
9538
- "errorMessage": "jg958W_errorMessage",
9539
- "metricValue": "jg958W_metricValue",
9540
- "chartContainer": "jg958W_chartContainer",
10463
+ "metricCard": "jg958W_metricCard",
10464
+ "cardActionBtn": "jg958W_cardActionBtn",
10465
+ "tableSection": "jg958W_tableSection",
10466
+ "paramLabel": "jg958W_paramLabel",
10467
+ "cardTitleRow": "jg958W_cardTitleRow",
10468
+ "cardBadge": "jg958W_cardBadge",
10469
+ "tableEmptyCell": "jg958W_tableEmptyCell",
9541
10470
  "configBar": "jg958W_configBar",
9542
10471
  "paramInput": "jg958W_paramInput",
10472
+ "scanMeta": "jg958W_scanMeta",
10473
+ "cardSummary": "jg958W_cardSummary",
9543
10474
  "strategyCard": "jg958W_strategyCard",
9544
- "paramLabel": "jg958W_paramLabel",
9545
- "emptyIcon": "jg958W_emptyIcon",
9546
- "strategyCards": "jg958W_strategyCards",
9547
- "nameCell": "jg958W_nameCell",
10475
+ "metricsGrid": "jg958W_metricsGrid",
10476
+ "mgmtRow": "jg958W_mgmtRow",
10477
+ "chartContainer": "jg958W_chartContainer",
10478
+ "cardActions": "jg958W_cardActions",
9548
10479
  "progressFill": "jg958W_progressFill",
10480
+ "strategyCards": "jg958W_strategyCards",
10481
+ "paramGroup": "jg958W_paramGroup",
10482
+ "tableTitle": "jg958W_tableTitle",
10483
+ "runBtn": "jg958W_runBtn",
9549
10484
  "cardTitle": "jg958W_cardTitle",
9550
- "emptyState": "jg958W_emptyState",
9551
- "tableEmptyCell": "jg958W_tableEmptyCell",
10485
+ "errorMessage": "jg958W_errorMessage",
9552
10486
  "tradesTableWrapper": "jg958W_tradesTableWrapper",
9553
- "root": "jg958W_root",
10487
+ "horizonSelector": "jg958W_horizonSelector",
9554
10488
  "metricLabel": "jg958W_metricLabel",
9555
- "trendUp": "jg958W_trendUp",
10489
+ "horizonBtn": "jg958W_horizonBtn",
10490
+ "emptyIcon": "jg958W_emptyIcon",
10491
+ "trendDown": "jg958W_trendDown",
9556
10492
  "reasonCell": "jg958W_reasonCell",
9557
- "scanMeta": "jg958W_scanMeta",
9558
- "paramGroup": "jg958W_paramGroup",
10493
+ "newStrategyBtn": "jg958W_newStrategyBtn",
10494
+ "emptyState": "jg958W_emptyState",
10495
+ "symbolValue": "jg958W_symbolValue",
10496
+ "trendUp": "jg958W_trendUp",
10497
+ "metricValue": "jg958W_metricValue",
10498
+ "nameCell": "jg958W_nameCell",
9559
10499
  "chartWrapper": "jg958W_chartWrapper",
9560
- "header": "jg958W_header",
9561
- "tableSection": "jg958W_tableSection",
9562
- "trendDown": "jg958W_trendDown",
9563
- "metricCard": "jg958W_metricCard",
9564
- "horizonBtn": "jg958W_horizonBtn",
9565
10500
  "tradesTable": "jg958W_tradesTable",
9566
- "tableTitle": "jg958W_tableTitle"
10501
+ "root": "jg958W_root",
10502
+ "progressWrap": "jg958W_progressWrap",
10503
+ "header": "jg958W_header"
9567
10504
  };
9568
10505
  //#endregion
9569
10506
  //#region src/client/ScreenerPane.tsx
9570
10507
  /**
9571
10508
  * 选股策略面板(一级「选股策略」分区的内容视图):
9572
- * 1. 选股器卡列表(内置名册,@dshtrading/strategies 纯函数)
10509
+ * 1. 选股器卡列表(内置 − 墓碑、覆盖原位替换、自定义追加;来源徽标 + 管理操作)
9573
10510
  * 2. 参数 + 扫描池上限 + 运行/停止条
9574
10511
  * 3. 扫描进度条 + 命中结果表(代码/名称/现价/动态指标列/信号说明)
9575
10512
  *
9576
10513
  * 扫描调度在本层:名册 fetchSymbols(桥 30min 缓存)→ 截断到扫描池上限 →
9577
10514
  * 受限并发逐标的拉 500 根日 K → 纯函数 evaluate。数据不足的标的由契约
9578
10515
  * 返回 null 静默跳过;单标的失败(含空 K 线响应)只计数不中断扫描。
10516
+ *
10517
+ * 选股器管理(2026-09-07):名册经 applyScreenerManagement 合成;桥拉取记录
10518
+ * 与墓碑,SSE 'strategies' 信号重拉;编辑器(ScreenerEditor)新建/编辑/覆盖。
9579
10519
  */
9580
10520
  const SCREENER_STORE_KEY = "dshtrading.screener.v1";
10521
+ /** 自定义选股器扫描时的单符号 Worker 超时(真实 500 根 K 线,较校验样例放宽)。 */
10522
+ const SCAN_EVAL_TIMEOUT_MS = 1e3;
9581
10523
  const DEFAULT_STORED$1 = {
9582
10524
  screenerId: "scr.ma-bull-align",
9583
10525
  paramsMap: {},
@@ -9607,10 +10549,68 @@ window.__ModuleLoader__.load({
9607
10549
  if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_STORED$1.scanLimit;
9608
10550
  return Math.min(SCAN_LIMIT_MAX, Math.max(SCAN_LIMIT_MIN, raw));
9609
10551
  });
10552
+ const [customDefs, setCustomDefs] = (0, react.useState)([]);
10553
+ const [records, setRecords] = (0, react.useState)([]);
10554
+ const [tombstones, setTombstones] = (0, react.useState)([]);
10555
+ const [reloadKey, setReloadKey] = (0, react.useState)(0);
10556
+ const bridgeRef = (0, react.useRef)(bridge);
10557
+ if (bridgeRef.current === null) bridgeRef.current = bridge;
10558
+ const stableBridge = bridgeRef.current;
10559
+ const hasManagement = stableBridge.fetchCustomScreeners !== void 0 && stableBridge.saveCustomScreener !== void 0 && stableBridge.deleteCustomScreener !== void 0 && stableBridge.resetScreener !== void 0;
10560
+ (0, react.useEffect)(() => {
10561
+ if (!hasManagement || stableBridge === void 0) return;
10562
+ let cancelled = false;
10563
+ let generation = 0;
10564
+ const load = async () => {
10565
+ const gen = ++generation;
10566
+ try {
10567
+ const rawRecords = await stableBridge.fetchCustomScreeners();
10568
+ const defs = [];
10569
+ const validRecords = [];
10570
+ for (const record of rawRecords) {
10571
+ const result = await validateCustomScreener(record);
10572
+ if (result.ok) {
10573
+ defs.push(result.definition);
10574
+ validRecords.push(result.record);
10575
+ }
10576
+ }
10577
+ const deleted = stableBridge.fetchStrategyTombstones !== void 0 ? await stableBridge.fetchStrategyTombstones() : [];
10578
+ if (cancelled || gen !== generation) return;
10579
+ setCustomDefs(defs);
10580
+ setRecords(validRecords);
10581
+ setTombstones(deleted);
10582
+ } catch (e) {
10583
+ console.warn("[dsh-trading] failed to load custom screeners:", e);
10584
+ }
10585
+ };
10586
+ load();
10587
+ const unsubscribe = stableBridge.subscribeTradingEvents?.({ strategies: () => {
10588
+ load();
10589
+ } });
10590
+ return () => {
10591
+ cancelled = true;
10592
+ unsubscribe?.();
10593
+ };
10594
+ }, [
10595
+ stableBridge,
10596
+ hasManagement,
10597
+ reloadKey
10598
+ ]);
10599
+ const roster = (0, react.useMemo)(() => hasManagement ? applyScreenerManagement(screenerParadigms, customDefs, tombstones) : [...screenerParadigms], [
10600
+ customDefs,
10601
+ tombstones,
10602
+ hasManagement
10603
+ ]);
10604
+ const modifiedIds = (0, react.useMemo)(() => new Set(records.map((r) => r.id).filter((id) => isBuiltinScreenerId(id))), [records]);
10605
+ const tombstonedScreeners = (0, react.useMemo)(() => {
10606
+ if (!hasManagement) return [];
10607
+ return screenerParadigms.filter((d) => tombstones.includes(d.id));
10608
+ }, [tombstones, hasManagement]);
9610
10609
  const currentScreener = (0, react.useMemo)(() => {
9611
- return screenerParadigms.find((s) => s.id === selectedId) ?? screenerParadigms[0];
9612
- }, [selectedId]);
10610
+ return roster.find((s) => s.id === selectedId) ?? roster[0] ?? null;
10611
+ }, [roster, selectedId]);
9613
10612
  const currentParams = (0, react.useMemo)(() => {
10613
+ if (currentScreener === null) return {};
9614
10614
  const custom = paramsMap[currentScreener.id] ?? {};
9615
10615
  const res = {};
9616
10616
  for (const p of currentScreener.params) res[p.key] = custom[p.key] ?? p.default;
@@ -9620,6 +10620,7 @@ window.__ModuleLoader__.load({
9620
10620
  writeJson(SCREENER_STORE_KEY, next);
9621
10621
  };
9622
10622
  const handleParamChange = (key, value) => {
10623
+ if (currentScreener === null) return;
9623
10624
  const nextMap = {
9624
10625
  ...paramsMap,
9625
10626
  [currentScreener.id]: {
@@ -9644,8 +10645,39 @@ window.__ModuleLoader__.load({
9644
10645
  scanLimit: clamped
9645
10646
  });
9646
10647
  };
10648
+ const forgetLocalParams = (id) => {
10649
+ setParamsMap((prev) => {
10650
+ if (prev[id] === void 0) return prev;
10651
+ const next = { ...prev };
10652
+ delete next[id];
10653
+ return next;
10654
+ });
10655
+ };
10656
+ const handleDeleteScreener = async (screener) => {
10657
+ if (stableBridge.deleteCustomScreener === void 0) return;
10658
+ if (!window.confirm(t("sv.mgmt.confirmDelete"))) return;
10659
+ await stableBridge.deleteCustomScreener(screener.id);
10660
+ forgetLocalParams(screener.id);
10661
+ if (selectedId === screener.id) setSelectedId("");
10662
+ };
10663
+ const handleRestoreScreener = async (id) => {
10664
+ if (stableBridge.resetScreener === void 0) return;
10665
+ if (modifiedIds.has(id) && !window.confirm(t("sv.mgmt.confirmRestore"))) return;
10666
+ const result = await stableBridge.resetScreener(id);
10667
+ if (result !== null && result.ok) forgetLocalParams(id);
10668
+ };
10669
+ const handleEditScreener = (screener) => {
10670
+ const record = modifiedIds.has(screener.id) || !isBuiltinScreenerId(screener.id) ? records.find((r) => r.id === screener.id) ?? null : builtinScreenerRecord(screener);
10671
+ if (record !== null) setEditor({ initial: record });
10672
+ };
10673
+ const handleSaveFromEditor = async (input) => {
10674
+ if (stableBridge.saveCustomScreener === void 0) return null;
10675
+ return stableBridge.saveCustomScreener(input);
10676
+ };
10677
+ const [editor, setEditor] = (0, react.useState)(null);
9647
10678
  const runIdRef = (0, react.useRef)(0);
9648
10679
  const [scanning, setScanning] = (0, react.useState)(false);
10680
+ const [scanScreener, setScanScreener] = (0, react.useState)(null);
9649
10681
  const [progress, setProgress] = (0, react.useState)({
9650
10682
  done: 0,
9651
10683
  total: 0,
@@ -9660,9 +10692,11 @@ window.__ModuleLoader__.load({
9660
10692
  setScanning(false);
9661
10693
  };
9662
10694
  const handleRun = async () => {
10695
+ if (currentScreener === null) return;
9663
10696
  const runId = runIdRef.current + 1;
9664
10697
  runIdRef.current = runId;
9665
10698
  setScanning(true);
10699
+ setScanScreener(currentScreener);
9666
10700
  setRows([]);
9667
10701
  setErrorMsg(null);
9668
10702
  setProgress({
@@ -9678,217 +10712,695 @@ window.__ModuleLoader__.load({
9678
10712
  setScanning(false);
9679
10713
  return;
9680
10714
  }
9681
- const universe = await bridge.fetchSymbols(market);
9682
- if (runIdRef.current !== runId) return;
9683
- if (!universe || universe.length === 0) {
9684
- setErrorMsg(t("sv.screener.noUniverse"));
9685
- setScanning(false);
10715
+ const universe = await bridge.fetchSymbols(market);
10716
+ if (runIdRef.current !== runId) return;
10717
+ if (!universe || universe.length === 0) {
10718
+ setErrorMsg(t("sv.screener.noUniverse"));
10719
+ setScanning(false);
10720
+ return;
10721
+ }
10722
+ const capped = universe.slice(0, scanLimit);
10723
+ setUniverseSize(universe.length);
10724
+ setProgress({
10725
+ done: 0,
10726
+ total: capped.length,
10727
+ hits: 0,
10728
+ failed: 0
10729
+ });
10730
+ const hits = [];
10731
+ let done = 0;
10732
+ let failed = 0;
10733
+ let cursor = 0;
10734
+ const evaluateSourceById = new Map(records.map((r) => [r.id, r.evaluateSource]));
10735
+ const evalOne = async (bars) => {
10736
+ const source = evaluateSourceById.get(currentScreener.id);
10737
+ if (source === void 0) return currentScreener.evaluate(bars, currentParams);
10738
+ return await workerComputeRunner(source, bars, currentParams, SCAN_EVAL_TIMEOUT_MS);
10739
+ };
10740
+ const worker = async () => {
10741
+ while (cursor < capped.length && runIdRef.current === runId) {
10742
+ const inst = capped[cursor];
10743
+ cursor += 1;
10744
+ try {
10745
+ const bars = await bridge.fetchKlines(market, inst.symbol, "1d", SCAN_KLINE_LIMIT);
10746
+ if (runIdRef.current !== runId) return;
10747
+ if (bars && bars.length > 0) {
10748
+ const match = await evalOne(bars);
10749
+ if (match) hits.push({
10750
+ symbol: inst.symbol,
10751
+ ...inst.name ? { name: inst.name } : {},
10752
+ price: bars[bars.length - 1].close,
10753
+ metrics: match.metrics,
10754
+ reason: match.reason,
10755
+ ...match.reasonKey !== void 0 ? { reasonKey: match.reasonKey } : {},
10756
+ ...match.reasonParams !== void 0 ? { reasonParams: match.reasonParams } : {}
10757
+ });
10758
+ } else failed += 1;
10759
+ } catch {
10760
+ failed += 1;
10761
+ }
10762
+ done += 1;
10763
+ if (runIdRef.current !== runId) return;
10764
+ setProgress({
10765
+ done,
10766
+ total: capped.length,
10767
+ hits: hits.length,
10768
+ failed
10769
+ });
10770
+ setRows([...hits]);
10771
+ }
10772
+ };
10773
+ await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, capped.length) }, () => worker()));
10774
+ if (runIdRef.current !== runId) return;
10775
+ if (failed > 0) setErrorMsg(`${t("sv.error.failed")} (${failed}/${capped.length})`);
10776
+ } catch (e) {
10777
+ if (runIdRef.current !== runId) return;
10778
+ setErrorMsg(`${t("sv.error.failed")}: ${String(e?.message ?? e)}`);
10779
+ } finally {
10780
+ if (runIdRef.current === runId) setScanning(false);
10781
+ }
10782
+ };
10783
+ if (currentScreener === null && tombstonedScreeners.length === 0 && !hasManagement) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10784
+ className: StrategyView_module_css_default.emptyState,
10785
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("sv.screener.rosterEmpty") })
10786
+ });
10787
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
10788
+ hasManagement && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10789
+ className: StrategyView_module_css_default.mgmtRow,
10790
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
10791
+ type: "button",
10792
+ className: StrategyView_module_css_default.newStrategyBtn,
10793
+ onClick: () => setEditor({ initial: null }),
10794
+ children: ["+ ", t("sv.mgmt.newScreener")]
10795
+ })]
10796
+ }),
10797
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10798
+ className: StrategyView_module_css_default.strategyCards,
10799
+ children: [roster.map((screener) => {
10800
+ const modified = modifiedIds.has(screener.id);
10801
+ const builtin = isBuiltinScreenerId(screener.id);
10802
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10803
+ className: StrategyView_module_css_default.strategyCard,
10804
+ "data-active": screener.id === selectedId ? "true" : void 0,
10805
+ "data-disabled": scanning ? "true" : void 0,
10806
+ onClick: () => {
10807
+ if (!scanning) setSelectedId(screener.id);
10808
+ },
10809
+ children: [
10810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10811
+ className: StrategyView_module_css_default.cardTitleRow,
10812
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10813
+ className: StrategyView_module_css_default.cardTitle,
10814
+ children: screenerName(screener, t)
10815
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10816
+ className: StrategyView_module_css_default.cardBadge,
10817
+ "data-kind": modified ? "modified" : builtin ? "builtin" : "custom",
10818
+ children: t(modified ? "sv.mgmt.badge.modified" : builtin ? "sv.mgmt.badge.builtin" : "sv.mgmt.badge.custom")
10819
+ })]
10820
+ }),
10821
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10822
+ className: StrategyView_module_css_default.cardSummary,
10823
+ children: screenerSummary(screener, t)
10824
+ }),
10825
+ hasManagement && !scanning && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10826
+ className: StrategyView_module_css_default.cardActions,
10827
+ onClick: (e) => e.stopPropagation(),
10828
+ children: [
10829
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10830
+ type: "button",
10831
+ className: StrategyView_module_css_default.cardActionBtn,
10832
+ onClick: () => handleEditScreener(screener),
10833
+ children: t("sv.mgmt.edit")
10834
+ }),
10835
+ builtin && modified && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10836
+ type: "button",
10837
+ className: StrategyView_module_css_default.cardActionBtn,
10838
+ onClick: () => {
10839
+ handleRestoreScreener(screener.id);
10840
+ },
10841
+ children: t("sv.mgmt.restore")
10842
+ }),
10843
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10844
+ type: "button",
10845
+ className: StrategyView_module_css_default.cardActionBtn,
10846
+ "data-danger": "true",
10847
+ onClick: () => {
10848
+ handleDeleteScreener(screener);
10849
+ },
10850
+ children: t("sv.mgmt.delete")
10851
+ })
10852
+ ]
10853
+ })
10854
+ ]
10855
+ }, screener.id);
10856
+ }), tombstonedScreeners.map((screener) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10857
+ className: StrategyView_module_css_default.strategyCard,
10858
+ "data-deleted": "true",
10859
+ children: [
10860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10861
+ className: StrategyView_module_css_default.cardTitleRow,
10862
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10863
+ className: StrategyView_module_css_default.cardTitle,
10864
+ children: screenerName(screener, t)
10865
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10866
+ className: StrategyView_module_css_default.cardBadge,
10867
+ "data-kind": "deleted",
10868
+ children: t("sv.mgmt.deleted")
10869
+ })]
10870
+ }),
10871
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10872
+ className: StrategyView_module_css_default.cardSummary,
10873
+ children: screenerSummary(screener, t)
10874
+ }),
10875
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10876
+ className: StrategyView_module_css_default.cardActions,
10877
+ onClick: (e) => e.stopPropagation(),
10878
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10879
+ type: "button",
10880
+ className: StrategyView_module_css_default.cardActionBtn,
10881
+ onClick: () => {
10882
+ handleRestoreScreener(screener.id);
10883
+ },
10884
+ children: t("sv.mgmt.restore")
10885
+ })
10886
+ })
10887
+ ]
10888
+ }, screener.id))]
10889
+ }),
10890
+ currentScreener === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10891
+ className: StrategyView_module_css_default.emptyState,
10892
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("sv.screener.rosterEmpty") })
10893
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
10894
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10895
+ className: StrategyView_module_css_default.configBar,
10896
+ children: [
10897
+ currentScreener.params.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10898
+ className: StrategyView_module_css_default.paramGroup,
10899
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10900
+ className: StrategyView_module_css_default.paramLabel,
10901
+ children: [screenerParamLabel(currentScreener, p, t), ":"]
10902
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10903
+ type: "number",
10904
+ className: StrategyView_module_css_default.paramInput,
10905
+ min: p.min,
10906
+ max: p.max,
10907
+ step: p.step,
10908
+ disabled: scanning,
10909
+ value: currentParams[p.key] ?? p.default,
10910
+ onChange: (e) => {
10911
+ const numVal = parseFloat(e.target.value);
10912
+ if (!Number.isNaN(numVal)) handleParamChange(p.key, numVal);
10913
+ }
10914
+ })]
10915
+ }, p.key)),
10916
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10917
+ className: StrategyView_module_css_default.paramGroup,
10918
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10919
+ className: StrategyView_module_css_default.paramLabel,
10920
+ children: [t("sv.screener.scanLimit"), ":"]
10921
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10922
+ type: "number",
10923
+ className: StrategyView_module_css_default.paramInput,
10924
+ min: SCAN_LIMIT_MIN,
10925
+ max: SCAN_LIMIT_MAX,
10926
+ step: 50,
10927
+ disabled: scanning,
10928
+ value: scanLimit,
10929
+ onChange: (e) => {
10930
+ const numVal = parseFloat(e.target.value);
10931
+ if (!Number.isNaN(numVal)) handleScanLimitChange(numVal);
10932
+ }
10933
+ })]
10934
+ }),
10935
+ scanning ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10936
+ type: "button",
10937
+ className: StrategyView_module_css_default.runBtn,
10938
+ onClick: handleStop,
10939
+ children: t("sv.screener.stop")
10940
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10941
+ type: "button",
10942
+ className: StrategyView_module_css_default.runBtn,
10943
+ onClick: () => {
10944
+ handleRun();
10945
+ },
10946
+ children: t("sv.screener.run")
10947
+ })
10948
+ ]
10949
+ }),
10950
+ (scanning || universeSize !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10951
+ className: StrategyView_module_css_default.scanMeta,
10952
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
10953
+ t("sv.screener.universePrefix"),
10954
+ " ",
10955
+ universeSize ?? "--",
10956
+ " · ",
10957
+ t("sv.screener.scanned"),
10958
+ " ",
10959
+ progress.done,
10960
+ "/",
10961
+ progress.total,
10962
+ " · ",
10963
+ t("sv.screener.hits"),
10964
+ " ",
10965
+ progress.hits,
10966
+ " ·",
10967
+ " ",
10968
+ t("sv.screener.failed"),
10969
+ " ",
10970
+ progress.failed
10971
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10972
+ className: StrategyView_module_css_default.progressWrap,
10973
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10974
+ className: StrategyView_module_css_default.progressFill,
10975
+ style: { width: progress.total > 0 ? `${progress.done / progress.total * 100}%` : "0%" }
10976
+ })
10977
+ })]
10978
+ }),
10979
+ errorMsg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10980
+ className: StrategyView_module_css_default.errorMessage,
10981
+ children: errorMsg
10982
+ }),
10983
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10984
+ className: StrategyView_module_css_default.tableSection,
10985
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10986
+ className: StrategyView_module_css_default.tableTitle,
10987
+ children: [
10988
+ t("sv.screener.hits"),
10989
+ " (",
10990
+ rows.length,
10991
+ ")"
10992
+ ]
10993
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10994
+ className: StrategyView_module_css_default.tradesTableWrapper,
10995
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
10996
+ className: StrategyView_module_css_default.tradesTable,
10997
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
10998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.symbol") }),
10999
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.name") }),
11000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.price") }),
11001
+ (scanning ? scanScreener : currentScreener)?.columns.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: screenerColumnLabel(scanning ? scanScreener : currentScreener, col, t) }, col.key)),
11002
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.reason") })
11003
+ ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
11004
+ colSpan: 4 + (scanning ? scanScreener : currentScreener).columns.length,
11005
+ className: StrategyView_module_css_default.tableEmptyCell,
11006
+ children: scanning ? t("sv.screener.scanning") : progress.total > 0 && progress.failed === 0 ? t("sv.screener.noHits") : t("sv.screener.emptyHint")
11007
+ }) }) : rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
11008
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.symbol }),
11009
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
11010
+ className: StrategyView_module_css_default.nameCell,
11011
+ children: row.name ?? "--"
11012
+ }),
11013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: fmtPrice(row.price) }),
11014
+ (scanning ? scanScreener : currentScreener).columns.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatMetric(row.metrics[col.key] ?? NaN, col.format) }, col.key)),
11015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
11016
+ className: StrategyView_module_css_default.reasonCell,
11017
+ children: screenerReason(row, t)
11018
+ })
11019
+ ] }, row.symbol)) })]
11020
+ })
11021
+ })]
11022
+ })
11023
+ ] }),
11024
+ editor !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScreenerEditor, {
11025
+ t,
11026
+ initial: editor.initial,
11027
+ onSave: handleSaveFromEditor,
11028
+ onSaved: () => setReloadKey((k) => k + 1),
11029
+ onClose: () => setEditor(null)
11030
+ })
11031
+ ] });
11032
+ }
11033
+ //#endregion
11034
+ //#region src/client/StrategyEditor.tsx
11035
+ /**
11036
+ * 策略编辑器(策略管理,2026-09-07):新建/编辑策略的表单面板。
11037
+ *
11038
+ * - 元数据(id/title/horizon/summary)+ 参数规格行编辑 + compute 源码 textarea。
11039
+ * - 「校验」走浏览器 Worker 版 validateCustomStrategy(多场景试算 + 信号序列),
11040
+ * 即时反馈不落盘;「保存」先本地预校验再交桥 PUT(host 侧 vm 沙箱复校验,
11041
+ * 通过才落盘)——两侧校验器同源(validate.ts),结果一致。
11042
+ * - 新建时 id 撞内置范式 → confirm 确认后带 overridesBuiltin 提交(桥的
11043
+ * TRADING_STRATEGY_OVERRIDE_CONFIRM 闸门要求显式确认位);编辑内置同理。
11044
+ */
11045
+ const HORIZONS = [
11046
+ "short",
11047
+ "swing",
11048
+ "long"
11049
+ ];
11050
+ function rowsFromParamsJson(paramsJson) {
11051
+ try {
11052
+ const parsed = JSON.parse(paramsJson);
11053
+ if (!Array.isArray(parsed)) return [];
11054
+ return parsed.map((p) => ({
11055
+ key: String(p.key ?? ""),
11056
+ label: String(p.label ?? ""),
11057
+ default: String(p.default ?? ""),
11058
+ min: String(p.min ?? ""),
11059
+ max: String(p.max ?? ""),
11060
+ step: String(p.step ?? 1)
11061
+ }));
11062
+ } catch {
11063
+ return [];
11064
+ }
11065
+ }
11066
+ function StrategyEditor({ t, initial, onSave, onClose, onSaved }) {
11067
+ const isEdit = initial !== null;
11068
+ const [id, setId] = (0, react.useState)(initial?.id ?? "");
11069
+ const [title, setTitle] = (0, react.useState)(initial?.title ?? "");
11070
+ const [horizon, setHorizon] = (0, react.useState)(initial?.horizon ?? "short");
11071
+ const [summary, setSummary] = (0, react.useState)(initial?.summary ?? "");
11072
+ const [rows, setRows] = (0, react.useState)(() => rowsFromParamsJson(initial?.paramsJson ?? "[]"));
11073
+ const [source, setSource] = (0, react.useState)(initial?.computeSource ?? "");
11074
+ const [busy, setBusy] = (0, react.useState)("");
11075
+ const [notice, setNotice] = (0, react.useState)(null);
11076
+ const idIsBuiltin = (0, react.useMemo)(() => BUILTIN_STRATEGY_IDS.has(id.trim().toLowerCase()), [id]);
11077
+ const buildInput = () => {
11078
+ const trimmedId = id.trim().toLowerCase();
11079
+ if (!trimmedId) return { error: `${t("sv.mgmt.field.id")}: ${t("sv.mgmt.fieldRequired")}` };
11080
+ const params = [];
11081
+ for (const row of rows) {
11082
+ const key = row.key.trim();
11083
+ if (!key) continue;
11084
+ const def = Number(row.default);
11085
+ const min = Number(row.min);
11086
+ const max = Number(row.max);
11087
+ const step = Number(row.step);
11088
+ if (!Number.isFinite(def) || !Number.isFinite(min) || !Number.isFinite(max)) return { error: `${t("sv.mgmt.field.params")} [${key}]: ${t("sv.mgmt.fieldRequired")}` };
11089
+ params.push({
11090
+ key,
11091
+ label: row.label.trim() || key,
11092
+ default: def,
11093
+ min,
11094
+ max,
11095
+ step: Number.isFinite(step) && step > 0 ? step : 1
11096
+ });
11097
+ }
11098
+ return {
11099
+ id: trimmedId,
11100
+ title: title.trim(),
11101
+ horizon,
11102
+ summary: summary.trim(),
11103
+ paramsJson: JSON.stringify(params),
11104
+ computeSource: source,
11105
+ overridesBuiltin: idIsBuiltin
11106
+ };
11107
+ };
11108
+ const handleValidate = async () => {
11109
+ const input = buildInput();
11110
+ if ("error" in input) {
11111
+ setNotice({
11112
+ kind: "error",
11113
+ text: input.error
11114
+ });
11115
+ return;
11116
+ }
11117
+ setBusy("validating");
11118
+ setNotice(null);
11119
+ try {
11120
+ const result = await validateCustomStrategy({
11121
+ id: input.id,
11122
+ title: input.title,
11123
+ horizon: input.horizon,
11124
+ summary: input.summary,
11125
+ paramsJson: input.paramsJson,
11126
+ computeSource: input.computeSource,
11127
+ createdAt: initial?.createdAt ?? Date.now()
11128
+ });
11129
+ setNotice(result.ok ? {
11130
+ kind: "ok",
11131
+ text: t("sv.mgmt.validationOk")
11132
+ } : {
11133
+ kind: "error",
11134
+ text: result.reason
11135
+ });
11136
+ } finally {
11137
+ setBusy("");
11138
+ }
11139
+ };
11140
+ const handleSave = async () => {
11141
+ const input = buildInput();
11142
+ if ("error" in input) {
11143
+ setNotice({
11144
+ kind: "error",
11145
+ text: input.error
11146
+ });
11147
+ return;
11148
+ }
11149
+ if (!isEdit && input.overridesBuiltin) {
11150
+ if (!window.confirm(t("sv.mgmt.confirmOverride"))) return;
11151
+ }
11152
+ setBusy("saving");
11153
+ setNotice(null);
11154
+ try {
11155
+ const pre = await validateCustomStrategy({
11156
+ id: input.id,
11157
+ title: input.title,
11158
+ horizon: input.horizon,
11159
+ summary: input.summary,
11160
+ paramsJson: input.paramsJson,
11161
+ computeSource: input.computeSource,
11162
+ createdAt: initial?.createdAt ?? Date.now()
11163
+ });
11164
+ if (!pre.ok) {
11165
+ setNotice({
11166
+ kind: "error",
11167
+ text: pre.reason
11168
+ });
11169
+ return;
11170
+ }
11171
+ const result = await onSave(input);
11172
+ if (result === null) {
11173
+ setNotice({
11174
+ kind: "error",
11175
+ text: t("sv.mgmt.saveFailed")
11176
+ });
11177
+ return;
11178
+ }
11179
+ if (!result.ok) {
11180
+ setNotice({
11181
+ kind: "error",
11182
+ text: `${t("sv.mgmt.saveFailed")}: ${result.reason}`
11183
+ });
9686
11184
  return;
9687
11185
  }
9688
- const capped = universe.slice(0, scanLimit);
9689
- setUniverseSize(universe.length);
9690
- setProgress({
9691
- done: 0,
9692
- total: capped.length,
9693
- hits: 0,
9694
- failed: 0
9695
- });
9696
- const hits = [];
9697
- let done = 0;
9698
- let failed = 0;
9699
- let cursor = 0;
9700
- const worker = async () => {
9701
- while (cursor < capped.length && runIdRef.current === runId) {
9702
- const inst = capped[cursor];
9703
- cursor += 1;
9704
- try {
9705
- const bars = await bridge.fetchKlines(market, inst.symbol, "1d", SCAN_KLINE_LIMIT);
9706
- if (runIdRef.current !== runId) return;
9707
- if (bars && bars.length > 0) {
9708
- const match = currentScreener.evaluate(bars, currentParams);
9709
- if (match) hits.push({
9710
- symbol: inst.symbol,
9711
- ...inst.name ? { name: inst.name } : {},
9712
- price: bars[bars.length - 1].close,
9713
- metrics: match.metrics,
9714
- reason: match.reason,
9715
- ...match.reasonKey !== void 0 ? { reasonKey: match.reasonKey } : {},
9716
- ...match.reasonParams !== void 0 ? { reasonParams: match.reasonParams } : {}
9717
- });
9718
- } else failed += 1;
9719
- } catch {
9720
- failed += 1;
9721
- }
9722
- done += 1;
9723
- if (runIdRef.current !== runId) return;
9724
- setProgress({
9725
- done,
9726
- total: capped.length,
9727
- hits: hits.length,
9728
- failed
9729
- });
9730
- setRows([...hits]);
9731
- }
9732
- };
9733
- await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, capped.length) }, () => worker()));
9734
- if (runIdRef.current !== runId) return;
9735
- if (failed > 0) setErrorMsg(`${t("sv.error.failed")} (${failed}/${capped.length})`);
9736
- } catch (e) {
9737
- if (runIdRef.current !== runId) return;
9738
- setErrorMsg(`${t("sv.error.failed")}: ${String(e?.message ?? e)}`);
11186
+ onSaved();
11187
+ onClose();
9739
11188
  } finally {
9740
- if (runIdRef.current === runId) setScanning(false);
11189
+ setBusy("");
9741
11190
  }
9742
11191
  };
9743
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
9744
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9745
- className: StrategyView_module_css_default.strategyCards,
9746
- children: screenerParadigms.map((screener) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9747
- className: StrategyView_module_css_default.strategyCard,
9748
- "data-active": screener.id === selectedId ? "true" : void 0,
9749
- "data-disabled": scanning ? "true" : void 0,
9750
- onClick: () => {
9751
- if (!scanning) setSelectedId(screener.id);
9752
- },
9753
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9754
- className: StrategyView_module_css_default.cardTitle,
9755
- children: screenerName(screener, t)
9756
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9757
- className: StrategyView_module_css_default.cardSummary,
9758
- children: screenerSummary(screener, t)
9759
- })]
9760
- }, screener.id))
9761
- }),
9762
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9763
- className: StrategyView_module_css_default.configBar,
11192
+ const updateRow = (index, patch) => {
11193
+ setRows((prev) => prev.map((row, i) => i === index ? {
11194
+ ...row,
11195
+ ...patch
11196
+ } : row));
11197
+ };
11198
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11199
+ className: StrategyEditor_module_css_default.overlay,
11200
+ role: "dialog",
11201
+ "aria-modal": "true",
11202
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11203
+ className: StrategyEditor_module_css_default.panel,
9764
11204
  children: [
9765
- currentScreener.params.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9766
- className: StrategyView_module_css_default.paramGroup,
9767
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
9768
- className: StrategyView_module_css_default.paramLabel,
9769
- children: [screenerParamLabel(currentScreener, p, t), ":"]
9770
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
9771
- type: "number",
9772
- className: StrategyView_module_css_default.paramInput,
9773
- min: p.min,
9774
- max: p.max,
9775
- step: p.step,
9776
- disabled: scanning,
9777
- value: currentParams[p.key] ?? p.default,
9778
- onChange: (e) => {
9779
- const numVal = parseFloat(e.target.value);
9780
- if (!Number.isNaN(numVal)) handleParamChange(p.key, numVal);
9781
- }
11205
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11206
+ className: StrategyEditor_module_css_default.panelHeader,
11207
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
11208
+ className: StrategyEditor_module_css_default.panelTitle,
11209
+ children: [t("sv.mgmt.editorTitle"), isEdit ? ` · ${initial.id}` : ""]
11210
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11211
+ type: "button",
11212
+ className: StrategyEditor_module_css_default.closeBtn,
11213
+ onClick: onClose,
11214
+ children: "✕"
9782
11215
  })]
9783
- }, p.key)),
11216
+ }),
9784
11217
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9785
- className: StrategyView_module_css_default.paramGroup,
9786
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
9787
- className: StrategyView_module_css_default.paramLabel,
9788
- children: [t("sv.screener.scanLimit"), ":"]
11218
+ className: StrategyEditor_module_css_default.formGrid,
11219
+ children: [
11220
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11221
+ className: StrategyEditor_module_css_default.field,
11222
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11223
+ className: StrategyEditor_module_css_default.fieldLabel,
11224
+ children: t("sv.mgmt.field.id")
11225
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11226
+ className: StrategyEditor_module_css_default.fieldInput,
11227
+ value: id,
11228
+ disabled: isEdit,
11229
+ onChange: (e) => setId(e.target.value),
11230
+ placeholder: "ema-stop-takeprofit"
11231
+ })]
11232
+ }),
11233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11234
+ className: StrategyEditor_module_css_default.field,
11235
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11236
+ className: StrategyEditor_module_css_default.fieldLabel,
11237
+ children: t("sv.mgmt.field.title")
11238
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11239
+ className: StrategyEditor_module_css_default.fieldInput,
11240
+ value: title,
11241
+ onChange: (e) => setTitle(e.target.value)
11242
+ })]
11243
+ }),
11244
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11245
+ className: StrategyEditor_module_css_default.field,
11246
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11247
+ className: StrategyEditor_module_css_default.fieldLabel,
11248
+ children: t("sv.mgmt.field.horizon")
11249
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
11250
+ className: StrategyEditor_module_css_default.fieldInput,
11251
+ value: horizon,
11252
+ onChange: (e) => setHorizon(e.target.value),
11253
+ children: HORIZONS.map((h) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
11254
+ value: h,
11255
+ children: t(`sv.horizon.${h}`)
11256
+ }, h))
11257
+ })]
11258
+ })
11259
+ ]
11260
+ }),
11261
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11262
+ className: StrategyEditor_module_css_default.field,
11263
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11264
+ className: StrategyEditor_module_css_default.fieldLabel,
11265
+ children: t("sv.mgmt.field.summary")
9789
11266
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
9790
- type: "number",
9791
- className: StrategyView_module_css_default.paramInput,
9792
- min: SCAN_LIMIT_MIN,
9793
- max: SCAN_LIMIT_MAX,
9794
- step: 50,
9795
- disabled: scanning,
9796
- value: scanLimit,
9797
- onChange: (e) => {
9798
- const numVal = parseFloat(e.target.value);
9799
- if (!Number.isNaN(numVal)) handleScanLimitChange(numVal);
9800
- }
11267
+ className: StrategyEditor_module_css_default.fieldInput,
11268
+ value: summary,
11269
+ onChange: (e) => setSummary(e.target.value)
9801
11270
  })]
9802
11271
  }),
9803
- scanning ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9804
- type: "button",
9805
- className: StrategyView_module_css_default.runBtn,
9806
- onClick: handleStop,
9807
- children: t("sv.screener.stop")
9808
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9809
- type: "button",
9810
- className: StrategyView_module_css_default.runBtn,
9811
- onClick: () => {
9812
- handleRun();
9813
- },
9814
- children: t("sv.screener.run")
9815
- })
9816
- ]
9817
- }),
9818
- (scanning || universeSize !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9819
- className: StrategyView_module_css_default.scanMeta,
9820
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
9821
- t("sv.screener.universePrefix"),
9822
- " ",
9823
- universeSize ?? "--",
9824
- " · ",
9825
- t("sv.screener.scanned"),
9826
- " ",
9827
- progress.done,
9828
- "/",
9829
- progress.total,
9830
- " · ",
9831
- t("sv.screener.hits"),
9832
- " ",
9833
- progress.hits,
9834
- " ·",
9835
- " ",
9836
- t("sv.screener.failed"),
9837
- " ",
9838
- progress.failed
9839
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9840
- className: StrategyView_module_css_default.progressWrap,
9841
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9842
- className: StrategyView_module_css_default.progressFill,
9843
- style: { width: progress.total > 0 ? `${progress.done / progress.total * 100}%` : "0%" }
9844
- })
9845
- })]
9846
- }),
9847
- errorMsg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9848
- className: StrategyView_module_css_default.errorMessage,
9849
- children: errorMsg
9850
- }),
9851
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9852
- className: StrategyView_module_css_default.tableSection,
9853
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9854
- className: StrategyView_module_css_default.tableTitle,
9855
- children: [
9856
- t("sv.screener.hits"),
9857
- " (",
9858
- rows.length,
9859
- ")"
9860
- ]
9861
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9862
- className: StrategyView_module_css_default.tradesTableWrapper,
9863
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
9864
- className: StrategyView_module_css_default.tradesTable,
9865
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
9866
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.symbol") }),
9867
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.name") }),
9868
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.price") }),
9869
- currentScreener.columns.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: screenerColumnLabel(currentScreener, col, t) }, col.key)),
9870
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.screener.col.reason") })
9871
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
9872
- colSpan: 4 + currentScreener.columns.length,
9873
- className: StrategyView_module_css_default.tableEmptyCell,
9874
- children: scanning ? t("sv.screener.scanning") : progress.total > 0 && progress.failed === 0 ? t("sv.screener.noHits") : t("sv.screener.emptyHint")
9875
- }) }) : rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
9876
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.symbol }),
9877
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
9878
- className: StrategyView_module_css_default.nameCell,
9879
- children: row.name ?? "--"
11272
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11273
+ className: StrategyEditor_module_css_default.field,
11274
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11275
+ className: StrategyEditor_module_css_default.fieldLabel,
11276
+ children: t("sv.mgmt.field.params")
11277
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11278
+ className: StrategyEditor_module_css_default.paramTable,
11279
+ children: [
11280
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11281
+ className: StrategyEditor_module_css_default.paramHeadRow,
11282
+ children: [
11283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.key") }),
11284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.label") }),
11285
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.default") }),
11286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.min") }),
11287
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.max") }),
11288
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sv.mgmt.param.step") }),
11289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {})
11290
+ ]
11291
+ }),
11292
+ rows.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11293
+ className: StrategyEditor_module_css_default.paramRow,
11294
+ children: [
11295
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11296
+ className: StrategyEditor_module_css_default.paramCell,
11297
+ value: row.key,
11298
+ onChange: (e) => updateRow(index, { key: e.target.value })
11299
+ }),
11300
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11301
+ className: StrategyEditor_module_css_default.paramCell,
11302
+ value: row.label,
11303
+ onChange: (e) => updateRow(index, { label: e.target.value })
11304
+ }),
11305
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11306
+ className: StrategyEditor_module_css_default.paramCell,
11307
+ type: "number",
11308
+ value: row.default,
11309
+ onChange: (e) => updateRow(index, { default: e.target.value })
11310
+ }),
11311
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11312
+ className: StrategyEditor_module_css_default.paramCell,
11313
+ type: "number",
11314
+ value: row.min,
11315
+ onChange: (e) => updateRow(index, { min: e.target.value })
11316
+ }),
11317
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11318
+ className: StrategyEditor_module_css_default.paramCell,
11319
+ type: "number",
11320
+ value: row.max,
11321
+ onChange: (e) => updateRow(index, { max: e.target.value })
11322
+ }),
11323
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11324
+ className: StrategyEditor_module_css_default.paramCell,
11325
+ type: "number",
11326
+ value: row.step,
11327
+ onChange: (e) => updateRow(index, { step: e.target.value })
11328
+ }),
11329
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11330
+ type: "button",
11331
+ className: StrategyEditor_module_css_default.paramRemove,
11332
+ title: t("sv.mgmt.removeParam"),
11333
+ onClick: () => setRows((prev) => prev.filter((_, i) => i !== index)),
11334
+ children: "✕"
11335
+ })
11336
+ ]
11337
+ }, index)),
11338
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
11339
+ type: "button",
11340
+ className: StrategyEditor_module_css_default.addParamBtn,
11341
+ onClick: () => setRows((prev) => [...prev, {
11342
+ key: "",
11343
+ label: "",
11344
+ default: "0",
11345
+ min: "0",
11346
+ max: "100",
11347
+ step: "1"
11348
+ }]),
11349
+ children: ["+ ", t("sv.mgmt.addParam")]
11350
+ })
11351
+ ]
11352
+ })]
11353
+ }),
11354
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11355
+ className: StrategyEditor_module_css_default.field,
11356
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11357
+ className: StrategyEditor_module_css_default.fieldLabel,
11358
+ children: t("sv.mgmt.field.source")
11359
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
11360
+ className: StrategyEditor_module_css_default.sourceArea,
11361
+ value: source,
11362
+ spellCheck: false,
11363
+ onChange: (e) => setSource(e.target.value),
11364
+ rows: 12
11365
+ })]
11366
+ }),
11367
+ notice !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11368
+ className: notice.kind === "ok" ? StrategyEditor_module_css_default.noticeOk : StrategyEditor_module_css_default.noticeError,
11369
+ children: notice.text
11370
+ }),
11371
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11372
+ className: StrategyEditor_module_css_default.actionBar,
11373
+ children: [
11374
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11375
+ type: "button",
11376
+ className: StrategyEditor_module_css_default.secondaryBtn,
11377
+ disabled: busy !== "",
11378
+ onClick: () => {
11379
+ handleValidate();
11380
+ },
11381
+ children: busy === "validating" ? t("sv.mgmt.validating") : t("sv.mgmt.validate")
11382
+ }),
11383
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: StrategyEditor_module_css_default.actionSpacer }),
11384
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11385
+ type: "button",
11386
+ className: StrategyEditor_module_css_default.secondaryBtn,
11387
+ onClick: onClose,
11388
+ children: t("sv.mgmt.cancel")
9880
11389
  }),
9881
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.price.toFixed(2) }),
9882
- currentScreener.columns.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatMetric(row.metrics[col.key] ?? NaN, col.format) }, col.key)),
9883
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
9884
- className: StrategyView_module_css_default.reasonCell,
9885
- children: screenerReason(row, t)
11390
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11391
+ type: "button",
11392
+ className: StrategyEditor_module_css_default.primaryBtn,
11393
+ disabled: busy !== "",
11394
+ onClick: () => {
11395
+ handleSave();
11396
+ },
11397
+ children: busy === "saving" ? t("sv.mgmt.validating") : t("sv.mgmt.save")
9886
11398
  })
9887
- ] }, row.symbol)) })]
11399
+ ]
9888
11400
  })
9889
- })]
11401
+ ]
9890
11402
  })
9891
- ] });
11403
+ });
9892
11404
  }
9893
11405
  //#endregion
9894
11406
  //#region src/client/StrategyView.tsx
@@ -9933,21 +11445,34 @@ window.__ModuleLoader__.load({
9933
11445
  const [selectedId, setSelectedId] = (0, react.useState)(stored.strategyId ?? "donchian-breakout");
9934
11446
  const [paramsMap, setParamsMap] = (0, react.useState)(stored.paramsMap ?? {});
9935
11447
  const [customDefs, setCustomDefs] = (0, react.useState)([]);
11448
+ const [records, setRecords] = (0, react.useState)([]);
11449
+ const [tombstones, setTombstones] = (0, react.useState)([]);
9936
11450
  const bridgeRef = (0, react.useRef)(bridge);
9937
11451
  if (bridgeRef.current === null) bridgeRef.current = bridge;
9938
11452
  const stableBridge = bridgeRef.current;
11453
+ const [reloadKey, setReloadKey] = (0, react.useState)(0);
9939
11454
  (0, react.useEffect)(() => {
9940
11455
  if (stableBridge === void 0) return;
9941
11456
  let cancelled = false;
11457
+ let generation = 0;
9942
11458
  const load = async () => {
11459
+ const gen = ++generation;
9943
11460
  try {
9944
- const records = await stableBridge.fetchCustomStrategies();
11461
+ const rawRecords = await stableBridge.fetchCustomStrategies();
9945
11462
  const defs = [];
9946
- for (const record of records) {
11463
+ const validRecords = [];
11464
+ for (const record of rawRecords) {
9947
11465
  const result = await validateCustomStrategy(record);
9948
- if (result.ok) defs.push(result.definition);
11466
+ if (result.ok) {
11467
+ defs.push(result.definition);
11468
+ validRecords.push(result.record);
11469
+ }
9949
11470
  }
9950
- if (!cancelled) setCustomDefs(defs);
11471
+ const deleted = stableBridge.fetchStrategyTombstones !== void 0 ? await stableBridge.fetchStrategyTombstones() : [];
11472
+ if (cancelled || gen !== generation) return;
11473
+ setCustomDefs(defs);
11474
+ setRecords(validRecords);
11475
+ setTombstones(deleted);
9951
11476
  } catch (e) {
9952
11477
  console.warn("[dsh-trading] failed to load custom strategies:", e);
9953
11478
  }
@@ -9960,12 +11485,15 @@ window.__ModuleLoader__.load({
9960
11485
  cancelled = true;
9961
11486
  unsubscribe();
9962
11487
  };
9963
- }, [stableBridge]);
9964
- const allStrategies = (0, react.useMemo)(() => [...strategyParadigms, ...customDefs], [customDefs]);
11488
+ }, [stableBridge, reloadKey]);
11489
+ const allStrategies = (0, react.useMemo)(() => applyStrategyManagement(strategyParadigms, customDefs, tombstones), [customDefs, tombstones]);
11490
+ const modifiedIds = (0, react.useMemo)(() => new Set(records.map((r) => r.id).filter((id) => isBuiltinStrategyId(id))), [records]);
11491
+ const [editor, setEditor] = (0, react.useState)(null);
9965
11492
  const currentStrategy = (0, react.useMemo)(() => {
9966
- return allStrategies.find((s) => s.id === selectedId) ?? allStrategies[0];
11493
+ return allStrategies.find((s) => s.id === selectedId) ?? allStrategies[0] ?? null;
9967
11494
  }, [allStrategies, selectedId]);
9968
11495
  const currentParams = (0, react.useMemo)(() => {
11496
+ if (currentStrategy === null) return {};
9969
11497
  const custom = paramsMap[currentStrategy.id] ?? {};
9970
11498
  const res = {};
9971
11499
  for (const p of currentStrategy.params) res[p.key] = custom[p.key] ?? p.default;
@@ -9996,6 +11524,7 @@ window.__ModuleLoader__.load({
9996
11524
  if (firstInHorizon) setSelectedId(firstInHorizon.id);
9997
11525
  };
9998
11526
  const handleParamChange = (key, value) => {
11527
+ if (currentStrategy === null) return;
9999
11528
  setParamsMap((prev) => ({
10000
11529
  ...prev,
10001
11530
  [currentStrategy.id]: {
@@ -10004,7 +11533,37 @@ window.__ModuleLoader__.load({
10004
11533
  }
10005
11534
  }));
10006
11535
  };
11536
+ const forgetLocalParams = (id) => {
11537
+ setParamsMap((prev) => {
11538
+ if (prev[id] === void 0) return prev;
11539
+ const next = { ...prev };
11540
+ delete next[id];
11541
+ return next;
11542
+ });
11543
+ };
11544
+ const handleDeleteStrategy = async (strat) => {
11545
+ if (stableBridge.deleteCustomStrategy === void 0) return;
11546
+ if (!window.confirm(t("sv.mgmt.confirmDelete"))) return;
11547
+ await stableBridge.deleteCustomStrategy(strat.id);
11548
+ forgetLocalParams(strat.id);
11549
+ if (selectedId === strat.id) setSelectedId("");
11550
+ };
11551
+ const handleRestoreStrategy = async (id) => {
11552
+ if (stableBridge.resetStrategy === void 0) return;
11553
+ if (modifiedIds.has(id) && !window.confirm(t("sv.mgmt.confirmRestore"))) return;
11554
+ const result = await stableBridge.resetStrategy(id);
11555
+ if (result !== null && result.ok) forgetLocalParams(id);
11556
+ };
11557
+ const handleEditStrategy = (strat) => {
11558
+ const record = modifiedIds.has(strat.id) || !isBuiltinStrategyId(strat.id) ? records.find((r) => r.id === strat.id) ?? null : builtinStrategyRecord(strat);
11559
+ if (record !== null) setEditor({ initial: record });
11560
+ };
11561
+ const handleSaveFromEditor = async (input) => {
11562
+ if (stableBridge.saveCustomStrategy === void 0) return null;
11563
+ return stableBridge.saveCustomStrategy(input);
11564
+ };
10007
11565
  const handleRunBacktest = async () => {
11566
+ if (currentStrategy === null) return;
10008
11567
  setLoading(true);
10009
11568
  setErrorMsg(null);
10010
11569
  try {
@@ -10090,272 +11649,376 @@ window.__ModuleLoader__.load({
10090
11649
  const horizonStrategies = (0, react.useMemo)(() => {
10091
11650
  return allStrategies.filter((s) => s.horizon === horizon);
10092
11651
  }, [allStrategies, horizon]);
11652
+ const mgmtAvailable = stableBridge.deleteCustomStrategy !== void 0 && stableBridge.saveCustomStrategy !== void 0 && stableBridge.resetStrategy !== void 0;
11653
+ const tombstonedStrategies = (0, react.useMemo)(() => {
11654
+ if (!mgmtAvailable) return [];
11655
+ return strategyParadigms.filter((d) => tombstones.includes(d.id));
11656
+ }, [tombstones, mgmtAvailable]);
10093
11657
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10094
11658
  className: StrategyView_module_css_default.root,
10095
11659
  "data-dshtrading-strategy-view": "",
10096
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10097
- className: StrategyView_module_css_default.header,
10098
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10099
- className: StrategyView_module_css_default.horizonSelector,
10100
- role: "tablist",
10101
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10102
- type: "button",
10103
- className: StrategyView_module_css_default.horizonBtn,
10104
- "data-active": section === "screener" ? "true" : void 0,
10105
- onClick: () => setSection("screener"),
10106
- children: t("sv.section.screener")
10107
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10108
- type: "button",
10109
- className: StrategyView_module_css_default.horizonBtn,
10110
- "data-active": section === "quant" ? "true" : void 0,
10111
- onClick: () => setSection("quant"),
10112
- children: t("sv.section.quant")
10113
- })]
10114
- }), section === "quant" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10115
- className: StrategyView_module_css_default.horizonSelector,
10116
- role: "tablist",
10117
- children: [
10118
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11660
+ children: [
11661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11662
+ className: StrategyView_module_css_default.header,
11663
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11664
+ className: StrategyView_module_css_default.horizonSelector,
11665
+ role: "tablist",
11666
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10119
11667
  type: "button",
10120
11668
  className: StrategyView_module_css_default.horizonBtn,
10121
- "data-active": horizon === "short" ? "true" : void 0,
10122
- onClick: () => switchHorizon("short"),
10123
- children: t("sv.horizon.short")
10124
- }),
10125
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11669
+ "data-active": section === "screener" ? "true" : void 0,
11670
+ onClick: () => setSection("screener"),
11671
+ children: t("sv.section.screener")
11672
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10126
11673
  type: "button",
10127
11674
  className: StrategyView_module_css_default.horizonBtn,
10128
- "data-active": horizon === "swing" ? "true" : void 0,
10129
- onClick: () => switchHorizon("swing"),
10130
- children: t("sv.horizon.swing")
10131
- }),
10132
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11675
+ "data-active": section === "quant" ? "true" : void 0,
11676
+ onClick: () => setSection("quant"),
11677
+ children: t("sv.section.quant")
11678
+ })]
11679
+ }), section === "quant" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11680
+ className: StrategyView_module_css_default.mgmtRow,
11681
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11682
+ className: StrategyView_module_css_default.horizonSelector,
11683
+ role: "tablist",
11684
+ children: [
11685
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11686
+ type: "button",
11687
+ className: StrategyView_module_css_default.horizonBtn,
11688
+ "data-active": horizon === "short" ? "true" : void 0,
11689
+ onClick: () => switchHorizon("short"),
11690
+ children: t("sv.horizon.short")
11691
+ }),
11692
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11693
+ type: "button",
11694
+ className: StrategyView_module_css_default.horizonBtn,
11695
+ "data-active": horizon === "swing" ? "true" : void 0,
11696
+ onClick: () => switchHorizon("swing"),
11697
+ children: t("sv.horizon.swing")
11698
+ }),
11699
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11700
+ type: "button",
11701
+ className: StrategyView_module_css_default.horizonBtn,
11702
+ "data-active": horizon === "long" ? "true" : void 0,
11703
+ onClick: () => switchHorizon("long"),
11704
+ children: t("sv.horizon.long")
11705
+ })
11706
+ ]
11707
+ }), mgmtAvailable && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
10133
11708
  type: "button",
10134
- className: StrategyView_module_css_default.horizonBtn,
10135
- "data-active": horizon === "long" ? "true" : void 0,
10136
- onClick: () => switchHorizon("long"),
10137
- children: t("sv.horizon.long")
10138
- })
10139
- ]
10140
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10141
- className: StrategyView_module_css_default.strategyCards,
10142
- children: horizonStrategies.map((strat) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10143
- className: StrategyView_module_css_default.strategyCard,
10144
- "data-active": strat.id === selectedId ? "true" : void 0,
10145
- onClick: () => setSelectedId(strat.id),
10146
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10147
- className: StrategyView_module_css_default.cardTitle,
10148
- children: strategyName(strat, t)
10149
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10150
- className: StrategyView_module_css_default.cardSummary,
10151
- children: strategySummary(strat, t)
11709
+ className: StrategyView_module_css_default.newStrategyBtn,
11710
+ onClick: () => setEditor({ initial: null }),
11711
+ children: ["+ ", t("sv.mgmt.new")]
10152
11712
  })]
10153
- }, strat.id))
10154
- })] })]
10155
- }), section === "screener" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScreenerPane, {
10156
- t,
10157
- market,
10158
- bridge: stableBridge
10159
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
10160
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10161
- className: StrategyView_module_css_default.configBar,
10162
- children: [
10163
- currentStrategy.params.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10164
- className: StrategyView_module_css_default.paramGroup,
10165
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10166
- className: StrategyView_module_css_default.paramLabel,
10167
- children: [paramLabel(currentStrategy, p, t), ":"]
10168
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
10169
- type: "number",
10170
- className: StrategyView_module_css_default.paramInput,
10171
- min: p.min,
10172
- max: p.max,
10173
- step: p.step,
10174
- value: currentParams[p.key] ?? p.default,
10175
- onChange: (e) => {
10176
- const numVal = parseFloat(e.target.value);
10177
- if (!Number.isNaN(numVal)) handleParamChange(p.key, numVal);
10178
- }
10179
- })]
10180
- }, p.key)),
10181
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10182
- className: StrategyView_module_css_default.paramGroup,
10183
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10184
- className: StrategyView_module_css_default.paramLabel,
10185
- children: t("sv.symbolLabel")
10186
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
10187
- className: StrategyView_module_css_default.symbolValue,
11713
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11714
+ className: StrategyView_module_css_default.strategyCards,
11715
+ children: [horizonStrategies.map((strat) => {
11716
+ const modified = modifiedIds.has(strat.id);
11717
+ const builtin = isBuiltinStrategyId(strat.id);
11718
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11719
+ className: StrategyView_module_css_default.strategyCard,
11720
+ "data-active": strat.id === selectedId ? "true" : void 0,
11721
+ onClick: () => setSelectedId(strat.id),
10188
11722
  children: [
10189
- symbol,
10190
- " ",
10191
- t("sv.intervalDaily")
11723
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11724
+ className: StrategyView_module_css_default.cardTitleRow,
11725
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11726
+ className: StrategyView_module_css_default.cardTitle,
11727
+ children: strategyName(strat, t)
11728
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11729
+ className: StrategyView_module_css_default.cardBadge,
11730
+ "data-kind": modified ? "modified" : builtin ? "builtin" : "custom",
11731
+ children: t(modified ? "sv.mgmt.badge.modified" : builtin ? "sv.mgmt.badge.builtin" : "sv.mgmt.badge.custom")
11732
+ })]
11733
+ }),
11734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11735
+ className: StrategyView_module_css_default.cardSummary,
11736
+ children: strategySummary(strat, t)
11737
+ }),
11738
+ mgmtAvailable && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11739
+ className: StrategyView_module_css_default.cardActions,
11740
+ onClick: (e) => e.stopPropagation(),
11741
+ children: [
11742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11743
+ type: "button",
11744
+ className: StrategyView_module_css_default.cardActionBtn,
11745
+ onClick: () => handleEditStrategy(strat),
11746
+ children: t("sv.mgmt.edit")
11747
+ }),
11748
+ builtin && modified && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11749
+ type: "button",
11750
+ className: StrategyView_module_css_default.cardActionBtn,
11751
+ onClick: () => {
11752
+ handleRestoreStrategy(strat.id);
11753
+ },
11754
+ children: t("sv.mgmt.restore")
11755
+ }),
11756
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11757
+ type: "button",
11758
+ className: StrategyView_module_css_default.cardActionBtn,
11759
+ "data-danger": "true",
11760
+ onClick: () => {
11761
+ handleDeleteStrategy(strat);
11762
+ },
11763
+ children: t("sv.mgmt.delete")
11764
+ })
11765
+ ]
11766
+ })
10192
11767
  ]
10193
- })]
10194
- }),
10195
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10196
- type: "button",
10197
- className: StrategyView_module_css_default.runBtn,
10198
- disabled: loading,
10199
- onClick: handleRunBacktest,
10200
- children: loading ? t("sv.running") : t("sv.run")
10201
- })
10202
- ]
10203
- }),
10204
- errorMsg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10205
- className: StrategyView_module_css_default.errorMessage,
10206
- children: errorMsg
11768
+ }, strat.id);
11769
+ }), tombstonedStrategies.map((strat) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11770
+ className: StrategyView_module_css_default.strategyCard,
11771
+ "data-deleted": "true",
11772
+ children: [
11773
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11774
+ className: StrategyView_module_css_default.cardTitleRow,
11775
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11776
+ className: StrategyView_module_css_default.cardTitle,
11777
+ children: strategyName(strat, t)
11778
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11779
+ className: StrategyView_module_css_default.cardBadge,
11780
+ "data-kind": "deleted",
11781
+ children: t("sv.mgmt.deleted")
11782
+ })]
11783
+ }),
11784
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11785
+ className: StrategyView_module_css_default.cardSummary,
11786
+ children: strategySummary(strat, t)
11787
+ }),
11788
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11789
+ className: StrategyView_module_css_default.cardActions,
11790
+ onClick: (e) => e.stopPropagation(),
11791
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11792
+ type: "button",
11793
+ className: StrategyView_module_css_default.cardActionBtn,
11794
+ onClick: () => {
11795
+ handleRestoreStrategy(strat.id);
11796
+ },
11797
+ children: t("sv.mgmt.restore")
11798
+ })
11799
+ })
11800
+ ]
11801
+ }, strat.id))]
11802
+ })] })]
10207
11803
  }),
10208
- result ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
11804
+ section === "screener" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScreenerPane, {
11805
+ t,
11806
+ market,
11807
+ bridge: stableBridge
11808
+ }) : currentStrategy === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11809
+ className: StrategyView_module_css_default.emptyState,
11810
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11811
+ className: StrategyView_module_css_default.emptyIcon,
11812
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconStrategy, { size: 36 })
11813
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("sv.mgmt.rosterEmpty") })]
11814
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
10209
11815
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10210
- className: StrategyView_module_css_default.metricsGrid,
11816
+ className: StrategyView_module_css_default.configBar,
10211
11817
  children: [
10212
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10213
- className: StrategyView_module_css_default.metricCard,
10214
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10215
- className: StrategyView_module_css_default.metricLabel,
10216
- children: t("sv.metrics.totalReturn")
10217
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10218
- className: `${StrategyView_module_css_default.metricValue} ${result.metrics.totalReturn >= 0 ? StrategyView_module_css_default.trendUp : StrategyView_module_css_default.trendDown}`,
10219
- children: formatPercent(result.metrics.totalReturn, true)
10220
- })]
10221
- }),
10222
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10223
- className: StrategyView_module_css_default.metricCard,
10224
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10225
- className: StrategyView_module_css_default.metricLabel,
10226
- children: t("sv.metrics.cagr")
10227
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10228
- className: `${StrategyView_module_css_default.metricValue} ${result.metrics.cagr >= 0 ? StrategyView_module_css_default.trendUp : StrategyView_module_css_default.trendDown}`,
10229
- children: formatPercent(result.metrics.cagr, true)
10230
- })]
10231
- }),
10232
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10233
- className: StrategyView_module_css_default.metricCard,
10234
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10235
- className: StrategyView_module_css_default.metricLabel,
10236
- children: t("sv.metrics.maxDrawdown")
10237
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10238
- className: `${StrategyView_module_css_default.metricValue} ${StrategyView_module_css_default.trendDown}`,
10239
- children: formatPercent(result.metrics.maxDrawdown)
10240
- })]
10241
- }),
10242
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10243
- className: StrategyView_module_css_default.metricCard,
10244
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10245
- className: StrategyView_module_css_default.metricLabel,
10246
- children: t("sv.metrics.sharpe")
10247
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10248
- className: StrategyView_module_css_default.metricValue,
10249
- children: formatNum(result.metrics.sharpe)
10250
- })]
10251
- }),
10252
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10253
- className: StrategyView_module_css_default.metricCard,
10254
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10255
- className: StrategyView_module_css_default.metricLabel,
10256
- children: t("sv.metrics.winRate")
10257
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10258
- className: StrategyView_module_css_default.metricValue,
10259
- children: formatPercent(result.metrics.winRate)
10260
- })]
10261
- }),
10262
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10263
- className: StrategyView_module_css_default.metricCard,
10264
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10265
- className: StrategyView_module_css_default.metricLabel,
10266
- children: t("sv.metrics.profitFactor")
10267
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10268
- className: StrategyView_module_css_default.metricValue,
10269
- children: formatNum(result.metrics.profitFactor)
11818
+ currentStrategy.params.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11819
+ className: StrategyView_module_css_default.paramGroup,
11820
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11821
+ className: StrategyView_module_css_default.paramLabel,
11822
+ children: [paramLabel(currentStrategy, p, t), ":"]
11823
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
11824
+ type: "number",
11825
+ className: StrategyView_module_css_default.paramInput,
11826
+ min: p.min,
11827
+ max: p.max,
11828
+ step: p.step,
11829
+ value: currentParams[p.key] ?? p.default,
11830
+ onChange: (e) => {
11831
+ const numVal = parseFloat(e.target.value);
11832
+ if (!Number.isNaN(numVal)) handleParamChange(p.key, numVal);
11833
+ }
10270
11834
  })]
10271
- }),
11835
+ }, p.key)),
10272
11836
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10273
- className: StrategyView_module_css_default.metricCard,
11837
+ className: StrategyView_module_css_default.paramGroup,
10274
11838
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10275
- className: StrategyView_module_css_default.metricLabel,
10276
- children: t("sv.metrics.tradeCount")
11839
+ className: StrategyView_module_css_default.paramLabel,
11840
+ children: t("sv.symbolLabel")
10277
11841
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
10278
- className: StrategyView_module_css_default.metricValue,
11842
+ className: StrategyView_module_css_default.symbolValue,
10279
11843
  children: [
10280
- result.metrics.tradeCount,
11844
+ symbol,
10281
11845
  " ",
10282
- t("sv.metrics.tradeUnit")
11846
+ t("sv.intervalDaily")
10283
11847
  ]
10284
11848
  })]
10285
11849
  }),
10286
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10287
- className: StrategyView_module_css_default.metricCard,
10288
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10289
- className: StrategyView_module_css_default.metricLabel,
10290
- children: t("sv.metrics.exposure")
10291
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10292
- className: StrategyView_module_css_default.metricValue,
10293
- children: formatPercent(result.metrics.exposure)
10294
- })]
11850
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11851
+ type: "button",
11852
+ className: StrategyView_module_css_default.runBtn,
11853
+ disabled: loading,
11854
+ onClick: handleRunBacktest,
11855
+ children: loading ? t("sv.running") : t("sv.run")
10295
11856
  })
10296
11857
  ]
10297
11858
  }),
10298
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10299
- className: StrategyView_module_css_default.chartContainer,
10300
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10301
- ref: chartContainerRef,
10302
- className: StrategyView_module_css_default.chartWrapper
10303
- })
11859
+ errorMsg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11860
+ className: StrategyView_module_css_default.errorMessage,
11861
+ children: errorMsg
10304
11862
  }),
10305
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10306
- className: StrategyView_module_css_default.tableSection,
10307
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10308
- className: StrategyView_module_css_default.tableTitle,
11863
+ result ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
11864
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11865
+ className: StrategyView_module_css_default.metricsGrid,
10309
11866
  children: [
10310
- t("sv.trades.title"),
10311
- " (",
10312
- result.trades.length,
10313
- " ",
10314
- t("sv.metrics.tradeUnit"),
10315
- ")"
11867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11868
+ className: StrategyView_module_css_default.metricCard,
11869
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11870
+ className: StrategyView_module_css_default.metricLabel,
11871
+ children: t("sv.metrics.totalReturn")
11872
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11873
+ className: `${StrategyView_module_css_default.metricValue} ${result.metrics.totalReturn >= 0 ? StrategyView_module_css_default.trendUp : StrategyView_module_css_default.trendDown}`,
11874
+ children: formatPercent(result.metrics.totalReturn, true)
11875
+ })]
11876
+ }),
11877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11878
+ className: StrategyView_module_css_default.metricCard,
11879
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11880
+ className: StrategyView_module_css_default.metricLabel,
11881
+ children: t("sv.metrics.cagr")
11882
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11883
+ className: `${StrategyView_module_css_default.metricValue} ${result.metrics.cagr >= 0 ? StrategyView_module_css_default.trendUp : StrategyView_module_css_default.trendDown}`,
11884
+ children: formatPercent(result.metrics.cagr, true)
11885
+ })]
11886
+ }),
11887
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11888
+ className: StrategyView_module_css_default.metricCard,
11889
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11890
+ className: StrategyView_module_css_default.metricLabel,
11891
+ children: t("sv.metrics.maxDrawdown")
11892
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11893
+ className: `${StrategyView_module_css_default.metricValue} ${StrategyView_module_css_default.trendDown}`,
11894
+ children: formatPercent(result.metrics.maxDrawdown)
11895
+ })]
11896
+ }),
11897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11898
+ className: StrategyView_module_css_default.metricCard,
11899
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11900
+ className: StrategyView_module_css_default.metricLabel,
11901
+ children: t("sv.metrics.sharpe")
11902
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11903
+ className: StrategyView_module_css_default.metricValue,
11904
+ children: formatNum(result.metrics.sharpe)
11905
+ })]
11906
+ }),
11907
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11908
+ className: StrategyView_module_css_default.metricCard,
11909
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11910
+ className: StrategyView_module_css_default.metricLabel,
11911
+ children: t("sv.metrics.winRate")
11912
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11913
+ className: StrategyView_module_css_default.metricValue,
11914
+ children: formatPercent(result.metrics.winRate)
11915
+ })]
11916
+ }),
11917
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11918
+ className: StrategyView_module_css_default.metricCard,
11919
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11920
+ className: StrategyView_module_css_default.metricLabel,
11921
+ children: t("sv.metrics.profitFactor")
11922
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11923
+ className: StrategyView_module_css_default.metricValue,
11924
+ children: formatNum(result.metrics.profitFactor)
11925
+ })]
11926
+ }),
11927
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11928
+ className: StrategyView_module_css_default.metricCard,
11929
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11930
+ className: StrategyView_module_css_default.metricLabel,
11931
+ children: t("sv.metrics.tradeCount")
11932
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
11933
+ className: StrategyView_module_css_default.metricValue,
11934
+ children: [
11935
+ result.metrics.tradeCount,
11936
+ " ",
11937
+ t("sv.metrics.tradeUnit")
11938
+ ]
11939
+ })]
11940
+ }),
11941
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11942
+ className: StrategyView_module_css_default.metricCard,
11943
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11944
+ className: StrategyView_module_css_default.metricLabel,
11945
+ children: t("sv.metrics.exposure")
11946
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11947
+ className: StrategyView_module_css_default.metricValue,
11948
+ children: formatPercent(result.metrics.exposure)
11949
+ })]
11950
+ })
10316
11951
  ]
10317
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10318
- className: StrategyView_module_css_default.tradesTableWrapper,
10319
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
10320
- className: StrategyView_module_css_default.tradesTable,
10321
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
10322
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.entryTime") }),
10323
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.exitTime") }),
10324
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.entryPrice") }),
10325
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.exitPrice") }),
10326
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.holdingBars") }),
10327
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.netReturn") }),
10328
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.exitReason") })
10329
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: result.trades.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
10330
- colSpan: 7,
10331
- className: StrategyView_module_css_default.tableEmptyCell,
10332
- children: t("sv.trades.empty")
10333
- }) }) : result.trades.map((tr, idx) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
10334
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatDate(tr.entryTime) }),
10335
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatDate(tr.exitTime) }),
10336
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: tr.entryPrice.toFixed(2) }),
10337
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: tr.exitPrice.toFixed(2) }),
10338
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: tr.holdingBars }),
10339
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
10340
- className: tr.returnPercent >= 0 ? StrategyView_module_css_default.trendUp : StrategyView_module_css_default.trendDown,
10341
- children: formatPercent(tr.returnPercent, true)
10342
- }),
10343
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
10344
- className: StrategyView_module_css_default.reasonCell,
10345
- children: exitReasonText(tr, t)
10346
- })
10347
- ] }, idx)) })]
11952
+ }),
11953
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11954
+ className: StrategyView_module_css_default.chartContainer,
11955
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11956
+ ref: chartContainerRef,
11957
+ className: StrategyView_module_css_default.chartWrapper
10348
11958
  })
10349
- })]
11959
+ }),
11960
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11961
+ className: StrategyView_module_css_default.tableSection,
11962
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11963
+ className: StrategyView_module_css_default.tableTitle,
11964
+ children: [
11965
+ t("sv.trades.title"),
11966
+ " (",
11967
+ result.trades.length,
11968
+ " ",
11969
+ t("sv.metrics.tradeUnit"),
11970
+ ")"
11971
+ ]
11972
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11973
+ className: StrategyView_module_css_default.tradesTableWrapper,
11974
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
11975
+ className: StrategyView_module_css_default.tradesTable,
11976
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
11977
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.entryTime") }),
11978
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.exitTime") }),
11979
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.entryPrice") }),
11980
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.exitPrice") }),
11981
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.holdingBars") }),
11982
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.netReturn") }),
11983
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("sv.trades.exitReason") })
11984
+ ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: result.trades.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
11985
+ colSpan: 7,
11986
+ className: StrategyView_module_css_default.tableEmptyCell,
11987
+ children: t("sv.trades.empty")
11988
+ }) }) : result.trades.map((tr, idx) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
11989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatDate(tr.entryTime) }),
11990
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatDate(tr.exitTime) }),
11991
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: fmtPrice(tr.entryPrice) }),
11992
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: fmtPrice(tr.exitPrice) }),
11993
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: tr.holdingBars }),
11994
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
11995
+ className: tr.returnPercent >= 0 ? StrategyView_module_css_default.trendUp : StrategyView_module_css_default.trendDown,
11996
+ children: formatPercent(tr.returnPercent, true)
11997
+ }),
11998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
11999
+ className: StrategyView_module_css_default.reasonCell,
12000
+ children: exitReasonText(tr, t)
12001
+ })
12002
+ ] }, idx)) })]
12003
+ })
12004
+ })]
12005
+ })
12006
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
12007
+ className: StrategyView_module_css_default.emptyState,
12008
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
12009
+ className: StrategyView_module_css_default.emptyIcon,
12010
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconStrategy, { size: 36 })
12011
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("sv.empty.hint") })]
10350
12012
  })
10351
- ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10352
- className: StrategyView_module_css_default.emptyState,
10353
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10354
- className: StrategyView_module_css_default.emptyIcon,
10355
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconStrategy, { size: 36 })
10356
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: t("sv.empty.hint") })]
12013
+ ] }),
12014
+ editor !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StrategyEditor, {
12015
+ t,
12016
+ initial: editor.initial,
12017
+ onSave: handleSaveFromEditor,
12018
+ onSaved: () => setReloadKey((k) => k + 1),
12019
+ onClose: () => setEditor(null)
10357
12020
  })
10358
- ] })]
12021
+ ]
10359
12022
  });
10360
12023
  }
10361
12024
  //#endregion
@@ -10442,18 +12105,18 @@ window.__ModuleLoader__.load({
10442
12105
  document.head.appendChild(tag);
10443
12106
  }
10444
12107
  var toolview_module_css_default = {
10445
- "meta": "MbbyQa_meta",
10446
12108
  "card": "MbbyQa_card",
10447
12109
  "spark": "MbbyQa_spark",
10448
- "metrics": "MbbyQa_metrics",
10449
12110
  "metric": "MbbyQa_metric",
10450
- "metricValue": "MbbyQa_metricValue",
10451
- "params": "MbbyQa_params",
10452
12111
  "chip": "MbbyQa_chip",
12112
+ "meta": "MbbyQa_meta",
10453
12113
  "reason": "MbbyQa_reason",
12114
+ "metricValue": "MbbyQa_metricValue",
12115
+ "metricLabel": "MbbyQa_metricLabel",
12116
+ "metrics": "MbbyQa_metrics",
10454
12117
  "title": "MbbyQa_title",
10455
12118
  "head": "MbbyQa_head",
10456
- "metricLabel": "MbbyQa_metricLabel"
12119
+ "params": "MbbyQa_params"
10457
12120
  };
10458
12121
  //#endregion
10459
12122
  //#region src/client/toolview.tsx
@@ -10705,6 +12368,49 @@ window.__ModuleLoader__.load({
10705
12368
  "sv.screener.col.price": "现价",
10706
12369
  "sv.screener.col.reason": "信号说明",
10707
12370
  "sv.author.failed": "策略校验未通过",
12371
+ "sv.mgmt.new": "新建策略",
12372
+ "sv.mgmt.edit": "编辑",
12373
+ "sv.mgmt.delete": "删除",
12374
+ "sv.mgmt.restore": "恢复默认",
12375
+ "sv.mgmt.deleted": "已删除",
12376
+ "sv.mgmt.badge.builtin": "内置",
12377
+ "sv.mgmt.badge.modified": "已修改",
12378
+ "sv.mgmt.badge.custom": "自定义",
12379
+ "sv.mgmt.confirmDelete": "确定删除该策略?内置策略删除后可「恢复默认」找回出厂代码;你对内置策略的修改和自定义策略的删除不可恢复。",
12380
+ "sv.mgmt.confirmOverride": "该 ID 与内置策略同名,保存将覆盖内置策略(可随时恢复出厂代码;本次修改保存后删除/恢复将不可找回)。继续?",
12381
+ "sv.mgmt.confirmRestore": "恢复出厂将清除该内置策略的全部修改(不可恢复),出厂代码会找回。继续?",
12382
+ "sv.mgmt.editorTitle": "策略编辑器",
12383
+ "sv.mgmt.field.id": "ID",
12384
+ "sv.mgmt.field.title": "名称",
12385
+ "sv.mgmt.field.horizon": "期限",
12386
+ "sv.mgmt.field.summary": "一句话思路",
12387
+ "sv.mgmt.field.params": "参数规格",
12388
+ "sv.mgmt.field.source": "策略源码 compute(bars, params)",
12389
+ "sv.mgmt.fieldRequired": "必填字段缺失或数值非法",
12390
+ "sv.mgmt.param.key": "键",
12391
+ "sv.mgmt.param.label": "名称",
12392
+ "sv.mgmt.param.default": "默认",
12393
+ "sv.mgmt.param.min": "最小",
12394
+ "sv.mgmt.param.max": "最大",
12395
+ "sv.mgmt.param.step": "步进",
12396
+ "sv.mgmt.addParam": "添加参数",
12397
+ "sv.mgmt.removeParam": "移除",
12398
+ "sv.mgmt.validate": "校验",
12399
+ "sv.mgmt.validating": "计算中…",
12400
+ "sv.mgmt.validationOk": "校验通过:5 场景试算 + 信号序列可复算",
12401
+ "sv.mgmt.save": "保存",
12402
+ "sv.mgmt.cancel": "取消",
12403
+ "sv.mgmt.saveFailed": "保存失败",
12404
+ "sv.mgmt.rosterEmpty": "名册为空:全部内置策略已删除且无自定义策略,可新建策略或恢复内置默认",
12405
+ "sv.mgmt.newScreener": "新建选股器",
12406
+ "sv.mgmt.editorTitleScreener": "选股器编辑器",
12407
+ "sv.mgmt.field.columns": "结果列",
12408
+ "sv.mgmt.field.evaluate": "选股器源码 evaluate(bars, params)",
12409
+ "sv.mgmt.column.format": "格式",
12410
+ "sv.mgmt.addColumn": "添加列",
12411
+ "sv.mgmt.confirmOverrideScreener": "该 ID 与内置选股器同名,保存将覆盖内置选股器(可随时恢复出厂代码;本次修改保存后删除/恢复将不可找回)。继续?",
12412
+ "sv.mgmt.validationOkScreener": "校验通过:5 场景试算,命中形状合法",
12413
+ "sv.screener.rosterEmpty": "选股器名册为空:全部内置选股器已删除且无自定义选股器,可新建或恢复内置默认",
10708
12414
  "strat.donchian-breakout": "唐奇安通道突破",
10709
12415
  "strat.donchian-breakout.summary": "收盘价突破前 N1 根最高价做多,跌破前 N2 根最低价离场(海龟经典简化版)",
10710
12416
  "strat.donchian-breakout.param.lookbackEntry": "突破周期 (N1)",
@@ -10823,6 +12529,49 @@ window.__ModuleLoader__.load({
10823
12529
  "sv.screener.col.price": "Price",
10824
12530
  "sv.screener.col.reason": "Signal",
10825
12531
  "sv.author.failed": "Validation failed",
12532
+ "sv.mgmt.new": "New Strategy",
12533
+ "sv.mgmt.edit": "Edit",
12534
+ "sv.mgmt.delete": "Delete",
12535
+ "sv.mgmt.restore": "Restore",
12536
+ "sv.mgmt.deleted": "Deleted",
12537
+ "sv.mgmt.badge.builtin": "Built-in",
12538
+ "sv.mgmt.badge.modified": "Modified",
12539
+ "sv.mgmt.badge.custom": "Custom",
12540
+ "sv.mgmt.confirmDelete": "Delete this strategy? Deleted built-ins can be restored to factory code via \"Restore\"; your modifications to a built-in and deleted custom strategies cannot be recovered.",
12541
+ "sv.mgmt.confirmOverride": "This ID collides with a built-in strategy. Saving will override it (factory code stays restorable; once saved, this modification is unrecoverable on delete/restore). Continue?",
12542
+ "sv.mgmt.confirmRestore": "Restoring factory default will permanently discard all modifications to this built-in strategy (unrecoverable). The factory code will be restored. Continue?",
12543
+ "sv.mgmt.editorTitle": "Strategy Editor",
12544
+ "sv.mgmt.field.id": "ID",
12545
+ "sv.mgmt.field.title": "Title",
12546
+ "sv.mgmt.field.horizon": "Horizon",
12547
+ "sv.mgmt.field.summary": "Summary",
12548
+ "sv.mgmt.field.params": "Parameters",
12549
+ "sv.mgmt.field.source": "Strategy source compute(bars, params)",
12550
+ "sv.mgmt.fieldRequired": "Required field missing or invalid number",
12551
+ "sv.mgmt.param.key": "Key",
12552
+ "sv.mgmt.param.label": "Label",
12553
+ "sv.mgmt.param.default": "Default",
12554
+ "sv.mgmt.param.min": "Min",
12555
+ "sv.mgmt.param.max": "Max",
12556
+ "sv.mgmt.param.step": "Step",
12557
+ "sv.mgmt.addParam": "Add Parameter",
12558
+ "sv.mgmt.removeParam": "Remove",
12559
+ "sv.mgmt.validate": "Validate",
12560
+ "sv.mgmt.validating": "Computing…",
12561
+ "sv.mgmt.validationOk": "Validation passed: 5 scenarios + replayable signal sequence",
12562
+ "sv.mgmt.save": "Save",
12563
+ "sv.mgmt.cancel": "Cancel",
12564
+ "sv.mgmt.saveFailed": "Save failed",
12565
+ "sv.mgmt.rosterEmpty": "Roster is empty: all built-in strategies deleted and no custom strategies. Create one or restore built-ins.",
12566
+ "sv.mgmt.newScreener": "New Screener",
12567
+ "sv.mgmt.editorTitleScreener": "Screener Editor",
12568
+ "sv.mgmt.field.columns": "Columns",
12569
+ "sv.mgmt.field.evaluate": "Screener source evaluate(bars, params)",
12570
+ "sv.mgmt.column.format": "Format",
12571
+ "sv.mgmt.addColumn": "Add Column",
12572
+ "sv.mgmt.confirmOverrideScreener": "This ID collides with a built-in screener. Saving will override it (factory code stays restorable; once saved, this modification is unrecoverable on delete/restore). Continue?",
12573
+ "sv.mgmt.validationOkScreener": "Validation passed: 5 scenarios, match shape valid",
12574
+ "sv.screener.rosterEmpty": "Screener roster is empty: all built-ins deleted and no custom screeners. Create one or restore built-ins.",
10826
12575
  "strat.donchian-breakout": "Donchian breakout",
10827
12576
  "strat.donchian-breakout.summary": "Go long when close breaks the N1-bar high; exit below the N2-bar low (classic turtle, simplified)",
10828
12577
  "strat.donchian-breakout.param.lookbackEntry": "Breakout lookback (N1)",
@@ -10914,7 +12663,15 @@ window.__ModuleLoader__.load({
10914
12663
  fetchKlines: (market, symbol, interval, limit) => faces.tradingBridge.fetchKlines(market, symbol, interval, limit),
10915
12664
  fetchCustomStrategies: () => faces.tradingBridge.fetchCustomStrategies(),
10916
12665
  fetchSymbols: (market) => faces.tradingBridge.fetchSymbols(market),
10917
- subscribeTradingEvents: (handlers) => faces.tradingBridge.subscribeTradingEvents(handlers)
12666
+ subscribeTradingEvents: (handlers) => faces.tradingBridge.subscribeTradingEvents(handlers),
12667
+ saveCustomStrategy: faces.tradingBridge.saveCustomStrategy?.bind(faces.tradingBridge),
12668
+ deleteCustomStrategy: faces.tradingBridge.deleteCustomStrategy?.bind(faces.tradingBridge),
12669
+ resetStrategy: faces.tradingBridge.resetStrategy?.bind(faces.tradingBridge),
12670
+ fetchStrategyTombstones: faces.tradingBridge.fetchStrategyTombstones?.bind(faces.tradingBridge),
12671
+ fetchCustomScreeners: faces.tradingBridge.fetchCustomScreeners?.bind(faces.tradingBridge),
12672
+ saveCustomScreener: faces.tradingBridge.saveCustomScreener?.bind(faces.tradingBridge),
12673
+ deleteCustomScreener: faces.tradingBridge.deleteCustomScreener?.bind(faces.tradingBridge),
12674
+ resetScreener: faces.tradingBridge.resetScreener?.bind(faces.tradingBridge)
10918
12675
  };
10919
12676
  faces.tradingStageViews.register({
10920
12677
  id: "strategy",