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