@dshtrading/client-ui-strategies 0.1.4 → 0.1.6

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